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