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:
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||
|
||||
/**
|
||||
* TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01
|
||||
* (D-01/D-03) and the V4/IDOR access-control invariant (T-12-14):
|
||||
*
|
||||
* - getForUser() without an existing row returns a default
|
||||
* { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite.
|
||||
* - setForUser() upserts on the @@unique userId (D-03); a second call with
|
||||
* a different value updates the SAME row rather than creating a new one.
|
||||
*
|
||||
* Uses the same hand-rolled prisma-shaped fake convention as
|
||||
* tender-saved-search.service.spec.ts / tender-triage.service.spec.ts
|
||||
* (in-memory Map, no live DB connection).
|
||||
*/
|
||||
|
||||
function makeFakePrisma() {
|
||||
const rows = new Map<string, any>();
|
||||
|
||||
return {
|
||||
tenderNotificationPref: {
|
||||
findUnique: async ({ where }: any) => rows.get(where.userId) ?? null,
|
||||
upsert: async ({ where, create, update }: any) => {
|
||||
const existing = rows.get(where.userId);
|
||||
const record = existing
|
||||
? { ...existing, ...update, updatedAt: new Date() }
|
||||
: { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() };
|
||||
rows.set(where.userId, record);
|
||||
return record;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TenderNotificationPrefService', () => {
|
||||
it('getForUser() returns a default digestInterval="daily" when no row exists (D-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
const result = await service.getForUser('u1');
|
||||
|
||||
expect(result.digestInterval).toBe('daily');
|
||||
});
|
||||
|
||||
it('setForUser() upserts on userId, creating a row scoped to (userId, tenantId) (D-03)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
const result = await service.setForUser('u1', 'tenant1', 'weekly');
|
||||
|
||||
expect(result.userId).toBe('u1');
|
||||
expect(result.tenantId).toBe('tenant1');
|
||||
expect(result.digestInterval).toBe('weekly');
|
||||
});
|
||||
|
||||
it('setForUser() called a second time updates the SAME row (@@unique userId), not a new one', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
await service.setForUser('u1', 'tenant1', 'weekly');
|
||||
const second = await service.setForUser('u1', 'tenant1', 'off');
|
||||
|
||||
expect(second.digestInterval).toBe('off');
|
||||
expect(await service.getForUser('u1')).toMatchObject({ digestInterval: 'off' });
|
||||
});
|
||||
|
||||
it('getForUser() is scoped strictly by userId — a foreign userId never sees another user\'s pref (V4 / IDOR)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderNotificationPrefService(prisma as any);
|
||||
|
||||
await service.setForUser('u1', 'tenant1', 'weekly');
|
||||
|
||||
const foreign = await service.getForUser('u2');
|
||||
expect(foreign.digestInterval).toBe('daily'); // default, not u1's 'weekly'
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user