Files
TREK/server/tests/unit/nest/feeds.service.test.ts
T

413 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for FeedsService — FEED-SVC-001 through FEED-SVC-021. The domain had
* no unit suite at all: 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) and the second-call branches of the token lifecycle.
*
* buildUserIcs/buildTripIcs consume CalendarService.buildTripCalendar's PARTS now
* instead of scanning a finished ICS document back apart, so the calendar is a stub
* here and the parts are the test's own. The token lookups are real SQL against an
* in-memory SQLite DB (same harness as calendar.service.test.ts).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: number | string, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: number | string, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
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 buildTripCalendar = vi.fn();
const svc = new FeedsService(
new DatabaseService(testDb),
{ buildTripCalendar } as unknown as CalendarService,
);
// ── 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(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
buildTripCalendar.mockReset();
buildTripCalendar.mockImplementation(() => calendarParts());
});
afterAll(() => {
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, tripId: String(trip.id) };
}
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, tripId } = seedTrip();
expect(await svc.getTripToken(tripId, user.id, BASE)).toEqual({ feed_url: null });
});
it('FEED-SVC-002: reports the absolute feed URL once a token exists', async () => {
const { user, tripId } = seedTrip('tok-trip');
expect(await svc.getTripToken(tripId, 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, tripId } = seedTrip('tok-trip');
expect((await svc.getTripToken(tripId, 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 { tripId } = seedTrip('tok-trip');
const { user: outsider } = createUser(testDb);
expect(await svc.getTripToken(tripId, 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, tripId } = seedTrip('tok-trip');
const { user: member } = createUser(testDb);
addTripMember(testDb, trip.id, member.id);
expect((await svc.getTripToken(tripId, 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, tripId } = seedTrip();
const first = await svc.generateTripToken(tripId, user.id, BASE);
const second = await svc.generateTripToken(tripId, 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, tripId } = seedTrip();
const before = (await svc.generateTripToken(tripId, user.id, BASE)).feed_url;
const oldToken = before.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
const after = (await svc.rotateTripToken(tripId, 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, tripId } = seedTrip();
const url = (await svc.generateTripToken(tripId, user.id, BASE)).feed_url;
const token = url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
await svc.disableTripToken(tripId, user.id);
expect(await svc.getTripToken(tripId, 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, tripId } = seedTrip();
const { user: outsider } = createUser(testDb);
const mine = (await svc.generateTripToken(tripId, user.id, BASE)).feed_url;
const myToken = mine.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
await svc.rotateTripToken(tripId, outsider.id, BASE);
expect((await svc.getTripToken(tripId, user.id, BASE)).feed_url).toBe(mine);
await svc.disableTripToken(tripId, outsider.id);
expect(await svc.buildTripIcs(myToken)).not.toBeNull();
});
});
// ── 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);
});
});