test(10-04): add two-tenant safety integration test (Success Criteria 4 & 5)
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).
This commit is contained in:
@@ -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<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.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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user