mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(server): add the schedule wiring
This commit is contained in:
Generated
+70
-57
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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<void>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user