Files
TREK/server/tests/integration/collab.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

861 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Collab (notes, polls, messages, reactions) integration tests.
* Covers COLLAB-001 to COLLAB-027.
*
* Note: File upload to collab notes (COLLAB-005/006/007) requires physical file I/O.
* Link preview (COLLAB-025/026) would need fetch mocking — skipped here.
*/
import { buildApp } from '../../src/bootstrap';
import { db as testDb } from '../../src/db/database';
import { CollabService } from '../../src/nest/collab/collab.service';
import { authCookie, generateToken } from '../helpers/auth';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import type { INestApplication } from '@nestjs/common';
import type { Application } from 'express';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
// Spy on the DI-native service's linkPreview so the SSRF-guarded fetch never
// runs; the rest of CollabService exercises its real SQL through the container.
// The original is kept so the guard cases below can put it back for one call —
// mockRestore would drop the spy for every test declared after them.
const realLinkPreview = CollabService.prototype.linkPreview;
const linkPreviewSpy = vi
.spyOn(CollabService.prototype, 'linkPreview')
.mockResolvedValue({ title: null, description: null, image: null, url: '' });
let nestApp: INestApplication;
let app: Application;
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
// Ensure uploads/files dir exists for collab file uploads
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Collab Notes
// ─────────────────────────────────────────────────────────────────────────────
describe('Collab notes', () => {
it('COLLAB-001 — POST /collab/notes creates a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Packing Ideas', content: 'Bring sunscreen', category: 'Planning' });
expect(res.status).toBe(201);
expect(res.body.note.title).toBe('Packing Ideas');
expect(res.body.note.content).toBe('Bring sunscreen');
});
it('COLLAB-001 — POST without title returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ content: 'No title' });
expect(res.status).toBe(400);
});
it('COLLAB-001 — non-member cannot create collab note', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(other.id))
.send({ title: 'Sneaky note' });
expect(res.status).toBe(404);
});
it('COLLAB-002 — GET /collab/notes returns all notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note A' });
await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note B' });
const res = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.notes).toHaveLength(2);
});
it('COLLAB-003 — PUT /collab/notes/:id updates a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Old Title', content: 'Old content' });
const noteId = create.body.note.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/collab/notes/${noteId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'New Title', content: 'New content', pinned: true });
expect(res.status).toBe(200);
expect(res.body.note.title).toBe('New Title');
expect(res.body.note.pinned).toBe(1);
});
it('COLLAB-003 — PUT non-existent note returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/collab/notes/99999`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Updated' });
expect(res.status).toBe(404);
});
it('COLLAB-004 — DELETE /collab/notes/:id removes note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'To Delete' });
const noteId = create.body.note.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
expect(list.body.notes).toHaveLength(0);
});
it('COLLAB-005 — POST /collab/notes/:id/files uploads a file to a note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note with file' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
expect(upload.status).toBe(201);
expect(upload.body.file).toBeDefined();
});
it('COLLAB-006 — uploading blocked extension to note is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note' });
const noteId = create.body.note.id;
// Create a temp .svg file
const svgPath = path.join(uploadsDir, 'collab_blocked.svg');
fs.writeFileSync(svgPath, '<svg></svg>');
try {
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', svgPath);
expect(res.status).toBe(400);
} finally {
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
}
});
it('COLLAB-007 — DELETE /collab/notes/:noteId/files/:fileId removes file from note', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Note with file' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const fileId = upload.body.file.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
it('COLLAB-P01 — attachment filename is stored bare (category + name; no files/ prefix)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Bare name' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
expect(upload.status).toBe(201);
const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
const note = list.body.notes.find((n: any) => n.id === noteId);
expect(note.attachments[0].filename).toMatch(/^[0-9a-f-]{36}\.pdf$/);
expect(note.attachments[0].url).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/);
// The bytes live in uploads/files under the bare name.
expect(fs.existsSync(path.join(uploadsDir, note.attachments[0].filename))).toBe(true);
});
it('COLLAB-P02 — deleting a note file removes its bytes from uploads/files', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Delete file bytes' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const fileId = upload.body.file.id;
const diskName = path.basename(upload.body.file.filename);
expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(true);
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}/files/${fileId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(false);
});
it('COLLAB-P03 — deleting a note removes its attachments’ bytes from uploads/files', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Delete note bytes' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const diskName = path.basename(upload.body.file.filename);
expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(true);
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/notes/${noteId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(fs.existsSync(path.join(uploadsDir, diskName))).toBe(false);
});
it('COLLAB-028 — uploaded note file URL uses authenticated download path, not /uploads/', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'URL check' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
expect(upload.status).toBe(201);
const fileUrl = upload.body.file.url;
expect(fileUrl).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/);
expect(fileUrl).not.toContain('/uploads/');
});
it('COLLAB-029 — note attachments in listing use authenticated download URLs', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'List URL check' });
const noteId = create.body.note.id;
await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
expect(list.status).toBe(200);
const note = list.body.notes.find((n: any) => n.id === noteId);
expect(note.attachments.length).toBe(1);
expect(note.attachments[0].url).toMatch(/^\/api\/trips\/\d+\/files\/\d+\/download$/);
expect(note.attachments[0].url).not.toContain('/uploads/');
});
it('COLLAB-030 — note file is downloadable via files endpoint with ephemeral token', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Downloadable note' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const fileUrl = upload.body.file.url;
// Obtain an ephemeral resource token (same flow as getAuthUrl on the client)
const tokenRes = await request(app)
.post('/api/auth/resource-token')
.set('Cookie', authCookie(user.id))
.send({ purpose: 'download' });
expect(tokenRes.status).toBe(200);
const { token } = tokenRes.body;
// Download with ?token= should succeed
const dl = await request(app).get(`${fileUrl}?token=${token}`);
expect(dl.status).toBe(200);
});
it('COLLAB-031 — note file download without auth returns 401', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/notes`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Auth required note' });
const noteId = create.body.note.id;
const upload = await request(app)
.post(`/api/trips/${trip.id}/collab/notes/${noteId}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_PDF);
const fileUrl = upload.body.file.url;
// Download without any auth should fail
const dl = await request(app).get(fileUrl);
expect(dl.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Polls
// ─────────────────────────────────────────────────────────────────────────────
describe('Polls', () => {
it('COLLAB-008 — POST /collab/polls creates a poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Where to eat?', options: ['Pizza', 'Sushi', 'Tacos'] });
expect(res.status).toBe(201);
expect(res.body.poll.question).toBe('Where to eat?');
expect(res.body.poll.options).toHaveLength(3);
});
it('COLLAB-008 — POST without question returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ options: ['A', 'B'] });
expect(res.status).toBe(400);
});
it('COLLAB-009 — GET /collab/polls returns polls', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Beach or mountains?', options: ['Beach', 'Mountains'] });
const res = await request(app).get(`/api/trips/${trip.id}/collab/polls`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.polls).toHaveLength(1);
});
it('COLLAB-010 — POST /collab/polls/:id/vote casts a vote', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Restaurant?', options: ['Italian', 'French'] });
const pollId = create.body.poll.id;
const vote = await request(app)
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
.set('Cookie', authCookie(user.id))
.send({ option_index: 0 });
expect(vote.status).toBe(200);
expect(vote.body.poll).toBeDefined();
});
it('COLLAB-011 — PUT /collab/polls/:id/close closes a poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Hotel?', options: ['Budget', 'Luxury'] });
const pollId = create.body.poll.id;
const close = await request(app)
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
.set('Cookie', authCookie(user.id));
expect(close.status).toBe(200);
expect(close.body.poll.is_closed).toBe(true);
});
it('COLLAB-012 — cannot vote on closed poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Closed?', options: ['Yes', 'No'] });
const pollId = create.body.poll.id;
await request(app).put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`).set('Cookie', authCookie(user.id));
const vote = await request(app)
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
.set('Cookie', authCookie(user.id))
.send({ option_index: 0 });
expect(vote.status).toBe(400);
});
it('COLLAB-013 — DELETE /collab/polls/:id removes poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const create = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Delete me?', options: ['Yes', 'No'] });
const pollId = create.body.poll.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/polls/${pollId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Messages
// ─────────────────────────────────────────────────────────────────────────────
describe('Messages', () => {
it('COLLAB-014 — POST /collab/messages sends a message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Hello, team!' });
expect(res.status).toBe(201);
expect(res.body.message.text).toBe('Hello, team!');
});
it('COLLAB-014 — POST without text returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: '' });
expect(res.status).toBe(400);
});
it('COLLAB-014 — non-member cannot send message', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(other.id))
.send({ text: 'Unauthorized' });
expect(res.status).toBe(404);
});
it('COLLAB-015 — GET /collab/messages returns messages in order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'First message' });
await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Second message' });
const res = await request(app).get(`/api/trips/${trip.id}/collab/messages`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.messages.length).toBeGreaterThanOrEqual(2);
});
it('COLLAB-016 — POST /collab/messages with reply_to links reply', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const parent = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Original' });
const parentId = parent.body.message.id;
const reply = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Reply here', reply_to: parentId });
expect(reply.status).toBe(201);
expect(reply.body.message.reply_to).toBe(parentId);
});
it('COLLAB-017 — DELETE /collab/messages/:id removes own message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Delete me' });
const msgId = msg.body.message.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
it("COLLAB-017 — cannot delete another user's message", async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(owner.id))
.send({ text: 'Owner message' });
const msgId = msg.body.message.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/collab/messages/${msgId}`)
.set('Cookie', authCookie(member.id));
expect(del.status).toBe(403);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reactions
// ─────────────────────────────────────────────────────────────────────────────
describe('Message reactions', () => {
it('COLLAB-018 — POST /collab/messages/:id/react adds a reaction', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'React to me' });
const msgId = msg.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
});
it('COLLAB-018 — POST react without emoji returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msg = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Test' });
const msgId = msg.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${msgId}/react`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Long text validation
// ─────────────────────────────────────────────────────────────────────────────
describe('Collab validation', () => {
it('COLLAB-018 — message text exceeding 5000 chars is rejected', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'A'.repeat(5001) });
expect(res.status).toBe(400);
});
it('COLLAB-008 — poll with fewer than 2 options returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Only one option?', options: ['Option A'] });
expect(res.status).toBe(400);
// The Zod pipe's standard envelope replaced the bespoke 'At least 2 options
// are required' string when the collab bodies adopted DTOs.
expect(res.body.error).toMatch(/options/i);
});
});
describe('Link preview', () => {
it('COLLAB-025 — GET /collab/link-preview as a non-member returns 404 (trip-access check added post-migration)', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(outsider.id));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('COLLAB-025 — GET /collab/link-preview without url returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app).get(`/api/trips/${trip.id}/collab/link-preview`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/url/i);
});
it('COLLAB-025 — GET /collab/link-preview returns preview for valid URL', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
linkPreviewSpy.mockResolvedValueOnce({
title: 'Example Domain',
description: 'A test page',
image: null,
url: 'https://example.com',
});
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.title).toBe('Example Domain');
});
it('COLLAB-026 — GET /collab/link-preview returns 400 when fetchLinkPreview returns error', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
linkPreviewSpy.mockResolvedValueOnce({
title: null,
description: null,
image: null,
url: 'http://127.0.0.1',
error: 'Requests to loopback and link-local addresses are not allowed',
} as any);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=http://127.0.0.1`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
// Every case above mocks linkPreview out, so the SSRF guard, the redirect
// refusal and the pinned dispatcher are unproven over HTTP: swapping
// checkSsrf(url, true) for checkSsrf(url) would leave this file green. These
// two put the real implementation back for one call each. No traffic leaves
// the machine — the guard refuses both targets before any fetch.
it.each([
['loopback', 'http://127.0.0.1:9/'],
// The unspecified address routes to loopback when connected to, and used to
// pass the guard: it is not '::1', and it does not start with '0.'.
['the unspecified address', 'http://[::]:9/'],
])('COLLAB-032 — GET /collab/link-preview refuses %s (%s) through the real guard', async (_label, url) => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
linkPreviewSpy.mockImplementationOnce(realLinkPreview);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=${encodeURIComponent(url)}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
// One constant reason. The guard knows three, and telling them apart would
// map out the internal DNS of the server one guessed hostname at a time.
expect(res.body).toEqual({ error: 'URL not allowed' });
});
it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
linkPreviewSpy.mockRejectedValueOnce(new Error('Unexpected error'));
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.title).toBeNull();
});
});
describe('Message reactions toggle', () => {
it('COLLAB-028 — POST /collab/messages/:msgId/react adds a reaction', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgRes = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Hello!' });
expect(msgRes.status).toBe(201);
const messageId = msgRes.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
expect(thumbsUp).toBeDefined();
expect(thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id)).toBe(true);
});
it('COLLAB-029 — POST /collab/messages/:msgId/react on same emoji removes it (toggle)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgRes = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Toggle me!' });
expect(msgRes.status).toBe(201);
const messageId = msgRes.body.message.id;
// First call — adds the reaction
await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
// Second call with same emoji — should toggle it off
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
// After toggling off, either the entry is absent or the user is no longer in it
const userStillReacted =
thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
expect(userStillReacted).toBeFalsy();
});
});