feat(11-06): saved-searches CRUD routes on TendersController (FILTER-06)

Adds GET/POST /saved-searches and PATCH/DELETE /saved-searches/:searchId,
registers TenderSavedSearchService as a module provider, and wires it into
the controller via extractTriageContext (userId/tenantId from the auth
context, never the body/query — T-11-14/V4 IDOR). Static saved-searches
routes are declared before @Get(':id') (Pitfall 5/T-11-16); mutation routes
use :searchId to avoid ambiguity with the Tender :id param.

Also fixes a Prisma InputJsonValue type mismatch in
TenderSavedSearchService (Rule 1 — caught by tsc --noEmit, same cast
convention as dashboard.service.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:48:00 +02:00
parent df94d921ef
commit 38fbf35c75
4 changed files with 294 additions and 18 deletions
+209 -16
View File
@@ -73,12 +73,37 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
return { user: { id: userId, tenantId }, tenantId } 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),
};
}
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({});
@@ -91,7 +116,12 @@ describe('TendersController — global read (not tenant-scoped)', () => {
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
const result = await controller.getTender('t1');
@@ -104,7 +134,12 @@ describe('TendersController — global read (not tenant-scoped)', () => {
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
});
@@ -115,7 +150,12 @@ describe('TendersController — admin source-config applies live to the schedule
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
@@ -128,7 +168,12 @@ describe('TendersController — admin source-config applies live to the schedule
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.saveSourceConfig({ isActive: false });
@@ -177,6 +222,99 @@ describe('TendersController — route declaration order (static route before :id
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);
});
});
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,
);
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,
);
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,
);
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,
);
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)', () => {
@@ -184,7 +322,12 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
@@ -206,7 +349,12 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({ sort: 'not-whitelisted' } as any);
@@ -218,7 +366,12 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({ page: 3, limit: 10 } as any);
@@ -233,7 +386,12 @@ describe('TendersController — GET /coverage', () => {
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
const result = await controller.getCoverage();
@@ -255,7 +413,12 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
@@ -266,7 +429,12 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTriage('t1', makeFakeRequest('u-real'));
@@ -277,7 +445,12 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
await controller.listTriage(manyIds, makeFakeRequest('u1'));
@@ -292,7 +465,12 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () =>
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.setTriage(
{ tenderId: 't1', isRead: true } as any,
@@ -312,7 +490,12 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
@@ -328,7 +511,12 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
@@ -342,7 +530,12 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
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);
const controller = new TendersController(
prisma as any,
scheduler,
triageService as any,
makeFakeSavedSearchService() as any,
);
await controller.listTenders({} as any, makeFakeRequest('u1'));