import { afterEach, describe, expect, it, vi } from 'vitest'; import { ProxmoxSchedulerService } from './proxmox-scheduler.service'; /** * ProxmoxSchedulerService.spec (Aufgabe 4) — Vorbild * `dkv-scheduler.service.spec.ts`: echte Fake-Registry (Map-basiert, * `getCronJob` wirft bei Unbekannt wie `@nestjs/schedule`), ECHTES `cron` * (Peer von `@nestjs/schedule`) — `cronTime.source` und `fireOnTick()` * sind die beobachtbaren Eigenschaften eines Auftrags. */ function makeFakeRegistry() { // biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe 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), }; } interface FakeServerRow { id: string; tenantId: string; pollIntervalMin: number; isActive: boolean; } function makeFakeProxmoxService( servers: FakeServerRow[] | Error, options: { pollShouldThrowFor?: string[] } = {}, ) { const polledServerIds: string[] = []; return { loadActiveServersForScheduler: vi.fn(async () => { if (servers instanceof Error) throw servers; return servers.filter((s) => s.isActive).map((s) => ({ id: s.id, tenantId: s.tenantId, pollIntervalMin: s.pollIntervalMin, })); }), loadActiveServersForTenantScheduling: vi.fn(async (tenantId: string) => { if (servers instanceof Error) return []; return servers .filter((s) => s.isActive && s.tenantId === tenantId) .map((s) => ({ pollIntervalMin: s.pollIntervalMin })); }), listActiveServerIdsForTenant: vi.fn(async (tenantId: string) => { if (servers instanceof Error) return []; return servers.filter((s) => s.isActive && s.tenantId === tenantId).map((s) => s.id); }), pollServer: vi.fn(async (_tenantId: string, serverId: string) => { polledServerIds.push(serverId); if (options.pollShouldThrowFor?.includes(serverId)) { throw new Error(`poll boom for ${serverId}`); } return { reachable: true, errorKind: null, errorDetail: null, metrics: null, rawSample: null }; }), __polledServerIds: polledServerIds, }; } function makeScheduler( servers: FakeServerRow[] | Error, options: { pollShouldThrowFor?: string[] } = {}, ) { const registry = makeFakeRegistry(); const proxmoxService = makeFakeProxmoxService(servers, options); const scheduler = new ProxmoxSchedulerService(registry as any, proxmoxService 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, proxmoxService, scheduler, logSpy, errorSpy }; } describe('ProxmoxSchedulerService — ein Auftrag je Mandant (Aufgabe 4, )', () => { const registries: ReturnType[] = []; afterEach(() => { for (const registry of registries) { for (const job of registry.__jobs.values()) job.stop(); registry.__jobs.clear(); } registries.length = 0; vi.restoreAllMocks(); }); it('Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter proxmox-poll:', async () => { const { registry, scheduler } = makeScheduler([ { id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true }, ]); registries.push(registry); await scheduler.onApplicationBootstrap(); expect([...registry.__jobs.keys()]).toEqual(['proxmox-poll:t1']); expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/15 * * * *'); }); it('Das Abfrageintervall eines Mandanten ist das KLEINSTE pollIntervalMin seiner aktiven Server', async () => { const { registry, scheduler } = makeScheduler([ { id: 's1', tenantId: 't1', pollIntervalMin: 30, isActive: true }, { id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true }, ]); registries.push(registry); await scheduler.onApplicationBootstrap(); expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/5 * * * *'); }); it('Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander', async () => { const { registry, scheduler } = makeScheduler([ { id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true }, { id: 's2', tenantId: 't2', pollIntervalMin: 10, isActive: true }, ]); registries.push(registry); await scheduler.onApplicationBootstrap(); expect(new Set(registry.__jobs.keys())).toEqual(new Set(['proxmox-poll:t1', 'proxmox-poll:t2'])); }); it('Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht', async () => { const { registry, scheduler, logSpy, errorSpy } = makeScheduler([]); registries.push(registry); await scheduler.onApplicationBootstrap(); expect(registry.__jobs.size).toBe(0); expect(logSpy).toHaveBeenCalled(); expect(errorSpy).not.toHaveBeenCalled(); }); it('Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen', async () => { const { scheduler, errorSpy } = makeScheduler(new Error('DB weg')); await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalled(); }); it('Der Planer haengt an onApplicationBootstrap, nicht an onModuleInit', () => { const registry = makeFakeRegistry(); const scheduler = new ProxmoxSchedulerService(registry as any, {} as any); expect(typeof (scheduler as unknown as { onApplicationBootstrap?: unknown }).onApplicationBootstrap).toBe( 'function', ); expect((scheduler as unknown as { onModuleInit?: unknown }).onModuleInit).toBeUndefined(); }); it('Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab', async () => { const { registry, scheduler, proxmoxService, errorSpy } = makeScheduler( [ { id: 's1', tenantId: 't1', pollIntervalMin: 5, isActive: true }, { id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true }, ], { pollShouldThrowFor: ['s1'] }, ); registries.push(registry); await scheduler.onApplicationBootstrap(); registry.__jobs.get('proxmox-poll:t1').fireOnTick(); await new Promise((resolve) => setImmediate(resolve)); expect((proxmoxService as any).__polledServerIds).toEqual(['s1', 's2']); expect(errorSpy).toHaveBeenCalled(); }); it('refreshTenant zieht den Auftrag eines Mandanten sofort nach — ohne Neustart', async () => { const { registry, scheduler } = makeScheduler([]); registries.push(registry); await scheduler.onApplicationBootstrap(); expect(registry.__jobs.size).toBe(0); (scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => [ { pollIntervalMin: 20 }, ]); await scheduler.refreshTenant('t1'); expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/20 * * * *'); }); it('refreshTenant entfernt den Auftrag, wenn keine aktiven Server mehr uebrig sind', async () => { const { registry, scheduler } = makeScheduler([ { id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true }, ]); registries.push(registry); await scheduler.onApplicationBootstrap(); expect(registry.__jobs.has('proxmox-poll:t1')).toBe(true); (scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => []); await scheduler.refreshTenant('t1'); expect(registry.__jobs.has('proxmox-poll:t1')).toBe(false); }); });