feat(12-04): tender-radar notification-pref service + routes, instantAlert passthrough
- TenderNotificationPrefService: per-user digestInterval CRUD (default
'daily', upsert on @@unique userId, D-01/D-03)
- UpdateNotificationPrefDto: @IsIn(['daily','weekly','off']) validation (V5)
- GET/PUT /modules/tender-radar/notification-pref, declared before
@Get(':id') (route-order pitfall)
- instantAlert passthrough in Create/UpdateSavedSearchDto and
TenderSavedSearchService.create/update (NOTIFY-02, D-04)
- All pref/profile routes scoped strictly via extractTriageContext(req),
never from body/query (T-12-14, IDOR)
- Updated tenders.controller.spec.ts fakes for the new constructor param
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,21 @@ function makeFakeSavedSearchService() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -103,6 +118,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({});
|
||||
@@ -121,6 +137,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getTender('t1');
|
||||
@@ -139,6 +156,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
@@ -155,6 +173,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
||||
@@ -173,6 +192,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.saveSourceConfig({ isActive: false });
|
||||
@@ -232,6 +252,60 @@ describe('TendersController — route declaration order (static route before :id
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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)', () => {
|
||||
@@ -245,6 +319,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
scheduler,
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
||||
@@ -262,6 +337,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
scheduler,
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.createSavedSearch(
|
||||
@@ -285,6 +361,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
scheduler,
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.updateSavedSearch(
|
||||
@@ -308,6 +385,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
scheduler,
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
||||
@@ -327,6 +405,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
||||
@@ -354,6 +433,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
||||
@@ -371,6 +451,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ page: 3, limit: 10 } as any);
|
||||
@@ -391,6 +472,7 @@ describe('TendersController — GET /coverage', () => {
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getCoverage();
|
||||
@@ -418,6 +500,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
||||
@@ -434,6 +517,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
||||
@@ -450,6 +534,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
||||
@@ -470,6 +555,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () =>
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.setTriage(
|
||||
@@ -495,6 +581,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||
@@ -516,6 +603,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||
@@ -535,6 +623,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
scheduler,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
||||
|
||||
Reference in New Issue
Block a user