636fe0df8f
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf apps/web+packages, noUselessEscapeInRegex, useConst, useExponentiationOperator) sowie fuenf ungesicherte Regeln (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate, useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei fehlender Sitzung geprueft) - noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60, die Fallmarke dokumentiert Absicht) - Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts), fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten) - Sechs weitere, im Plan nicht namentlich gelistete aber gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich bereinigt (groups.service.spec.ts, cert-manager.test.tsx, ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/ noUnusedImports/noUnusedFunctionParameters zu erreichen Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...). Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten 621/542 — eine Differenz von 1, weil das Streichen des Namens aus `catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint --force 5/5. Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben): - force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad, nicht die HTTP-Methode - change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf der Seite stehen (keine Weiterleitung, keine Aktualisierung der Benutzerablage) - VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst sich doppelt ausloesen - SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
171 lines
7.3 KiB
TypeScript
171 lines
7.3 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
|
|
|
/**
|
|
* TenderSchedulerService.spec — the phase's headline acceptance criterion
|
|
* (Success Criteria 4 & 5, INGEST-06, T-10-10): proves the ABSENCE of
|
|
* tenant-count-scaled behavior, not correct per-tenant iteration (there are
|
|
* no per-tenant DÖE configs to iterate — the config is a genuine
|
|
* platform-wide singleton, RESEARCH.md Pitfall D).
|
|
*
|
|
* Uses the same hand-rolled prisma-shaped fake convention as
|
|
* tender-ingestion.service.spec.ts / ldap.service.spec.ts, driving the REAL
|
|
* ModuleRegistryService (unmocked) so the activation call path is genuine,
|
|
* not a stand-in.
|
|
*
|
|
* forTenant() just returns the same client in these tests (identical
|
|
* convention to ldap.service.spec.ts) — tenant scoping/RLS binding is not
|
|
* what this file tests, only ModuleRegistryService.activateForTenant's
|
|
* poll-once-fan-out-many behavior. Needed since 260910-exd (Aufgabe 3)
|
|
* converted ModuleRegistryService.activateForTenant to forTenant(), and the
|
|
* hand-rolled fake below does not implement `$extends`.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((p: unknown) => p),
|
|
}));
|
|
|
|
function makeFakePrisma() {
|
|
const modules = new Map<string, any>();
|
|
modules.set('mod-1', {
|
|
id: 'mod-1',
|
|
slug: 'tender-radar',
|
|
name: 'Ausschreibungs-Radar',
|
|
});
|
|
const activations = new Map<string, any>();
|
|
const tenders: unknown[] = [];
|
|
const configs = new Map<string, any>();
|
|
configs.set('doe-opendata', {
|
|
id: 'cfg1',
|
|
sourceType: 'doe-opendata',
|
|
pollIntervalMin: 60,
|
|
isActive: true,
|
|
lastIngestedDay: null,
|
|
});
|
|
|
|
return {
|
|
module: {
|
|
findUnique: vi.fn(async ({ where }: any) => {
|
|
if (where.id) return modules.get(where.id) ?? null;
|
|
if (where.slug) {
|
|
return Array.from(modules.values()).find((m) => m.slug === where.slug) ?? null;
|
|
}
|
|
return null;
|
|
}),
|
|
},
|
|
tenantModuleActivation: {
|
|
upsert: vi.fn(async ({ where, create, update }: any) => {
|
|
const key = `${where.tenantId_moduleId.tenantId}:${where.tenantId_moduleId.moduleId}`;
|
|
const existing = activations.get(key);
|
|
const record = existing ? { ...existing, ...update } : { ...create };
|
|
activations.set(key, record);
|
|
return { ...record, module: modules.get(record.moduleId) };
|
|
}),
|
|
},
|
|
tenderSourcePollConfig: {
|
|
findUnique: vi.fn(async ({ where }: any) => configs.get(where.sourceType) ?? null),
|
|
},
|
|
tender: {
|
|
count: vi.fn(async () => tenders.length),
|
|
},
|
|
__store: { modules, activations, tenders, configs },
|
|
};
|
|
}
|
|
|
|
describe('TenderSchedulerService — poll-once-fan-out-many (INGEST-06, Success Criteria 4 & 5)', () => {
|
|
it('activating the module for a 2nd tenant triggers zero additional DÖE calls, zero additional cron jobs, and zero additional Tender rows', async () => {
|
|
const prisma = makeFakePrisma();
|
|
|
|
const addCronJob = vi.fn();
|
|
const getCronJob = vi.fn(() => {
|
|
throw new Error('not registered');
|
|
});
|
|
const deleteCronJob = vi.fn();
|
|
const schedulerRegistry = { addCronJob, getCronJob, deleteCronJob } as any;
|
|
|
|
const pollDueSources = vi.fn().mockResolvedValue(undefined);
|
|
const tenderIngestionService = { pollDueSources } as any;
|
|
|
|
const scheduler = new TenderSchedulerService(
|
|
schedulerRegistry,
|
|
tenderIngestionService,
|
|
prisma as any,
|
|
);
|
|
const moduleRegistryService = new ModuleRegistryService(prisma as any);
|
|
|
|
// Platform boot: the scheduler initializes exactly once, independent of
|
|
// any tenant — this is the one and only cron-job registration.
|
|
await scheduler.onApplicationBootstrap();
|
|
expect(addCronJob).toHaveBeenCalledTimes(1);
|
|
expect(addCronJob.mock.calls[0][0]).toBe('tender-doe-poll');
|
|
|
|
// Tenant A activates the tender-radar module.
|
|
await moduleRegistryService.activateForTenant('tenant-a', 'mod-1');
|
|
|
|
expect(addCronJob).toHaveBeenCalledTimes(1); // still just the one global job
|
|
expect(pollDueSources).not.toHaveBeenCalled(); // activation never triggers a poll
|
|
expect(await prisma.tender.count()).toBe(0);
|
|
|
|
// Tenant B activates the SAME module — the headline acceptance criterion.
|
|
await moduleRegistryService.activateForTenant('tenant-b', 'mod-1');
|
|
|
|
expect(addCronJob).toHaveBeenCalledTimes(1); // zero additional cron jobs
|
|
expect(pollDueSources).not.toHaveBeenCalled(); // zero additional DÖE HTTP calls
|
|
expect(await prisma.tender.count()).toBe(0); // zero additional Tender rows
|
|
|
|
// Absence, not presence: no per-tenant iteration ever happened.
|
|
expect(prisma.__store.activations.size).toBe(2); // both tenants recorded their own activation row
|
|
expect(prisma.tenderSourcePollConfig.findUnique).toHaveBeenCalledTimes(1); // only the scheduler's own init call
|
|
});
|
|
|
|
it('registers a single global job with setInterval() taking no tenant parameter', () => {
|
|
const prisma = makeFakePrisma();
|
|
const addCronJob = vi.fn();
|
|
const getCronJob = vi.fn(() => {
|
|
throw new Error('not registered');
|
|
});
|
|
const schedulerRegistry = { addCronJob, getCronJob, deleteCronJob: vi.fn() } as any;
|
|
const tenderIngestionService = { pollDueSources: vi.fn() } as any;
|
|
|
|
const scheduler = new TenderSchedulerService(
|
|
schedulerRegistry,
|
|
tenderIngestionService,
|
|
prisma as any,
|
|
);
|
|
|
|
scheduler.setInterval(60);
|
|
|
|
expect(addCronJob).toHaveBeenCalledTimes(1);
|
|
expect(addCronJob.mock.calls[0][0]).toBe('tender-doe-poll');
|
|
// setInterval's only parameter is the interval — no tenant id anywhere in its signature.
|
|
expect(TenderSchedulerService.prototype.setInterval.length).toBe(1);
|
|
});
|
|
|
|
it('has no activeTenantId field and never falls back to findFirst() — poll-once-fan-out-many invariant', () => {
|
|
const source = readFileSync(join(__dirname, 'tender-scheduler.service.ts'), 'utf8');
|
|
expect(source).not.toMatch(/activeTenantId/);
|
|
expect(source).not.toMatch(/findFirst/);
|
|
expect(source).toMatch(/findUnique/);
|
|
});
|
|
|
|
it('registers the cron via onApplicationBootstrap (NOT onModuleInit) so the poll-config seed runs first on a fresh DB (#prod-bootstrap)', () => {
|
|
// Regression guard: TendersModule.onModuleInit seeds the doe-opendata
|
|
// poll config. onModuleInit hooks run in an unspecified order relative to
|
|
// one another, so on a FRESH database the scheduler must NOT read the
|
|
// config in its own onModuleInit — it would see the config absent and
|
|
// never register the single global cron that drives DÖE + RSS + email-alert
|
|
// ingestion (observed live on a fresh prod DB: 0 tenders, cron unregistered).
|
|
// onApplicationBootstrap runs after every module's onModuleInit → seed done.
|
|
const source = readFileSync(join(__dirname, 'tender-scheduler.service.ts'), 'utf8');
|
|
expect(source).toMatch(/implements OnApplicationBootstrap/);
|
|
expect(source).toMatch(/async onApplicationBootstrap\(/);
|
|
expect(source).not.toMatch(/async onModuleInit\(/);
|
|
// The lifecycle method actually exists on the instance.
|
|
expect(typeof TenderSchedulerService.prototype.onApplicationBootstrap).toBe(
|
|
'function',
|
|
);
|
|
});
|
|
});
|