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:
2026-08-12 11:37:56 +02:00
parent 71dcb302a3
commit adb72f611f
8 changed files with 346 additions and 91 deletions
+42 -22
View File
@@ -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);
});