import { afterEach, describe, expect, it, vi } from 'vitest'; import { DkvSchedulerService } from './dkv-scheduler.service'; /** * DkvSchedulerService.spec (Etappe 3c, 260914-eym, WINDOWS #21) — der * Planer fuehrt seit diesem Durchlauf EINEN Cron-Auftrag JE aktivem * Mandanten (`dkv-inbox-poll:`). Diese Tests nageln fest: * * - IDENTITAET MIT EINEM MANDANTEN (morgen alpha, ein Mandant): genau ein * Auftrag, dieselbe Cron-Expression wie bisher, der Tick ruft * `processInbox` mit dieser tenantId, eine inaktive/fehlende Config * registriert nichts und protokolliert dieselbe Zeile wie bisher. * - INVARIANTE MIT ZWEI MANDANTEN (assumption-delta "promote"): zwei * Auftraege; die Aenderung des einen laesst den anderen unberuehrt. * - FEHLERTOLERANZ: ein werfender Startpfad blockiert den Start nicht. * * Fake-Registry (Map-basiert, `getCronJob` wirft bei Unbekannt wie * @nestjs/schedule), Fake-DkvService, ECHTES `cron` (liegt unter * apps/api/node_modules als Peer von @nestjs/schedule) — `cronTime.source` * und `fireOnTick()` sind die beobachtbaren Eigenschaften eines Auftrags. */ function makeFakeRegistry() { const jobs = new Map(); return { __jobs: jobs, addCronJob: vi.fn((name: string, job: any) => { if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`); jobs.set(name, job); }), getCronJob: vi.fn((name: string) => { const job = jobs.get(name); if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`); return job; }), deleteCronJob: vi.fn((name: string) => { const job = jobs.get(name); if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`); jobs.delete(name); }), getCronJobs: vi.fn(() => jobs), }; } function makeFakeDkvService(configs: Array<{ tenantId: string; pollIntervalMin: number; isActive: boolean }> | Error) { return { loadActiveConfigsForScheduler: vi.fn(async () => { if (configs instanceof Error) throw configs; return configs.filter((c) => c.isActive); }), processInbox: vi.fn(async (_tenantId: string) => undefined), }; } function makeScheduler( configs: Array<{ tenantId: string; pollIntervalMin: number; isActive: boolean }> | Error, ) { const registry = makeFakeRegistry(); const dkvService = makeFakeDkvService(configs); const scheduler = new DkvSchedulerService(registry as any, dkvService as any); const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined); const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined); return { registry, dkvService, scheduler, logSpy, errorSpy }; } describe('DkvSchedulerService — ein Auftrag je Mandant (260914-eym, WINDOWS #21)', () => { const registries: ReturnType[] = []; afterEach(() => { // Jeden registrierten (echten) Cron-Auftrag stoppen, sonst haelt ein // laufender Timer den Testprozess offen. for (const registry of registries) { for (const job of registry.__jobs.values()) job.stop(); registry.__jobs.clear(); } registries.length = 0; vi.restoreAllMocks(); }); it('Test 1: EIN aktiver Mandant, pollIntervalMin 15 -> genau ein Auftrag dkv-inbox-poll: mit cronTime.source "*/15 * * * *" (Identitaet zu heute)', async () => { const { registry, scheduler, dkvService } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: true }]); registries.push(registry); await scheduler.onModuleInit(); expect(dkvService.loadActiveConfigsForScheduler).toHaveBeenCalledTimes(1); expect([...registry.__jobs.keys()]).toEqual(['dkv-inbox-poll:t1']); expect(scheduler.registeredTenantIds()).toEqual(['t1']); const job = registry.__jobs.get('dkv-inbox-poll:t1'); expect(job.cronTime.source).toBe('*/15 * * * *'); expect(job.isActive).toBe(true); }); it('Test 2: pollIntervalMin 120 -> "0 */2 * * *" (Stundenfeld, unveraenderte Berechnung)', async () => { const { registry, scheduler } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 120, isActive: true }]); registries.push(registry); await scheduler.onModuleInit(); expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('0 */2 * * *'); }); it('Test 3: fireOnTick() ruft processInbox genau mit dieser tenantId', async () => { const { registry, scheduler, dkvService } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: true }]); registries.push(registry); await scheduler.onModuleInit(); registry.__jobs.get('dkv-inbox-poll:t1').fireOnTick(); await new Promise((r) => setImmediate(r)); expect(dkvService.processInbox).toHaveBeenCalledTimes(1); expect(dkvService.processInbox).toHaveBeenCalledWith('t1'); }); it('Test 4: inaktive oder keine Config -> kein Auftrag, Protokollzeile "no active config found"', async () => { const inactive = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: false }]); registries.push(inactive.registry); await inactive.scheduler.onModuleInit(); expect(inactive.registry.__jobs.size).toBe(0); expect(inactive.scheduler.registeredTenantIds()).toEqual([]); expect(inactive.logSpy).toHaveBeenCalledWith( 'DKV scheduler: no active config found — cron job not registered', ); const none = makeScheduler([]); registries.push(none.registry); await none.scheduler.onModuleInit(); expect(none.registry.__jobs.size).toBe(0); expect(none.logSpy).toHaveBeenCalledWith( 'DKV scheduler: no active config found — cron job not registered', ); }); it('Test 5 (Invariante): ZWEI Mandanten -> zwei Auftraege; setInterval(30, t2) ersetzt nur t2, stopJob(t1) entfernt nur t1', async () => { const { registry, scheduler, dkvService } = makeScheduler([ { tenantId: 't1', pollIntervalMin: 15, isActive: true }, { tenantId: 't2', pollIntervalMin: 60, isActive: true }, ]); registries.push(registry); await scheduler.onModuleInit(); expect(scheduler.registeredTenantIds().sort()).toEqual(['t1', 't2']); expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('*/15 * * * *'); expect(registry.__jobs.get('dkv-inbox-poll:t2').cronTime.source).toBe('0 */1 * * *'); const t1JobBefore = registry.__jobs.get('dkv-inbox-poll:t1'); scheduler.setInterval(30, 't2'); expect(registry.__jobs.get('dkv-inbox-poll:t2').cronTime.source).toBe('*/30 * * * *'); expect(registry.__jobs.get('dkv-inbox-poll:t1')).toBe(t1JobBefore); expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('*/15 * * * *'); // Der Tick von t2 ruft weiterhin nur t2. registry.__jobs.get('dkv-inbox-poll:t2').fireOnTick(); await new Promise((r) => setImmediate(r)); expect(dkvService.processInbox).toHaveBeenCalledWith('t2'); expect(dkvService.processInbox).not.toHaveBeenCalledWith('t1'); scheduler.stopJob('t1'); expect(scheduler.registeredTenantIds()).toEqual(['t2']); expect(t1JobBefore.isActive).toBe(false); expect(registry.__jobs.get('dkv-inbox-poll:t2').isActive).toBe(true); }); it('Test 6: loadActiveConfigsForScheduler wirft -> Fehler gefangen und protokolliert, kein Auftrag, Start nicht blockiert', async () => { const { registry, scheduler, errorSpy } = makeScheduler(new Error('db down')); registries.push(registry); await expect(scheduler.onModuleInit()).resolves.toBeUndefined(); expect(registry.__jobs.size).toBe(0); expect(errorSpy).toHaveBeenCalledWith('DKV scheduler init failed: db down'); }); it('Test 7: stopJob fuer einen nicht registrierten Mandanten ist ein No-Op (kein Throw)', () => { const { registry, scheduler } = makeScheduler([]); registries.push(registry); expect(() => scheduler.stopJob('unbekannt')).not.toThrow(); expect(registry.__jobs.size).toBe(0); }); });