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:
@@ -14,47 +14,79 @@ import { TenderTriageService } from './tender-triage.service';
|
||||
* against the applied migration on the live dev DB), the service's read
|
||||
* path must not leak orphaned rows (Pitfall 6).
|
||||
*
|
||||
* Uses the same hand-rolled prisma-shaped fake convention as
|
||||
* tender-ingestion.service.spec.ts / tenders.controller.spec.ts (in-memory
|
||||
* Map, no live DB connection).
|
||||
* Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()`
|
||||
* wraps the SAME in-memory Map with a per-model, per-call logging layer —
|
||||
* a pure identity mock (`(p) => p`, the ldap-era mistake) 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 makeFakePrisma() {
|
||||
const rows = new Map<string, any>();
|
||||
const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`;
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
return {
|
||||
tenderTriage: {
|
||||
upsert: vi.fn(async ({ where, update, create }: any) => {
|
||||
const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId);
|
||||
const existing = rows.get(k);
|
||||
const record = existing
|
||||
? { ...existing, ...update, updatedAt: new Date() }
|
||||
: { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
||||
rows.set(k, record);
|
||||
return record;
|
||||
}),
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
return Array.from(rows.values()).filter((r) => {
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false;
|
||||
if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false;
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
// Simulates the schema-level `onDelete: Cascade` FK constraint
|
||||
// (Pitfall 6). Real enforcement is verified separately by applying
|
||||
// the 20260721160000_add_tender_triage migration and checking
|
||||
// `SELECT * FROM "TenderTriage"` after a live delete — this fake
|
||||
// proves the service's read path (listForUser/favoriteIds) is
|
||||
// consistent with that cascade once rows are gone.
|
||||
_simulateTenderCascadeDelete: (tenderId: string) => {
|
||||
for (const [k, r] of rows) {
|
||||
if (r.tenderId === tenderId) rows.delete(k);
|
||||
}
|
||||
},
|
||||
const tenderTriage = {
|
||||
upsert: vi.fn(async ({ where, update, create }: any) => {
|
||||
const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId);
|
||||
const existing = rows.get(k);
|
||||
const record = existing
|
||||
? { ...existing, ...update, updatedAt: new Date() }
|
||||
: { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
||||
rows.set(k, record);
|
||||
return record;
|
||||
}),
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
return Array.from(rows.values()).filter((r) => {
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false;
|
||||
if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false;
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
// Simulates the schema-level `onDelete: Cascade` FK constraint
|
||||
// (Pitfall 6). Real enforcement is verified separately by applying
|
||||
// the 20260721160000_add_tender_triage migration and checking
|
||||
// `SELECT * FROM "TenderTriage"` after a live delete — this fake
|
||||
// proves the service's read path (listForUser/favoriteIds) is
|
||||
// consistent with that cascade once rows are gone.
|
||||
_simulateTenderCascadeDelete: (tenderId: string) => {
|
||||
for (const [k, r] of rows) {
|
||||
if (r.tenderId === tenderId) rows.delete(k);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const fake: any = {
|
||||
tenderTriage,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const wrapped: any = {};
|
||||
for (const method of ['upsert', 'findMany']) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: 'tenderTriage', method });
|
||||
return (tenderTriage as any)[method](...args);
|
||||
};
|
||||
}
|
||||
return { tenderTriage: wrapped };
|
||||
},
|
||||
};
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) => c.tenantId === tenantId && c.model === 'tenderTriage' && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf tenderTriage.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe('TenderTriageService', () => {
|
||||
@@ -65,7 +97,7 @@ describe('TenderTriageService', () => {
|
||||
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
|
||||
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
|
||||
|
||||
const rows = await service.listForUser('u1', ['t1']);
|
||||
const rows = await service.listForUser('u1', 'tenant1', ['t1']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].isRead).toBe(true);
|
||||
});
|
||||
@@ -108,7 +140,7 @@ describe('TenderTriageService', () => {
|
||||
|
||||
await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true });
|
||||
|
||||
const foreignRows = await service.listForUser('u2', ['t1']);
|
||||
const foreignRows = await service.listForUser('u2', 'tenant1', ['t1']);
|
||||
expect(foreignRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -116,7 +148,7 @@ describe('TenderTriageService', () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderTriageService(prisma as any);
|
||||
|
||||
const rows = await service.listForUser('u1', []);
|
||||
const rows = await service.listForUser('u1', 'tenant1', []);
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled();
|
||||
@@ -130,8 +162,8 @@ describe('TenderTriageService', () => {
|
||||
await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false });
|
||||
await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true });
|
||||
|
||||
expect(await service.favoriteIds('u1')).toEqual(['t1']);
|
||||
expect(await service.favoriteIds('u2')).toEqual(['t3']);
|
||||
expect(await service.favoriteIds('u1', 'tenant1')).toEqual(['t1']);
|
||||
expect(await service.favoriteIds('u2', 'tenant1')).toEqual(['t3']);
|
||||
});
|
||||
|
||||
it('cascade: after a tender\'s triage rows are removed (DB onDelete: Cascade), it no longer appears for any user', async () => {
|
||||
@@ -141,7 +173,38 @@ describe('TenderTriageService', () => {
|
||||
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
|
||||
prisma.tenderTriage._simulateTenderCascadeDelete('t1');
|
||||
|
||||
expect(await service.listForUser('u1', ['t1'])).toHaveLength(0);
|
||||
expect(await service.favoriteIds('u1')).toEqual([]);
|
||||
expect(await service.listForUser('u1', 'tenant1', ['t1'])).toHaveLength(0);
|
||||
expect(await service.favoriteIds('u1', 'tenant1')).toEqual([]);
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() (260909-laa, Aufgabe 2) -----------------------
|
||||
|
||||
describe('Bindung an forTenant() (260909-laa)', () => {
|
||||
it('setTriage() bindet tenderTriage.upsert an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderTriageService(prisma as any);
|
||||
|
||||
await service.setTriage('u1', 't1', 'tender-x', { isRead: true });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'upsert');
|
||||
});
|
||||
|
||||
it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderTriageService(prisma as any);
|
||||
|
||||
await service.listForUser('u1', 't1', ['tender-x']);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'findMany');
|
||||
});
|
||||
|
||||
it('favoriteIds() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderTriageService(prisma as any);
|
||||
|
||||
await service.favoriteIds('u1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'findMany');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user