From 399bbda0fab49625093c13cf86414b46bb570b8d Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 10 Aug 2026 21:22:13 +0200 Subject: [PATCH] feat(server): add the schedule wiring --- package-lock.json | 127 +++++++++------- server/package.json | 2 + .../nest/app-config/runtime-env.service.ts | 5 + server/src/nest/app.module.ts | 3 +- .../nest/scheduling/cron-registrar.service.ts | 80 ++++++++++ .../src/nest/scheduling/scheduling.module.ts | 25 +++ .../tests/integration/scheduler-gate.test.ts | 80 ++++++++++ .../unit/nest/cron-registrar.service.test.ts | 143 ++++++++++++++++++ 8 files changed, 407 insertions(+), 58 deletions(-) create mode 100644 server/src/nest/scheduling/cron-registrar.service.ts create mode 100644 server/src/nest/scheduling/scheduling.module.ts create mode 100644 server/tests/integration/scheduler-gate.test.ts create mode 100644 server/tests/unit/nest/cron-registrar.service.test.ts diff --git a/package-lock.json b/package-lock.json index 02909331f..e25eb6070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5400,6 +5400,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/swagger": { "version": "11.4.5", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz", @@ -6256,8 +6269,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.62.0", @@ -6271,8 +6283,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.62.0", @@ -6286,8 +6297,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.62.0", @@ -6301,8 +6311,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.62.0", @@ -6316,8 +6325,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.62.0", @@ -6331,8 +6339,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.62.0", @@ -6346,8 +6353,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.62.0", @@ -6361,8 +6367,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.62.0", @@ -6376,8 +6381,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.62.0", @@ -6404,8 +6408,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.62.0", @@ -6419,8 +6422,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.62.0", @@ -6434,8 +6436,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.62.0", @@ -6449,8 +6450,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.62.0", @@ -6464,8 +6464,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.62.0", @@ -6479,8 +6478,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.62.0", @@ -6494,8 +6492,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.0", @@ -6509,8 +6506,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.62.0", @@ -6537,8 +6533,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.62.0", @@ -6552,8 +6547,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.62.0", @@ -6567,8 +6561,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.62.0", @@ -6582,8 +6575,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.0", @@ -6597,8 +6589,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.62.0", @@ -6612,8 +6603,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@scarf/scarf": { "version": "1.4.0", @@ -7339,6 +7329,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.4.tgz", + "integrity": "sha512-V536ZAd6ZJztrrBlLcDFaaZrXNAL2E5uGmssWf/dpSiLkmkLScXUYhUBnWPmtW+cIqnNHzf6//TCMpIc9SCRRQ==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -9608,6 +9604,23 @@ "node": ">= 12.0.0" } }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -13755,6 +13768,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -19781,17 +19803,6 @@ "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", "license": "MIT" }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/utif2": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", @@ -21156,6 +21167,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.5", "@simplewebauthn/server": "^13.1.2", "@trek/nest-mcp": "*", @@ -21166,6 +21178,7 @@ "compression": "^1.8.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "cron": "^4.4.0", "dotenv": "^16.4.1", "express": "^4.18.3", "fast-xml-parser": "^5.5.10", diff --git a/server/package.json b/server/package.json index 65f321484..94db19826 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.5", "@simplewebauthn/server": "^13.1.2", "@trek/nest-mcp": "*", @@ -40,6 +41,7 @@ "compression": "^1.8.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "cron": "^4.4.0", "dotenv": "^16.4.1", "express": "^4.18.3", "fast-xml-parser": "^5.5.10", diff --git a/server/src/nest/app-config/runtime-env.service.ts b/server/src/nest/app-config/runtime-env.service.ts index 636021b70..61eee77b3 100644 --- a/server/src/nest/app-config/runtime-env.service.ts +++ b/server/src/nest/app-config/runtime-env.service.ts @@ -21,6 +21,11 @@ export class RuntimeEnvService { return readEnv().demo.enabled; } + /** True only under exactly NODE_ENV='test' (case-sensitive, like the :memory: DB pick). */ + isTest(): boolean { + return readEnv().app.isTest; + } + nodeEnv(): string | undefined { return readEnv().app.nodeEnv; } diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index ae98ff7fc..991f22c12 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -54,6 +54,7 @@ import { AddonsModule } from './addons/addons.module'; import { AuditModule } from './audit/audit.module'; import { PermissionsModule } from './permissions/permissions.module'; import { PluginsModule } from './plugins/plugins.module'; +import { SchedulingModule } from './scheduling/scheduling.module'; import { McpModule } from '@trek/nest-mcp'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy'; import { TrekExceptionFilter } from './common/trek-exception.filter'; @@ -66,7 +67,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/scheduling/cron-registrar.service.ts b/server/src/nest/scheduling/cron-registrar.service.ts new file mode 100644 index 000000000..d0a21eee1 --- /dev/null +++ b/server/src/nest/scheduling/cron-registrar.service.ts @@ -0,0 +1,80 @@ +import { Injectable, type OnApplicationShutdown } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; +import { readEnv } from '../../app-config'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; + +/** + * The one way TREK code schedules a cron. Job providers register here from + * their onApplicationBootstrap instead of touching cron/@nestjs/schedule + * directly, because this is where two process-wide invariants live: + * + * - Tests never tick. buildApp() is shared with the integration/e2e harness, + * so onApplicationBootstrap runs on every suite boot; register() refuses to + * schedule when NODE_ENV is 'test' (readEnv().app.isTest, read at register + * time — tests/setup.ts pins NODE_ENV='test' before any app boots). + * Providers gate their boot sweeps and banner logs behind isEnabled() for + * the same reason. + * - Shutdown stops everything. @nestjs/schedule's orchestrator only closes + * jobs declared via @Cron decorators, never registry-added ones, so this + * service stops its own in onApplicationShutdown — fired by nestApp.close() + * in prod and by the suites' afterAll close() alike. + * + * The timezone is resolved per register() call (readEnv().app.tz || 'UTC'), + * keeping the retired scheduler's read-at-schedule-time behavior: a dynamic + * job that re-registers picks up a changed TZ. timezone: 'none' schedules in + * the server-local zone (the demo reset and the trek-photo sweep always ran + * that way). + */ +@Injectable() +export class CronRegistrarService implements OnApplicationShutdown { + /** Names this service registered — the registry may hold other owners' jobs. */ + private readonly names = new Set(); + + constructor( + private readonly registry: SchedulerRegistry, + private readonly runtimeEnv: RuntimeEnvService, + ) {} + + /** False under NODE_ENV=test — the single gate keeping the suites timer-free. */ + isEnabled(): boolean { + return !this.runtimeEnv.isTest(); + } + + /** + * Schedule a cron. An existing job under the same name is stopped and + * replaced, so a dynamic job restarts by calling register() again. + * Returns false without scheduling anything when isEnabled() is false. + */ + register( + name: string, + expression: string, + onTick: () => void | Promise, + opts?: { timezone?: 'app' | 'none' }, + ): boolean { + this.unregister(name); + if (!this.isEnabled()) return false; + const timeZone = opts?.timezone === 'none' ? undefined : readEnv().app.tz || 'UTC'; + const job = CronJob.from({ cronTime: expression, onTick, start: true, timeZone }); + this.registry.addCronJob(name, job); + this.names.add(name); + return true; + } + + /** Stop and drop a job by name. A name that was never registered is a no-op. */ + unregister(name: string): void { + if (!this.names.has(name)) return; + this.registry.deleteCronJob(name); // deleteCronJob stops the job before dropping it + this.names.delete(name); + } + + get jobCount(): number { + return this.names.size; + } + + onApplicationShutdown(): void { + for (const name of [...this.names]) { + this.unregister(name); + } + } +} diff --git a/server/src/nest/scheduling/scheduling.module.ts b/server/src/nest/scheduling/scheduling.module.ts new file mode 100644 index 000000000..512392a15 --- /dev/null +++ b/server/src/nest/scheduling/scheduling.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppConfigModule } from '../app-config/app-config.module'; +import { CronRegistrarService } from './cron-registrar.service'; + +/** + * Shared cron infrastructure — deliberately NOT @Global. The e2e suites boot + * partial module graphs (DatabaseModule + one domain module), where a global + * module absent from the compiled tree resolves nothing: every module that + * owns a job imports SchedulingModule explicitly, so its TestingModule keeps + * compiling. ScheduleModule.forRoot() lives here (not app.module.ts) for the + * same reason, and AppConfigModule is imported so RuntimeEnvService resolves + * in those partial graphs too. + * + * No @Cron decorators exist anywhere in the tree — jobs go through + * CronRegistrarService so the test gate and shutdown semantics apply. Keep it + * that way: a decorator-declared job would be mounted by the schedule + * explorer on every harness boot, gate or no gate. + */ +@Module({ + imports: [ScheduleModule.forRoot(), AppConfigModule], + providers: [CronRegistrarService], + exports: [CronRegistrarService], +}) +export class SchedulingModule {} diff --git a/server/tests/integration/scheduler-gate.test.ts b/server/tests/integration/scheduler-gate.test.ts new file mode 100644 index 000000000..7bb9f12cd --- /dev/null +++ b/server/tests/integration/scheduler-gate.test.ts @@ -0,0 +1,80 @@ +/** + * SCHED-GATE — the no-timers-in-the-harness regression test. buildApp() is + * shared with every integration/e2e suite, and job providers register their + * crons from onApplicationBootstrap, which app.init() fires on every boot. + * This proves the CronRegistrarService NODE_ENV=test gate holds: a full + * harness boot registers zero cron jobs, intervals or timeouts, and closing + * the app leaves nothing behind. If this ever goes red, some job bypassed the + * registrar (or used a @Cron decorator) and every suite is now running real + * timers. + */ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import type { INestApplication } from '@nestjs/common'; + +const { testDb, dbMock } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + 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: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); + +import { SchedulerRegistry } from '@nestjs/schedule'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service'; +import { buildApp } from '../../src/bootstrap'; + +describe('SCHED-GATE — the harness boots without scheduling anything', () => { + let app: INestApplication; + + beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + app = await buildApp(); + }); + + afterAll(() => { + testDb.close(); + }); + + it('SCHED-GATE-001 — the registrar reports the test gate closed', () => { + expect(app.get(CronRegistrarService).isEnabled()).toBe(false); + }); + + it('SCHED-GATE-002 — a full app boot registers zero cron jobs, intervals and timeouts', () => { + const registry = app.get(SchedulerRegistry); + expect(registry.getCronJobs().size).toBe(0); + expect(registry.getIntervals()).toHaveLength(0); + expect(registry.getTimeouts()).toHaveLength(0); + expect(app.get(CronRegistrarService).jobCount).toBe(0); + }); + + it('SCHED-GATE-003 — app.close() leaves the registrar empty (shutdown parity)', async () => { + const registrar = app.get(CronRegistrarService); + await app.close(); + expect(registrar.jobCount).toBe(0); + }); +}); diff --git a/server/tests/unit/nest/cron-registrar.service.test.ts b/server/tests/unit/nest/cron-registrar.service.test.ts new file mode 100644 index 000000000..bb9ee135d --- /dev/null +++ b/server/tests/unit/nest/cron-registrar.service.test.ts @@ -0,0 +1,143 @@ +/** + * CronRegistrarService — the shared cron gate every job provider schedules + * through. Proves the NODE_ENV=test gate refuses to schedule, the timezone is + * resolved at register() time ('app' vs 'none'), re-registering a name replaces + * the old job, unregister is idempotent, and onApplicationShutdown stops + * everything the registrar owns. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const h = vi.hoisted(() => ({ + jobs: [] as Array<{ + cronTime: string; + timeZone?: string; + start?: boolean; + onTick: () => unknown; + stopped: boolean; + stop(): void; + }>, +})); +vi.mock('cron', () => ({ + CronJob: { + from: (opts: { cronTime: string; timeZone?: string; start?: boolean; onTick: () => unknown }) => { + const job = { + ...opts, + stopped: false, + stop() { + this.stopped = true; + }, + }; + h.jobs.push(job); + return job; + }, + }, +})); + +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; + +function makeRegistrar(isTest: boolean) { + const registry = new SchedulerRegistry(); + const runtimeEnv = { isTest: () => isTest } as RuntimeEnvService; + return { registrar: new CronRegistrarService(registry, runtimeEnv), registry }; +} + +describe('CronRegistrarService', () => { + const originalTz = process.env.TZ; + + beforeEach(() => { + h.jobs.length = 0; + delete process.env.TZ; + }); + + afterEach(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; + }); + + it('CRONREG-001 — refuses to schedule under the test gate (register returns false, nothing created)', () => { + const { registrar, registry } = makeRegistrar(true); + expect(registrar.isEnabled()).toBe(false); + expect(registrar.register('job', '0 * * * *', () => {})).toBe(false); + expect(h.jobs).toHaveLength(0); + expect(registry.getCronJobs().size).toBe(0); + expect(registrar.jobCount).toBe(0); + }); + + it('CRONREG-002 — schedules a started job with tz UTC when TZ is unset', () => { + const { registrar, registry } = makeRegistrar(false); + expect(registrar.register('job', '0 3 * * *', () => {})).toBe(true); + expect(h.jobs).toHaveLength(1); + expect(h.jobs[0].cronTime).toBe('0 3 * * *'); + expect(h.jobs[0].start).toBe(true); + expect(h.jobs[0].timeZone).toBe('UTC'); + expect(registry.getCronJobs().size).toBe(1); + expect(registrar.jobCount).toBe(1); + }); + + it('CRONREG-003 — resolves the app timezone from the env at register() time', () => { + const { registrar } = makeRegistrar(false); + process.env.TZ = 'Europe/Zurich'; + registrar.register('job', '0 9 * * *', () => {}); + expect(h.jobs[0].timeZone).toBe('Europe/Zurich'); + + // A re-register picks up a changed TZ — the read is per call, never cached. + process.env.TZ = 'America/New_York'; + registrar.register('job', '0 9 * * *', () => {}); + expect(h.jobs[1].timeZone).toBe('America/New_York'); + }); + + it("CRONREG-004 — timezone: 'none' schedules in the server-local zone even when TZ is set", () => { + const { registrar } = makeRegistrar(false); + process.env.TZ = 'Europe/Zurich'; + registrar.register('job', '0 * * * *', () => {}, { timezone: 'none' }); + expect(h.jobs[0].timeZone).toBeUndefined(); + }); + + it('CRONREG-005 — re-registering a name stops the old job and replaces it', () => { + const { registrar, registry } = makeRegistrar(false); + registrar.register('job', '0 2 * * *', () => {}); + registrar.register('job', '0 4 * * *', () => {}); + expect(h.jobs[0].stopped).toBe(true); + expect(h.jobs[1].stopped).toBe(false); + expect(registry.getCronJobs().size).toBe(1); + expect(registrar.jobCount).toBe(1); + expect(registry.getCronJob('job').cronTime).toBe('0 4 * * *'); + }); + + it('CRONREG-006 — unregister stops and drops the job, and is a no-op for unknown names', () => { + const { registrar, registry } = makeRegistrar(false); + registrar.register('job', '0 2 * * *', () => {}); + registrar.unregister('job'); + expect(h.jobs[0].stopped).toBe(true); + expect(registry.getCronJobs().size).toBe(0); + expect(registrar.jobCount).toBe(0); + expect(() => registrar.unregister('job')).not.toThrow(); + expect(() => registrar.unregister('never-existed')).not.toThrow(); + }); + + it('CRONREG-007 — onApplicationShutdown stops every job the registrar owns', () => { + const { registrar, registry } = makeRegistrar(false); + registrar.register('a', '0 2 * * *', () => {}); + registrar.register('b', '0 4 * * *', () => {}); + registrar.onApplicationShutdown(); + expect(h.jobs.every(j => j.stopped)).toBe(true); + expect(registry.getCronJobs().size).toBe(0); + expect(registrar.jobCount).toBe(0); + }); + + it('CRONREG-008 — the disabled path still clears an already-registered name (restart parity)', () => { + // A dynamic job (auto-backup) restarts by re-registering; if settings turn + // it off the register call must still drop the old schedule. + const registry = new SchedulerRegistry(); + let testMode = false; + const registrar = new CronRegistrarService(registry, { isTest: () => testMode } as RuntimeEnvService); + registrar.register('job', '0 2 * * *', () => {}); + testMode = true; + expect(registrar.register('job', '0 4 * * *', () => {})).toBe(false); + expect(h.jobs[0].stopped).toBe(true); + expect(registry.getCronJobs().size).toBe(0); + expect(registrar.jobCount).toBe(0); + }); +});