/** * Unit tests for the DI-native CollabService — COLLAB-SVC-001 to COLLAB-SVC-038 * (001–030 moved 1:1 from the legacy tests/unit/services/collabService.test.ts; * 031–033 (collab.bridge delegation) died with the bridge; 034–038 pin the post-migration * hardening: transactional writes, trip-scoped getFormattedNoteById, the * integer vote guard and malformed-URL absorption). Covers votePoll edge * cases, listMessages pagination, deleteMessage ownership, updateNote partial * fields, linkPreview, avatarUrl, createMessage reply validation. Uses a real * in-memory SQLite DB so SQL logic is exercised faithfully. */ 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: () => {}, })); vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); // Stub checkSsrf so linkPreview 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 } from '../../../src/nest/common/avatarUrl'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { CollabService } from '../../../src/nest/collab/collab.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService()); 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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); svc.closePoll(trip.id, poll!.id); const result = svc.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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); const result = svc.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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); const result = svc.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 = svc.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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); const result = svc.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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); svc.votePoll(trip.id, poll!.id, user1.id, 0); const result = svc.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(); svc.createMessage(trip.id, user1.id, 'Hello'); svc.createMessage(trip.id, user1.id, 'World'); const msgs = svc.listMessages(trip.id); expect(msgs).toHaveLength(2); }); it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', () => { const { user1, trip } = setup(); svc.createMessage(trip.id, user1.id, 'First'); svc.createMessage(trip.id, user1.id, 'Second'); const r3 = svc.createMessage(trip.id, user1.id, 'Third'); const id3 = r3.message!.id; const msgs = svc.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(); svc.createMessage(trip.id, user1.id, 'A'); svc.createMessage(trip.id, user1.id, 'B'); svc.createMessage(trip.id, user1.id, 'C'); const msgs = svc.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 = svc.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 = svc.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 = svc.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 = svc.createMessage(trip.id, user1.id, 'Original'); const r2 = svc.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 = svc.createMessage(trip.id, user1.id, 'My message'); const result = svc.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 = svc.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 = svc.createMessage(trip.id, user1.id, 'Delete me'); const result = svc.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 = svc.createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' }); svc.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 = svc.createNote(trip.id, user1.id, { title: 'T', content: 'Old content' }); svc.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 = svc.createNote(trip.id, user1.id, { title: 'T' }); svc.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 = svc.createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' }); svc.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 = svc.updateNote(trip.id, 9999, { title: 'Ghost' }); expect(result).toBeNull(); }); it('COLLAB-SVC-024: updates pinned flag', () => { const { user1, trip } = setup(); const note = svc.createNote(trip.id, user1.id, { title: 'T', pinned: false }); svc.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); }); }); // ── linkPreview ─────────────────────────────────────────────────────────────── describe('linkPreview', () => { 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 () => ` `, })); const result = await svc.linkPreview('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 tag when no og:title', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => `<html><head><title>Page Title`, })); const result = await svc.linkPreview('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 svc.linkPreview('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 svc.linkPreview('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 svc.linkPreview('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 () => ` `, })); const result = await svc.linkPreview('https://example.com/meta'); expect(result.description).toBe('Meta description here'); }); }); // COLLAB-SVC-031..033 (collab.bridge delegation) were deleted with the bridge — // its last consumers (the legacy tripService and the legacy get_trip_summary // registrar) migrated into the DI-native TripsService/TripsMcp. // ── Post-migration hardening (transactions, scoping, guards) ────────────────── describe('hardening', () => { it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService()); const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); failing.votePoll(trip.id, poll!.id, user1.id, 0); const realRun = dbs.run.bind(dbs); const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => { if (sql.includes('INSERT INTO collab_poll_votes')) throw new Error('boom'); return realRun(sql, ...params); }); // Single-choice switch: DELETE prior votes, then the INSERT fails — the // transaction must roll the DELETE back too. expect(() => failing.votePoll(trip.id, poll!.id, user1.id, 1)).toThrow('boom'); spy.mockRestore(); const votes = testDb.prepare('SELECT option_index FROM collab_poll_votes WHERE poll_id = ?').all(poll!.id) as { option_index: number }[]; expect(votes).toEqual([{ option_index: 0 }]); }); it('COLLAB-SVC-035: deleteNote is atomic — trip_files rows survive a failed note DELETE', () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService()); const note = failing.createNote(trip.id, user1.id, { title: 'With file' }); testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)') .run(trip.id, note.id, 'files/a.pdf', 'a.pdf'); const realRun = dbs.run.bind(dbs); const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => { if (sql.includes('DELETE FROM collab_notes')) throw new Error('boom'); return realRun(sql, ...params); }); expect(() => failing.deleteNote(trip.id, note.id)).toThrow('boom'); spy.mockRestore(); expect(testDb.prepare('SELECT COUNT(*) as c FROM trip_files WHERE note_id = ?').get(note.id)).toEqual({ c: 1 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_notes WHERE id = ?').get(note.id)).toEqual({ c: 1 }); }); it('COLLAB-SVC-036: getFormattedNoteById is trip-scoped and null-safe', () => { const { user1, trip } = setup(); const otherTrip = createTrip(testDb, user1.id); const note = svc.createNote(trip.id, user1.id, { title: 'Scoped' }); expect(svc.getFormattedNoteById(trip.id, note.id)!.title).toBe('Scoped'); expect(svc.getFormattedNoteById(otherTrip.id, note.id)).toBeNull(); expect(svc.getFormattedNoteById(trip.id, 9999)).toBeNull(); }); it('COLLAB-SVC-037: votePoll rejects a non-integer option index with "invalid_index"', () => { const { user1, trip } = setup(); const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); expect(svc.votePoll(trip.id, poll!.id, user1.id, '0' as unknown as number).error).toBe('invalid_index'); expect(svc.votePoll(trip.id, poll!.id, user1.id, 0.5).error).toBe('invalid_index'); expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_poll_votes WHERE poll_id = ?').get(poll!.id)).toEqual({ c: 0 }); }); it('COLLAB-SVC-038: linkPreview returns the fallback for a malformed URL without throwing', async () => { const result = await svc.linkPreview('not a url'); expect(result).toEqual({ title: null, description: null, image: null, url: 'not a url' }); expect(mockCheckSsrf).not.toHaveBeenCalled(); }); });