feat(11-05): GET/PUT /modules/tender-radar/triage routes + favOnly filter

Adds the batch-triage read/write routes (declared before @Get(':id') per
the route-order pitfall, T-11-13) and wires them through
TenderTriageService with userId/tenantId always derived from the request
context, never the body (T-11-10 / V4 IDOR). Extends TenderQueryDto/
buildTenderWhere with favOnly (UI-04): the controller resolves the
current user's favorited tenderIds server-side before building the
where-clause, and an empty favorites list yields zero matches instead of
the unfiltered catalog. Both batch-ids and favIds in-lists are bounded
(T-11-11 DoS). tenders.controller.spec.ts constructor calls updated for
the new TenderTriageService dependency (Rule 3 — required to keep the
existing suite compiling/passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:33:40 +02:00
parent 58b0f3da50
commit 5f97eca804
7 changed files with 371 additions and 16 deletions
+149 -9
View File
@@ -55,11 +55,30 @@ function makeFakePrisma() {
};
}
/**
* 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 only). */
function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
return { user: { id: userId, tenantId }, tenantId } as any;
}
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({});
@@ -71,7 +90,8 @@ describe('TendersController — global read (not tenant-scoped)', () => {
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
const result = await controller.getTender('t1');
@@ -83,7 +103,8 @@ describe('TendersController — global read (not tenant-scoped)', () => {
it('GET /:id throws NotFoundException for a missing id', async () => {
const prisma = makeFakePrisma();
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
const controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
});
@@ -93,7 +114,8 @@ describe('TendersController — admin source-config applies live to the schedule
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
@@ -105,7 +127,8 @@ describe('TendersController — admin source-config applies live to the schedule
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.saveSourceConfig({ isActive: false });
@@ -141,13 +164,27 @@ describe('TendersController — route declaration order (static route before :id
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(coverageIdx).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);
});
});
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
@@ -168,7 +205,8 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ sort: 'not-whitelisted' } as any);
@@ -179,7 +217,8 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
await controller.listTenders({ page: 3, limit: 10 } as any);
@@ -193,7 +232,8 @@ 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 controller = new TendersController(prisma as any, scheduler);
const triageService = makeFakeTriageService();
const controller = new TendersController(prisma as any, scheduler, triageService as any);
const result = await controller.getCoverage();
@@ -209,3 +249,103 @@ describe('TendersController — GET /coverage', () => {
});
});
});
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);
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);
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);
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);
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);
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);
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);
await controller.listTenders({} as any, makeFakeRequest('u1'));
expect(triageService.favoriteIds).not.toHaveBeenCalled();
});
});