mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
00cbc5aae3
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
876 lines
34 KiB
TypeScript
876 lines
34 KiB
TypeScript
/**
|
||
* 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 { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import request from 'supertest';
|
||
import type { Application } from 'express';
|
||
import type { INestApplication } from '@nestjs/common';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
|
||
vi.mock('../../src/db/database', async () => {
|
||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||
return buildDbMock(createSnapshotTestDb());
|
||
});
|
||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||
|
||
import { db as testDb } from '../../src/db/database';
|
||
import { buildApp } from '../../src/bootstrap';
|
||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||
import { authCookie, generateToken } from '../helpers/auth';
|
||
import { CollabService } from '../../src/nest/collab/collab.service';
|
||
|
||
// 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();
|
||
});
|
||
});
|