From 543a65b9542d2c82ee528fd9158680be1560e280 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 24 Jul 2026 15:57:30 -0400 Subject: [PATCH] test: assert the v1 gate at real entry points, including the missing-dep guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the integration-level checks the leaf tests could not give. Every previous round's tests exercised a mechanism by handing it the token it needed, which passes for a broken system; these drive the actual entry points instead. Covers: default-off when the admin never opted in AND that the engine does not merely refuse to fire but never arms; explicit false stays off; explicit opt-in (boolean and object-with-limits) turns on; Run Now refuses under the SCHEDULES_DISABLED lever; Run Now refuses when the BASE config disables it even though a principal-merged view re-enables. Also asserts the construction-time dep validation throws for a missing dep — the exact class of failure (the JS adapter is not typechecked against SchedulesServiceDeps) that shipped the deletion-barrier probe unwired twice. --- packages/api/src/schedules/gate.spec.ts | 100 ++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/api/src/schedules/gate.spec.ts diff --git a/packages/api/src/schedules/gate.spec.ts b/packages/api/src/schedules/gate.spec.ts new file mode 100644 index 0000000000..5c58fa7182 --- /dev/null +++ b/packages/api/src/schedules/gate.spec.ts @@ -0,0 +1,100 @@ +import type { SchedulesServiceDeps } from './service'; +import { createSchedulesService } from './service'; + +jest.mock('../stream/GenerationJobManager', () => ({ + GenerationJobManager: { getJobStore: () => null, abortJob: jest.fn(), isRedis: false }, +})); + +type Cfg = { interfaceConfig?: { schedules?: unknown } }; + +function makeService(base: Cfg, merged: Cfg = base) { + const deps = { + methods: { + countActiveRuns: jest.fn(async () => 0), + getCapacityOccupancy: jest.fn(async () => ({ takenSlots: [], unslotted: 0 })), + ensureScheduleIndexes: jest.fn(async () => undefined), + acquireManualRunLease: jest.fn(async () => null), + }, + getAppConfig: jest.fn(async (options?: { baseOnly?: boolean }) => + options?.baseOnly === true ? base : merged, + ), + findUserById: jest.fn(async () => null), + findBalance: jest.fn(async () => null), + upsertBalance: jest.fn(async () => null), + resolveAgentFireAccess: jest.fn(async () => 'ok' as const), + isUserDeleting: jest.fn(async () => false), + } as unknown as SchedulesServiceDeps; + return createSchedulesService(deps); +} + +const schedule = { id: 's1', user: 'u1' } as never; +const limits = { + enabled: true, + maxPerUser: 10, + minIntervalMinutes: 60, + autoDisableAfterFailures: 5, + fireConcurrency: 5, +}; + +describe('v1 experimental gate, asserted at real entry points', () => { + afterEach(() => { + delete process.env.SCHEDULES_DISABLED; + }); + + it('is OFF when the admin never opted in, and does not arm the engine', async () => { + const service = makeService({}); + expect((await service.getLimits()).enabled).toBe(false); + // The engine must not merely refuse to fire — it must not start at all. + expect(await service.initializeScheduleEngine()).toBeUndefined(); + }); + + it('stays OFF for an explicit false', async () => { + expect((await makeService({ interfaceConfig: { schedules: false } }).getLimits()).enabled).toBe( + false, + ); + }); + + it('turns ON for an explicit opt-in', async () => { + expect((await makeService({ interfaceConfig: { schedules: true } }).getLimits()).enabled).toBe( + true, + ); + const tuned = makeService({ interfaceConfig: { schedules: { maxPerUser: 3 } } }); + const resolved = await tuned.getLimits(); + expect(resolved.enabled).toBe(true); + expect(resolved.maxPerUser).toBe(3); + }); + + it('REFUSES a manual run-now while the global kill switch is on', async () => { + // Run Now dispatches the same billed generation as an automatic fire, so gating only + // the engine tick would leave this path open. + process.env.SCHEDULES_DISABLED = 'true'; + const service = makeService({ interfaceConfig: { schedules: true } }); + const result = await service.fireScheduleNow(schedule, limits); + expect(result).toEqual({ fired: false, skipped: 'disabled' }); + }); + + it('refuses run-now when the BASE config disables it, even if a principal re-enables', async () => { + const service = makeService( + { interfaceConfig: { schedules: false } }, + { interfaceConfig: { schedules: true } }, + ); + const result = await service.fireScheduleNow(schedule, limits); + expect(result).toEqual({ fired: false, skipped: 'disabled' }); + }); + + it('constructing the service without a required dep fails LOUDLY at boot', () => { + // The JS adapter is not typechecked against SchedulesServiceDeps, which is how the + // deletion-barrier probe shipped unwired twice. A missing dep must not surface as a + // cryptic per-fire "is not a function". + expect(() => + createSchedulesService({ + methods: {}, + getAppConfig: jest.fn(), + findUserById: jest.fn(), + findBalance: jest.fn(), + upsertBalance: jest.fn(), + resolveAgentFireAccess: jest.fn(), + } as unknown as SchedulesServiceDeps), + ).toThrow(/isUserDeleting/); + }); +});