Files
tessera-ctl/apps/api/src/tenders/tender-matching.service.spec.ts
T
schalli 86c184f8d5 test(12-03): add failing tests for instant-alert dispatch in matchDelta
RED: covers D-04 (instantAlert=true only), D-05 (bundling per profile/tick),
D-06 (stamp notifiedAt/channel=instant only after success), retry-safety on
send failure (per-profile catch, other profiles unaffected), and no-op when
a profile has no fresh notifiedAt=NULL matches this tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:20:43 +02:00

395 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { TenderMatchingService } from './tender-matching.service';
/**
* TenderMatchingService.matchDelta — delta-only matching (Plan 12-01, Task
* 2, RED first). Covers the NOTIFY-03 core invariant: matching runs ONLY
* against the tender IDs newly upserted in the current poll tick
* (`newTenderIds`), never a historical/full-table rescan (D-07 — this is
* the structural backfill-flood prevention), and match creation is an
* idempotent upsert on @@unique([tenderId, savedSearchId]) with `update:
* {}` so a re-match never resets an already-set `notifiedAt` (D-06).
*
* A hand-rolled Prisma-shaped mock is used (this repo's established
* convention — see tender-ingestion.service.spec.ts) rather than a live
* DB connection.
*/
const PROFILE_A = {
id: 'search-a',
userId: 'user-1',
tenantId: 'tenant-1',
filters: { q: 'Straßenbau' },
};
const PROFILE_B = {
id: 'search-b',
userId: 'user-2',
tenantId: 'tenant-1',
filters: { plz: '10' },
};
const PROFILE_C = {
id: 'search-c',
userId: 'user-3',
tenantId: 'tenant-2',
filters: { bundesland: 'Bayern' },
};
function makeFakePrisma(opts: {
savedSearches?: any[];
matchingTenderIds?: Set<string>;
existingMatches?: Map<string, any>;
tenders?: Map<string, any>;
users?: Map<string, any>;
}) {
const matches = opts.existingMatches ?? new Map<string, any>();
const savedSearches = opts.savedSearches ?? [PROFILE_A, PROFILE_B, PROFILE_C];
const matchingTenderIds = opts.matchingTenderIds ?? new Set<string>();
const tenders = opts.tenders ?? new Map<string, any>();
const users = opts.users ?? new Map<string, any>();
const prisma = {
tenderSavedSearch: {
findMany: vi.fn(async () => savedSearches),
},
tender: {
// Simulates buildTenderWhere(profile.filters) AND id IN newTenderIds:
// the fake ignores the actual `where` shape (buildTenderWhere is
// unit-tested elsewhere) and just intersects the caller-provided
// `id: { in: [...] }` filter against matchingTenderIds — the set of
// tender IDs that would satisfy this profile's filters.
findMany: vi.fn(async ({ where }: any) => {
const idFilter: string[] = where.AND.find((c: any) => c.id)?.id?.in ?? [];
const hits = idFilter.filter((id) => matchingTenderIds.has(id));
return hits.map((id) => ({ id }));
}),
},
tenderMatch: {
upsert: vi.fn(async ({ where, update, create }: any) => {
const key = `${where.tenderId_savedSearchId.tenderId}::${where.tenderId_savedSearchId.savedSearchId}`;
const existing = matches.get(key);
if (existing) {
matches.set(key, { ...existing, ...update });
return matches.get(key);
}
const created = {
id: `match-${matches.size + 1}`,
notifiedAt: null,
notifiedChannel: null,
...create,
};
matches.set(key, created);
return created;
}),
// Instant-dispatch fresh-match lookup (Plan 12-03): savedSearchId +
// notifiedAt:null + tenderId IN newTenderIds, joined with `tender`.
findMany: vi.fn(async ({ where }: any) => {
const savedSearchId = where.savedSearchId;
const tenderIdIn: string[] | undefined = where.tenderId?.in;
const wantsUnnotified = 'notifiedAt' in where && where.notifiedAt === null;
const rows = Array.from(matches.values()).filter((m: any) => {
if (savedSearchId !== undefined && m.savedSearchId !== savedSearchId) return false;
if (wantsUnnotified && m.notifiedAt != null) return false;
if (tenderIdIn && !tenderIdIn.includes(m.tenderId)) return false;
return true;
});
return rows.map((m: any) => ({
...m,
tender: tenders.get(m.tenderId) ?? { id: m.tenderId, title: m.tenderId },
}));
}),
updateMany: vi.fn(async ({ where, data }: any) => {
const ids: string[] = where.id.in;
let count = 0;
for (const m of matches.values()) {
if (ids.includes(m.id)) {
Object.assign(m, data);
count++;
}
}
return { count };
}),
},
user: {
findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null),
},
__store: { matches, savedSearches, tenders, users },
};
return prisma;
}
function makeFakeMail(opts: { sendInstant?: ReturnType<typeof vi.fn> } = {}) {
return {
sendInstant: opts.sendInstant ?? vi.fn().mockResolvedValue(true),
};
}
describe('TenderMatchingService.matchDelta — delta-only (D-07, Pitfall 2)', () => {
it('queries only newTenderIds (id IN newTenderIds) via buildTenderWhere per profile — never an unfiltered full-table scan', async () => {
const matchingTenderIds = new Set(['new-1', 'new-2']);
const prisma = makeFakePrisma({ matchingTenderIds });
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1', 'new-2']);
for (const call of (prisma.tender.findMany as any).mock.calls) {
const [{ where }] = call;
expect(where.AND).toBeDefined();
const idClause = where.AND.find((c: any) => c.id);
expect(idClause).toBeDefined();
expect(idClause.id.in).toEqual(['new-1', 'new-2']);
}
});
it('produces ZERO matches when ~2188 pre-existing tenders are simulated as NOT in newTenderIds (profile creation causes no backfill flood)', async () => {
// Simulate a large pre-existing catalog: none of it is ever passed as
// newTenderIds, so it can never surface as a match — delta-only is a
// structural guarantee, not merely a query optimization.
const preExistingIds = Array.from({ length: 2188 }, (_, i) => `existing-${i}`);
const matchingTenderIds = new Set(preExistingIds); // even if ALL of them would match the filter...
const prisma = makeFakePrisma({ matchingTenderIds });
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta([]); // ...an empty delta yields 0 matches
expect(prisma.tenderMatch.upsert).not.toHaveBeenCalled();
expect(prisma.__store.matches.size).toBe(0);
});
});
describe('TenderMatchingService.matchDelta — Match-Erzeugung', () => {
it('creates exactly one tenderMatch.upsert per (matching tender x profile) pair, with notifiedAt left undefined (stays NULL)', async () => {
const matchingTenderIds = new Set(['new-1']);
const prisma = makeFakePrisma({
savedSearches: [PROFILE_A],
matchingTenderIds,
});
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1']);
expect(prisma.tenderMatch.upsert).toHaveBeenCalledTimes(1);
const call = (prisma.tenderMatch.upsert as any).mock.calls[0][0];
expect(call.where).toEqual({
tenderId_savedSearchId: { tenderId: 'new-1', savedSearchId: PROFILE_A.id },
});
expect(call.create).toEqual({
tenderId: 'new-1',
savedSearchId: PROFILE_A.id,
userId: PROFILE_A.userId,
tenantId: PROFILE_A.tenantId,
});
expect(call.create.notifiedAt).toBeUndefined();
});
it('evaluates every active profile independently — a tender matching two profiles yields two match rows', async () => {
const matchingTenderIds = new Set(['new-1']);
const prisma = makeFakePrisma({
savedSearches: [PROFILE_A, PROFILE_B],
matchingTenderIds,
});
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1']);
expect(prisma.tenderMatch.upsert).toHaveBeenCalledTimes(2);
});
});
describe('TenderMatchingService.matchDelta — Idempotenz (D-06)', () => {
it('upserts with update:{} — a re-match of the same pair does NOT overwrite an already-set notifiedAt', async () => {
const matchingTenderIds = new Set(['new-1']);
const existingMatches = new Map<string, any>([
[
`new-1::${PROFILE_A.id}`,
{
id: 'match-existing',
tenderId: 'new-1',
savedSearchId: PROFILE_A.id,
userId: PROFILE_A.userId,
tenantId: PROFILE_A.tenantId,
notifiedAt: new Date('2026-07-01T00:00:00Z'),
notifiedChannel: 'instant',
},
],
]);
const prisma = makeFakePrisma({
savedSearches: [PROFILE_A],
matchingTenderIds,
existingMatches,
});
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1']);
const call = (prisma.tenderMatch.upsert as any).mock.calls[0][0];
expect(call.update).toEqual({});
const stored = prisma.__store.matches.get(`new-1::${PROFILE_A.id}`);
expect(stored.notifiedAt).toEqual(new Date('2026-07-01T00:00:00Z'));
expect(stored.notifiedChannel).toBe('instant');
});
});
describe('TenderMatchingService.matchDelta — leeres Delta', () => {
it('does nothing (no DB access at all) when newTenderIds is empty', async () => {
const prisma = makeFakePrisma({});
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await expect(service.matchDelta([])).resolves.toBeUndefined();
expect(prisma.tenderSavedSearch.findMany).not.toHaveBeenCalled();
expect(prisma.tender.findMany).not.toHaveBeenCalled();
expect(prisma.tenderMatch.upsert).not.toHaveBeenCalled();
});
});
/**
* Instant-Dispatch (Plan 12-03, Task 1, RED first) — NOTIFY-02 + die
* Instant-Hälfte von NOTIFY-03: am Ende von matchDelta (NACH allen
* Match-Upserts dieses Ticks) werden Profile mit instantAlert=true auf
* frische (notifiedAt=NULL) Treffer dieses Ticks geprüft und — falls
* vorhanden — zu EINER Sammel-Sofort-Mail gebündelt (D-05), NUR bei
* erfolgreichem sendInstant sofort mit notifiedAt='instant' gestempelt
* (D-06 — dasselbe Gate, das der Digest liest).
*/
const INSTANT_PROFILE = {
id: 'search-instant',
userId: 'user-instant',
tenantId: 'tenant-1',
name: 'Instant-Profil',
filters: { q: 'Straßenbau' },
instantAlert: true,
};
const NON_INSTANT_PROFILE = {
id: 'search-normal',
userId: 'user-normal',
tenantId: 'tenant-1',
name: 'Normal-Profil',
filters: { q: 'Straßenbau' },
instantAlert: false,
};
const INSTANT_USER = { id: 'user-instant', email: 'instant@tenant-1.de', tenantId: 'tenant-1' };
const NORMAL_USER = { id: 'user-normal', email: 'normal@tenant-1.de', tenantId: 'tenant-1' };
describe('TenderMatchingService.matchDelta — Instant-Dispatch nur instantAlert=true (D-04)', () => {
it('a profile with instantAlert=true triggers sendInstant, a matching profile with instantAlert=false does NOT', async () => {
const matchingTenderIds = new Set(['new-1']);
const users = new Map([
['user-instant', INSTANT_USER],
['user-normal', NORMAL_USER],
]);
const prisma = makeFakePrisma({
savedSearches: [INSTANT_PROFILE, NON_INSTANT_PROFILE],
matchingTenderIds,
users,
});
const sendInstant = vi.fn().mockResolvedValue(true);
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
await service.matchDelta(['new-1']);
expect(sendInstant).toHaveBeenCalledTimes(1);
const [recipient, tenantId, searchArg] = sendInstant.mock.calls[0];
expect(recipient).toEqual({ email: INSTANT_USER.email });
expect(tenantId).toBe(INSTANT_PROFILE.tenantId);
expect(searchArg).toEqual({ name: INSTANT_PROFILE.name });
});
});
describe('TenderMatchingService.matchDelta — Bündelung pro Profil/Tick (D-05)', () => {
it('three freshly-matched tenders of the same instantAlert profile in one tick yield exactly ONE sendInstant call with all three tenders', async () => {
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
const users = new Map([['user-instant', INSTANT_USER]]);
const tenders = new Map([
['new-1', { id: 'new-1', title: 'Tender 1' }],
['new-2', { id: 'new-2', title: 'Tender 2' }],
['new-3', { id: 'new-3', title: 'Tender 3' }],
]);
const prisma = makeFakePrisma({
savedSearches: [INSTANT_PROFILE],
matchingTenderIds,
users,
tenders,
});
const sendInstant = vi.fn().mockResolvedValue(true);
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
await service.matchDelta(['new-1', 'new-2', 'new-3']);
expect(sendInstant).toHaveBeenCalledTimes(1);
const tendersArg = sendInstant.mock.calls[0][3];
expect(tendersArg).toHaveLength(3);
expect(tendersArg.map((t: any) => t.id).sort()).toEqual(['new-1', 'new-2', 'new-3']);
});
});
describe('TenderMatchingService.matchDelta — Stempelung nach Erfolg (D-06)', () => {
it('after a successful sendInstant, exactly the sent matches carry notifiedAt=now, notifiedChannel="instant"', async () => {
const matchingTenderIds = new Set(['new-1']);
const users = new Map([['user-instant', INSTANT_USER]]);
const prisma = makeFakePrisma({
savedSearches: [INSTANT_PROFILE],
matchingTenderIds,
users,
});
const sendInstant = vi.fn().mockResolvedValue(true);
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
await service.matchDelta(['new-1']);
const stored = prisma.__store.matches.get(`new-1::${INSTANT_PROFILE.id}`);
expect(stored.notifiedAt).toBeInstanceOf(Date);
expect(stored.notifiedChannel).toBe('instant');
});
});
describe('TenderMatchingService.matchDelta — Retry-Sicherheit (Robustheit)', () => {
it('when sendInstant fails/throws for one profile, notifiedAt stays NULL for that profile and the rest of the tick (other profiles) still runs', async () => {
const matchingTenderIds = new Set(['new-1', 'new-2']);
const FAILING_PROFILE = { ...INSTANT_PROFILE, id: 'search-fail', userId: 'user-fail', name: 'Fail-Profil' };
const OK_PROFILE = { ...INSTANT_PROFILE, id: 'search-ok', userId: 'user-ok', name: 'OK-Profil' };
const users = new Map([
['user-fail', { id: 'user-fail', email: 'fail@tenant-1.de', tenantId: 'tenant-1' }],
['user-ok', { id: 'user-ok', email: 'ok@tenant-1.de', tenantId: 'tenant-1' }],
]);
const prisma = makeFakePrisma({
savedSearches: [FAILING_PROFILE, OK_PROFILE],
matchingTenderIds,
users,
});
const sendInstant = vi.fn(async (_recipient: any, _tenantId: string, search: any) => {
if (search.name === 'Fail-Profil') throw new Error('SMTP down');
return true;
});
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
await expect(service.matchDelta(['new-1', 'new-2'])).resolves.toBeUndefined();
expect(sendInstant).toHaveBeenCalledTimes(2); // both profiles attempted
const failStored = prisma.__store.matches.get(`new-1::${FAILING_PROFILE.id}`);
expect(failStored.notifiedAt).toBeNull();
const okStored = prisma.__store.matches.get(`new-1::${OK_PROFILE.id}`);
expect(okStored.notifiedAt).toBeInstanceOf(Date);
expect(okStored.notifiedChannel).toBe('instant');
});
});
describe('TenderMatchingService.matchDelta — nichts Neues löst keinen Alert aus', () => {
it('an instantAlert profile without any fresh notifiedAt=NULL matches this tick never calls sendInstant', async () => {
// matchingTenderIds empty -> no new TenderMatch rows created this tick,
// so the fresh (notifiedAt=NULL, tenderId IN newTenderIds) lookup is empty.
const users = new Map([['user-instant', INSTANT_USER]]);
const prisma = makeFakePrisma({
savedSearches: [INSTANT_PROFILE],
matchingTenderIds: new Set<string>(),
users,
});
const sendInstant = vi.fn().mockResolvedValue(true);
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
await service.matchDelta(['new-1']);
expect(sendInstant).not.toHaveBeenCalled();
});
});