From 8cbf4c12b88d29ddce8e2ed2762543a9053f1d93 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 16:12:08 +0200 Subject: [PATCH] fix(quick-260909-laa): dritte Eindeutigkeitsverletzung uebersetzen statt sie zu behaupten --- .../src/tenders/tender-triage.service.spec.ts | 46 ++++++++++++++++++ apps/api/src/tenders/tender-triage.service.ts | 48 +++++++++++++------ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/apps/api/src/tenders/tender-triage.service.spec.ts b/apps/api/src/tenders/tender-triage.service.spec.ts index 6d0b905..243e963 100644 --- a/apps/api/src/tenders/tender-triage.service.spec.ts +++ b/apps/api/src/tenders/tender-triage.service.spec.ts @@ -1,3 +1,4 @@ +import { ConflictException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TenderTriageService } from './tender-triage.service'; @@ -207,4 +208,49 @@ describe('TenderTriageService', () => { expectBoundCall(prisma, 't1', 'findMany'); }); }); + + /** + * Gegenrichtung der Bindung (Befund F, 260909-laa). Der Eindeutigkeits- + * schluessel `@@unique([userId, tenderId])` traegt keine Mandanten- + * dimension. Ist die vorhandene Zeile unter dem gebundenen Kontext + * unsichtbar, findet das `upsert` sie nicht, versucht anzulegen und + * laeuft in die Eindeutigkeitsverletzung — aus stillem Ueberschreiben + * wird ein harter Fehler. + * + * Diese Pruefung fehlte in der ersten Lieferung von 260909-laa: die + * Zusammenfassung behauptete die Uebersetzung fuer ALLE DREI + * mandantenlosen Eindeutigkeitsschluessel, gebaut war sie nur fuer zwei. + * Vom Verifizierer gefunden, hier nachgereicht. + */ + describe('P2002 auf mandantenlosem Eindeutigkeitsschluessel (Befund F)', () => { + it('setTriage() uebersetzt die Eindeutigkeitsverletzung in eine ConflictException statt in einen 500', async () => { + const prisma = makeFakePrisma(); + const verletzung: any = new Error( + 'Unique constraint failed on the fields: (`userId`,`tenderId`)', + ); + verletzung.code = 'P2002'; + prisma.tenderTriage.upsert = vi.fn(async () => { + throw verletzung; + }); + const service = new TenderTriageService(prisma as any); + + await expect( + service.setTriage('u1', 't1', 'tender-x', { isFavorite: true }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('setTriage() reicht jeden anderen Datenbankfehler unveraendert durch', async () => { + const prisma = makeFakePrisma(); + const anderer: any = new Error('Verbindung verloren'); + anderer.code = 'P1001'; + prisma.tenderTriage.upsert = vi.fn(async () => { + throw anderer; + }); + const service = new TenderTriageService(prisma as any); + + await expect( + service.setTriage('u1', 't1', 'tender-x', { isFavorite: true }), + ).rejects.toBe(anderer); + }); + }); }); diff --git a/apps/api/src/tenders/tender-triage.service.ts b/apps/api/src/tenders/tender-triage.service.ts index f47b3dc..c1b109a 100644 --- a/apps/api/src/tenders/tender-triage.service.ts +++ b/apps/api/src/tenders/tender-triage.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ConflictException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; @@ -39,6 +39,17 @@ export class TenderTriageService { * with the same params never creates a second row. Only the fields * present in `dto` are touched; the other flag (and its timestamp) is * left as-is on both the update and create branches. + * + * Gegenrichtung der Bindung (Befund F, 260909-laa): der Eindeutigkeits- + * schluessel `@@unique([userId, tenderId])` traegt KEINE Mandanten- + * dimension. Ist die vorhandene Zeile unter dem gebundenen Kontext + * unsichtbar (veralteter Mandant in der Sitzung), findet das `upsert` + * sie nicht, versucht anzulegen und laeuft in die Eindeutigkeits- + * verletzung — aus stillem Ueberschreiben wird ein harter Fehler. Der + * `P2002`-Zweig uebersetzt das in eine verstaendliche deutsche Meldung + * statt in einen 500, wie in `tender-saved-search.service.ts`. Gemessen + * in `rls-scratch-check.mjs`, Pruefung + * `tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit`. */ async setTriage( userId: string, @@ -59,19 +70,28 @@ export class TenderTriageService { } const tenantPrisma = forTenant(this.prisma, tenantId) as any; - return tenantPrisma.tenderTriage.upsert({ - where: { userId_tenderId: { userId, tenderId } }, - update, - create: { - userId, - tenantId, - tenderId, - isRead: dto.isRead ?? false, - isFavorite: dto.isFavorite ?? false, - readAt: dto.isRead ? now : null, - favoritedAt: dto.isFavorite ? now : null, - }, - }); + try { + return await tenantPrisma.tenderTriage.upsert({ + where: { userId_tenderId: { userId, tenderId } }, + update, + create: { + userId, + tenantId, + tenderId, + isRead: dto.isRead ?? false, + isFavorite: dto.isFavorite ?? false, + readAt: dto.isRead ? now : null, + favoritedAt: dto.isFavorite ? now : null, + }, + }); + } catch (error: any) { + if (error?.code === 'P2002') { + throw new ConflictException( + 'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.', + ); + } + throw error; + } } /**