feat(260923-dhh): Proxmox-Modul Aufgabe 4 - Hintergrundabfrage je Mandant, Verbindungstest
- proxmox-scheduler.service.ts: ein Cron-Auftrag je aktivem Mandant (proxmox-poll:<tenantId>), onApplicationBootstrap (nicht onModuleInit, Tender-Muster), Abfrageintervall = kleinstes pollIntervalMin der aktiven Server, ein fehlgeschlagener Server bricht die Tick-Schleife nicht ab, refreshTenant() zieht nach jedem Speichern sofort nach - proxmox.service.ts: loadActiveServersForScheduler() als einziger forSystem()-Aufruf des Moduls (Erlaubnisliste in rls-access-inventory.spec.ts), testConnection() schreibt nicht ins Zwischenlager, pollServer() bekommt eine Zehn-Sekunden-Sperre (T-DHH-06) - proxmox.controller.ts: POST servers/:id/test, create() zieht den Planer nach dem Anlegen sofort nach - Zugriffsklassifikation: proxmoxServer wechselt auf system-gebunden (Startpfad des Planers), proxmoxServerStatus bleibt gebunden Tore: api 1306/1306 (>=1240), type-check 4/4, rls-access-inventory und rls-coverage gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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<string, any>();
|
||||
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, <behavior>)', () => {
|
||||
const registries: ReturnType<typeof makeFakeRegistry>[] = [];
|
||||
|
||||
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:<tenantId>', 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user