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:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||
|
||||
/**
|
||||
@@ -9,28 +10,72 @@ import { TenderNotificationPrefService } from './tender-notification-pref.servic
|
||||
* { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite.
|
||||
* - setForUser() upserts on the @@unique userId (D-03); a second call with
|
||||
* a different value updates the SAME row rather than creating a new one.
|
||||
* - setForUser() translates a P2002 (unique-constraint violation on a
|
||||
* stale-tenant upsert, T-LAA-07/Befund F) into a German ConflictException
|
||||
* instead of a raw error.
|
||||
*
|
||||
* Uses the same hand-rolled prisma-shaped fake convention as
|
||||
* tender-saved-search.service.spec.ts / tender-triage.service.spec.ts
|
||||
* (in-memory Map, no live DB connection).
|
||||
* Bindung an forTenant() (260909-laa, Befund C/H) — Muster aus
|
||||
* `groups.service.spec.ts` (260909-jts): `__makeBoundClient()` wraps the
|
||||
* SAME in-memory Map with a per-call logging layer, so a forgotten
|
||||
* `forTenant()` call is visible as a missing log entry, not just a passing
|
||||
* test either way.
|
||||
*/
|
||||
|
||||
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 boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
return {
|
||||
tenderNotificationPref: {
|
||||
findUnique: async ({ where }: any) => rows.get(where.userId) ?? null,
|
||||
upsert: async ({ where, create, update }: any) => {
|
||||
const existing = rows.get(where.userId);
|
||||
const record = existing
|
||||
? { ...existing, ...update, updatedAt: new Date() }
|
||||
: { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
||||
rows.set(where.userId, record);
|
||||
return record;
|
||||
},
|
||||
function throwUniqueViolation(): never {
|
||||
const err: any = new Error('Unique constraint failed on the fields: (`userId`)');
|
||||
err.code = 'P2002';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const tenderNotificationPref = {
|
||||
findUnique: async ({ where }: any) => rows.get(where.userId) ?? null,
|
||||
upsert: async ({ where, create, update }: any) => {
|
||||
const existing = rows.get(where.userId);
|
||||
const record = existing
|
||||
? { ...existing, ...update, updatedAt: new Date() }
|
||||
: { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
||||
rows.set(where.userId, record);
|
||||
return record;
|
||||
},
|
||||
__throwUniqueViolationOnNextUpsert: false,
|
||||
};
|
||||
|
||||
const fake: any = {
|
||||
tenderNotificationPref,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const wrapped: any = {};
|
||||
for (const method of ['findUnique', 'upsert']) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: 'tenderNotificationPref', method });
|
||||
return (tenderNotificationPref as any)[method](...args);
|
||||
};
|
||||
}
|
||||
return { tenderNotificationPref: wrapped };
|
||||
},
|
||||
__throwUniqueViolation: throwUniqueViolation,
|
||||
};
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
function expectBoundCall(prisma: any, tenantId: string, method: string) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) =>
|
||||
c.tenantId === tenantId && c.model === 'tenderNotificationPref' && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf tenderNotificationPref.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe('TenderNotificationPrefService', () => {
|
||||
@@ -38,7 +83,7 @@ describe('TenderNotificationPrefService', () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
const result = await service.getForUser('u1');
|
||||
const result = await service.getForUser('u1', 'tenant1');
|
||||
|
||||
expect(result.digestInterval).toBe('daily');
|
||||
});
|
||||
@@ -62,7 +107,7 @@ describe('TenderNotificationPrefService', () => {
|
||||
const second = await service.setForUser('u1', 'tenant1', 'off');
|
||||
|
||||
expect(second.digestInterval).toBe('off');
|
||||
expect(await service.getForUser('u1')).toMatchObject({ digestInterval: 'off' });
|
||||
expect(await service.getForUser('u1', 'tenant1')).toMatchObject({ digestInterval: 'off' });
|
||||
});
|
||||
|
||||
it('getForUser() is scoped strictly by userId — a foreign userId never sees another user\'s pref (V4 / IDOR)', async () => {
|
||||
@@ -71,7 +116,43 @@ describe('TenderNotificationPrefService', () => {
|
||||
|
||||
await service.setForUser('u1', 'tenant1', 'weekly');
|
||||
|
||||
const foreign = await service.getForUser('u2');
|
||||
const foreign = await service.getForUser('u2', 'tenant1');
|
||||
expect(foreign.digestInterval).toBe('daily'); // default, not u1's 'weekly'
|
||||
});
|
||||
|
||||
// --- Fehlerbehandlung (260909-laa, Befund F / T-LAA-07) -------------------
|
||||
|
||||
it('setForUser() translates a P2002 unique-constraint violation into a German ConflictException, never a raw error', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.tenderNotificationPref.upsert = vi.fn(async () => {
|
||||
prisma.__throwUniqueViolation();
|
||||
});
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
await expect(service.setForUser('u1', 'tenant1', 'weekly')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() (260909-laa, Aufgabe 2) -----------------------
|
||||
|
||||
describe('Bindung an forTenant() (260909-laa)', () => {
|
||||
it('getForUser() bindet tenderNotificationPref.findUnique an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
await service.getForUser('u1', 't1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'findUnique');
|
||||
});
|
||||
|
||||
it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
await service.setForUser('u1', 't1', 'weekly');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'upsert');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user