Files
TREK/server/tests/e2e/school-holidays.e2e.test.ts
Maurice 724c88c201 style: format server and shared with prettier
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.
2026-10-10 21:23:37 +02:00

293 lines
14 KiB
TypeScript

import { db } from '../../src/db/database';
import { SchoolHolidayCountries } from '../../src/db/entities/SchoolHolidayCountries.entity';
import { SchoolHolidayPeriods } from '../../src/db/entities/SchoolHolidayPeriods.entity';
import { SchoolHolidayRegions } from '../../src/db/entities/SchoolHolidayRegions.entity';
import { VacayHolidayCalendars } from '../../src/db/entities/VacayHolidayCalendars.entity';
import { VacayPlans } from '../../src/db/entities/VacayPlans.entity';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { createTestRegistry } from '../../src/nest-mcp';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { validateBodyContracts } from '../../src/nest/common/validate-body-contracts';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { withRequestContext } from '../../src/nest/database/request-context';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { SchoolHolidaysModule } from '../../src/nest/school-holidays/school-holidays.module';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { deleteRows, findRows, insertRow, insertRowIgnoringConflict } from '../helpers/factories/rows';
import { makeAdmin, makeUser } from '../helpers/factories/users';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../helpers/db-mock');
const db = createSnapshotTestDb();
return { db, closeDb: () => {} };
});
const base = '/api/school-holiday-catalog';
const winter = { name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' };
let app: INestApplication;
let service: SchoolHolidaysService;
let orm: MikroORM;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
SchoolHolidaysModule,
],
}).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ZodValidationPipe());
app.useGlobalFilters(new TrekExceptionFilter());
await app.init();
service = app.get(SchoolHolidaysService);
orm = app.get(MikroORM);
await makeAdmin(orm, { id: 1, username: 'admin', email: 'admin@test.local' });
await makeUser(orm, { id: 2, username: 'member', email: 'member@test.local' });
});
afterAll(async () => {
await app.close();
db.close();
});
beforeEach(async () => {
await deleteRows(orm, VacayHolidayCalendars);
await deleteRows(orm, SchoolHolidayPeriods);
await deleteRows(orm, SchoolHolidayRegions);
await deleteRows(orm, SchoolHolidayCountries);
});
async function seed() {
await service.createCountry({ code: 'US', name: 'USA' });
return await service.createRegion('US', { name: 'Seattle schools', revision: 0, holidays: [winter] });
}
// Plan 3f Task 2: `SchoolHolidaysService` now reads/writes through
// repositories, which fail closed outside a MikroORM request context
// (`TrekRepository.validateRequestContext`, program rule — D6). An HTTP call
// through `request(app.getHttpServer())...` gets one for free (`@mikro-orm
// /nestjs`'s per-request middleware); a direct `service.xxx(...)`/
// `mcp.xxx(...)` call from this test file — bypassing both the HTTP
// transport and the MCP registry's own `onInvoke` wrapper — does not, so
// every `it(...)` below that touches `service`/`mcp` directly (every one
// except the pure-401/403 auth-gate test, which never reaches a repository)
// wraps its body in `withRequestContext(orm, ...)`, the same helper an MCP
// tool call, a cron tick or a plugin RPC dispatch uses in production
// (`request-context.ts`'s own docstring; `doc-sync.e2e.test.ts`'s
// precedent). This is a test-harness adaptation, not a behavior change —
// nothing about `SchoolHolidaysService`'s public contract moved.
describe('global manual school holidays', () => {
it('requires authentication and admin rights for every write', async () => {
await request(app.getHttpServer()).get(base).expect(401);
await request(app.getHttpServer()).post(`${base}/countries`).send({ code: 'US', name: 'USA' }).expect(401);
for (const [method, path, body] of [
['post', '/countries', { code: 'US', name: 'USA' }],
['post', '/countries/US/regions', { name: 'District', revision: 0, holidays: [] }],
['put', '/regions/1', { name: 'District', revision: 1, holidays: [] }],
['delete', '/countries/US', {}],
['delete', '/regions/1?revision=1', {}],
] as const) {
await request(app.getHttpServer())
[method](`${base}${path}`)
.set('Cookie', sessionCookie(2))
.send(body)
.expect(403);
}
expect(() => validateBodyContracts(app)).not.toThrow();
});
it('lets any member discover regions and read dates without external requests', () =>
withRequestContext(orm, async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const country = await request(app.getHttpServer())
.post(`${base}/countries`)
.set('Cookie', sessionCookie(1))
.send({ code: 'US', name: 'USA' })
.expect(201);
expect(country.body.code).toBe('US');
const created = await request(app.getHttpServer())
.post(`${base}/countries/US/regions`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Seattle', revision: 0, holidays: [winter] })
.expect(201);
const catalog = await request(app.getHttpServer()).get(base).set('Cookie', sessionCookie(2)).expect(200);
expect(catalog.body.regions[0].code).toBe(`US-MANUAL-${created.body.id}`);
for (const year of [2026, 2027]) {
const holidays = await request(app.getHttpServer())
.get(`${base}/regions/${created.body.id}/holidays/${year}`)
.set('Cookie', sessionCookie(2))
.expect(200);
expect(holidays.body).toEqual([winter]);
}
expect(await service.holidays(created.body.id, '2025')).toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
}));
it('validates dates and body shape before writing', () =>
withRequestContext(orm, async () => {
await service.createCountry({ code: 'US', name: 'USA' });
for (const holidays of [
[{ ...winter, endDate: '2026-01-01' }],
[{ ...winter, startDate: '2026-02-30' }],
[{ ...winter, name: ' ' }],
]) {
await request(app.getHttpServer())
.post(`${base}/countries/US/regions`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Seattle', revision: 0, holidays })
.expect(400);
}
expect((await service.catalog()).regions).toEqual([]);
}));
it('preserves ids on rename and rejects stale saves atomically', () =>
withRequestContext(orm, async () => {
const region = await seed();
const updated = await request(app.getHttpServer())
.put(`${base}/regions/${region.id}`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Renamed', revision: 1, holidays: [] })
.expect(200);
expect(updated.body).toMatchObject({ code: region.code, revision: 2, holidays: [] });
await request(app.getHttpServer())
.put(`${base}/regions/${region.id}`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Stale', revision: 1, holidays: [winter] })
.expect(409);
expect((await service.region(region.id)).name).toBe('Renamed');
expect((await service.region(region.id)).holidays).toEqual([]);
await request(app.getHttpServer())
.delete(`${base}/regions/${region.id}?revision=1`)
.set('Cookie', sessionCookie(1))
.expect(409);
}));
it('rejects duplicates, missing countries and nonzero initial revisions', () =>
withRequestContext(orm, async () => {
await seed();
await expect(service.createCountry({ code: 'US', name: 'America' })).rejects.toThrow('already exists');
await expect(service.createRegion('US', { name: 'SEATTLE SCHOOLS', revision: 0, holidays: [] })).rejects.toThrow(
'already exists',
);
await expect(service.createRegion('CA', { name: 'School', revision: 0, holidays: [] })).rejects.toThrow(
'not found',
);
await expect(service.createRegion('US', { name: 'School', revision: 1, holidays: [] })).rejects.toThrow(
'revision zero',
);
const other = await service.createRegion('US', { name: 'Other', revision: 0, holidays: [] });
await expect(
service.updateRegion(other.id, { name: 'Seattle schools', revision: 1, holidays: [] }),
).rejects.toThrow('already exists');
}));
it('protects used regions and deletes unused regions with their periods', () =>
withRequestContext(orm, async () => {
const region = await seed();
await insertRowIgnoringConflict(orm, VacayPlans, { id: 1, owner: 1 });
await insertRow(orm, VacayHolidayCalendars, { plan: 1, type: 'school_holiday', region: region.code });
await request(app.getHttpServer())
.delete(`${base}/regions/${region.id}?revision=1`)
.set('Cookie', sessionCookie(1))
.expect(409);
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(409);
await deleteRows(orm, VacayHolidayCalendars);
await request(app.getHttpServer())
.delete(`${base}/regions/${region.id}?revision=1`)
.set('Cookie', sessionCookie(1))
.expect(200);
expect(await findRows(orm, SchoolHolidayPeriods)).toEqual([]);
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(200);
expect(await service.catalog()).toEqual({ countries: [], regions: [] });
}));
it('reports missing regions and malformed year and id parameters', () =>
withRequestContext(orm, async () => {
const region = await seed();
const get = async (path: string) =>
(await request(app.getHttpServer()).get(path).set('Cookie', sessionCookie(2))).status;
expect(await get(`${base}/regions/${region.id}`)).toBe(200);
expect(await get(`${base}/regions/999999`)).toBe(404);
expect(await get(`${base}/regions/invalid`)).toBe(400);
expect(await get(`${base}/regions/${region.id}/holidays/xx`)).toBe(400);
const missingCountry = await request(app.getHttpServer())
.delete(`${base}/countries/CA`)
.set('Cookie', sessionCookie(1));
expect(missingCountry.status).toBe(404);
}));
it('exposes the same manual catalog and periods through MCP', () =>
withRequestContext(orm, async () => {
const region = await seed();
const mcp = app.get(SchoolHolidaysMcp);
expect(() =>
createTestRegistry([mcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }),
).not.toThrow();
expect(JSON.stringify(await mcp.catalog())).toContain(region.code);
expect(JSON.stringify(await mcp.forYear({ regionId: region.id, year: 2027 }))).toContain('Winter break');
}));
it('denies every MCP mutation to normal users without changing the catalog', () =>
withRequestContext(orm, async () => {
const region = await seed();
const mcp = app.get(SchoolHolidaysMcp);
const member = { userId: 2, scopes: null, isStaticToken: false };
const before = await service.region(region.id);
const writes = [
() => mcp.createCountry({ code: 'CA', name: 'Canada' }, member),
() => mcp.createRegion({ country: 'US', name: 'Other', revision: 0, holidays: [] }, member),
() => mcp.updateRegion({ regionId: region.id, name: 'Changed', revision: 1, holidays: [] }, member),
() => mcp.deleteRegion({ regionId: region.id, revision: 1 }, member),
() => mcp.deleteCountry({ code: 'US' }, member),
];
for (const write of writes) expect(await write()).toHaveProperty('isError', true);
expect(await service.region(region.id)).toEqual(before);
expect((await service.catalog()).countries).toHaveLength(1);
expect((await service.catalog()).regions).toHaveLength(1);
}));
it('supports the complete admin MCP workflow with validation and revision protection', () =>
withRequestContext(orm, async () => {
const mcp = app.get(SchoolHolidaysMcp);
const admin = { userId: 1, scopes: null, isStaticToken: false };
expect(await mcp.createCountry({ code: 'US', name: 'USA' }, admin)).not.toHaveProperty('isError', true);
expect(
await mcp.createRegion({ country: 'US', name: 'Seattle', revision: 0, holidays: [winter] }, admin),
).not.toHaveProperty('isError', true);
const region = (await service.catalog()).regions[0];
expect(JSON.stringify(await mcp.region({ regionId: region.id }))).toContain('Winter break');
const edit = {
regionId: region.id,
name: 'Seattle Public Schools',
revision: 1,
holidays: [{ ...winter, name: 'Winter holiday' }],
};
expect(await mcp.updateRegion(edit, admin)).not.toHaveProperty('isError', true);
expect(await mcp.updateRegion(edit, admin)).toHaveProperty('isError', true);
expect(
await mcp.updateRegion({ ...edit, revision: 2, holidays: [{ ...winter, endDate: '2025-01-01' }] }, admin),
).toHaveProperty('isError', true);
expect(await service.region(region.id)).toMatchObject({
revision: 2,
holidays: [{ ...winter, name: 'Winter holiday' }],
});
expect(await mcp.deleteCountry({ code: 'US' }, admin)).toHaveProperty('isError', true);
expect(await mcp.deleteRegion({ regionId: region.id, revision: 1 }, admin)).toHaveProperty('isError', true);
expect(await mcp.deleteRegion({ regionId: region.id, revision: 2 }, admin)).not.toHaveProperty('isError', true);
expect(await mcp.deleteCountry({ code: 'US' }, admin)).not.toHaveProperty('isError', true);
expect(await service.catalog()).toEqual({ countries: [], regions: [] });
}));
});