feat(17-02): RSS feeds get an owner — platform-wide vs personal (D-02)
- TenderRssFeedSource.userId/tenantId (nullable): null = platform-wide
(admin-managed, includes the existing service.bund.de default),
set = personal feed owned by exactly one user
- Migration replaces url @unique with @@unique([userId, url]) — two
users can now follow the same address independently; existing rows
keep an empty owner (platform-wide, unchanged behavior)
- Service: listForUser/createForUser/createPlatform replace list/create
- Controller: GET/POST /rss-feeds move from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar'); POST with scope:'platform' still requires
ADMIN/SUPER_ADMIN, checked inline (T-17-08)
- tenders.module.ts seed switched from upsert-on-url to find-then-create
(Rule 3, pulled forward from Task 3): the new compound unique index
requires a non-null userId in Prisma's generated type, so a
platform-wide row can no longer be addressed via upsert
- Files modified: apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/dto/tender-rss-feed.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
This commit is contained in:
@@ -89,15 +89,31 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
|
||||
|
||||
/**
|
||||
* Fake TenderRssFeedSourceService for controller-level wiring tests (Plan
|
||||
* 14-02, Task 3). Default stubs echo/list-nothing; individual tests
|
||||
* override via `.mockResolvedValueOnce`/reassigning the mock — including
|
||||
* `create` rejecting with BadRequestException to prove the denylist error
|
||||
* surfaces through the controller unchanged.
|
||||
* 14-02, Task 3; ownership split since Phase 17, Plan 02, D-02). Default
|
||||
* stubs echo/list-nothing; individual tests override via
|
||||
* `.mockResolvedValueOnce`/reassigning the mock — including `createForUser`/
|
||||
* `createPlatform` rejecting with BadRequestException to prove the
|
||||
* denylist error surfaces through the controller unchanged.
|
||||
*/
|
||||
function makeFakeRssFeedService() {
|
||||
return {
|
||||
list: vi.fn(async () => [] as any[]),
|
||||
create: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, ...dto })),
|
||||
listForUser: vi.fn(async (_userId: string) => [] as any[]),
|
||||
createForUser: vi.fn(
|
||||
async (ctx: { userId: string; tenantId: string }, dto: any) => ({
|
||||
id: 'feed-1',
|
||||
isActive: true,
|
||||
userId: ctx.userId,
|
||||
tenantId: ctx.tenantId,
|
||||
...dto,
|
||||
}),
|
||||
),
|
||||
createPlatform: vi.fn(async (dto: any) => ({
|
||||
id: 'feed-1',
|
||||
isActive: true,
|
||||
userId: null,
|
||||
tenantId: null,
|
||||
...dto,
|
||||
})),
|
||||
remove: vi.fn(async (_id: string) => ({ success: true })),
|
||||
};
|
||||
}
|
||||
@@ -871,13 +887,14 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', () => {
|
||||
it('GET /rss-feeds delegates to tenderRssFeedSource.list()', async () => {
|
||||
describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => {
|
||||
it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId) and maps isPlatformWide, stripping userId', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
rssFeedService.list.mockResolvedValueOnce([
|
||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
||||
rssFeedService.listForUser.mockResolvedValueOnce([
|
||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', userId: null },
|
||||
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', userId: 'u1' },
|
||||
]);
|
||||
const controller = new TendersController(
|
||||
prisma as any,
|
||||
@@ -889,15 +906,17 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.listRssFeeds();
|
||||
const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1'));
|
||||
|
||||
expect(rssFeedService.list).toHaveBeenCalledTimes(1);
|
||||
expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1');
|
||||
expect(result).toEqual([
|
||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true },
|
||||
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false },
|
||||
]);
|
||||
expect(result.every((f: any) => !('userId' in f))).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /rss-feeds delegates to tenderRssFeedSource.create(dto)', async () => {
|
||||
it('POST /rss-feeds with scope omitted creates a personal feed via createForUser({userId,tenantId}, dto)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
@@ -911,17 +930,18 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
makeFakeEmailConfigService() as any,
|
||||
);
|
||||
|
||||
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
||||
await controller.createRssFeed(dto);
|
||||
const dto = { url: 'https://mine.invalid/rss.xml', label: 'mine' } as any;
|
||||
await controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1'));
|
||||
|
||||
expect(rssFeedService.create).toHaveBeenCalledWith(dto);
|
||||
expect(rssFeedService.createForUser).toHaveBeenCalledWith({ userId: 'u1', tenantId: 'tenant1' }, dto);
|
||||
expect(rssFeedService.createPlatform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
rssFeedService.create.mockRejectedValueOnce(
|
||||
rssFeedService.createForUser.mockRejectedValueOnce(
|
||||
new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"),
|
||||
);
|
||||
const controller = new TendersController(
|
||||
@@ -935,10 +955,10 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', (
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.createRssFeed({
|
||||
url: 'https://www.vergabe24.de/rss.xml',
|
||||
label: 'vergabe24',
|
||||
} as any),
|
||||
controller.createRssFeed(
|
||||
{ url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24' } as any,
|
||||
makeFakeRequest('u1', 'tenant1'),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user