import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Role } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { TendersController } from './tenders.controller'; /** * TendersController.spec — automated proof for INGEST-06 and the * tenant-gated-not-scoped invariant (RESEARCH.md V4, T-10-14): * * - The global read path (`GET /`) never adds the tenant's id to the * prisma `where` clause — the catalog is platform-wide, not row-scoped. * - Saving the admin source-config drives the scheduler's setInterval() * (single argument, no tenant id) / stopJob() exactly as the DKV analog * does, minus the tenant argument. * * Uses the same hand-rolled prisma-shaped fake convention as * tender-ingestion.service.spec.ts / tender-scheduler.service.spec.ts * (in-memory fakes, no live DB connection). */ function makeFakePrisma() { const tenders = new Map(); tenders.set('t1', { id: 't1', title: 'Beispielausschreibung', status: 'active', sourcePortal: 'doe-opendata', sources: [ { sourcePortal: 'doe-opendata', sourceUrl: 'https://doe.example/t1', sourceNoticeId: 'doe-1', }, { sourcePortal: 'cosinex-dtvp', sourceUrl: 'https://dtvp.example/t1', sourceNoticeId: 'dtvp-1', }, ], }); const configs = new Map(); configs.set('doe-opendata', { id: 'cfg1', sourceType: 'doe-opendata', pollIntervalMin: 60, isActive: true, lastIngestedDay: null, }); return { tender: { findMany: vi.fn(async (_args?: any) => Array.from(tenders.values())), count: vi.fn(async (_args?: any) => tenders.size), findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null), groupBy: vi.fn(async (_args?: any) => [ { sourcePortal: 'doe-opendata', _count: tenders.size }, ]), }, tenderSourcePollConfig: { findUnique: vi.fn(async ({ where }: any) => configs.get(where.sourceType) ?? null), upsert: vi.fn(async ({ where, update, create }: any) => { const existing = configs.get(where.sourceType); const record = existing ? { ...existing, ...update } : { ...create }; configs.set(where.sourceType, record); return record; }), }, }; } /** * Fake TenderTriageService for controller-level wiring tests (Plan 11-05, * Task 2). Default stubs return empty results — individual tests override * via `.mockResolvedValueOnce`/reassigning the mock as needed. */ function makeFakeTriageService() { return { favoriteIds: vi.fn(async (_userId: string) => [] as string[]), listForUser: vi.fn(async (_userId: string, _tenderIds: string[]) => [] as any[]), setTriage: vi.fn(async (_userId: string, _tenantId: string, _tenderId: string, _dto: any) => ({})), }; } /** * Minimal authenticated Express Request fake (userId/tenantId/role). * `role` defaults to `Role.USER` — Phase 17, Plan 02 (T-17-08) added it so * `extractTriageContext` can resolve the caller's role from the SAME place * `RolesGuard` reads it, for the `POST /rss-feeds` scope:'platform' check. */ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1', role: Role = Role.USER) { return { user: { id: userId, tenantId, role }, tenantId } as any; } /** * Fake TenderRssFeedSourceService for controller-level wiring tests (Plan * 14-02, Task 3; ownership split since Phase 17, Plan 02, D-02). Default * stubs echo/list-nothing; individual tests override via * `.mockResolvedValueOnce`/reassigning the mock — including `createForUser`/ * `createPlatform` rejecting with BadRequestException to prove the * denylist error surfaces through the controller unchanged. */ function makeFakeRssFeedService() { return { listForUser: vi.fn(async (_userId: string) => [] as any[]), createForUser: vi.fn( async (ctx: { userId: string; tenantId: string }, dto: any) => ({ id: 'feed-1', isActive: true, userId: ctx.userId, tenantId: ctx.tenantId, ...dto, }), ), createPlatform: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, userId: null, tenantId: null, ...dto, })), remove: vi.fn(async (_id: string) => ({ success: true })), }; } /** * Fake TenderEmailConfigService for controller-level wiring tests (Plan * 14-03, D-06/D-07/CONFIG-02; per-user ownership since Phase 17, Plan 01, * D-01). Default stubs echo/return null; individual tests override via * `.mockResolvedValueOnce`/reassigning the mock. */ function makeFakeEmailConfigService() { return { getConfigForApi: vi.fn(async (_userId: string) => null as any), saveConfig: vi.fn(async (_ctx: { userId: string; tenantId: string }, dto: any) => ({ id: 'ec-1', ...dto, })), testConnection: vi.fn(async (_userId: string, _dto: any) => ({ success: true }) as any), }; } /** * Fake TenderSavedSearchService for controller-level wiring tests (Plan * 11-06, Task 2). Default stubs return empty/echo results — individual * tests override via `.mockResolvedValueOnce`/reassigning the mock. */ function makeFakeSavedSearchService() { return { list: vi.fn(async (_userId: string) => [] as any[]), create: vi.fn(async (_userId: string, _tenantId: string, dto: any) => ({ id: 'ss-1', ...dto, })), update: vi.fn(async (_id: string, _userId: string, dto: any) => ({ id: 'ss-1', ...dto, })), remove: vi.fn(async (_id: string, _userId: string) => undefined), }; } /** * Fake TenderNotificationPrefService for controller-level wiring tests * (Plan 12-04, NOTIFY-01/T-12-14). Default stubs return a daily default / * echo the upserted value; individual tests override via * `.mockResolvedValueOnce`/reassigning the mock. */ function makeFakeNotificationPrefService() { return { getForUser: vi.fn(async (_userId: string) => ({ digestInterval: 'daily' })), setForUser: vi.fn(async (_userId: string, _tenantId: string, digestInterval: string) => ({ digestInterval, })), }; } /** * Fake TenderIngestionService for the poll-now controller wiring test * (Quick 260723-lvg). Only `pollDueSources` is exercised by the controller. */ function makeFakeIngestionService() { return { pollDueSources: vi.fn(async () => undefined), }; } describe('TendersController — POST /poll-now (admin manual trigger)', () => { it('pollNow() calls pollDueSources() exactly once and returns { ok: true }', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const ingestionService = makeFakeIngestionService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ingestionService as any, ); const result = await controller.pollNow(); expect(ingestionService.pollDueSources).toHaveBeenCalledTimes(1); expect(result).toEqual({ ok: true }); }); it('pollNow carries @Roles(ADMIN, SUPER_ADMIN) metadata', () => { const roles = Reflect.getMetadata(ROLES_KEY, TendersController.prototype.pollNow); expect(roles).toContain(Role.ADMIN); expect(roles).toContain(Role.SUPER_ADMIN); }); }); describe('TendersController — global read (not tenant-scoped)', () => { it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({}); expect(prisma.tender.findMany).toHaveBeenCalledTimes(1); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.where).not.toHaveProperty('tenantId'); }); it('GET /:id returns the tender when found and never scopes by tenant', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.getTender('t1'); expect(result.id).toBe('t1'); const callArgs = prisma.tender.findUnique.mock.calls[0][0]; expect(callArgs.where).toEqual({ id: 't1' }); }); it('GET /:id includes the sources relation so the response carries all TenderSource links (13-06, SCHEMA-03)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.getTender('t1'); const callArgs = prisma.tender.findUnique.mock.calls[0][0]; expect(callArgs.include).toEqual({ sources: { select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true }, }, }); expect(result.sources).toEqual([ { sourcePortal: 'doe-opendata', sourceUrl: 'https://doe.example/t1', sourceNoticeId: 'doe-1', }, { sourcePortal: 'cosinex-dtvp', sourceUrl: 'https://dtvp.example/t1', sourceNoticeId: 'dtvp-1', }, ]); }); it('GET /:id throws NotFoundException for a missing id', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException); }); }); describe('TendersController — admin source-config applies live to the scheduler (INGEST-06)', () => { it('PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 }); expect(scheduler.setInterval).toHaveBeenCalledTimes(1); expect(scheduler.setInterval).toHaveBeenCalledWith(30); expect(scheduler.stopJob).not.toHaveBeenCalled(); }); it('PUT /source-config with isActive=false calls scheduler.stopJob()', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.saveSourceConfig({ isActive: false }); expect(scheduler.stopJob).toHaveBeenCalledTimes(1); expect(scheduler.setInterval).not.toHaveBeenCalled(); }); }); describe('TendersController — route declaration order (static route before :id)', () => { // Regression guard for the GET /source-config → 404 bug: NestJS RouterExplorer // maps routes in method-declaration order. When `@Get(':id')` is declared // before `@Get('source-config')`, the param route captures "source-config" as // an id and shadows the static handler — 401 unauthenticated, 404 once past the // guard (Tender "source-config" not found). Unit-calling the methods directly // (the tests above) bypasses routing and cannot catch this, so we assert the // declaration order explicitly. it('declares getSourceConfig before getTender so GET /:id cannot shadow it', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const sourceConfigIdx = methods.indexOf('getSourceConfig'); const idIdx = methods.indexOf('getTender'); expect(sourceConfigIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(sourceConfigIdx).toBeLessThan(idIdx); }); it('declares getCoverage before getTender so GET /:id cannot shadow it (Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const coverageIdx = methods.indexOf('getCoverage'); const idIdx = methods.indexOf('getTender'); expect(coverageIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(coverageIdx).toBeLessThan(idIdx); }); it('declares getDenylistedPortals before getTender so GET /:id cannot shadow "denylisted-portals" (Plan 14-04, Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const denylistIdx = methods.indexOf('getDenylistedPortals'); const idIdx = methods.indexOf('getTender'); expect(denylistIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(denylistIdx).toBeLessThan(idIdx); }); it('declares listRssFeeds/createRssFeed/removeRssFeed before getTender so GET /:id cannot shadow "rss-feeds" (Plan 14-02, Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const listIdx = methods.indexOf('listRssFeeds'); const createIdx = methods.indexOf('createRssFeed'); const removeIdx = methods.indexOf('removeRssFeed'); const idIdx = methods.indexOf('getTender'); expect(listIdx).toBeGreaterThanOrEqual(0); expect(createIdx).toBeGreaterThanOrEqual(0); expect(removeIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(listIdx).toBeLessThan(idIdx); expect(createIdx).toBeLessThan(idIdx); expect(removeIdx).toBeLessThan(idIdx); }); it('declares getEmailConfig/saveEmailConfig/testEmailConnection before getTender so GET /:id cannot shadow "email-config" (Plan 14-03, Pitfall 5; testEmailConnection added Quick 260907-let)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const getIdx = methods.indexOf('getEmailConfig'); const saveIdx = methods.indexOf('saveEmailConfig'); const testIdx = methods.indexOf('testEmailConnection'); const idIdx = methods.indexOf('getTender'); expect(getIdx).toBeGreaterThanOrEqual(0); expect(saveIdx).toBeGreaterThanOrEqual(0); expect(testIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(getIdx).toBeLessThan(idIdx); expect(saveIdx).toBeLessThan(idIdx); expect(testIdx).toBeLessThan(idIdx); }); it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const listTriageIdx = methods.indexOf('listTriage'); const setTriageIdx = methods.indexOf('setTriage'); const idIdx = methods.indexOf('getTender'); expect(listTriageIdx).toBeGreaterThanOrEqual(0); expect(setTriageIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(listTriageIdx).toBeLessThan(idIdx); expect(setTriageIdx).toBeLessThan(idIdx); }); it('declares listSavedSearches before getTender so GET /:id cannot shadow "saved-searches" (Plan 11-06, Pitfall 5/T-11-16)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const listSavedSearchesIdx = methods.indexOf('listSavedSearches'); const idIdx = methods.indexOf('getTender'); expect(listSavedSearchesIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(listSavedSearchesIdx).toBeLessThan(idIdx); }); it('declares getNotificationPref/setNotificationPref before getTender so GET /:id cannot shadow "notification-pref" (Plan 12-04, Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const getPrefIdx = methods.indexOf('getNotificationPref'); const setPrefIdx = methods.indexOf('setNotificationPref'); const idIdx = methods.indexOf('getTender'); expect(getPrefIdx).toBeGreaterThanOrEqual(0); expect(setPrefIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(getPrefIdx).toBeLessThan(idIdx); expect(setPrefIdx).toBeLessThan(idIdx); }); it('declares pollNow before getTender so GET /:id cannot shadow "poll-now" (Quick 260723-lvg, Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const pollNowIdx = methods.indexOf('pollNow'); const idIdx = methods.indexOf('getTender'); expect(pollNowIdx).toBeGreaterThanOrEqual(0); expect(idIdx).toBeGreaterThanOrEqual(0); expect(pollNowIdx).toBeLessThan(idIdx); }); }); describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, T-12-14)', () => { it('GET /notification-pref derives userId from req.user and delegates to tenderNotificationPref.getForUser(userId) — never from a query param (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const prefService = makeFakeNotificationPrefService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, prefService as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.getNotificationPref(makeFakeRequest('u-real')); expect(prefService.getForUser).toHaveBeenCalledWith('u-real'); }); it('PUT /notification-pref delegates to tenderNotificationPref.setForUser with userId/tenantId from the auth context, not the body', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const prefService = makeFakeNotificationPrefService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, prefService as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.setNotificationPref( { digestInterval: 'weekly' } as any, makeFakeRequest('u1', 'tenant1'), ); expect(prefService.setForUser).toHaveBeenCalledWith('u1', 'tenant1', 'weekly'); }); }); describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-14/15/16)', () => { it('GET /saved-searches derives userId from req.user and delegates to tenderSavedSearch.list(userId) — never from a query param (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const savedSearchService = makeFakeSavedSearchService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listSavedSearches(makeFakeRequest('u-real')); expect(savedSearchService.list).toHaveBeenCalledWith('u-real'); }); it('POST /saved-searches delegates to tenderSavedSearch.create with userId/tenantId from the auth context, not the body', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const savedSearchService = makeFakeSavedSearchService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.createSavedSearch( { name: 'Bau NRW', filters: { q: 'Bau' } } as any, makeFakeRequest('u1', 'tenant1'), ); expect(savedSearchService.create).toHaveBeenCalledWith('u1', 'tenant1', { name: 'Bau NRW', filters: { q: 'Bau' }, }); }); it('PATCH /saved-searches/:searchId delegates to tenderSavedSearch.update(searchId, userId, dto) — userId from the auth context', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const savedSearchService = makeFakeSavedSearchService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.updateSavedSearch( 'ss-1', { name: 'Neuer Name' } as any, makeFakeRequest('u1'), ); expect(savedSearchService.update).toHaveBeenCalledWith('ss-1', 'u1', { name: 'Neuer Name', }); }); it('DELETE /saved-searches/:searchId delegates to tenderSavedSearch.remove(searchId, userId) — userId from the auth context', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const savedSearchService = makeFakeSavedSearchService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1')); expect(savedSearchService.remove).toHaveBeenCalledWith('ss-1', 'u1'); expect(result).toEqual({ success: true }); }); }); describe('TendersController — listTenders uses the query builder (sort whitelist + pagination bounds)', () => { it('passes buildTenderWhere/buildOrderBy output through to prisma.tender.findMany', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.orderBy).toEqual({ deadlineAt: 'asc' }); expect(callArgs.where.AND).toEqual( expect.arrayContaining([ { OR: [ { title: { contains: 'Bau', mode: 'insensitive' } }, { buyerName: { contains: 'Bau', mode: 'insensitive' } }, ], }, ]), ); }); it('an unknown sort key falls back to the publishedAt-desc default via buildOrderBy', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({ sort: 'not-whitelisted' } as any); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.orderBy).toEqual({ publishedAt: 'desc' }); }); it('respects page/limit for skip/take (pagination bounds unchanged, T-10-15)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({ page: 3, limit: 10 } as any); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.skip).toBe(20); expect(callArgs.take).toBe(10); }); }); describe('TendersController — GET /coverage', () => { it('returns distinct sourcePortal distribution and total for active tenders', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.getCoverage(); expect(prisma.tender.groupBy).toHaveBeenCalledWith( expect.objectContaining({ by: ['sourcePortal'], where: { status: 'active' }, }), ); expect(result).toEqual({ total: 1, sources: [{ sourcePortal: 'doe-opendata', count: 1 }], }); }); }); describe('TendersController — GET /denylisted-portals (Plan 14-04, UI-06/D-12)', () => { it('returns vergabe24 and aumass each with their canonical URL, derived from DENYLISTED_PORTALS', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.getDenylistedPortals(); expect(result).toEqual({ portals: [ { portal: 'vergabe24', url: 'https://www.vergabe24.de' }, { portal: 'aumass', url: 'https://www.aumass.de' }, ], }); }); }); describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () => { it('parses the comma-separated ids param and delegates to tenderTriage.listForUser(userId, ids)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1')); expect(triageService.listForUser).toHaveBeenCalledWith('u1', ['t1', 't2', 't3']); }); it('derives userId from req.user, never from the query string (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTriage('t1', makeFakeRequest('u-real')); expect(triageService.listForUser).toHaveBeenCalledWith('u-real', ['t1']); }); it('caps the ids batch at MAX_TRIAGE_BATCH_IDS (T-11-11 DoS)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(','); await controller.listTriage(manyIds, makeFakeRequest('u1')); const calledIds = triageService.listForUser.mock.calls[0][1]; expect(calledIds.length).toBeLessThanOrEqual(200); }); }); describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () => { it('delegates to tenderTriage.setTriage with userId/tenantId from the request context, not the body', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.setTriage( { tenderId: 't1', isRead: true } as any, makeFakeRequest('u1', 'tenant1'), ); expect(triageService.setTriage).toHaveBeenCalledWith('u1', 'tenant1', 't1', { isRead: true, isFavorite: undefined, }); }); }); describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', () => { it('favOnly=true resolves favoriteIds(userId) from the auth context and passes them into the where-builder', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); triageService.favoriteIds.mockResolvedValueOnce(['t1']); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); expect(triageService.favoriteIds).toHaveBeenCalledWith('u1'); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.where.AND).toEqual( expect.arrayContaining([{ id: { in: ['t1'] } }]), ); }); it('favOnly=true with no favorites yields a zero-match query, never the unfiltered catalog', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); triageService.favoriteIds.mockResolvedValueOnce([]); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); const callArgs = prisma.tender.findMany.mock.calls[0][0]; expect(callArgs.where.AND).toEqual( expect.arrayContaining([{ id: { in: ['__none__'] } }]), ); }); it('favOnly unset never calls tenderTriage.favoriteIds', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const triageService = makeFakeTriageService(); const controller = new TendersController( prisma as any, scheduler, triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await controller.listTenders({} as any, makeFakeRequest('u1')); expect(triageService.favoriteIds).not.toHaveBeenCalled(); }); }); describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => { it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId) and maps isPlatformWide, stripping userId', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); rssFeedService.listForUser.mockResolvedValueOnce([ { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', userId: null }, { id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', userId: 'u1' }, ]); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1')); expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1'); expect(result).toEqual([ { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true }, { id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false }, ]); expect(result.every((f: any) => !('userId' in f))).toBe(true); }); it('POST /rss-feeds with scope omitted creates a personal feed via createForUser({userId,tenantId}, dto)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); const dto = { url: 'https://mine.invalid/rss.xml', label: 'mine' } as any; await controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1')); expect(rssFeedService.createForUser).toHaveBeenCalledWith({ userId: 'u1', tenantId: 'tenant1' }, dto); expect(rssFeedService.createPlatform).not.toHaveBeenCalled(); }); it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); rssFeedService.createForUser.mockRejectedValueOnce( new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"), ); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); await expect( controller.createRssFeed( { url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24' } as any, makeFakeRequest('u1', 'tenant1'), ), ).rejects.toBeInstanceOf(BadRequestException); }); it('DELETE /rss-feeds/:feedId delegates to tenderRssFeedSource.remove(feedId, {userId, isAdmin}) — isAdmin false for a USER', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); const result = await controller.removeRssFeed( 'feed-1', makeFakeRequest('u1', 'tenant1', Role.USER), ); expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1', { userId: 'u1', isAdmin: false }); expect(result).toEqual({ success: true }); }); it('DELETE /rss-feeds/:feedId passes isAdmin true for an ADMIN account', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); await controller.removeRssFeed('feed-1', makeFakeRequest('admin-1', 'tenant1', Role.ADMIN)); expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1', { userId: 'admin-1', isAdmin: true }); }); it('POST /rss-feeds with scope:"platform" and role USER is rejected with ForbiddenException, service never called (T-17-08)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund', scope: 'platform' } as any; await expect( controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1', Role.USER)), ).rejects.toBeInstanceOf(ForbiddenException); expect(rssFeedService.createPlatform).not.toHaveBeenCalled(); expect(rssFeedService.createForUser).not.toHaveBeenCalled(); }); it('POST /rss-feeds with scope:"platform" and role ADMIN delegates to tenderRssFeedSource.createPlatform(dto) (T-17-08)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, rssFeedService as any, makeFakeEmailConfigService() as any, ); const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund', scope: 'platform' } as any; await controller.createRssFeed(dto, makeFakeRequest('admin-1', 'tenant1', Role.ADMIN)); expect(rssFeedService.createPlatform).toHaveBeenCalledWith(dto); expect(rssFeedService.createForUser).not.toHaveBeenCalled(); }); }); describe('TendersController — email-config (Plan 14-03, per-user since Phase 17 Plan 01 D-01, T-14-03-05/T-17-01)', () => { it('GET /email-config resolves userId from the auth context and delegates to tenderEmailConfig.getConfigForApi(userId)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const emailConfigService = makeFakeEmailConfigService(); emailConfigService.getConfigForApi.mockResolvedValueOnce({ userId: 'u1', tenantId: 'tenant1', protocol: 'imap', hasPassword: true, } as any); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, emailConfigService as any, ); const result = await controller.getEmailConfig(makeFakeRequest('u1', 'tenant1')); expect(emailConfigService.getConfigForApi).toHaveBeenCalledWith('u1'); expect(result).toEqual({ userId: 'u1', tenantId: 'tenant1', protocol: 'imap', hasPassword: true, }); }); it('PUT /email-config delegates to tenderEmailConfig.saveConfig with {userId, tenantId} from the auth context, never the body', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const emailConfigService = makeFakeEmailConfigService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, emailConfigService as any, ); const dto = { protocol: 'imap', encryption: 'ssl-tls', host: 'imap.example.test' } as any; await controller.saveEmailConfig(dto, makeFakeRequest('u1', 'tenant1')); expect(emailConfigService.saveConfig).toHaveBeenCalledWith( { userId: 'u1', tenantId: 'tenant1' }, dto, ); }); it('two different users of the same tenant each resolve their own userId — never the other user\'s (T-17-01, IDOR)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const emailConfigService = makeFakeEmailConfigService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, emailConfigService as any, ); await controller.getEmailConfig(makeFakeRequest('user-a', 'tenant1')); await controller.getEmailConfig(makeFakeRequest('user-b', 'tenant1')); expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(1, 'user-a'); expect(emailConfigService.getConfigForApi).toHaveBeenNthCalledWith(2, 'user-b'); }); it('POST /email-config/test resolves userId from the auth context, even when the body carries a different identity field (T-QT16-01, IDOR)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const emailConfigService = makeFakeEmailConfigService(); const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, emailConfigService as any, ); const dto = { protocol: 'imap', encryption: 'ssl-tls', userId: 'attacker-supplied-id', } as any; await controller.testEmailConnection(dto, makeFakeRequest('u1', 'tenant1')); expect(emailConfigService.testConnection).toHaveBeenCalledWith('u1', dto); }); }); describe('TendersController — D-13 private (email-alert) tender visibility (Phase 14, Plan 03)', () => { /** * Bespoke fake prisma for these tests: unlike makeFakePrisma() (which * ignores `where` entirely), this one evaluates the ownerTenantId-related * AND clauses buildTenderWhere produces — proving the visibility filter * actually excludes/includes rows, not just that the where clause "looks * right" in isolation (tender-query.builder.spec.ts already covers that). */ function makeFakeVisibilityPrisma() { const tenders = new Map([ ['global-1', { id: 'global-1', title: 'Global Tender', ownerTenantId: null, sources: [] }], ['private-a', { id: 'private-a', title: 'Private Tender (Tenant A)', ownerTenantId: 'tenant-a', sources: [] }], ]); function matchesOwnerTenantClause(tender: any, where: any): boolean { const and = (where?.AND ?? []) as any[]; for (const clause of and) { if (clause && typeof clause === 'object' && 'ownerTenantId' in clause) { if (clause.ownerTenantId !== tender.ownerTenantId) return false; } else if (clause && typeof clause === 'object' && 'OR' in clause) { const or = clause.OR as any[]; const isOwnerOrClause = or.every( (c) => c && typeof c === 'object' && 'ownerTenantId' in c, ); if (isOwnerOrClause) { const matches = or.some((c) => c.ownerTenantId === tender.ownerTenantId); if (!matches) return false; } } } return true; } return { tender: { findMany: vi.fn(async ({ where }: any) => Array.from(tenders.values()).filter((t) => matchesOwnerTenantClause(t, where)), ), count: vi.fn(async ({ where }: any) => Array.from(tenders.values()).filter((t) => matchesOwnerTenantClause(t, where)).length, ), findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null), }, tenderSourcePollConfig: { findUnique: vi.fn(), upsert: vi.fn() }, }; } it('GET / for tenant-a includes both the global tender and its own private tender', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.listTenders({} as any, makeFakeRequest('u-a', 'tenant-a')); const ids = result.items.map((t: any) => t.id).sort(); expect(ids).toEqual(['global-1', 'private-a']); }); it('GET / for tenant-b (a different tenant) excludes tenant-a\'s private tender, includes the global one', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.listTenders({} as any, makeFakeRequest('u-b', 'tenant-b')); const ids = result.items.map((t: any) => t.id).sort(); expect(ids).toEqual(['global-1']); }); it('GET / with no resolvable tenant context (no req) fails closed to only the global tender', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.listTenders({} as any); const ids = result.items.map((t: any) => t.id).sort(); expect(ids).toEqual(['global-1']); }); it('GET /:id returns the null-owner tender to both tenant-a and tenant-b', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const forA = await controller.getTender('global-1', makeFakeRequest('u-a', 'tenant-a')); const forB = await controller.getTender('global-1', makeFakeRequest('u-b', 'tenant-b')); expect(forA.id).toBe('global-1'); expect(forB.id).toBe('global-1'); }); it('GET /:id returns tenant-a\'s private tender to tenant-a', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); const result = await controller.getTender('private-a', makeFakeRequest('u-a', 'tenant-a')); expect(result.id).toBe('private-a'); }); it('GET /:id 404s for tenant-b requesting tenant-a\'s private tender (no cross-tenant detail leak)', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await expect( controller.getTender('private-a', makeFakeRequest('u-b', 'tenant-b')), ).rejects.toBeInstanceOf(NotFoundException); }); it('GET /:id 404s for an unauthenticated request (no req) to a private tender', async () => { const prisma = makeFakeVisibilityPrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const controller = new TendersController( prisma as any, scheduler, makeFakeTriageService() as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, makeFakeRssFeedService() as any, makeFakeEmailConfigService() as any, ); await expect(controller.getTender('private-a')).rejects.toBeInstanceOf(NotFoundException); }); });