feat(laa-03): binde die Je-Treffer-Haelften der Hintergrunddienste und schliesse die Dokumente
tender-digest.scheduler.ts: die uebergreifende Kandidatenabfrage (tenderMatch.findMany mit distinct:['userId']) bleibt bewusst ungebunden und waehlt zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit aus; innerhalb der Schleife binden tenderNotificationPref.findUnique, tenderMatch.findMany/updateMany und user.findUnique an den Mandanten DIESER Kandidatenzeile. tender-matching.service.ts: die Profilabfrage (tenderSavedSearch.findMany) und der Lesezugriff auf den plattformweiten Tender-Katalog (D-03) bleiben ungebunden; innerhalb der Profilschleife bindet die Treffer-Anlage (tenderMatch.upsert), im nachgelagerten Instant-Dispatch binden tenderMatch.findMany/updateMany und user.findUnique — je EIN gebundener Client pro Profil, nicht neu je Treffer. Beide Dateien tragen Codekommentare, die die uebergreifenden Abfragen ausdruecklich als Etappe-3-Uebergabe benennen — die Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen, nicht verwischt. Alle drei betroffenen Testdateien (inkl. der gemeinsamen Integrationsdatei) bekommen den Zwei-Client-Nachweis, Tests fuer Zwei-Mandanten-Laeufe und die lautlose Fehlerform (kein Versand, notifiedAt bleibt NULL). Falsifiziert: ein probeweiser Rueckbau der user.findUnique-Bindung im Instant-Dispatch von tender-matching.service.ts machte genau den erwarteten Test rot, danach zurueckgenommen. rls-access-inventory.spec.ts gemessen und docs/mandantentrennung-zugriffsklassifikation.md nachgezogen: Stand der fuenf betroffenen Paare (tender-digest.scheduler.ts/ tenderMatch,tenderNotificationPref,user; tender-matching.service.ts/ tenderMatch,user) auf gemischt bzw. gebunden; Bereichsuebersicht und Klassen-Verteilung neu gemessen (tenders jetzt 36 ungebunden/26 gebunden); "Der Hintergrunddienst als Falle" um den Abschluss beider Dateien ergaenzt. docs/mandantentrennung-etappe2-fehlerrichtung.md: (t4) um den Nachtrag ergaenzt, dass die Je-Treffer-Haelften geschlossen sind und die uebergreifenden Haelften an Etappe 3 uebergeben bleiben. 770 Tests gruen, Typpruefung sauber, Wegwerf-Werkzeug 32/32, kein Schema-/Migrations-/Compose-/Umgebungsdatei-Diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,19 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||
* 2026-07-27 is a verified Monday, 2026-07-28 a verified Tuesday
|
||||
* (Europe/Berlin) — passed explicitly to `runDigest(now)` rather than faking
|
||||
* the system clock.
|
||||
*
|
||||
* Bindung an forTenant() je Schleifendurchlauf (260909-laa, Aufgabe 3,
|
||||
* Befund C/H): `__makeBoundClient()` liefert denselben protokollierenden
|
||||
* Wrapper wie in den Nutzer-CRUD-Diensten dieses Bereichs (Muster aus
|
||||
* `groups.service.spec.ts`) — die uebergreifende Kandidatenabfrage
|
||||
* (`tenderMatch.findMany` mit `distinct`) bleibt UNGEBUNDEN, die drei
|
||||
* Zugriffe je Kandidatenzeile (`tenderNotificationPref.findUnique`,
|
||||
* `tenderMatch.findMany`/`updateMany`, `user.findUnique`) binden an den
|
||||
* Mandanten DIESER Zeile.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
}));
|
||||
|
||||
const MONDAY = new Date('2026-07-27T10:00:00Z');
|
||||
const TUESDAY = new Date('2026-07-28T10:00:00Z');
|
||||
@@ -29,6 +42,7 @@ function makeFakePrisma(opts: {
|
||||
const matches = opts.matches ?? [];
|
||||
const prefs = opts.prefs ?? new Map<string, any>();
|
||||
const users = opts.users ?? new Map<string, any>();
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
const tenderMatch = {
|
||||
findMany: vi.fn(async ({ where, distinct }: any) => {
|
||||
@@ -62,16 +76,35 @@ function makeFakePrisma(opts: {
|
||||
return { count };
|
||||
}),
|
||||
};
|
||||
const tenderNotificationPref = {
|
||||
findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null),
|
||||
};
|
||||
const user = {
|
||||
findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null),
|
||||
};
|
||||
|
||||
const modelsByName: Record<string, any> = { tenderMatch, tenderNotificationPref, user };
|
||||
|
||||
return {
|
||||
tenderMatch,
|
||||
tenderNotificationPref: {
|
||||
findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null),
|
||||
},
|
||||
tenderNotificationPref,
|
||||
user,
|
||||
__store: { matches, prefs, users },
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = {};
|
||||
for (const [modelName, model] of Object.entries(modelsByName)) {
|
||||
const wrapped: any = {};
|
||||
for (const method of Object.keys(model)) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: modelName, method });
|
||||
return model[method](...args);
|
||||
};
|
||||
}
|
||||
bound[modelName] = wrapped;
|
||||
}
|
||||
return bound;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,3 +357,90 @@ describe('TenderDigestScheduler — Cron-Registrierung (Phase-10-Muster)', () =>
|
||||
expect(registry.addCronJob.mock.calls[0][0]).toBe('tender-digest');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() je Schleifendurchlauf (260909-laa, Aufgabe 3) ---
|
||||
|
||||
describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () => {
|
||||
it('die uebergreifende Kandidatenabfrage bindet NICHT — forTenant() wird fuer sie nicht aufgerufen', async () => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
|
||||
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'off' }]]);
|
||||
const matches = [makeMatch({ userId: 'user-1' })];
|
||||
const prisma = makeFakePrisma({ matches, prefs, users });
|
||||
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
|
||||
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
|
||||
|
||||
await scheduler.runDigest(TUESDAY);
|
||||
|
||||
// Die Kandidatenabfrage selbst (findMany mit distinct, VOR der Schleife)
|
||||
// lief auf dem unveraenderten `this.prisma`, nie auf einem gebundenen
|
||||
// Client — genau EIN forTenant()-Aufruf (fuer den einen Kandidaten,
|
||||
// dessen Praeferenz 'off' vor jedem weiteren Zugriff abbricht), nicht
|
||||
// zwei oder mehr fuer die Kandidatenabfrage selbst.
|
||||
expect(forTenant).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('die Zugriffe je Kandidatenzeile binden an den Mandanten DIESER Zeile — Zugriffe innerhalb der Schleife laufen auf dem gebundenen Client', async () => {
|
||||
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
|
||||
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
|
||||
const matches = [makeMatch({ userId: 'user-1', tenantId: 'tenant-1' })];
|
||||
const prisma = makeFakePrisma({ matches, prefs, users });
|
||||
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
|
||||
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
|
||||
|
||||
await scheduler.runDigest(TUESDAY);
|
||||
|
||||
const boundModels = prisma.__boundCallLog
|
||||
.filter((c: any) => c.tenantId === 'tenant-1')
|
||||
.map((c: any) => `${c.model}.${c.method}`);
|
||||
expect(boundModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tenderNotificationPref.findUnique',
|
||||
'tenderMatch.findMany',
|
||||
'user.findUnique',
|
||||
'tenderMatch.updateMany',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('zwei Mandanten in einem Lauf: jeder Kandidat bindet an den Mandanten SEINER Zeile, nicht einmal global mit dem ersten', async () => {
|
||||
const users = new Map([
|
||||
['user-1', { id: 'user-1', email: 'a@tenant-a.de', tenantId: 'tenant-a' }],
|
||||
['user-2', { id: 'user-2', email: 'b@tenant-b.de', tenantId: 'tenant-b' }],
|
||||
]);
|
||||
const prefs = new Map([
|
||||
['user-1', { userId: 'user-1', digestInterval: 'daily' }],
|
||||
['user-2', { userId: 'user-2', digestInterval: 'daily' }],
|
||||
]);
|
||||
const matches = [
|
||||
makeMatch({ id: 'm1', userId: 'user-1', tenantId: 'tenant-a' }),
|
||||
makeMatch({ id: 'm2', userId: 'user-2', tenantId: 'tenant-b' }),
|
||||
];
|
||||
const prisma = makeFakePrisma({ matches, prefs, users });
|
||||
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
|
||||
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
|
||||
|
||||
await scheduler.runDigest(TUESDAY);
|
||||
|
||||
const tenantsBoundForUserFindUnique = prisma.__boundCallLog
|
||||
.filter((c: any) => c.model === 'user' && c.method === 'findUnique')
|
||||
.map((c: any) => c.tenantId)
|
||||
.sort();
|
||||
expect(tenantsBoundForUserFindUnique).toEqual(['tenant-a', 'tenant-b']);
|
||||
});
|
||||
|
||||
it('lautlose Fehlerform: liefert der gebundene Lesezugriff auf den Benutzer nichts, wird nichts versendet UND notifiedAt bleibt NULL (wiederholbar)', async () => {
|
||||
const users = new Map<string, any>(); // kein Konto sichtbar unter dem gebundenen Kontext
|
||||
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
|
||||
const openMatch = makeMatch({ id: 'm-open', userId: 'user-1', tenantId: 'tenant-1' });
|
||||
const matches = [openMatch];
|
||||
const prisma = makeFakePrisma({ matches, prefs, users });
|
||||
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
|
||||
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
|
||||
|
||||
await scheduler.runDigest(TUESDAY);
|
||||
|
||||
expect(mail.sendDigest).not.toHaveBeenCalled();
|
||||
expect(openMatch.notifiedAt).toBeNull(); // wiederholbar beim naechsten Lauf
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user