Files
tessera-ctl/apps/api/src/tenders/tender-scheduler.service.spec.ts
T
schalli 9c0eefee90 feat(quick-260910-exd): ModuleRegistryService binden, Klassifikation abschliessen
- module-registry.service.ts: findActiveForTenant, activateForTenant,
  isModuleActive binden je einen Aktivierungszugriff, deactivateForTenant
  bindet beide (Lesen+Schreiben) ueber EINEN Klienten unter tenantPrisma;
  alle sechs Katalogzugriffe (findAll/findBySlug/beide
  Existenzpruefungen/isModuleActive-Katalogsuche/seedModule) bleiben
  bewusst ungebunden, mit Kommentar der Messung von Bedingung trennt
- isModuleActive-Kopfkommentar richtiggestellt: der Waechter ruft sie
  nicht auf (0 Aufrufer, TEIL 3 von Aufgabe 1) — Waechter nimmt findBySlug
  + ModuleAccessService.getAccessibleModuleIds
- module-registry.service.spec.ts: NEU, Zwei-Klienten-Nachweis, deckt die
  bislang ungetestete Datei mit elf der siebzehn Zugriffe des Bereichs ab,
  inkl. der lauten (deactivate ohne Aktivierung) und stillen (isModuleActive
  ohne Aktivierung) Richtung und dem Katalog-Wachhund
- tender-scheduler.service.spec.ts: forTenant() auf Identitaet gemockt
  (dieselbe Konvention wie ldap.service.spec.ts) — cross-area Bruch durch
  die Umstellung von activateForTenant behoben (Rule 1/3)
- docs/mandantentrennung-zugriffsklassifikation.md: alle fuenf
  handgepflegten Stellen nachgezogen (Bestandsaufnahme, Uebersichtszeile
  7/10, Summenzeile 108/134, Klassen-Verteilung unveraendert bei 63 Paaren,
  Hintergrunddienst-Abschnitt haelt die Abwesenheit eines sechsten Falls
  fest) — alle gemessen, nicht abgeschrieben, Befund K haelt exakt
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtrag mit
  tatsaechlich umgesetzten Pfaden, beiden Falsifizierungsnachweisen
  (Testname+Meldung), und der Feststellung zum unveraenderten
  Controller-Kommentar
- .planning/WINDOWS.md: neuer offener Eintrag #23 (deviation) — kein Signal
  unterscheidet "keine Freigabe" von "Abfrage fand nichts", mit
  Vorabpruefung fuer Etappe 4 und begruendeter Verwerfung einer
  Laufzeitwarnung
- 833 Tests gruen (56 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-10 11:30:37 +02:00

171 lines
7.3 KiB
TypeScript

import { readFileSync } from 'fs';
import { join } from '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',
);
});
});