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:
@@ -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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user