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. */ 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.onModuleInit(); 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/); }); });