feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig
- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos entfernt; MailService baut je Versand einen nodemailer-Transport aus getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts (4 Tests, T-GWH-03 geschlossen) - settings: Startpfad-Methode samt vier Spec-Tests geloescht; auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung) - ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId); Tests 301/306 umgedreht, neuer Altzeilen-Test - tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test) - tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test) - tender-notifications.integration.spec: Mock um forSystem - Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8), TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung ldapconfig-systemkontext-include-fieldmappings-beider-mandanten -> Alle 253 Pruefungen bestanden - Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe; Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert) - Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden - Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen: (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt, cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245 rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei admin-seed -> 3 rot - Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
// Systemkontext (260914-eym): die Kandidatenabfrage laeuft ueber forSystem().
|
||||
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
const MONDAY = new Date('2026-07-27T10:00:00Z');
|
||||
@@ -84,6 +86,7 @@ function makeFakePrisma(opts: {
|
||||
};
|
||||
|
||||
const modelsByName: Record<string, any> = { tenderMatch, tenderNotificationPref, user };
|
||||
const systemCallLog: { model: string; method: string }[] = [];
|
||||
|
||||
return {
|
||||
tenderMatch,
|
||||
@@ -91,6 +94,19 @@ function makeFakePrisma(opts: {
|
||||
user,
|
||||
__store: { matches, prefs, users },
|
||||
__boundCallLog: boundCallLog,
|
||||
__systemCallLog: systemCallLog,
|
||||
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog,
|
||||
// tenderMatch.findMany unveraendert (dieselbe Fake-Implementierung).
|
||||
__makeSystemClient() {
|
||||
return {
|
||||
tenderMatch: {
|
||||
findMany: async (args: any) => {
|
||||
systemCallLog.push({ model: 'tenderMatch', method: 'findMany' });
|
||||
return tenderMatch.findMany(args);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = {};
|
||||
for (const [modelName, model] of Object.entries(modelsByName)) {
|
||||
@@ -380,6 +396,26 @@ describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () =>
|
||||
expect(forTenant).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('die Kandidatenabfrage laeuft ueber den System-Klienten: __systemCallLog enthaelt genau tenderMatch.findMany, nichts aus der Schleife (260914-eym)', async () => {
|
||||
vi.mocked(forSystem).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: '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);
|
||||
|
||||
expect(forSystem).toHaveBeenCalledTimes(1);
|
||||
expect(forSystem).toHaveBeenCalledWith(prisma);
|
||||
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderMatch', method: 'findMany' }]);
|
||||
// Die Schleife (Praeferenz, Treffer, Benutzer, Stempelung) lief gebunden,
|
||||
// nicht ueber den System-Klienten.
|
||||
expect(prisma.__boundCallLog.length).toBeGreaterThan(0);
|
||||
expect(mail.sendDigest).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' }]]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { TenderMailItem, TenderMailService } from './tender-mail.service';
|
||||
|
||||
/**
|
||||
@@ -105,9 +105,13 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
async runDigest(now: Date = new Date()): Promise<void> {
|
||||
// Candidate users: distinct userId with at least one un-notified match,
|
||||
// across ALL tenants — a single findMany, never a per-tenant iteration.
|
||||
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — der bewusste Fan-out
|
||||
// über alle Mandanten dieses Bereichs; Etappe-3-Uebergabe (Systemkontext
|
||||
// für Hintergrundläufe wird dort entschieden, hier NICHT vorweggenommen).
|
||||
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
|
||||
// 260909-laa ist damit eingeloest): `forSystem()` liest TenderMatch
|
||||
// ALLER Mandanten NUR lesend (`system_read_policy ... FOR SELECT`,
|
||||
// Migration 20260914120000) — ohne diese Regel saehe der Digest nach dem
|
||||
// Scharfschalten 0 Kandidaten und wuerde stumm. Die Schleife unten
|
||||
// bleibt je Kandidatenzeile GEBUNDEN (bewusst ohne Benutzer, wie in 3b).
|
||||
// Eine LEERE Kandidatenliste ist Nichtstun: `notifiedAt` bleibt NULL.
|
||||
//
|
||||
// Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
|
||||
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten
|
||||
@@ -117,7 +121,8 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
|
||||
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
|
||||
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
|
||||
const candidates = await this.prisma.tenderMatch.findMany({
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
|
||||
where: { notifiedAt: null },
|
||||
select: { userId: true, tenantId: true },
|
||||
distinct: ['userId'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { TenderMatchingService } from './tender-matching.service';
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,8 @@ import { TenderMatchingService } from './tender-matching.service';
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
// Systemkontext (260914-eym): die Profilabfrage laeuft ueber forSystem().
|
||||
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
const PROFILE_A = {
|
||||
@@ -125,6 +127,7 @@ function makeFakePrisma(opts: {
|
||||
};
|
||||
|
||||
const modelsByName: Record<string, any> = { tenderMatch, user };
|
||||
const systemCallLog: { model: string; method: string }[] = [];
|
||||
|
||||
const prisma = {
|
||||
tenderSavedSearch,
|
||||
@@ -133,6 +136,20 @@ function makeFakePrisma(opts: {
|
||||
user,
|
||||
__store: { matches, savedSearches, tenders, users },
|
||||
__boundCallLog: boundCallLog,
|
||||
__systemCallLog: systemCallLog,
|
||||
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog;
|
||||
// nur tenderSavedSearch — ein Katalogzugriff ueber diesen Klienten
|
||||
// wuerde hart scheitern ("tender is undefined").
|
||||
__makeSystemClient() {
|
||||
return {
|
||||
tenderSavedSearch: {
|
||||
findMany: async () => {
|
||||
systemCallLog.push({ model: 'tenderSavedSearch', method: 'findMany' });
|
||||
return tenderSavedSearch.findMany();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = {};
|
||||
for (const [modelName, model] of Object.entries(modelsByName)) {
|
||||
@@ -445,6 +462,23 @@ describe('TenderMatchingService.matchDelta — Bindung an forTenant() (260909-la
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, PROFILE_A.tenantId);
|
||||
});
|
||||
|
||||
it('die Profilabfrage laeuft ueber den System-Klienten, der Katalog-Lesezugriff NICHT (weiter roher Client) (260914-eym)', async () => {
|
||||
vi.mocked(forSystem).mockClear();
|
||||
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(forSystem).toHaveBeenCalledTimes(1);
|
||||
expect(forSystem).toHaveBeenCalledWith(prisma);
|
||||
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderSavedSearch', method: 'findMany' }]);
|
||||
// Katalog: roher Client, genau einmal (ein Profil).
|
||||
expect(prisma.tender.findMany).toHaveBeenCalledTimes(1);
|
||||
// Treffer-Anlage gebunden.
|
||||
expect(prisma.__boundCallLog.some((c: any) => c.model === 'tenderMatch' && c.method === 'upsert')).toBe(true);
|
||||
});
|
||||
|
||||
it('die Treffer-Anlage bindet an den Mandanten DES PROFILS — EIN gebundener Client je Profil, nicht je Treffer', async () => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { TenderMailService } from './tender-mail.service';
|
||||
import { buildTenderWhere } from './tender-query.builder';
|
||||
import type { TenderQueryDto } from './dto/tender-query.dto';
|
||||
@@ -64,11 +64,17 @@ export class TenderMatchingService {
|
||||
async matchDelta(newTenderIds: string[]): Promise<void> {
|
||||
if (!newTenderIds.length) return;
|
||||
|
||||
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — Profile aller Mandanten
|
||||
// werden gegen neue Treffer geprueft, der bewusste Fan-out dieses
|
||||
// Bereichs; Etappe-3-Uebergabe (Systemkontext fuer Hintergrundlaeufe
|
||||
// wird dort entschieden, hier NICHT vorweggenommen).
|
||||
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
|
||||
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
|
||||
// 260909-laa ist damit eingeloest): Profile ALLER Mandanten werden gegen
|
||||
// neue Treffer geprueft — `forSystem()` liest TenderSavedSearch NUR
|
||||
// lesend (`system_read_policy ... FOR SELECT`, Migration 20260914120000);
|
||||
// ohne diese Regel saehe der Abgleich nach dem Scharfschalten 0 Profile
|
||||
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
|
||||
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
|
||||
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
|
||||
await systemPrisma.tenderSavedSearch.findMany();
|
||||
|
||||
for (const search of savedSearches) {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
// Systemkontext (260914-eym): Profil- und Kandidatenabfrage laufen ueber forSystem().
|
||||
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -136,6 +138,14 @@ function makeSharedFakePrisma(opts: {
|
||||
user: userModel,
|
||||
__store: { matches },
|
||||
__boundCallLog: boundCallLog,
|
||||
// Systemkontext-Klient (260914-eym): dieselben Fake-Implementierungen,
|
||||
// ohne Bindungsprotokoll.
|
||||
__makeSystemClient() {
|
||||
return {
|
||||
tenderSavedSearch: { findMany: async () => [savedSearch] },
|
||||
tenderMatch: { findMany: async (args: any) => tenderMatch.findMany(args) },
|
||||
};
|
||||
},
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = {};
|
||||
for (const [modelName, model] of Object.entries(modelsByName)) {
|
||||
|
||||
Reference in New Issue
Block a user