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:
2026-07-22 09:28:02 +02:00
parent 1a0cd375c2
commit 9e7ba5353d
9 changed files with 343 additions and 1 deletions
@@ -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'));