import { ConflictException, NotFoundException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { TenderSavedSearchService } from './tender-saved-search.service'; /** * TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06 * (D-08/D-11) and the V4/IDOR access-control invariant (T-11-14): * * - create() scopes to (userId, tenantId); a second profile with the same * name for the SAME user is rejected (@@unique([userId,name])) — the * same name IS allowed across different users (scoped per-user, not * globally unique). * - list() is scoped strictly by userId — a foreign userId sees nothing * (IDOR). * - update()/remove() verify userId ownership before mutating, throwing * NotFoundException for both a missing row AND a foreign-owned row * (FavoritesService pattern — never distinguishes the two, to avoid * leaking existence of another user's profile). * * Uses the same hand-rolled prisma-shaped fake convention as * tender-triage.service.spec.ts / tenders.controller.spec.ts (in-memory * Map, no live DB connection). The fake simulates Prisma's P2002 unique- * constraint violation the same way a live Postgres unique index would. */ function makeFakePrisma() { const rows = new Map(); let counter = 0; function findByUserAndName(userId: string, name: string, excludeId?: string) { return Array.from(rows.values()).find( (r) => r.userId === userId && r.name === name && r.id !== excludeId, ); } function throwUniqueViolation(): never { const err: any = new Error('Unique constraint failed on the fields: (`userId`,`name`)'); err.code = 'P2002'; throw err; } return { tenderSavedSearch: { create: async ({ data }: any) => { if (findByUserAndName(data.userId, data.name)) throwUniqueViolation(); counter += 1; const record = { id: `ss-${counter}`, ...data, createdAt: new Date(), updatedAt: new Date(), }; rows.set(record.id, record); return record; }, findMany: async ({ where }: any) => Array.from(rows.values()).filter((r) => r.userId === where.userId), findUnique: async ({ where }: any) => rows.get(where.id) ?? null, update: async ({ where, data }: any) => { const existing = rows.get(where.id); const nextName = data.name ?? existing.name; if (findByUserAndName(existing.userId, nextName, existing.id)) { throwUniqueViolation(); } const record = { ...existing, ...data, updatedAt: new Date() }; rows.set(where.id, record); return record; }, delete: async ({ where }: any) => { rows.delete(where.id); }, }, }; } describe('TenderSavedSearchService', () => { it('create() creates a row scoped to userId + tenantId', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const result = await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: { q: 'Bau', bundesland: 'Nordrhein-Westfalen' }, }); expect(result.userId).toBe('u1'); expect(result.tenantId).toBe('tenant1'); expect(result.name).toBe('Bau NRW'); expect(result.filters).toEqual({ q: 'Bau', bundesland: 'Nordrhein-Westfalen' }); }); it('create() rejects a second profile with the same name for the same user (@@unique([userId,name]))', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }); await expect( service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }), ).rejects.toBeInstanceOf(ConflictException); }); it('create() allows the same name for two different users (unique is scoped per-user, not global)', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }); const result = await service.create('u2', 'tenant1', { name: 'Bau NRW', filters: {} }); expect(result.userId).toBe('u2'); }); it('list() scopes strictly by userId — a foreign user sees nothing (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {} }); expect(await service.list('u2')).toEqual([]); expect(await service.list('u1')).toHaveLength(1); }); it('update() applies a rename + filters change when owned by the caller', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: { q: 'x' } }); const updated = await service.update(created.id, 'u1', { name: 'Neu', filters: { q: 'y' }, }); expect(updated.name).toBe('Neu'); expect(updated.filters).toEqual({ q: 'y' }); }); it('update() throws NotFoundException when the row is owned by a different user (ownership check, IDOR)', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); await expect( service.update(created.id, 'u2', { name: 'Uebernahme' }), ).rejects.toBeInstanceOf(NotFoundException); }); it('update() throws NotFoundException for a nonexistent id', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); await expect( service.update('missing', 'u1', { name: 'x' }), ).rejects.toBeInstanceOf(NotFoundException); }); it('update() rejects a rename that collides with another of this user\'s existing profiles', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); await service.create('u1', 'tenant1', { name: 'Erstes Profil', filters: {} }); const second = await service.create('u1', 'tenant1', { name: 'Zweites Profil', filters: {} }); await expect( service.update(second.id, 'u1', { name: 'Erstes Profil' }), ).rejects.toBeInstanceOf(ConflictException); }); it('remove() throws NotFoundException when the row is owned by a different user (ownership check, IDOR)', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); await expect(service.remove(created.id, 'u2')).rejects.toBeInstanceOf(NotFoundException); }); it('remove() deletes the row when owned by the caller', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {} }); await service.remove(created.id, 'u1'); expect(await service.list('u1')).toEqual([]); }); // --- instantAlert passthrough (NOTIFY-02, D-04) --------------------------- it('create() persists instantAlert=true when supplied', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const result = await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {}, instantAlert: true, }); expect(result.instantAlert).toBe(true); }); it('create() leaves instantAlert unset (falls back to the Prisma column default) when omitted', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const result = await service.create('u1', 'tenant1', { name: 'Bau NRW', filters: {}, }); expect(result.instantAlert).toBeUndefined(); }); it('update() persists an instantAlert toggle for an owned profile', async () => { const prisma = makeFakePrisma(); const service = new TenderSavedSearchService(prisma as any); const created = await service.create('u1', 'tenant1', { name: 'Alt', filters: {}, instantAlert: false, }); const updated = await service.update(created.id, 'u1', { instantAlert: true }); expect(updated.instantAlert).toBe(true); }); });