feat(vacay): add manual school holiday calendars

This commit is contained in:
Maurice
2026-09-12 11:37:57 +02:00
committed by Maurice
parent 4f8605b529
commit 41b18f970c
55 changed files with 1562 additions and 34 deletions
+20
View File
@@ -4534,6 +4534,26 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER');
}
},
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS school_holiday_countries (
code TEXT PRIMARY KEY, name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS school_holiday_regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
country TEXT NOT NULL REFERENCES school_holiday_countries(code),
name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(country, name)
);
CREATE TABLE IF NOT EXISTS school_holiday_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE,
name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
CHECK (end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id);
`);
},
];
if (currentVersion < migrations.length) {
+2 -1
View File
@@ -27,6 +27,7 @@ import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
import { NotificationsModule } from './notifications/notifications.module';
import { AtlasModule } from './atlas/atlas.module';
import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
import { VacayModule } from './vacay/vacay.module';
import { PackingModule } from './packing/packing.module';
import { BudgetModule } from './budget/budget.module';
@@ -79,7 +80,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -0,0 +1,47 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, UseGuards, BadRequestException } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema } from '@trek/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { SchoolHolidaysService } from './school-holidays.service';
export class SchoolHolidayCountryDto extends createZodDto(schoolHolidayCountryRequestSchema) {}
export class SchoolHolidayRegionDto extends createZodDto(schoolHolidayRegionRequestSchema) {}
@Controller('api/school-holiday-catalog')
@UseGuards(JwtAuthGuard)
export class SchoolHolidaysController {
constructor(private readonly holidays: SchoolHolidaysService) {}
@Get()
catalog() { return this.holidays.catalog(); }
@Get('regions/:id')
region(@Param('id', ParseIntPipe) id: number) { return this.holidays.region(id); }
@Get('regions/:id/holidays/:year')
forYear(@Param('id', ParseIntPipe) id: number, @Param('year') year: string) {
if (!/^\d{4}$/.test(year)) throw new BadRequestException('Invalid year');
return this.holidays.holidays(id, year);
}
@Post('countries')
@UseGuards(AdminGuard)
createCountry(@Body() body: SchoolHolidayCountryDto) { return this.holidays.createCountry(body); }
@Delete('countries/:code')
@UseGuards(AdminGuard)
deleteCountry(@Param('code') code: string) { return this.holidays.deleteCountry(code); }
@Post('countries/:code/regions')
@UseGuards(AdminGuard)
createRegion(@Param('code') code: string, @Body() body: SchoolHolidayRegionDto) { return this.holidays.createRegion(code, body); }
@Put('regions/:id')
@UseGuards(AdminGuard)
updateRegion(@Param('id', ParseIntPipe) id: number, @Body() body: SchoolHolidayRegionDto) { return this.holidays.updateRegion(id, body); }
@Delete('regions/:id')
@UseGuards(AdminGuard)
deleteRegion(@Param('id', ParseIntPipe) id: number, @Query('revision', ParseIntPipe) revision: number) { return this.holidays.deleteRegion(id, revision); }
}
@@ -0,0 +1,95 @@
import { HttpException } from '@nestjs/common';
import { z } from 'zod';
import { idSchema, schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared';
import { McpController, Tool, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, errorResult, ok } from '../../nest-mcp';
import { adminRequired } from '../../mcp/tools/_shared';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { SchoolHolidaysService } from './school-holidays.service';
@McpController()
export class SchoolHolidaysMcp {
constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {}
private adminWrite(ctx: McpContext, write: () => unknown) {
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
try { return ok(write()); }
catch (error) {
if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message);
throw error;
}
}
@Tool({
name: 'list_manual_school_holiday_regions',
description: 'List the global manual school holiday catalog. Use regions[].code verbatim as the region with add_holiday_calendar and type school_holiday. These calendars require no external API.',
inputSchema: {}, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
catalog() { return ok(this.holidays.catalog()); }
@Tool({
name: 'get_manual_school_holiday_region',
description: 'Read a manual school region, its current revision and all named holiday periods. Read before updating: updates replace the complete list of periods, and the current revision is required.',
inputSchema: { regionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
region({ regionId }: { regionId: number }) { return ok(this.holidays.region(regionId)); }
@Tool({
name: 'list_manual_school_holidays',
description: 'Read named school breaks for a manual region and year, including breaks spanning a year boundary. Both boundary dates are included.',
inputSchema: { regionId: idSchema, year: z.number().int().min(1000).max(9999) },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'vacay', mode: 'read' },
})
forYear({ regionId, year }: { regionId: number; year: number }) { return ok({ holidays: this.holidays.holidays(regionId, String(year)) }); }
@Tool({
name: 'create_manual_school_holiday_country',
description: 'Admin only. Create a country in the instance-wide manual school holiday catalog with its two-letter uppercase country code and display name. Check the catalog first to avoid duplicates.',
inputSchema: schoolHolidayCountryRequestSchema.shape, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'vacay', mode: 'write' },
})
createCountry(body: SchoolHolidayCountryRequest, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createCountry(schoolHolidayCountryRequestSchema.parse(body)));
}
@Tool({
name: 'create_manual_school_holiday_region',
description: 'Admin only. Create a region or school district in an existing manual country, with named holiday periods. Use revision 0 for creation. Dates are inclusive YYYY-MM-DD. All users can select this region.',
inputSchema: { country: schoolHolidayCountryRequestSchema.shape.code, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'vacay', mode: 'write' },
})
createRegion({ country, ...body }: SchoolHolidayRegionRequest & { country: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createRegion(country, schoolHolidayRegionRequestSchema.parse(body)));
}
@Tool({
name: 'update_manual_school_holiday_region',
description: 'Admin only. Rename a global school region and replace its complete list of holiday periods. First read get_manual_school_holiday_region and preserve periods that should remain. Supply its current revision; stale updates are rejected. Changes affect every calendar using this region.',
inputSchema: { regionId: idSchema, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'vacay', mode: 'write' },
})
updateRegion({ regionId, ...body }: SchoolHolidayRegionRequest & { regionId: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.updateRegion(regionId, schoolHolidayRegionRequestSchema.parse(body)));
}
@Tool({
name: 'delete_manual_school_holiday_region',
description: 'Admin only. Delete a global manual school region and its periods using the current revision. Regions selected by a vacation calendar cannot be deleted.',
inputSchema: { regionId: idSchema, revision: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteRegion(regionId, revision));
}
@Tool({
name: 'delete_manual_school_holiday_country',
description: 'Admin only. Delete an empty country from the global manual school holiday catalog. Remove its unused regions first.',
inputSchema: { code: schoolHolidayCountryRequestSchema.shape.code },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteCountry({ code }: { code: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteCountry(code));
}
}
@@ -0,0 +1,8 @@
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Module } from '@nestjs/common';
import { SchoolHolidaysController } from './school-holidays.controller';
import { SchoolHolidaysService } from './school-holidays.service';
import { SchoolHolidaysMcp } from './school-holidays.mcp';
@Module({ imports: [McpSharedModule], controllers: [SchoolHolidaysController], providers: [SchoolHolidaysService, SchoolHolidaysMcp] })
export class SchoolHolidaysModule {}
@@ -0,0 +1,96 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayPeriod, SchoolHolidayRegion, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
@Injectable()
export class SchoolHolidaysService {
constructor(private readonly db: DatabaseService) {}
catalog(): SchoolHolidayCatalog {
return {
countries: this.db.all<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries ORDER BY name, code'),
regions: this.db.all<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id"),
};
}
country(code: string): SchoolHolidayCountryRequest {
const country = this.db.get<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries WHERE code = ?', code);
if (!country) throw new NotFoundException('Country not found');
return country;
}
createCountry(country: SchoolHolidayCountryRequest) {
const inserted = this.db.run('INSERT OR IGNORE INTO school_holiday_countries (code, name) VALUES (?, ?)', country.code, country.name);
if (!inserted.changes) throw new ConflictException('Country already exists');
return country;
}
deleteCountry(code: string) {
return this.db.transaction(() => {
this.country(code);
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1', code)) {
throw new ConflictException('Remove the regions before deleting this country');
}
this.db.run('DELETE FROM school_holiday_countries WHERE code = ?', code);
return { success: true };
});
}
region(id: number): SchoolHolidayRegionDetail {
const region = this.db.get<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions WHERE id = ?", id);
if (!region) throw new NotFoundException('Region not found');
return { ...region, holidays: this.db.all<SchoolHolidayPeriod>('SELECT name, start_date AS startDate, end_date AS endDate FROM school_holiday_periods WHERE region_id = ? ORDER BY start_date, end_date, name', id) };
}
private checkName(country: string, name: string, id: number) {
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE AND id != ?', country, name, id)) {
throw new ConflictException('A region with this name already exists');
}
}
private writePeriods(id: number, holidays: SchoolHolidayPeriod[]) {
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
const insert = this.db.prepare('INSERT INTO school_holiday_periods (region_id, name, start_date, end_date) VALUES (?, ?, ?, ?)');
for (const holiday of holidays) insert.run(id, holiday.name, holiday.startDate, holiday.endDate);
}
createRegion(country: string, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
this.country(country);
this.checkName(country, body.name, 0);
if (body.revision !== 0) throw new ConflictException('New regions must have revision zero');
const inserted = this.db.run('INSERT INTO school_holiday_regions (country, name) VALUES (?, ?)', country, body.name);
const id = Number(inserted.lastInsertRowid);
this.writePeriods(id, body.holidays);
return this.region(id);
});
}
updateRegion(id: number, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
const region = this.region(id);
this.checkName(region.country, body.name, id);
const updated = this.db.run('UPDATE school_holiday_regions SET name = ?, revision = revision + 1 WHERE id = ? AND revision = ?', body.name, id, body.revision);
if (!updated.changes) throw new ConflictException('This region changed. Reopen it before saving again.');
this.writePeriods(id, body.holidays);
return this.region(id);
});
}
deleteRegion(id: number, revision: number) {
return this.db.transaction(() => {
const region = this.region(id);
if (region.revision !== revision) throw new ConflictException('This region changed. Reload before deleting it.');
if (this.db.get("SELECT id FROM vacay_holiday_calendars WHERE type = 'school_holiday' AND region = ? LIMIT 1", region.code)) {
throw new ConflictException('This region is used by vacation calendars and cannot be deleted');
}
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
this.db.run('DELETE FROM school_holiday_regions WHERE id = ?', id);
return { success: true };
});
}
holidays(id: number, year: string): SchoolHolidayPeriod[] {
return this.region(id).holidays.filter(holiday => holiday.startDate <= `${year}-12-31` && holiday.endDate >= `${year}-01-01`);
}
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import { manualSchoolRegionId } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
@@ -578,6 +579,7 @@ export class VacayService {
// -------------------------------------------------------------------------
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
this.validateManualRegion(region, type);
const result = this.db.run(
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
@@ -595,6 +597,7 @@ export class VacayService {
): VacayHolidayCalendar | null {
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return null;
this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
const { region, label, color, sort_order, type } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
@@ -620,6 +623,14 @@ export class VacayService {
return true;
}
private validateManualRegion(code: string, type: string) {
if (!code.includes('-MANUAL-')) return;
const id = manualSchoolRegionId(code);
if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) {
throw new BadRequestException('Unknown manual school holiday region');
}
}
// -------------------------------------------------------------------------
// User colors
// -------------------------------------------------------------------------
@@ -0,0 +1,178 @@
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
const Database = require('better-sqlite3');
return { db: new Database(':memory:') };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {} }));
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { SchoolHolidaysModule } from '../../src/nest/school-holidays/school-holidays.module';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { validateBodyContracts } from '../../src/nest/common/validate-body-contracts';
import { createTestRegistry } from '../../src/nest-mcp';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
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;
beforeAll(async () => {
db.pragma('foreign_keys = ON');
createTables(db);
runMigrations(db);
db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'admin', 'admin@test.local', '', 'admin'), (2, 'member', 'member@test.local', '', 'user')").run();
const module = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, SchoolHolidaysModule] }).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ZodValidationPipe());
app.useGlobalFilters(new TrekExceptionFilter());
await app.init();
service = app.get(SchoolHolidaysService);
});
afterAll(async () => { await app.close(); db.close(); });
beforeEach(() => {
db.exec('DELETE FROM vacay_holiday_calendars; DELETE FROM school_holiday_periods; DELETE FROM school_holiday_regions; DELETE FROM school_holiday_countries;');
});
function seed() {
service.createCountry({ code: 'US', name: 'USA' });
return service.createRegion('US', { name: 'Seattle schools', revision: 0, holidays: [winter] });
}
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', 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(service.holidays(created.body.id, '2025')).toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it('validates dates and body shape before writing', async () => {
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(service.catalog().regions).toEqual([]);
});
it('preserves ids on rename and rejects stale saves atomically', async () => {
const region = 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(service.region(region.id).name).toBe('Renamed');
expect(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', () => {
seed();
expect(() => service.createCountry({ code: 'US', name: 'America' })).toThrow('already exists');
expect(() => service.createRegion('US', { name: 'SEATTLE SCHOOLS', revision: 0, holidays: [] })).toThrow('already exists');
expect(() => service.createRegion('CA', { name: 'School', revision: 0, holidays: [] })).toThrow('not found');
expect(() => service.createRegion('US', { name: 'School', revision: 1, holidays: [] })).toThrow('revision zero');
const other = service.createRegion('US', { name: 'Other', revision: 0, holidays: [] });
expect(() => service.updateRegion(other.id, { name: 'Seattle schools', revision: 1, holidays: [] })).toThrow('already exists');
});
it('protects used regions and deletes unused regions with their periods', async () => {
const region = seed();
db.prepare('INSERT OR IGNORE INTO vacay_plans (id, owner_id) VALUES (1, 1)').run();
db.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (1, 'school_holiday', ?)").run(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);
db.exec('DELETE FROM vacay_holiday_calendars');
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(200);
expect(db.prepare('SELECT * FROM school_holiday_periods').all()).toEqual([]);
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(200);
expect(service.catalog()).toEqual({ countries: [], regions: [] });
});
it('reports missing regions and malformed year and id parameters', async () => {
const region = seed();
await request(app.getHttpServer()).get(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(2)).expect(200);
await request(app.getHttpServer()).get(`${base}/regions/999999`).set('Cookie', sessionCookie(2)).expect(404);
await request(app.getHttpServer()).get(`${base}/regions/invalid`).set('Cookie', sessionCookie(2)).expect(400);
await request(app.getHttpServer()).get(`${base}/regions/${region.id}/holidays/xx`).set('Cookie', sessionCookie(2)).expect(400);
await request(app.getHttpServer()).delete(`${base}/countries/CA`).set('Cookie', sessionCookie(1)).expect(404);
});
it('exposes the same manual catalog and periods through MCP', () => {
const region = seed();
const mcp = app.get(SchoolHolidaysMcp);
expect(() => createTestRegistry([mcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })).not.toThrow();
expect(JSON.stringify(mcp.catalog())).toContain(region.code);
expect(JSON.stringify(mcp.forYear({ regionId: region.id, year: 2027 }))).toContain('Winter break');
});
it('denies every MCP mutation to normal users without changing the catalog', () => {
const region = seed();
const mcp = app.get(SchoolHolidaysMcp);
const member = { userId: 2, scopes: null, isStaticToken: false };
const before = 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(write()).toHaveProperty('isError', true);
expect(service.region(region.id)).toEqual(before);
expect(service.catalog().countries).toHaveLength(1);
expect(service.catalog().regions).toHaveLength(1);
});
it('supports the complete admin MCP workflow with validation and revision protection', () => {
const mcp = app.get(SchoolHolidaysMcp);
const admin = { userId: 1, scopes: null, isStaticToken: false };
expect(mcp.createCountry({ code: 'US', name: 'USA' }, admin)).not.toHaveProperty('isError', true);
expect(mcp.createRegion({ country: 'US', name: 'Seattle', revision: 0, holidays: [winter] }, admin)).not.toHaveProperty('isError', true);
const region = service.catalog().regions[0];
expect(JSON.stringify(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(mcp.updateRegion(edit, admin)).not.toHaveProperty('isError', true);
expect(mcp.updateRegion(edit, admin)).toHaveProperty('isError', true);
expect(mcp.updateRegion({ ...edit, revision: 2, holidays: [{ ...winter, endDate: '2025-01-01' }] }, admin)).toHaveProperty('isError', true);
expect(service.region(region.id)).toMatchObject({ revision: 2, holidays: [{ ...winter, name: 'Winter holiday' }] });
expect(mcp.deleteCountry({ code: 'US' }, admin)).toHaveProperty('isError', true);
expect(mcp.deleteRegion({ regionId: region.id, revision: 1 }, admin)).toHaveProperty('isError', true);
expect(mcp.deleteRegion({ regionId: region.id, revision: 2 }, admin)).not.toHaveProperty('isError', true);
expect(mcp.deleteCountry({ code: 'US' }, admin)).not.toHaveProperty('isError', true);
expect(service.catalog()).toEqual({ countries: [], regions: [] });
});
});
@@ -1,4 +1,6 @@
import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { db } from '../../src/db/database';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
@@ -221,6 +223,7 @@ export function createMcpTestRegistry(): McpRegistry {
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
+3
View File
@@ -27,6 +27,9 @@ import type { RateLimitService } from '../../src/nest/common/rate-limit.service'
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
const RESET_TABLES = [
'school_holiday_periods',
'school_holiday_regions',
'school_holiday_countries',
// Collab
'file_links',
'collab_message_reactions',
@@ -309,6 +309,19 @@ describe('updatePlan', () => {
// ── addHolidayCalendar ────────────────────────────────────────────────────────
describe('addHolidayCalendar', () => {
it('validates manual region references for creates and updates', () => {
const { plan } = setupUserWithPlan();
testDb.prepare("INSERT INTO school_holiday_countries (code, name) VALUES ('US', 'USA')").run();
const inserted = testDb.prepare("INSERT INTO school_holiday_regions (country, name) VALUES ('US', 'Seattle')").run();
const code = `US-MANUAL-${inserted.lastInsertRowid}`;
const calendar = svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday');
expect(calendar.region).toBe(code);
expect(svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined)?.region).toBe(code);
expect(() => svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).toThrow('Unknown manual');
for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted.lastInsertRowid}`]) {
expect(() => svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).toThrow('Unknown manual');
}
});
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
const { plan } = setupUserWithPlan();