Files
tessera-ctl/apps/api/src/tenders/tender-notification-pref.service.spec.ts
T
schalli 07fc653f52 feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht
- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
  dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
  tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
  durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
  Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
  Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
  umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
  TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
  runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
  WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
  SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
  runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
  Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
  Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
  searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
  alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
  Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
  203/203 bestanden (vorher 146).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 17:39:17 +02:00

162 lines
6.3 KiB
TypeScript

import { ConflictException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { TenderNotificationPrefService } from './tender-notification-pref.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01
* (D-01/D-03) and the V4/IDOR access-control invariant (T-12-14):
*
* - getForUser() without an existing row returns a default
* { 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.
*
* 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 }[] = [];
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', () => {
it('getForUser() returns a default digestInterval="daily" when no row exists (D-01)', async () => {
const prisma = makeFakePrisma();
const service = new TenderNotificationPrefService(prisma as any);
const result = await service.getForUser('u1', 'tenant1');
expect(result.digestInterval).toBe('daily');
});
it('setForUser() upserts on userId, creating a row scoped to (userId, tenantId) (D-03)', async () => {
const prisma = makeFakePrisma();
const service = new TenderNotificationPrefService(prisma as any);
const result = await service.setForUser('u1', 'tenant1', 'weekly');
expect(result.userId).toBe('u1');
expect(result.tenantId).toBe('tenant1');
expect(result.digestInterval).toBe('weekly');
});
it('setForUser() called a second time updates the SAME row (@@unique userId), not a new one', async () => {
const prisma = makeFakePrisma();
const service = new TenderNotificationPrefService(prisma as any);
await service.setForUser('u1', 'tenant1', 'weekly');
const second = await service.setForUser('u1', 'tenant1', 'off');
expect(second.digestInterval).toBe('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 () => {
const prisma = makeFakePrisma();
const service = new TenderNotificationPrefService(prisma as any);
await service.setForUser('u1', 'tenant1', 'weekly');
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');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
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');
});
});
});