test(11-05): add failing TenderTriageService spec + TenderTriage schema/migration

RED phase (TDD) for UI-03/04 per-user triage (gelesen/ungelesen, Favorit).
Adds the TenderTriage Prisma model (userId-scoped, onDelete: Cascade to
Tender per Pitfall 6) and its migration, plus a failing spec proving
upsert idempotency, strict userId scoping (V4/IDOR, T-11-10), and cascade
consistency — service implementation follows in the GREEN commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:28:38 +02:00
parent 4c22d7731a
commit 7bb695d37a
3 changed files with 203 additions and 0 deletions
@@ -0,0 +1,32 @@
-- CreateTable
-- UI-03/04 (D-09/D-10/D-11) — per-user Triage-Zustand (gelesen/ungelesen,
-- Favorit) pro Tender. Scoping-Muster wie FavoriteLink (T-08-06, Pitfall 4):
-- userId-Scoping im Service, KEIN forTenant()/RLS. tenantId wird zusätzlich
-- mitgeführt (T-11-12, spätere Tenant-Isolation), ist aber nicht das
-- Scoping-Feld. ON DELETE CASCADE stellt sicher, dass Retention-Löschungen
-- (Phase 10, 90 Tage) keine verwaisten Triage-Zeilen hinterlassen (Pitfall 6).
CREATE TABLE IF NOT EXISTS "TenderTriage" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"tenderId" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
"readAt" TIMESTAMP(3),
"favoritedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TenderTriage_pkey" PRIMARY KEY ("id"),
CONSTRAINT "TenderTriage_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "Tender"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
-- Upsert-Target für setTriage (@@unique([userId,tenderId])).
CREATE UNIQUE INDEX IF NOT EXISTS "TenderTriage_userId_tenderId_key" ON "TenderTriage"("userId", "tenderId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TenderTriage_userId_idx" ON "TenderTriage"("userId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TenderTriage_tenderId_idx" ON "TenderTriage"("tenderId");
+24
View File
@@ -290,6 +290,30 @@ model Tender {
@@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance @@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance
@@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome) @@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome)
// Deliberately NO tenant column and NO tenant index — this is global data (D-03) // Deliberately NO tenant column and NO tenant index — this is global data (D-03)
triage TenderTriage[]
}
// UI-03/04 — per-user Triage-Zustand pro Tender (gelesen/ungelesen, Favorit).
// Scoping-Muster wie FavoriteLink (T-08-06, Pitfall 4): userId-Scoping im
// Service, KEIN forTenant()/RLS — RLS existiert nur für Auth-Kerntabellen.
// tenantId wird zusätzlich mitgeführt (spätere Tenant-Isolation, T-11-12),
// ist aber NICHT das Scoping-Feld — jede Query filtert auf userId.
model TenderTriage {
id String @id @default(uuid())
userId String
tenantId String
tenderId String
isRead Boolean @default(false)
isFavorite Boolean @default(false)
readAt DateTime?
favoritedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
@@unique([userId, tenderId]) // Upsert-Target (setTriage); ein Triage-Row je (user,tender)
@@index([userId])
@@index([tenderId])
} }
// Singleton-per-source admin poll config (INGEST-06 foundation). // Singleton-per-source admin poll config (INGEST-06 foundation).
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest';
import { TenderTriageService } from './tender-triage.service';
/**
* TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/
* D-11) and the V4/IDOR access-control invariant (T-11-10):
*
* - setTriage() is idempotent: repeated calls with the same params upsert
* onto exactly one row (@@unique([userId,tenderId]) target).
* - listForUser()/favoriteIds() are scoped strictly by userId — a foreign
* userId must see nothing, even for the same tenderId (IDOR).
* - Cascade: once a tender's triage rows are removed (simulating the DB's
* `onDelete: Cascade` FK — real enforcement is verified separately
* 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).
*/
function makeFakePrisma() {
const rows = new Map<string, any>();
const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`;
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);
}
},
},
};
}
describe('TenderTriageService', () => {
it('setTriage is idempotent: calling twice with identical params results in exactly one row', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
const rows = await service.listForUser('u1', ['t1']);
expect(rows).toHaveLength(1);
expect(rows[0].isRead).toBe(true);
});
it('setTriage upserts isFavorite=true and stamps favoritedAt', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
const result = await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
expect(result.isFavorite).toBe(true);
expect(result.favoritedAt).toBeInstanceOf(Date);
});
it('setTriage toggling isRead back to false clears readAt', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: false });
expect(result.isRead).toBe(false);
expect(result.readAt).toBeNull();
});
it('a partial update (isRead only) does not clobber a previously-set isFavorite', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: true });
expect(result.isRead).toBe(true);
expect(result.isFavorite).toBe(true);
});
it('listForUser scopes strictly by userId — a foreign user sees nothing for the same tenderId (V4 / IDOR)', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true });
const foreignRows = await service.listForUser('u2', ['t1']);
expect(foreignRows).toHaveLength(0);
});
it('listForUser with an empty tenderIds array short-circuits to an empty result without querying prisma', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
const rows = await service.listForUser('u1', []);
expect(rows).toEqual([]);
expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled();
});
it('favoriteIds returns only tenderIds favorited by that exact user, never another user\'s (V4 / IDOR)', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
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']);
});
it('cascade: after a tender\'s triage rows are removed (DB onDelete: Cascade), it no longer appears for any user', async () => {
const prisma = makeFakePrisma();
const service = new TenderTriageService(prisma as any);
await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true });
(prisma as any)._simulateTenderCascadeDelete('t1');
expect(await service.listForUser('u1', ['t1'])).toHaveLength(0);
expect(await service.favoriteIds('u1')).toEqual([]);
});
});