From 444c68b8ec195853df7ada23a77f05388d36c819 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 11:11:09 +0200 Subject: [PATCH] test(10-04): add two-tenant safety integration test (Success Criteria 4 & 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the phase's headline acceptance criterion: activating tender-radar for a 2nd tenant triggers zero additional DÖE calls, zero additional cron jobs (still exactly one 'tender-doe-poll'), and zero additional Tender rows. Drives the real (unmocked) ModuleRegistryService against a fake prisma to exercise the genuine activateForTenant() call path. Passes immediately because Task 2's TenderSchedulerService already implements the poll-once-fan-out-many invariant correctly — this test locks in and regression-proofs that already-correct architecture rather than driving new production code (documented in SUMMARY under TDD Gate Compliance). --- .../tenders/tender-scheduler.service.spec.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 apps/api/src/tenders/tender-scheduler.service.spec.ts diff --git a/apps/api/src/tenders/tender-scheduler.service.spec.ts b/apps/api/src/tenders/tender-scheduler.service.spec.ts new file mode 100644 index 0000000..f534148 --- /dev/null +++ b/apps/api/src/tenders/tender-scheduler.service.spec.ts @@ -0,0 +1,142 @@ +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/); + }); +});