Files
TREK/server/tests/e2e/collab.e2e.test.ts
T
Maurice fa0edfd714 test(server): suites record broadcasts on a FakeRealtimeService instead of mocking src/websocket
A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
2026-10-10 20:16:10 +02:00

279 lines
14 KiB
TypeScript

/**
* Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints
* through the real JwtAuthGuard against a real migrated temp SQLite db
* (createSnapshotTestDb(), no hand-rolled CREATE TABLEs), running CollabService's
* real queries (DI-injected, no service mock). The permission check and
* the chat/note notification are mocked; this focuses
* on auth, trip-access 404, permission 403, the create-201 status codes, the
* vote/react 200 overrides and the persisted rows. Rows are seeded and read
* through the factories in tests/helpers/factories.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
// Since the permissions DI migration, the check is a spy on the container's
// PermissionsService singleton (created in beforeAll, after build()).
let checkPermission: MockInstance;
import { CollabModule } from '../../src/nest/collab/collab.module';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
import { db } from '../../src/db/database';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { makeUser } from '../helpers/factories/users';
import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows';
import { CollabLinks } from '../../src/db/entities/CollabLinks.entity';
import { CollabMessageReactions } from '../../src/db/entities/CollabMessageReactions.entity';
import { CollabMessages } from '../../src/db/entities/CollabMessages.entity';
import { CollabNotes } from '../../src/db/entities/CollabNotes.entity';
import { CollabPollVotes } from '../../src/db/entities/CollabPollVotes.entity';
import { CollabPolls } from '../../src/db/entities/CollabPolls.entity';
import { TripFiles } from '../../src/db/entities/TripFiles.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
let orm: TestOrm;
describe('Collab e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, CollabModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// AppModule registers this as APP_PIPE; the harness only pulls CollabModule,
// so without it the write routes would run unvalidated here and a broken
// contract would still look green.
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
orm = await createTestOrm(db);
// Pinned id: sessionCookie(1) signs for exactly this user.
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
// Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible
// directly now, a real query, so trip 5's real row is (re-)seeded every
// test, owned by user 1.
await deleteRows(orm, Trips, { id: 5 });
await insertRow(orm, Trips, { id: 5, title: 'Trip', user: 1 });
checkPermission.mockReturnValue(true);
await deleteRows(orm, CollabMessageReactions);
await deleteRows(orm, CollabPollVotes);
await deleteRows(orm, CollabMessages);
await deleteRows(orm, CollabPolls);
await deleteRows(orm, TripFiles);
await deleteRows(orm, CollabNotes);
await deleteRows(orm, CollabLinks);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/trips/5/collab/notes')).status).toBe(401);
});
it('200 list notes for an accessible trip', async () => {
await insertRow(orm, CollabNotes, { id: 1, trip: 5, user: 1, title: 'N' });
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.notes).toHaveLength(1);
expect(res.body.notes[0]).toMatchObject({ id: 1, title: 'N', category: 'General', attachments: [] });
});
it('404 when the trip is not accessible', async () => {
await deleteRows(orm, Trips, { id: 5 });
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on note create with permission, row persisted', async () => {
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
expect(res.status).toBe(201);
expect(res.body.note).toMatchObject({ title: 'N', category: 'General', color: '#6366f1', pinned: 0 });
const row = await findRow(orm, CollabNotes, { trip: 5 });
expect(row?.title).toBe('N');
});
it('403 on note create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
it('200 on poll vote (not 201), vote persisted', async () => {
await insertRow(orm, CollabPolls, { id: 7, trip: 5, user: 1, question: 'Q?', options: JSON.stringify(['A', 'B']) });
const res = await request(server).post('/api/trips/5/collab/polls/7/vote').set('Cookie', sessionCookie(1)).send({ option_index: 0 });
expect(res.status).toBe(200);
expect(res.body.poll).toMatchObject({ id: 7, is_closed: false });
expect(res.body.poll.options[0].voters).toHaveLength(1);
const vote = await findRow(orm, CollabPollVotes, { poll: 7 });
expect(vote?.option_index).toBe(0);
});
it('201 on message create, row persisted', async () => {
const res = await request(server).post('/api/trips/5/collab/messages').set('Cookie', sessionCookie(1)).send({ text: 'hi' });
expect(res.status).toBe(201);
expect(res.body.message).toMatchObject({ text: 'hi', trip_id: 5, user_id: 1 });
const row = await findRow(orm, CollabMessages, { trip: 5 });
expect(row?.text).toBe('hi');
});
it('200 on react (not 201), reaction persisted and toggled', async () => {
await insertRow(orm, CollabMessages, { id: 3, trip: 5, user: 1, text: 'react me' });
const res = await request(server).post('/api/trips/5/collab/messages/3/react').set('Cookie', sessionCookie(1)).send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toHaveLength(1);
expect(res.body.reactions[0]).toMatchObject({ emoji: '👍', count: 1 });
expect(await countRows(orm, CollabMessageReactions, { message: 3 })).toBe(1);
});
// The advisory this route was reported under: it answered anyone with a session,
// for any trip id, and drove an outbound fetch from that.
it('404 on link-preview for a trip the caller cannot reach', async () => {
await deleteRows(orm, Trips, { id: 5 });
const res = await request(server)
.get('/api/trips/5/collab/link-preview?url=https://example.com/')
.set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('401 on link-preview without a session cookie', async () => {
const res = await request(server).get('/api/trips/5/collab/link-preview?url=https://example.com/');
expect(res.status).toBe(401);
});
// A read-only member still gets previews: the route is requested while rendering
// the chat, so gating it on the write permission would blank the chat for them.
it('200 on link-preview for a member without collab_edit', async () => {
checkPermission.mockReturnValue(false);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, headers: { get: () => null }, text: async () => '<title>Lesbar</title>',
}));
const res = await request(server)
.get('/api/trips/5/collab/link-preview?url=https://example.com/reader')
.set('Cookie', sessionCookie(1));
vi.unstubAllGlobals();
expect(res.status).toBe(200);
expect(res.body.title).toBe('Lesbar');
});
it('429 once the caller has spent a minute of preview fetches', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, headers: { get: () => null }, text: async () => '<title>T</title>',
}));
// Distinct URLs, because a repeat is served from the cache and costs nothing.
let last = 200;
for (let i = 0; i < 61 && last === 200; i++) {
last = (await request(server)
.get(`/api/trips/5/collab/link-preview?url=${encodeURIComponent(`https://example.com/e2e-${i}`)}`)
.set('Cookie', sessionCookie(1))).status;
}
vi.unstubAllGlobals();
expect(last).toBe(429);
// The counters live on the container singleton, so a spent budget would
// follow this user into every test declared after it.
await app.get(RateLimitService).reset('collab_link_preview');
});
it('400 on link-preview without a url', async () => {
const res = await request(server).get('/api/trips/5/collab/link-preview').set('Cookie', sessionCookie(1));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'URL is required' });
});
describe('shared links', () => {
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/trips/5/collab/links')).status).toBe(401);
});
it('404 when the trip is not accessible', async () => {
await deleteRows(orm, Trips, { id: 5 });
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
});
it('201 on create, and the row is persisted with the acting user', async () => {
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1))
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
expect(res.status).toBe(201);
expect(res.body.link).toMatchObject({ title: 'Ferry', url: 'https://example.com/ferry' });
const row = await findRow(orm, CollabLinks, { trip: 5 });
expect(row).toMatchObject({ title: 'Ferry', user_id: 1 });
});
it('403 on create without collab_edit', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1))
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
expect(res.status).toBe(403);
});
it('400 when the body does not satisfy the contract', async () => {
const res = await request(server).post('/api/trips/5/collab/links').set('Cookie', sessionCookie(1)).send({ title: 'Ferry' });
expect(res.status).toBe(400);
});
it('200 on list, pinned first', async () => {
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test', pinned: 0 });
await insertRow(orm, CollabLinks, { id: 2, trip: 5, user: 1, title: 'Pinned', url: 'https://b.test', pinned: 1 });
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.links.map((l: { title: string }) => l.title)).toEqual(['Pinned', 'Plain']);
});
it('200 on update and the pin lands in the row', async () => {
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test' });
const res = await request(server).put('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1)).send({ pinned: true });
expect(res.status).toBe(200);
expect(res.body.link).toMatchObject({ id: 1, pinned: 1 });
});
it('404 on update and delete of a link that is not there', async () => {
expect((await request(server).put('/api/trips/5/collab/links/99').set('Cookie', sessionCookie(1)).send({ pinned: true })).status).toBe(404);
expect((await request(server).delete('/api/trips/5/collab/links/99').set('Cookie', sessionCookie(1))).status).toBe(404);
});
it('200 on delete and the row is gone', async () => {
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test' });
const res = await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(await countRows(orm, CollabLinks)).toBe(0);
});
it('403 on delete without collab_edit', async () => {
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test' });
checkPermission.mockReturnValue(false);
expect((await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1))).status).toBe(403);
});
});
});