mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
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.
216 lines
11 KiB
TypeScript
216 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 { buildApp } from '../../src/bootstrap';
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
import { CalendarService } from '../../src/nest/calendar/calendar.service';
|
|
import { createTrip, createUser } from '../helpers/factories';
|
|
import { updateRows } from '../helpers/factories/rows';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import type { Application } from 'express';
|
|
import { randomUUID } from 'node:crypto';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|