mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
test: add comprehensive coverage for OAuth scopes, MCP, and core services
Adds new and expanded test suites across client and server to cover the OAuth 2.1 scope system, MCP session manager, collab service, unified memories helpers, OIDC service, budget slice, and OAuth authorize page. Also extends SonarQube coverage exclusions to include bootstrapping files (migrations, scheduler, main.tsx, types.ts) that are not meaningfully testable.
This commit is contained in:
@@ -528,4 +528,150 @@ describe('MCP token management', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.tokens)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-024 — DELETE /admin/mcp-tokens/:id returns 404 for missing token', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/admin/mcp-tokens/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OAuth sessions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OAuth sessions', () => {
|
||||
it('ADMIN-025 — GET /admin/oauth-sessions returns list', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/oauth-sessions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-026 — DELETE /admin/oauth-sessions/:id returns 404 for missing session', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/admin/oauth-sessions/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// OIDC settings
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OIDC settings', () => {
|
||||
it('ADMIN-027 — GET /admin/oidc returns OIDC configuration', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/oidc')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ADMIN-028 — PUT /admin/oidc updates OIDC settings', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/oidc')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ issuer: 'https://accounts.example.com', client_id: 'my-client', oidc_only: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Demo baseline
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Demo baseline', () => {
|
||||
it('ADMIN-029 — POST /admin/save-demo-baseline returns 404 when DEMO_MODE is not set', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/save-demo-baseline')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GitHub releases / version check
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GitHub releases and version check', () => {
|
||||
it('ADMIN-030 — GET /admin/github-releases returns array (even if GitHub unreachable)', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/github-releases?per_page=5&page=1')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-031 — GET /admin/version-check returns version info', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/version-check')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('current');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Additional list routes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Admin list routes', () => {
|
||||
it('ADMIN-032 — GET /admin/invites lists invites', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/invites')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.invites)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-033 — GET /admin/bag-tracking returns bag tracking setting', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/bag-tracking')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ADMIN-034 — GET /admin/packing-templates lists templates', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/packing-templates')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.templates)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-035 — GET /admin/addons lists addons', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/addons')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Unit tests for MCP sessionManager — SESS-001 to SESS-010.
|
||||
* Covers revokeUserSessions and revokeUserSessionsForClient.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { sessions, revokeUserSessions, revokeUserSessionsForClient, McpSession } from '../../../src/mcp/sessionManager';
|
||||
|
||||
function makeSession(overrides: Partial<McpSession> = {}): McpSession {
|
||||
return {
|
||||
server: { close: vi.fn() } as any,
|
||||
transport: { close: vi.fn() } as any,
|
||||
userId: 1,
|
||||
scopes: null,
|
||||
clientId: null,
|
||||
isStaticToken: false,
|
||||
lastActivity: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessions.clear();
|
||||
});
|
||||
|
||||
describe('revokeUserSessions', () => {
|
||||
it('SESS-001: removes all sessions for the given userId', () => {
|
||||
sessions.set('sid-1', makeSession({ userId: 1 }));
|
||||
sessions.set('sid-2', makeSession({ userId: 1 }));
|
||||
sessions.set('sid-3', makeSession({ userId: 2 }));
|
||||
|
||||
revokeUserSessions(1);
|
||||
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
expect(sessions.has('sid-2')).toBe(false);
|
||||
expect(sessions.has('sid-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('SESS-002: calls server.close() and transport.close() for each revoked session', () => {
|
||||
const s = makeSession({ userId: 1 });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
revokeUserSessions(1);
|
||||
|
||||
expect(s.server.close).toHaveBeenCalledOnce();
|
||||
expect(s.transport.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('SESS-003: does nothing when no sessions match userId', () => {
|
||||
sessions.set('sid-1', makeSession({ userId: 2 }));
|
||||
|
||||
revokeUserSessions(99);
|
||||
|
||||
expect(sessions.has('sid-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('SESS-004: does nothing when sessions map is empty', () => {
|
||||
expect(() => revokeUserSessions(1)).not.toThrow();
|
||||
expect(sessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('SESS-005: tolerates server.close() throwing (swallows error)', () => {
|
||||
const s = makeSession({ userId: 1 });
|
||||
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('close failed'); });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
expect(() => revokeUserSessions(1)).not.toThrow();
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('SESS-006: tolerates transport.close() throwing (swallows error)', () => {
|
||||
const s = makeSession({ userId: 1 });
|
||||
(s.transport.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('transport error'); });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
expect(() => revokeUserSessions(1)).not.toThrow();
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeUserSessionsForClient', () => {
|
||||
it('SESS-007: removes only sessions matching both userId and clientId', () => {
|
||||
sessions.set('sid-1', makeSession({ userId: 1, clientId: 'client-a' }));
|
||||
sessions.set('sid-2', makeSession({ userId: 1, clientId: 'client-b' }));
|
||||
sessions.set('sid-3', makeSession({ userId: 2, clientId: 'client-a' }));
|
||||
|
||||
revokeUserSessionsForClient(1, 'client-a');
|
||||
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
expect(sessions.has('sid-2')).toBe(true); // different client
|
||||
expect(sessions.has('sid-3')).toBe(true); // different user
|
||||
});
|
||||
|
||||
it('SESS-008: calls close() on matching sessions only', () => {
|
||||
const match = makeSession({ userId: 1, clientId: 'client-a' });
|
||||
const noMatch = makeSession({ userId: 1, clientId: 'client-b' });
|
||||
sessions.set('sid-match', match);
|
||||
sessions.set('sid-nomatch', noMatch);
|
||||
|
||||
revokeUserSessionsForClient(1, 'client-a');
|
||||
|
||||
expect(match.server.close).toHaveBeenCalledOnce();
|
||||
expect(noMatch.server.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SESS-009: does nothing when no sessions match userId+clientId', () => {
|
||||
sessions.set('sid-1', makeSession({ userId: 1, clientId: 'other' }));
|
||||
|
||||
revokeUserSessionsForClient(1, 'client-a');
|
||||
|
||||
expect(sessions.has('sid-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('SESS-010: tolerates close() throwing for matched sessions', () => {
|
||||
const s = makeSession({ userId: 1, clientId: 'c' });
|
||||
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('x'); });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
expect(() => revokeUserSessionsForClient(1, 'c')).not.toThrow();
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,13 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
});
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock }));
|
||||
|
||||
const { mockGetTripSummary } = vi.hoisted(() => ({
|
||||
mockGetTripSummary: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/services/tripService', () => ({
|
||||
getTripSummary: mockGetTripSummary,
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
@@ -59,6 +66,30 @@ beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
isAddonEnabledMock.mockReturnValue(true);
|
||||
|
||||
// Default mock: returns a trip-summary-shaped value from the real in-memory DB
|
||||
// so that the trip title / existence match what tests insert, but budget/packing
|
||||
// are arrays (as prompts.ts expects), not the object shape getTripSummary now returns.
|
||||
mockGetTripSummary.mockImplementation((tripId: any) => {
|
||||
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
|
||||
if (!trip) return null;
|
||||
const members = testDb.prepare(`
|
||||
SELECT u.id, u.username as name, u.email
|
||||
FROM trip_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.trip_id = ?
|
||||
`).all(tripId) as any[];
|
||||
const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[];
|
||||
const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[];
|
||||
return {
|
||||
trip,
|
||||
days: [],
|
||||
members,
|
||||
budget: budgetRows, // array shape expected by prompts.ts
|
||||
packing: packingRows, // array shape expected by prompts.ts
|
||||
reservations: [],
|
||||
collabNotes: [],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -89,6 +120,15 @@ function listRegisteredPrompts(server: McpServer): string[] {
|
||||
return Object.keys(prompts);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return only the text of a prompt result, ignoring error shapes. */
|
||||
async function invokePromptText(server: McpServer, name: string, args: Record<string, unknown>): Promise<string> {
|
||||
return invokePrompt(server, name, args);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// token_auth_notice
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -152,6 +192,40 @@ describe('Prompt: trip-summary', () => {
|
||||
expect(err.message).not.toContain('access denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "Trip not found." when getTripSummary returns null for accessible trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Ghost Trip' });
|
||||
|
||||
// Override mock to return null (covers lines 46-48 in prompts.ts)
|
||||
mockGetTripSummary.mockReturnValueOnce(null);
|
||||
|
||||
const server = buildServer(user.id);
|
||||
const text = await invokePromptText(server, 'trip-summary', { tripId: trip.id });
|
||||
expect(text).toContain('Trip not found.');
|
||||
});
|
||||
|
||||
it('handles null optional trip fields gracefully (covers || fallbacks)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: '' });
|
||||
|
||||
// Return summary with minimal trip fields (no title, no dates, no description)
|
||||
mockGetTripSummary.mockReturnValueOnce({
|
||||
trip: { id: trip.id, title: null, description: null, start_date: null, end_date: null, currency: null, user_id: user.id },
|
||||
days: [],
|
||||
members: [],
|
||||
budget: [],
|
||||
packing: [],
|
||||
reservations: [],
|
||||
collabNotes: [],
|
||||
});
|
||||
|
||||
const server = buildServer(user.id);
|
||||
const text = await invokePromptText(server, 'trip-summary', { tripId: trip.id });
|
||||
expect(text).toContain('Untitled');
|
||||
expect(text).toContain('?'); // start/end date fallback
|
||||
expect(text).toContain('EUR'); // currency fallback
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -208,6 +282,21 @@ describe('Prompt: packing-list', () => {
|
||||
// Items should be in checklist format
|
||||
expect(text).toMatch(/\[[ x]\]/);
|
||||
});
|
||||
|
||||
it('uses tripId as title fallback when getTripSummary returns null (covers || {} branch)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Null Trip' });
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Hygiene' });
|
||||
|
||||
// Null out the getTripSummary call inside packing-list (line 94: || {})
|
||||
mockGetTripSummary.mockReturnValueOnce(null);
|
||||
|
||||
const server = buildServer(user.id);
|
||||
const text = await invokePromptText(server, 'packing-list', { tripId: trip.id });
|
||||
expect(text).toContain('Toothbrush');
|
||||
// Falls back to 'Trip' literal since trip?.title is undefined (getTripSummary null → || {})
|
||||
expect(text).toContain('Packing List: Trip');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,4 +362,43 @@ describe('Prompt: budget-overview', () => {
|
||||
expect(err.message).toContain('is not a function');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "Trip not found." when getTripSummary returns null for accessible trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Ghost Trip' });
|
||||
|
||||
// Override mock to return null (covers lines 116-118 in prompts.ts)
|
||||
mockGetTripSummary.mockReturnValueOnce(null);
|
||||
|
||||
const server = buildServer(user.id);
|
||||
const text = await invokePromptText(server, 'budget-overview', { tripId: trip.id });
|
||||
expect(text).toContain('Trip not found.');
|
||||
});
|
||||
|
||||
it('renders budget by category with correct totals and per-person calculation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Budget Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Flight', category: 'Transport', total_price: 200 });
|
||||
createBudgetItem(testDb, trip.id, { name: 'Bus', category: 'Transport', total_price: 50 });
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 300 });
|
||||
|
||||
const server = buildServer(user.id);
|
||||
const text = await invokePromptText(server, 'budget-overview', { tripId: trip.id });
|
||||
expect(text).toContain('Budget Trip');
|
||||
expect(text).toContain('Transport');
|
||||
expect(text).toContain('Accommodation');
|
||||
expect(text).toContain('550'); // Transport total
|
||||
expect(text).toContain('300'); // Accommodation total
|
||||
});
|
||||
|
||||
it('renders "No expenses recorded." when budget array is empty', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Empty Budget' });
|
||||
|
||||
const server = buildServer(user.id);
|
||||
const text = await invokePromptText(server, 'budget-overview', { tripId: trip.id });
|
||||
expect(text).toContain('No expenses recorded.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Unit tests for collabService — COLLAB-SVC-001 to COLLAB-SVC-030.
|
||||
* Covers votePoll edge cases, listMessages pagination, deleteMessage ownership,
|
||||
* updateNote partial fields, fetchLinkPreview, avatarUrl, createMessage reply validation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
// ── DB setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Stub checkSsrf so fetchLinkPreview tests can control SSRF behaviour
|
||||
const { mockCheckSsrf, mockCreatePinnedDispatcher } = vi.hoisted(() => ({
|
||||
mockCheckSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '93.184.216.34' })),
|
||||
mockCreatePinnedDispatcher: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: mockCheckSsrf,
|
||||
createPinnedDispatcher: mockCreatePinnedDispatcher,
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import {
|
||||
avatarUrl,
|
||||
votePoll,
|
||||
listMessages,
|
||||
createMessage,
|
||||
deleteMessage,
|
||||
updateNote,
|
||||
createNote,
|
||||
createPoll,
|
||||
closePoll,
|
||||
fetchLinkPreview,
|
||||
} from '../../../src/services/collabService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: true, resolvedIp: '93.184.216.34' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockCheckSsrf.mockReset();
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: true, resolvedIp: '93.184.216.34' });
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function setup() {
|
||||
const { user: user1 } = createUser(testDb);
|
||||
const { user: user2 } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user1.id);
|
||||
return { user1, user2, trip };
|
||||
}
|
||||
|
||||
// ── avatarUrl ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('COLLAB-SVC-001: returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-002: returns upload path when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'abc.jpg' })).toBe('/uploads/avatars/abc.jpg');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-003: returns null when avatar is empty string', () => {
|
||||
expect(avatarUrl({ avatar: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── votePoll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('votePoll', () => {
|
||||
it('COLLAB-SVC-004: returns error "closed" when poll is closed', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
closePoll(trip.id, poll!.id);
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
expect(result.error).toBe('closed');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-005: returns error "invalid_index" for negative index', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, -1);
|
||||
expect(result.error).toBe('invalid_index');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-006: returns error "invalid_index" for out-of-range index', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 5);
|
||||
expect(result.error).toBe('invalid_index');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', () => {
|
||||
const { user1, trip } = setup();
|
||||
const result = votePoll(trip.id, 9999, user1.id, 0);
|
||||
expect(result.error).toBe('not_found');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-008: successfully votes and returns poll with voters', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.poll).toBeDefined();
|
||||
expect(result.poll!.options[0].voters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-009: toggles vote off when voted again on same option', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||||
|
||||
votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
expect(result.poll!.options[0].voters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listMessages with before cursor ──────────────────────────────────────────
|
||||
|
||||
describe('listMessages', () => {
|
||||
it('COLLAB-SVC-010: returns all messages when no before cursor', () => {
|
||||
const { user1, trip } = setup();
|
||||
createMessage(trip.id, user1.id, 'Hello');
|
||||
createMessage(trip.id, user1.id, 'World');
|
||||
|
||||
const msgs = listMessages(trip.id);
|
||||
expect(msgs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r1 = createMessage(trip.id, user1.id, 'First');
|
||||
const r2 = createMessage(trip.id, user1.id, 'Second');
|
||||
const r3 = createMessage(trip.id, user1.id, 'Third');
|
||||
|
||||
const id3 = r3.message!.id;
|
||||
const msgs = listMessages(trip.id, id3);
|
||||
expect(msgs.length).toBe(2);
|
||||
const texts = msgs.map(m => m.text);
|
||||
expect(texts).toContain('First');
|
||||
expect(texts).toContain('Second');
|
||||
expect(texts).not.toContain('Third');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', () => {
|
||||
const { user1, trip } = setup();
|
||||
createMessage(trip.id, user1.id, 'A');
|
||||
createMessage(trip.id, user1.id, 'B');
|
||||
createMessage(trip.id, user1.id, 'C');
|
||||
|
||||
const msgs = listMessages(trip.id);
|
||||
expect(msgs[0].text).toBe('A');
|
||||
expect(msgs[2].text).toBe('C');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-013: includes reactions grouped by emoji', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r = createMessage(trip.id, user1.id, 'React me');
|
||||
const msgId = r.message!.id;
|
||||
testDb.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(msgId, user1.id, '👍');
|
||||
|
||||
const msgs = listMessages(trip.id);
|
||||
expect(msgs[0].reactions).toBeDefined();
|
||||
expect(msgs[0].reactions).toHaveLength(1);
|
||||
expect(msgs[0].reactions[0].emoji).toBe('👍');
|
||||
});
|
||||
});
|
||||
|
||||
// ── createMessage with invalid replyTo ───────────────────────────────────────
|
||||
|
||||
describe('createMessage', () => {
|
||||
it('COLLAB-SVC-014: returns error when replyTo message does not exist', () => {
|
||||
const { user1, trip } = setup();
|
||||
const result = createMessage(trip.id, user1.id, 'Reply to nothing', 9999);
|
||||
expect(result.error).toBe('reply_not_found');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-015: creates message with valid replyTo', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r1 = createMessage(trip.id, user1.id, 'Original');
|
||||
const r2 = createMessage(trip.id, user1.id, 'Reply', r1.message!.id);
|
||||
expect(r2.error).toBeUndefined();
|
||||
expect(r2.message!.reply_to).toBe(r1.message!.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteMessage ownership check ─────────────────────────────────────────────
|
||||
|
||||
describe('deleteMessage', () => {
|
||||
it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', () => {
|
||||
const { user1, user2, trip } = setup();
|
||||
const r = createMessage(trip.id, user1.id, 'My message');
|
||||
|
||||
const result = deleteMessage(trip.id, r.message!.id, user2.id);
|
||||
expect(result.error).toBe('not_owner');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-017: returns error "not_found" for nonexistent message', () => {
|
||||
const { user1, trip } = setup();
|
||||
const result = deleteMessage(trip.id, 9999, user1.id);
|
||||
expect(result.error).toBe('not_found');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-018: marks message as deleted when owner deletes it', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r = createMessage(trip.id, user1.id, 'Delete me');
|
||||
|
||||
const result = deleteMessage(trip.id, r.message!.id, user1.id);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const row = testDb.prepare('SELECT deleted FROM collab_messages WHERE id = ?').get(r.message!.id) as any;
|
||||
expect(row.deleted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateNote partial fields ─────────────────────────────────────────────────
|
||||
|
||||
describe('updateNote', () => {
|
||||
it('COLLAB-SVC-019: updates only title when other fields are undefined', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' });
|
||||
|
||||
updateNote(trip.id, note.id, { title: 'Updated' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.title).toBe('Updated');
|
||||
expect(updated.content).toBe('Some content'); // unchanged
|
||||
expect(updated.website).toBe('https://example.com'); // unchanged
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T', content: 'Old content' });
|
||||
|
||||
updateNote(trip.id, note.id, { content: '' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.content).toBe('');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-021: updates website when website is defined', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T' });
|
||||
|
||||
updateNote(trip.id, note.id, { website: 'https://new.example.com' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.website).toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' });
|
||||
|
||||
updateNote(trip.id, note.id, { website: '' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.website).toBe('');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-023: returns null when note does not exist', () => {
|
||||
const { trip } = setup();
|
||||
const result = updateNote(trip.id, 9999, { title: 'Ghost' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-024: updates pinned flag', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T', pinned: false });
|
||||
|
||||
updateNote(trip.id, note.id, { pinned: true });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.pinned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchLinkPreview ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('fetchLinkPreview', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-025: returns OG title and description from HTML', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => `
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Test Title" />
|
||||
<meta property="og:description" content="Test Description" />
|
||||
<meta property="og:image" content="https://example.com/image.jpg" />
|
||||
<meta property="og:site_name" content="Example" />
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/page');
|
||||
expect(result.title).toBe('Test Title');
|
||||
expect(result.description).toBe('Test Description');
|
||||
expect(result.image).toBe('https://example.com/image.jpg');
|
||||
expect(result.url).toBe('https://example.com/page');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-026: falls back to <title> tag when no og:title', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => `<html><head><title>Page Title</title></head></html>`,
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/');
|
||||
expect(result.title).toBe('Page Title');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-027: returns fallback when fetch response is not ok', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
text: async () => '',
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/bad');
|
||||
expect(result.title).toBeNull();
|
||||
expect(result.description).toBeNull();
|
||||
expect(result.url).toBe('https://example.com/bad');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-028: returns fallback when SSRF check blocks the URL', async () => {
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'SSRF blocked' });
|
||||
|
||||
const result = await fetchLinkPreview('https://169.254.169.254/');
|
||||
expect(result.title).toBeNull();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-029: returns fallback when fetch throws (network error)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/net-error');
|
||||
expect(result.title).toBeNull();
|
||||
expect(result.url).toBe('https://example.com/net-error');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-030: falls back to meta description tag when no og:description', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => `
|
||||
<html><head>
|
||||
<meta name="description" content="Meta description here" />
|
||||
</head></html>
|
||||
`,
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/meta');
|
||||
expect(result.description).toBe('Meta description here');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Unit tests for memories/helpersService — MEM-HELPERS-001 to MEM-HELPERS-020.
|
||||
* Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { mockSafeFetch } = vi.hoisted(() => ({
|
||||
mockSafeFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => {
|
||||
class SsrfBlockedError extends Error {
|
||||
constructor(msg: string) { super(msg); this.name = 'SsrfBlockedError'; }
|
||||
}
|
||||
return {
|
||||
safeFetch: mockSafeFetch,
|
||||
SsrfBlockedError,
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })),
|
||||
};
|
||||
});
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService';
|
||||
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
mockSafeFetch.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── mapDbError ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mapDbError', () => {
|
||||
it('MEM-HELPERS-001: returns 409 for unique constraint error', () => {
|
||||
const err = new Error('UNIQUE constraint failed: users.email');
|
||||
const result = mapDbError(err, 'fallback');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error.status).toBe(409);
|
||||
expect(result.error.message).toBe('Resource already exists');
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-002: returns 409 for generic constraint error', () => {
|
||||
const err = new Error('constraint violation');
|
||||
const result = mapDbError(err, 'fallback');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error.status).toBe(409);
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => {
|
||||
const err = new Error('Something went wrong');
|
||||
const result = mapDbError(err, 'fallback');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error.status).toBe(500);
|
||||
expect(result.error.message).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-004: returns 500 for generic DB error', () => {
|
||||
const err = new Error('disk I/O error');
|
||||
const result = mapDbError(err, 'fallback');
|
||||
expect(result.error.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAlbumIdFromLink ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAlbumIdFromLink', () => {
|
||||
it('MEM-HELPERS-005: returns 404 when trip access is denied', () => {
|
||||
const result = getAlbumIdFromLink('9999', 'link-1', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-006: returns 404 when album link is not found', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error.status).toBe(404);
|
||||
expect(result.error.message).toBe('Album link not found');
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-007: returns album_id when link exists', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Insert with auto-increment id (INTEGER PRIMARY KEY)
|
||||
const ins = testDb.prepare(
|
||||
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, user.id, 'immich', 'album-123', 'My Album');
|
||||
const linkId = ins.lastInsertRowid;
|
||||
|
||||
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
|
||||
expect(result.success).toBe(true);
|
||||
expect((result as any).data).toBe('album-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pipeAsset ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pipeAsset', () => {
|
||||
function mockResponse(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
end: vi.fn(),
|
||||
json: vi.fn(),
|
||||
headersSent: false,
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('MEM-HELPERS-009: calls response.end() when resp.body is null', async () => {
|
||||
mockSafeFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: { get: vi.fn(() => null) },
|
||||
body: null,
|
||||
});
|
||||
const res = mockResponse();
|
||||
|
||||
await pipeAsset('https://example.com/asset', res);
|
||||
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-010: returns 400 when SsrfBlockedError is thrown', async () => {
|
||||
mockSafeFetch.mockRejectedValue(new SsrfBlockedError('SSRF blocked'));
|
||||
const res = mockResponse({ headersSent: false });
|
||||
|
||||
await pipeAsset('https://internal.example.com/asset', res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-011: returns 500 for generic fetch error', async () => {
|
||||
mockSafeFetch.mockRejectedValue(new Error('Network error'));
|
||||
const res = mockResponse({ headersSent: false });
|
||||
|
||||
await pipeAsset('https://example.com/asset', res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Failed to fetch asset' });
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-012: calls response.end() when headersSent is true on error', async () => {
|
||||
mockSafeFetch.mockRejectedValue(new Error('fail'));
|
||||
const res = mockResponse({ headersSent: true });
|
||||
|
||||
await pipeAsset('https://example.com/asset', res);
|
||||
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MEM-HELPERS-013: sets content-type header when present in response', async () => {
|
||||
mockSafeFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (h: string) => {
|
||||
if (h === 'content-type') return 'image/jpeg';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
body: null,
|
||||
});
|
||||
const res = mockResponse();
|
||||
|
||||
await pipeAsset('https://example.com/img.jpg', res);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/jpeg');
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Unit tests for memories/unifiedService — MEM-UNIFIED-001 to MEM-UNIFIED-010.
|
||||
* Covers error paths: access denied, disabled provider, no providers enabled.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import {
|
||||
listTripPhotos,
|
||||
listTripAlbumLinks,
|
||||
addTripPhotos,
|
||||
setTripPhotoSharing,
|
||||
removeTripPhoto,
|
||||
createTripAlbumLink,
|
||||
removeAlbumLink,
|
||||
} from '../../../src/services/memories/unifiedService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// Ensure default providers are enabled (resetTestDb seeds them but doesn't reset enabled flag)
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── listTripPhotos ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listTripPhotos', () => {
|
||||
it('MEM-UNIFIED-001: returns 404 when user cannot access trip', () => {
|
||||
const result = listTripPhotos('9999', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-002: returns 400 when no photo providers are enabled', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Disable all providers
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 0').run();
|
||||
|
||||
const result = listTripPhotos(String(trip.id), user.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(400);
|
||||
expect((result as any).error.message).toMatch(/no photo providers enabled/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listTripAlbumLinks ────────────────────────────────────────────────────────
|
||||
|
||||
describe('listTripAlbumLinks', () => {
|
||||
it('MEM-UNIFIED-003: returns 404 when user cannot access trip', () => {
|
||||
const result = listTripAlbumLinks('9999', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-004: returns 400 when no photo providers are enabled', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 0').run();
|
||||
|
||||
const result = listTripAlbumLinks(String(trip.id), user.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addTripPhotos ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('addTripPhotos', () => {
|
||||
it('MEM-UNIFIED-005: returns 404 when user cannot access trip', async () => {
|
||||
const result = await addTripPhotos('9999', 1, false, [{ provider: 'immich', asset_ids: ['a1'] }], 'sid');
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-006: returns 400 when provider is found but disabled (covers line 25)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Insert a disabled provider
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run('disabled-prov', 'Disabled', 'Disabled provider', 'Image', 0, 99);
|
||||
|
||||
const result = await addTripPhotos(
|
||||
String(trip.id),
|
||||
user.id,
|
||||
false,
|
||||
[{ provider: 'disabled-prov', asset_ids: ['asset-x'] }],
|
||||
'sid',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(400);
|
||||
expect((result as any).error.message).toMatch(/not enabled/i);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-007: returns 400 when provider is not found', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = await addTripPhotos(
|
||||
String(trip.id),
|
||||
user.id,
|
||||
false,
|
||||
[{ provider: 'nonexistent-provider', asset_ids: ['asset-x'] }],
|
||||
'sid',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(400);
|
||||
expect((result as any).error.message).toMatch(/not supported/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setTripPhotoSharing ───────────────────────────────────────────────────────
|
||||
|
||||
describe('setTripPhotoSharing', () => {
|
||||
it('MEM-UNIFIED-008: returns 404 when user cannot access trip', async () => {
|
||||
const result = await setTripPhotoSharing('9999', 1, 'immich', 'asset-1', true);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeTripPhoto ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeTripPhoto', () => {
|
||||
it('MEM-UNIFIED-009: returns 404 when user cannot access trip', () => {
|
||||
const result = removeTripPhoto('9999', 1, 'immich', 'asset-1');
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createTripAlbumLink ───────────────────────────────────────────────────────
|
||||
|
||||
describe('createTripAlbumLink', () => {
|
||||
it('MEM-UNIFIED-010: returns 404 when user cannot access trip', () => {
|
||||
const result = createTripAlbumLink('9999', 1, 'immich', 'album-1', 'My Album');
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
|
||||
it('MEM-UNIFIED-011: returns 400 when provider is disabled', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100);
|
||||
|
||||
const result = createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album');
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeAlbumLink ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeAlbumLink', () => {
|
||||
it('MEM-UNIFIED-012: returns 404 when user cannot access trip', () => {
|
||||
const result = removeAlbumLink('9999', '1', 1);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as any).error.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -389,3 +389,74 @@ describe('findOrCreateUser', () => {
|
||||
expect(token.used_count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
|
||||
|
||||
describe('exchangeCodeForToken', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-030: sends correct POST body and returns token data', async () => {
|
||||
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
|
||||
|
||||
const mockTokenData = { access_token: 'tok', token_type: 'Bearer' };
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockTokenData,
|
||||
}));
|
||||
|
||||
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
|
||||
const result = await exchangeCodeForToken(doc, 'auth-code-123', 'https://app/callback', 'client-id', 'client-secret');
|
||||
|
||||
expect(result.access_token).toBe('tok');
|
||||
expect(result._ok).toBe(true);
|
||||
expect(result._status).toBe(200);
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://oidc.example.com/token');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-031: reflects _ok=false when provider returns error status', async () => {
|
||||
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: 'invalid_grant' }),
|
||||
}));
|
||||
|
||||
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
|
||||
const result = await exchangeCodeForToken(doc, 'bad-code', 'https://app/callback', 'c', 's');
|
||||
|
||||
expect(result._ok).toBe(false);
|
||||
expect(result._status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUserInfo ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-032: fetches userinfo with Bearer token and returns parsed JSON', async () => {
|
||||
const { getUserInfo } = await import('../../../src/services/oidcService');
|
||||
|
||||
const userInfoData = { sub: 'user-sub', email: 'user@example.com', name: 'User Name' };
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
json: async () => userInfoData,
|
||||
}));
|
||||
|
||||
const result = await getUserInfo('https://oidc.example.com/userinfo', 'access-token-123');
|
||||
|
||||
expect(result.sub).toBe('user-sub');
|
||||
expect(result.email).toBe('user@example.com');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user