feat(laa-02): binde die fuenf Nutzer-CRUD-Dienste des Bereichs tenders an forTenant()

tender-saved-search.service.ts, tender-triage.service.ts,
tender-notification-pref.service.ts und tender-email-config.service.ts
laufen jetzt vollstaendig ueber forTenant() — vier neue Parameter (list,
update, remove, listForUser, favoriteIds, getForUser, getConfigForApi,
testConnection bekommen tenantId), die anwendungsseitige userId-Filterung
bleibt unveraendert (Befund E: die Policies haben keine Benutzerdimension).

tender-rss-feed.service.ts bindet nur createForUser (Zaehler + Anlage,
beide ausschliesslich auf persoenlichen Zeilen); listForUser, createPlatform
und remove bleiben mit Codekommentar bewusst ungebunden (WINDOWS #19 —
eine gebundene plattformweite Zeile waere unter jedem Mandanten unsichtbar,
ein gebundenes Einfuegen ohne Mandant wuerde abgewiesen).

tender-notification-pref.service.ts und tender-email-config.service.ts
uebersetzen eine P2002-Verletzung auf dem tenantlosen upsert-Schluessel
(Befund F) in eine verstaendliche deutsche Meldung statt eines rohen
Fehlers.

tenders.controller.ts reicht tenantId an den acht betroffenen
Aufrufstellen durch extractTriageContext() durch (kein neuer
Aufloesungsweg); die drei RSS-Aufrufstellen bleiben unveraendert, da ihre
Dienstmethoden nicht binden.

Alle sieben angefassten Testdateien bekommen den Zwei-Client-Nachweis
(__makeBoundClient ueber demselben Speicher) und Bindungstests je
umgestellter Methode; tender-rss-feed.service.spec.ts zusaetzlich den
Gegentest, dass die drei unveraendert bleibenden Pfade forTenant() NICHT
aufrufen. Falsifiziert: ein probeweiser Rueckbau der list()-Bindung in
tender-saved-search.service.ts machte genau den erwarteten Bindungstest
rot, danach zurueckgenommen.

docs/mandantentrennung-zugriffsklassifikation.md: Stand der fuenf Paare
auf gebunden bzw. gemischt nachgezogen; tenderRssFeedSource von
muss-mandantengebunden auf beides umklassifiziert (derselbe Praezedenzfall
wie ldapConfig in 260909-ipc).

761 Tests gruen (743 + 18 neue Bindungsnachweise), 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:
2026-09-09 15:53:53 +02:00
parent 349814747e
commit 3336a6e419
13 changed files with 790 additions and 229 deletions
@@ -13,8 +13,17 @@ import { TenderEmailConfigService } from './tender-email-config.service';
* `where: { userId }` upsert target), and two new cases prove the actual
* new capability: two users of the SAME tenant get two independent rows,
* and tenantId is written on create (denormalized, D-01).
*
* Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()`
* wraps the SAME in-memory Map with a per-call logging layer — a pure
* identity mock would leave a forgotten `forTenant()` call invisible to
* every test. Muster aus `groups.service.spec.ts` (260909-jts).
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
function makeFakeCrypto() {
return {
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
@@ -27,28 +36,55 @@ function makeFakeCrypto() {
function makeFakePrisma() {
const configs = new Map<string, any>();
return {
tenderEmailConfig: {
findUnique: vi.fn(async ({ where, select }: any) => {
const row = configs.get(where.userId);
if (!row) return null;
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
}),
upsert: vi.fn(async ({ where, update, create, select }: any) => {
const existing = configs.get(where.userId);
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.userId, row);
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
}),
},
__store: configs,
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const tenderEmailConfig = {
findUnique: vi.fn(async ({ where, select }: any) => {
const row = configs.get(where.userId);
if (!row) return null;
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
}),
upsert: vi.fn(async ({ where, update, create, select }: any) => {
const existing = configs.get(where.userId);
const row = existing ? { ...existing, ...update } : { id: `cfg-${configs.size + 1}`, ...create };
configs.set(where.userId, row);
if (!select) return row;
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
}),
};
const fake: any = {
tenderEmailConfig,
__store: configs,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
const wrapped: any = {};
for (const method of Object.keys(tenderEmailConfig)) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: 'tenderEmailConfig', method });
return (tenderEmailConfig as any)[method](...args);
};
}
return { tenderEmailConfig: wrapped };
},
};
return fake;
}
function expectBoundCall(prisma: any, tenantId: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === 'tenderEmailConfig' && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf tenderEmailConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
describe('TenderEmailConfigService', () => {
@@ -57,7 +93,7 @@ describe('TenderEmailConfigService', () => {
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
const result = await service.getConfigForApi('user-missing');
const result = await service.getConfigForApi('user-missing', 'tenant-x');
expect(result).toBeNull();
});
@@ -81,7 +117,7 @@ describe('TenderEmailConfigService', () => {
} as any,
);
const apiResult = await service.getConfigForApi('user-a');
const apiResult = await service.getConfigForApi('user-a', 'tenant-a');
expect(apiResult).not.toBeNull();
expect(apiResult).not.toHaveProperty('password');
@@ -107,7 +143,7 @@ describe('TenderEmailConfigService', () => {
} as any,
);
const apiResult = await service.getConfigForApi('user-b');
const apiResult = await service.getConfigForApi('user-b', 'tenant-b');
expect(apiResult!.hasPassword).toBe(false);
expect(apiResult!.username).toBeNull();
@@ -223,8 +259,8 @@ describe('TenderEmailConfigService', () => {
{ protocol: 'imap', encryption: 'ssl-tls', host: 'imap.user-g2.test' } as any,
);
const configG1 = await service.getConfigForApi('user-g1');
const configG2 = await service.getConfigForApi('user-g2');
const configG1 = await service.getConfigForApi('user-g1', 'tenant-shared');
const configG2 = await service.getConfigForApi('user-g2', 'tenant-shared');
expect(configG1!.host).toBe('imap.user-g1.test');
expect(configG2!.host).toBe('imap.user-g2.test');
@@ -250,7 +286,7 @@ describe('TenderEmailConfigService', () => {
exchangeProvider as any,
);
const result = await service.testConnection('user-h', {
const result = await service.testConnection('user-h', 'tenant-h', {
protocol: 'imap',
encryption: 'ssl-tls',
host: 'imap.example.test',
@@ -286,7 +322,7 @@ describe('TenderEmailConfigService', () => {
} as any,
);
await service.testConnection('user-i', {
await service.testConnection('user-i', 'tenant-i', {
protocol: 'imap',
encryption: 'ssl-tls',
} as any);
@@ -307,7 +343,7 @@ describe('TenderEmailConfigService', () => {
exchangeProvider as any,
);
await service.testConnection('user-j', {
await service.testConnection('user-j', 'tenant-j', {
protocol: 'exchange',
encryption: 'ssl-tls',
username: 'ews-user',
@@ -318,4 +354,73 @@ describe('TenderEmailConfigService', () => {
expect(imapProvider.testConnection).not.toHaveBeenCalled();
});
});
// --- Bindung an forTenant() (260909-laa, Aufgabe 2) -----------------------
describe('Bindung an forTenant() (260909-laa)', () => {
it('getConfigForApi() bindet beide tenderEmailConfig.findUnique-Zugriffe an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig(
{ userId: 'user-k', tenantId: 't1' },
{ protocol: 'imap', encryption: 'ssl-tls', username: 'k', password: 'p' } as any,
);
prisma.__boundCallLog.length = 0;
await service.getConfigForApi('user-k', 't1');
const findUniqueCalls = prisma.__boundCallLog.filter(
(c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique',
);
expect(findUniqueCalls.length).toBe(2);
});
it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const service = new TenderEmailConfigService(prisma as any, crypto as any);
await service.saveConfig(
{ userId: 'user-l', tenantId: 't1' },
{ protocol: 'imap', encryption: 'ssl-tls', username: 'only-username' } as any,
);
expectBoundCall(prisma, 't1', 'findUnique');
expectBoundCall(prisma, 't1', 'upsert');
});
it('testConnection() bindet den Zugangsdaten-Rueckgriff an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const crypto = makeFakeCrypto();
const { imapProvider, exchangeProvider } = makeFakeProviders();
const service = new TenderEmailConfigService(
prisma as any,
crypto as any,
imapProvider as any,
exchangeProvider as any,
);
await service.saveConfig(
{ userId: 'user-m', tenantId: 't1' },
{ protocol: 'imap', encryption: 'ssl-tls', username: 'm', password: 'p' } as any,
);
prisma.__boundCallLog.length = 0;
await service.testConnection('user-m', 't1', {
protocol: 'imap',
encryption: 'ssl-tls',
} as any);
expectBoundCall(prisma, 't1', 'findUnique');
});
function makeFakeProviders() {
return {
imapProvider: { testConnection: vi.fn(async () => ({ success: true })) },
exchangeProvider: { testConnection: vi.fn(async () => ({ success: true })) },
};
}
});
});