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(); modules.set('mod-1', { id: 'mod-1', slug: 'tender-radar', name: 'Ausschreibungs-Radar', }); const activations = new Map(); const tenders: unknown[] = []; const configs = new Map(); 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', ); }); });