feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21)
- Helfer forSystem(prisma) in prisma-tenant.extension.ts (Array-Form, setzt app.system_context='true' und die beiden anderen Variablen ausdruecklich leer); forTenant()/withTenantTransaction() setzen app.system_context='' als Literal (4 neue Spec-Tests) - Migration 20260914120000_rls_system_context_read: is_system_context() (COALESCE, STABLE) und system_read_policy FOR SELECT auf DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch — lokal angewendet (36 Migrationen, pg_proc 1, 5 system_read_policy, 34 Regeln) - migration-sql.spec.ts: describe-Block fuer die neue Migration (6 Tests) - rls-scratch-check.mjs: Funktion aus der Migration geschnitten, forSystemQuery/buildInlineSystemClient, Reset in forTenantQuery/ buildInlineExtendedClient, runSystemContextChecks (4 Funktionsfaelle + 9 Kennungen DkvModuleConfig) -> Alle 216 Pruefungen bestanden - rls-access-inventory.spec.ts: fuenfte Erkennungsform const X = forSystem(, Stand system-gebunden mit Vorrangregel, FORSYSTEM_ALLOWED_CALL_SITES (exakte Zahl je Datei, 3 Tests), Proben C/D/E - DKV: loadActiveConfigsForScheduler() ueber forSystem (findMany isActive, CONFIG_SAFE_SELECT, orderBy tenantId); DkvSchedulerService mit Auftrag je Mandant dkv-inbox-poll:<tenantId>, activeTenantId ersatzlos entfernt, setInterval/stopJob je Mandant, registeredTenantIds(); Controller stopJob(tenantId); neue dkv-scheduler.service.spec.ts (7 Tests), dkv.service.spec.ts Tests 6/7 umgestellt - Klassifikation: dkv.service.ts/dkvModuleConfig system-gebunden, Header mit fuenfter Erkennungsform und viertem Stand-Wert - Baseline: 63 Dateien / 1051 Tests, tsc 0, Werkzeug 216 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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:<tenantId>`). 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<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),
|
||||
};
|
||||
}
|
||||
|
||||
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<typeof makeFakeRegistry>[] = [];
|
||||
|
||||
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:<t> 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user