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,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([]);
});
});