mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
420 lines
19 KiB
TypeScript
420 lines
19 KiB
TypeScript
/**
|
||
* Unit tests for FeedsService — FEED-SVC-001 through FEED-SVC-022. The domain had
|
||
* no unit suite before the SQL statements moved into `TripsRepository`/
|
||
* `UsersRepository` (Plan 3d Task 5); everything rested on
|
||
* tests/e2e/feeds.e2e.test.ts, which drives the HTTP surface and therefore only
|
||
* ever merges a single trip's calendar. These cases pin what the e2e cannot reach
|
||
* — the merge rules buildUserIcs applies across several trips (TZID dedupe, a
|
||
* failing trip being skipped, header vs body folding), the second-call branches
|
||
* of the token lifecycle, and the R4 hole (`generateTripToken` returning a URL
|
||
* for an unstored token past the guard).
|
||
*
|
||
* `TripsRepository`/`UsersRepository` are real, against a real migrated-and-
|
||
* seeded in-memory SQLite DB (`createSnapshotTestDb()`, the `Trips.repository
|
||
* .test.ts` precedent) — `tripId`/`userId` are real `number`s now (the route
|
||
* guard resolves and validates the trip id before `FeedsService` ever runs; see
|
||
* `feeds.controller.ts`'s `@Trip()` usage), so there is no raw-string seam left
|
||
* to stub around.
|
||
*
|
||
* buildUserIcs/buildTripIcs consume CalendarService.buildTripCalendar's PARTS
|
||
* instead of scanning a finished ICS document back apart, so the calendar is a
|
||
* stub here and the parts are the test's own.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
|
||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||
import { Users } from '../../../src/db/entities/Users.entity';
|
||
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||
import { FeedsService } from '../../../src/nest/feeds/feeds.service';
|
||
import { FeedsModule } from '../../../src/nest/feeds/feeds.module';
|
||
import {
|
||
FeedsPublicController,
|
||
TripFeedTokenController,
|
||
UserFeedTokenController,
|
||
} from '../../../src/nest/feeds/feeds.controller';
|
||
import type { CalendarService, TripCalendar } from '../../../src/nest/calendar/calendar.service';
|
||
import { expectRegisteredProvider, expectRegisteredController } from '../../helpers/module-providers';
|
||
|
||
const BASE = 'https://trek.example.test';
|
||
|
||
const testDb = createSnapshotTestDb();
|
||
let t: TestOrm;
|
||
let tripsRepo: TripsRepository;
|
||
let usersRepo: UsersRepository;
|
||
let svc: FeedsService;
|
||
|
||
const buildTripCalendar = vi.fn();
|
||
|
||
// ── Calendar parts the stub hands back ────────────────────────────────────────
|
||
|
||
const vevent = (summary: string) =>
|
||
`BEGIN:VEVENT\r\nUID:trek-trip-${summary}@trek\r\nDTSTAMP:20260101T000000Z\r\n` +
|
||
`DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:${summary}\r\nEND:VEVENT\r\n`;
|
||
|
||
const vtimezone = (tzid: string, tzname = tzid) =>
|
||
`BEGIN:VTIMEZONE\r\nTZID:${tzid}\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n` +
|
||
`TZOFFSETFROM:+0900\r\nTZOFFSETTO:+0900\r\nTZNAME:${tzname}\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n`;
|
||
|
||
const calendarParts = (overrides: Partial<TripCalendar> = {}): TripCalendar => ({
|
||
calName: 'Sample',
|
||
filename: 'sample.ics',
|
||
timezones: new Map<string, string>(),
|
||
events: [vevent('Sample')],
|
||
...overrides,
|
||
});
|
||
|
||
beforeAll(async () => {
|
||
t = await createTestOrm(testDb);
|
||
tripsRepo = t.repo(Trips);
|
||
usersRepo = t.repo(Users);
|
||
svc = new FeedsService(tripsRepo, usersRepo, { buildTripCalendar } as unknown as CalendarService);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
t.clear();
|
||
buildTripCalendar.mockReset();
|
||
buildTripCalendar.mockImplementation(() => calendarParts());
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await t.close();
|
||
testDb.close();
|
||
});
|
||
|
||
function seedTrip(token?: string) {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
if (token) testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
|
||
return { user, trip };
|
||
}
|
||
|
||
function seedUserWithToken(token: string, overrides: Partial<{ username: string }> = {}) {
|
||
const { user } = createUser(testDb, overrides);
|
||
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, user.id);
|
||
return user;
|
||
}
|
||
|
||
// ── Trip feed token ───────────────────────────────────────────────────────────
|
||
|
||
describe('trip feed token lifecycle', () => {
|
||
it('FEED-SVC-001: reports no URL while the trip has no token', async () => {
|
||
const { user, trip } = seedTrip();
|
||
|
||
expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({ feed_url: null });
|
||
});
|
||
|
||
it('FEED-SVC-002: reports the absolute feed URL once a token exists', async () => {
|
||
const { user, trip } = seedTrip('tok-trip');
|
||
|
||
expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({
|
||
feed_url: `${BASE}/api/feed/trip/tok-trip.ics`,
|
||
});
|
||
});
|
||
|
||
it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', async () => {
|
||
// APP_URL is user-supplied config; pasted with a trailing slash it would
|
||
// otherwise produce https://host//api/feed/... which some clients reject.
|
||
const { user, trip } = seedTrip('tok-trip');
|
||
|
||
expect((await svc.getTripToken(trip.id, user.id, `${BASE}/`)).feed_url).toBe(
|
||
`${BASE}/api/feed/trip/tok-trip.ics`,
|
||
);
|
||
});
|
||
|
||
it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', async () => {
|
||
// The token is the credential for the public feed, so leaking it through the
|
||
// authenticated GET would hand a stranger the whole trip.
|
||
const { trip } = seedTrip('tok-trip');
|
||
const { user: outsider } = createUser(testDb);
|
||
|
||
expect(await svc.getTripToken(trip.id, outsider.id, BASE)).toEqual({ feed_url: null });
|
||
});
|
||
|
||
// Membership is what the service checks, and that stays true: whether the
|
||
// caller may manage the credential at all is decided one layer up, by
|
||
// TripAccessGuard + @RequirePermission('share_manage') on the controller.
|
||
it('FEED-SVC-005: a trip shared with the user as a member resolves too', async () => {
|
||
const { trip } = seedTrip('tok-trip');
|
||
const { user: member } = createUser(testDb);
|
||
addTripMember(testDb, trip.id, member.id);
|
||
|
||
expect((await svc.getTripToken(trip.id, member.id, BASE)).feed_url).toBe(
|
||
`${BASE}/api/feed/trip/tok-trip.ics`,
|
||
);
|
||
});
|
||
|
||
it('FEED-SVC-006: generate mints a token once and stays idempotent', async () => {
|
||
// Enabling twice must not invalidate a URL the user already handed to their
|
||
// calendar client — that is what rotate is for.
|
||
const { user, trip } = seedTrip();
|
||
|
||
const first = await svc.generateTripToken(trip.id, user.id, BASE);
|
||
const second = await svc.generateTripToken(trip.id, user.id, BASE);
|
||
|
||
expect(first.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/trip/[0-9a-f-]+\\.ics$`));
|
||
expect(second.feed_url).toBe(first.feed_url);
|
||
});
|
||
|
||
it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', async () => {
|
||
const { user, trip } = seedTrip();
|
||
const before = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url;
|
||
const oldToken = before.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||
|
||
const after = (await svc.rotateTripToken(trip.id, user.id, BASE)).feed_url;
|
||
|
||
expect(after).not.toBe(before);
|
||
expect(await svc.buildTripIcs(oldToken)).toBeNull();
|
||
});
|
||
|
||
it('FEED-SVC-008: disable clears the column so the public URL dies', async () => {
|
||
const { user, trip } = seedTrip();
|
||
const url = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url;
|
||
const token = url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||
|
||
await svc.disableTripToken(trip.id, user.id);
|
||
|
||
expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({ feed_url: null });
|
||
expect(await svc.buildTripIcs(token)).toBeNull();
|
||
});
|
||
|
||
it('FEED-SVC-008b: the writes refuse a trip the acting user cannot reach', async () => {
|
||
// The route guard is what enforces share_manage; this is the second lock, so
|
||
// a caller reaching the service another way cannot mint or clear a token on
|
||
// a trip id it merely guessed.
|
||
const { user, trip } = seedTrip();
|
||
const { user: outsider } = createUser(testDb);
|
||
const mine = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url;
|
||
const myToken = mine.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||
|
||
await svc.rotateTripToken(trip.id, outsider.id, BASE);
|
||
expect((await svc.getTripToken(trip.id, user.id, BASE)).feed_url).toBe(mine);
|
||
|
||
await svc.disableTripToken(trip.id, outsider.id);
|
||
expect(await svc.buildTripIcs(myToken)).not.toBeNull();
|
||
});
|
||
|
||
// R4 (inventory §18.7, plan ruling R4 — mirrored, not fixed): FD1 finds no
|
||
// row for a trip the acting user cannot reach, a fresh token is minted
|
||
// anyway, and FD2's write (`setFeedTokenIfReachable`) affects 0 rows — but
|
||
// `generateTripToken` still hands back a URL for that never-stored token.
|
||
// Named for the hole, per the task brief; see the task report for the
|
||
// one-line fix proposal (compare the affected count and 404/refuse on 0).
|
||
it('R4 HOLE — generateTripToken returns a feed_url for a token it never stored, for a trip the caller cannot reach', async () => {
|
||
const { trip } = seedTrip(); // no token yet
|
||
const { user: stranger } = createUser(testDb);
|
||
|
||
const result = await svc.generateTripToken(trip.id, stranger.id, BASE);
|
||
|
||
// A URL came back...
|
||
expect(result.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/trip/[0-9a-f-]+\\.ics$`));
|
||
// ...but the column was never written (setFeedTokenIfReachable affected 0
|
||
// rows: the stranger fails REACHABLE), so the URL 404s for anyone who tries it.
|
||
const mintedToken = result.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||
expect((testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(trip.id) as { feed_token: string | null }).feed_token).toBeNull();
|
||
expect(await svc.buildTripIcs(mintedToken)).toBeNull();
|
||
// Proven directly at the repository too — the affected count IS the 0-row signal.
|
||
expect(await tripsRepo.setFeedTokenIfReachable(trip.id, stranger.id, mintedToken)).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ── User (all-trips) feed token ───────────────────────────────────────────────
|
||
|
||
describe('user feed token lifecycle', () => {
|
||
it('FEED-SVC-009: reports null before and the absolute URL after generation', async () => {
|
||
const { user } = createUser(testDb);
|
||
|
||
expect(await svc.getUserToken(user.id, BASE)).toEqual({ feed_url: null });
|
||
|
||
const generated = (await svc.generateUserToken(user.id, BASE)).feed_url;
|
||
|
||
expect(generated).toMatch(new RegExp(`^${BASE}/api/feed/user/[0-9a-f-]+\\.ics$`));
|
||
expect((await svc.getUserToken(user.id, BASE)).feed_url).toBe(generated);
|
||
});
|
||
|
||
it('FEED-SVC-010: generate is idempotent — the existing URL is returned unchanged', async () => {
|
||
const { user } = createUser(testDb);
|
||
|
||
const first = await svc.generateUserToken(user.id, BASE);
|
||
const second = await svc.generateUserToken(user.id, BASE);
|
||
|
||
expect(second.feed_url).toBe(first.feed_url);
|
||
});
|
||
|
||
it('FEED-SVC-011: rotate issues a fresh token and the previous URL stops resolving', async () => {
|
||
const { user } = createUser(testDb);
|
||
const before = (await svc.generateUserToken(user.id, BASE)).feed_url;
|
||
const oldToken = before.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
||
|
||
const after = (await svc.rotateUserToken(user.id, BASE)).feed_url;
|
||
|
||
expect(after).not.toBe(before);
|
||
expect(await svc.buildUserIcs(oldToken)).toBeNull();
|
||
});
|
||
|
||
it('FEED-SVC-012: disable clears the column so the public URL dies', async () => {
|
||
const { user } = createUser(testDb);
|
||
const url = (await svc.generateUserToken(user.id, BASE)).feed_url;
|
||
const token = url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
||
|
||
await svc.disableUserToken(user.id);
|
||
|
||
expect(await svc.getUserToken(user.id, BASE)).toEqual({ feed_url: null });
|
||
expect(await svc.buildUserIcs(token)).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── buildTripIcs ──────────────────────────────────────────────────────────────
|
||
|
||
describe('buildTripIcs', () => {
|
||
it('FEED-SVC-013: an unknown token yields null without asking the calendar', async () => {
|
||
seedTrip('tok-trip');
|
||
|
||
expect(await svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||
expect(buildTripCalendar).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FEED-SVC-014: a calendar that throws yields null instead of propagating', async () => {
|
||
// The public feed is unauthenticated: a trip the calendar cannot render (a row
|
||
// deleted mid-request, unparseable data) has to come back as a 404, not a 500
|
||
// that a subscribing client retries hourly forever.
|
||
seedTrip('tok-trip');
|
||
buildTripCalendar.mockImplementation(() => {
|
||
throw new Error('calendar exploded');
|
||
});
|
||
|
||
expect(await svc.buildTripIcs('tok-trip')).toBeNull();
|
||
});
|
||
|
||
it('FEED-SVC-015: the refresh hints sit in the preamble, ahead of X-WR-CALNAME and every component', async () => {
|
||
// REFRESH-INTERVAL/X-PUBLISHED-TTL are calendar properties: RFC 5545 puts them
|
||
// before the first component, and clients that scan only the preamble stop
|
||
// re-fetching if they slip behind a VTIMEZONE. The document is concatenated from
|
||
// the calendar's parts now, so the order is an assembly decision, not a given.
|
||
const { trip } = seedTrip('tok-trip');
|
||
buildTripCalendar.mockImplementation(() =>
|
||
calendarParts({
|
||
calName: 'Golden Trip',
|
||
filename: 'golden-trip.ics',
|
||
timezones: new Map([['Asia/Tokyo', vtimezone('Asia/Tokyo')]]),
|
||
}),
|
||
);
|
||
|
||
const result = await svc.buildTripIcs('tok-trip');
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result!.filename).toBe('golden-trip.ics');
|
||
expect(result!.ics).toContain(
|
||
'METHOD:PUBLISH\r\nREFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n' +
|
||
'X-WR-CALNAME:Golden Trip\r\nBEGIN:VTIMEZONE\r\n',
|
||
);
|
||
expect(result!.ics.startsWith('BEGIN:VCALENDAR\r\nVERSION:2.0\r\n')).toBe(true);
|
||
expect(result!.ics.endsWith('END:VEVENT\r\nEND:VCALENDAR\r\n')).toBe(true);
|
||
expect(buildTripCalendar).toHaveBeenCalledWith(trip.id);
|
||
});
|
||
});
|
||
|
||
// ── buildUserIcs ──────────────────────────────────────────────────────────────
|
||
|
||
describe('buildUserIcs', () => {
|
||
it('FEED-SVC-016: an unknown token yields null without asking the calendar', async () => {
|
||
seedUserWithToken('tok-user');
|
||
|
||
expect(await svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||
expect(buildTripCalendar).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', async () => {
|
||
// One unrenderable trip must not take the whole all-trips subscription down —
|
||
// the user would silently lose every calendar entry because of a single bad row.
|
||
const user = seedUserWithToken('tok-user');
|
||
const good = createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||
const broken = createTrip(testDb, user.id, { start_date: '2026-02-01' });
|
||
const alsoGood = createTrip(testDb, user.id, { start_date: '2026-03-01' });
|
||
buildTripCalendar.mockImplementation((id: number) => {
|
||
if (id === broken.id) throw new Error('calendar exploded');
|
||
return calendarParts({ events: [vevent(`Trip${id}`)] });
|
||
});
|
||
|
||
const result = await svc.buildUserIcs('tok-user');
|
||
|
||
expect(buildTripCalendar).toHaveBeenCalledTimes(3);
|
||
expect(result!.ics).toContain(`SUMMARY:Trip${good.id}\r\n`);
|
||
expect(result!.ics).toContain(`SUMMARY:Trip${alsoGood.id}\r\n`);
|
||
expect(result!.ics).not.toContain(`SUMMARY:Trip${broken.id}\r\n`);
|
||
expect(result!.ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||
});
|
||
|
||
it('FEED-SVC-018: a TZID shared by two trips is defined exactly once, ahead of every VEVENT', async () => {
|
||
// Two VTIMEZONE blocks with the same TZID make the document invalid and clients
|
||
// drop the events referencing it; a block emitted after the VEVENT that uses it
|
||
// does not resolve either (#1453). First definition wins.
|
||
const user = seedUserWithToken('tok-user');
|
||
const first = createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||
createTrip(testDb, user.id, { start_date: '2026-02-01' });
|
||
buildTripCalendar.mockImplementation((id: number) => ({
|
||
calName: `Trip ${id}`,
|
||
filename: `trip-${id}.ics`,
|
||
timezones: new Map([
|
||
['Asia/Tokyo', vtimezone('Asia/Tokyo', id === first.id ? 'Asia/Tokyo' : 'Second/Definition')],
|
||
]),
|
||
events: [vevent(`Trip${id}`)],
|
||
}));
|
||
|
||
const { ics } = (await svc.buildUserIcs('tok-user'))!;
|
||
|
||
expect(ics.split('BEGIN:VTIMEZONE').length - 1).toBe(1);
|
||
expect(ics).toContain('TZNAME:Asia/Tokyo\r\n');
|
||
expect(ics).not.toContain('Second/Definition');
|
||
expect(ics.indexOf('BEGIN:VTIMEZONE')).toBeLessThan(ics.indexOf('BEGIN:VEVENT'));
|
||
});
|
||
|
||
it('FEED-SVC-019: the header is never folded, the body always is', async () => {
|
||
// Folding is applied to the body only, on purpose: a long display name would
|
||
// otherwise wrap X-WR-CALNAME across two physical lines, which several clients
|
||
// render as a truncated calendar title. The body still has to fold — RFC 5545
|
||
// caps a content line at 75 octets.
|
||
const username = 'Ferdinand-Bartholomew-'.repeat(5);
|
||
const user = seedUserWithToken('tok-user', { username });
|
||
createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||
const longSummary = 'A'.repeat(120);
|
||
buildTripCalendar.mockImplementation(() => calendarParts({ events: [vevent(longSummary)] }));
|
||
|
||
const { ics, calName } = (await svc.buildUserIcs('tok-user'))!;
|
||
|
||
expect(calName).toBe(`${username} – All Trips`);
|
||
const preamble = ics.slice(0, ics.indexOf('BEGIN:VEVENT'));
|
||
expect(preamble).toContain(`X-WR-CALNAME:${calName}\r\n`);
|
||
expect(preamble.split('\r\n').filter((line) => line.startsWith(' '))).toEqual([]);
|
||
// The body is folded and unfolds back to the original content line.
|
||
expect(ics).toContain('\r\n ');
|
||
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${longSummary}\r\n`);
|
||
});
|
||
|
||
it('FEED-SVC-020: the display name is escaped for the header but returned raw', async () => {
|
||
// An unescaped ; or , ends the property value early, so the calendar shows up
|
||
// under a truncated name. The returned calName feeds the HTTP layer, not ICS,
|
||
// and must stay verbatim.
|
||
const user = seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' });
|
||
createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||
|
||
const { ics, calName } = (await svc.buildUserIcs('tok-user'))!;
|
||
|
||
expect(calName).toBe('Alice; Bob, Co\\Ltd – All Trips');
|
||
expect(ics).toContain('X-WR-CALNAME:Alice\\; Bob\\, Co\\\\Ltd – All Trips\r\n');
|
||
});
|
||
});
|
||
|
||
describe('FeedsService wiring', () => {
|
||
it('FEED-SVC-021: the module registers the service and all three controllers', async () => {
|
||
expectRegisteredProvider(FeedsModule, FeedsService);
|
||
expectRegisteredController(FeedsModule, FeedsPublicController);
|
||
expectRegisteredController(FeedsModule, TripFeedTokenController);
|
||
expectRegisteredController(FeedsModule, UserFeedTokenController);
|
||
});
|
||
});
|