Files
TREK/server/tests/integration/feeds-request-context.test.ts
T
Maurice 00cbc5aae3 test(server): src/config takes fixed test values from the setup file, so suites stop mocking it
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.
2026-10-10 20:16:10 +02:00

212 lines
11 KiB
TypeScript

/**
* Anonymous ICS feed request-context ratchet (Plan 3d Task 0, deliverable
* 4a — inventory §12/§18.17).
*
* `GET /api/feed/trip/:token.ics` and `GET /api/feed/user/:token.ics`
* (`feeds.controller.ts`'s `FeedsPublicController`, class-level `@Public`)
* are ordinary Nest routes reached by an anonymous caller with no session —
* `@Public()` exempts only the auth guard, and the per-request EntityManager
* fork `buildApp()` mounts as a pathless middleware (bootstrap.ts's
* `mikroOrmRequestContext`) runs ahead of every route including these two,
* the same argument `nest-mcp/registry.ts`'s own
* middleware wiring makes for `/mcp`. No existing test pinned this before
* this file (inventory §12): the e2e feeds harness builds its own module
* graph, not `buildApp()`'s real production wiring, and `FeedsService`'s own
* `buildTripIcs`/`buildUserIcs` swallow EVERY calendar exception into a
* plain `return null` with no log line at all (`feeds.service.ts:130,
* :176` — bare `catch {}`), so a future regression that makes a converted
* (Task 4/5) repository read throw `cannotUseGlobalContext` would come back
* as the exact SAME 404 `{"error":"Feed not found"}` a genuinely wrong
* token produces — indistinguishable by response shape alone (§18.17: "the
* boot probe must diff bodies and the log, not statuses alone").
*
* SEAM-FEED-001 is the ratchet that actually distinguishes the two: it
* drives the REAL route for a VALID token with a stub standing in for the
* eventual repository-backed `CalendarService.buildTripCalendar` (the same
* technique `orm-request-context-seams.test.ts`'s SEAM-002 uses for the
* pre-init uploads route) and asserts BOTH that the HTTP response succeeds
* AND that a genuine repository read, performed from exactly the point in
* the request lifecycle the real implementation will occupy, resolves
* against the SAME `MikroORM` instance `app.get(MikroORM)` does — a
* `cannotUseGlobalContext` throw there fails this test outright (it is not
* swallowed the way `FeedsService`'s own catch swallows a normal
* `CalendarService` throw, because the read happens with `await`, inside
* the stub, before the stub returns). SEAM-FEED-002 pins today's genuine
* "wrong token" 404 body, unmutated. SEAM-FEED-003 is the mutation proof:
* it forces the exact `cannotUseGlobalContext`-shaped throw §18.17
* describes and shows the route degrades to the SAME 404 body a wrong
* token produces — proving, by direct observation, that the 404 ALONE
* cannot tell the two apart, which is exactly why SEAM-FEED-001's positive
* "the read must actually happen and succeed" assertion — not a status-code
* check — is the ratchet that would actually go red the day a real
* conversion regresses this.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { INestApplication } from '@nestjs/common';
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(), getOnlineUserIds: vi.fn(() => []) }));
import { randomUUID } from 'node:crypto';
import { MikroORM } from '@mikro-orm/core';
import request from 'supertest';
import type { Application } from 'express';
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { CalendarService } from '../../src/nest/calendar/calendar.service';
import { Trips } from '../../src/db/entities/Trips.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { createTrip, createUser } from '../helpers/factories';
import { updateRows } from '../helpers/factories/rows';
describe('Anonymous ICS feed routes run inside a request context', () => {
let app: INestApplication;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
testDb.close();
});
it('SEAM-FEED-001: GET /api/feed/trip/:token.ics for a VALID token performs a real repository read, inside the request, against the SAME MikroORM app.get(MikroORM) does', async () => {
const orm = app.get(MikroORM);
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Context-Proof Trip' });
const token = randomUUID();
await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token });
let repoRead: unknown;
let caught: unknown;
const calendar = app.get(CalendarService);
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async (tripId: string | number) => {
try {
repoRead = await orm.em.getRepository(Trips).findOne({ id: Number(tripId) });
} catch (e) {
caught = e;
}
return { calName: 'Stub Calendar', filename: 'stub.ics', timezones: new Map(), events: [] };
});
try {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/trip/${token}.ics`);
expect(caught).toBeUndefined();
expect(repoRead).toBeTruthy();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
expect(res.text).toContain('BEGIN:VCALENDAR');
expect(res.text).toContain('Stub Calendar');
} finally {
spy.mockRestore();
}
});
it('SEAM-FEED-002: GET /api/feed/trip/:token.ics for a WRONG token is the legacy 404 body, unmutated code', async () => {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/trip/${randomUUID()}.ics`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Feed not found' });
});
/**
* The mutation §18.17 warns about, applied for real: `CalendarService
* .buildTripCalendar` throws the exact shape a missing request context
* throws (`ValidationError.cannotUseGlobalContext()`'s message —
* `@mikro-orm/core/errors.js`). `FeedsService.buildTripIcs`'s bare
* `catch {}` (`feeds.service.ts:130`) swallows it into `return null`,
* same as a wrong token — proving the 404 body ALONE cannot distinguish
* the two, which is why SEAM-FEED-001's positive read-succeeded assertion
* (not a status/body check) is the test that actually catches a real
* regression of this shape.
*/
it('SEAM-FEED-003 (mutation proof): a forced cannotUseGlobalContext throw inside the calendar builder degrades a VALID token to the SAME 404 body as a wrong one — the 404 alone cannot tell them apart', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mutation-Proof Trip' });
const token = randomUUID();
await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token });
const calendar = app.get(CalendarService);
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async () => {
throw new Error(
"ValidationError: Using global context, please provide the EM instance via ContextProvider.forkEntityManager() or wrap your queries via em.transactional(cb) or MikroORM.RequestContext.create(em, cb) (cannotUseGlobalContext)",
);
});
try {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/trip/${token}.ics`);
// Same shape as SEAM-FEED-002's genuinely-wrong-token 404 — this IS the
// hole §18.17 documents. SEAM-FEED-001 is what a real regression here
// actually fails.
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Feed not found' });
} finally {
spy.mockRestore();
}
});
it('SEAM-FEED-004: GET /api/feed/user/:token.ics for a WRONG token is the legacy 404 body', async () => {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/user/${randomUUID()}.ics`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Feed not found' });
});
/**
* M2 (Plan 3d Task 0 review, folded into Task 7's fix wave): SEAM-FEED-001..
* 004 only ever drove `/api/feed/trip/…`; the user feed had no valid-token
* case at all. `FeedsService.buildUserIcs` catches EVERY per-trip failure
* into `// skip failed trips` (`feeds.service.ts:151`) and still answers
* 200 with an EMPTY calendar — so a `cannotUseGlobalContext` regression on
* this route is invisible to a status/body check the same way SEAM-FEED-003
* showed for the trip feed, but WORSE: the trip feed at least degrades to a
* 404, while this route stays 200 either way. The assertion that actually
* catches it is SEAM-FEED-001's own shape — a genuine repository read
* happened, against the real request-scoped `MikroORM`, AND the sentinel
* event the stub returns is actually IN the body — never a status check
* alone, which cannot tell "the read happened" from "every trip's read was
* silently swallowed".
*/
it('SEAM-FEED-005: GET /api/feed/user/:token.ics for a VALID token performs a real repository read and the resulting event reaches the body, inside the request, against the SAME MikroORM app.get(MikroORM) does', async () => {
const orm = app.get(MikroORM);
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'User-Feed Context-Proof Trip' });
const token = randomUUID();
await updateRows(app.get(MikroORM), Users, { id: user.id }, { feed_token: token });
let repoRead: unknown;
let caught: unknown;
const calendar = app.get(CalendarService);
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async (tripId: string | number) => {
try {
repoRead = await orm.em.getRepository(Trips).findOne({ id: Number(tripId) });
} catch (e) {
caught = e;
}
return { calName: 'Stub Calendar', filename: 'stub.ics', timezones: new Map(), events: ['BEGIN:VEVENT\r\nSUMMARY:Sentinel Event\r\nEND:VEVENT\r\n'] };
});
try {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/user/${token}.ics`);
expect(caught).toBeUndefined();
expect(repoRead).toBeTruthy();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
// NOT a status-only check: this is what distinguishes a genuine read
// from the route's own per-trip swallow, which would return the SAME
// 200 with an EMPTY calendar (no VEVENT at all) if the read failed
// with a request-context error.
expect(res.text).toContain('BEGIN:VEVENT');
expect(res.text).toContain('Sentinel Event');
} finally {
spy.mockRestore();
}
});
});