test(17-02): D-06 ownerTenantId tagging for RSS ingestion + seed idempotency

- RssAdapter.fetchTenders tags every record from a feed with a tenantId
  with the same D-13 ownerTenantId origin marking email-alert records
  carry since Phase 14; platform-wide feeds (no tenantId) stay unmarked.
  The pure parseFeed mapping is untouched — tagging happens in the
  fan-out loop that knows which row a batch came from
- Extracted the service.bund.de seed out of TendersModule.onModuleInit
  into seedServiceBundRssFeed() (tenders.seed.ts, same pattern as the
  existing seedTendersModule), so the find-then-create idempotency added
  in Task 1 is unit-tested directly instead of only via a Nest bootstrap
- New rss-feed-migration-sql.spec.ts: text-only check of the Task 1
  migration file (nullable columns, dropped/created indexes, no
  existing-row mutation, correct ordering)

- Files modified: apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tenders.seed.ts, apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/tenders.seed.spec.ts, apps/api/src/tenders/rss-feed-migration-sql.spec.ts
This commit is contained in:
2026-08-12 11:45:27 +02:00
parent 96161556db
commit 7dee116254
6 changed files with 306 additions and 37 deletions
@@ -306,6 +306,82 @@ describe('RssAdapter', () => {
expect(records).toEqual([]); // oversized feed skipped, catch-per-feed (D-01)
});
describe('D-06 ownerTenantId tagging (Phase 17, Plan 02, personal vs platform-wide feeds)', () => {
it('a feed row with a tenantId produces ONLY tagged records', async () => {
const feeds = [
{
id: 'f1',
url: 'https://personal.invalid/rss.xml',
label: 'personal-feed',
isActive: true,
userId: 'user-a',
tenantId: 'tenant-a',
},
];
const prisma = makeFakePrisma(feeds);
stubFetchResolvingXml({ 'https://personal.invalid/rss.xml': SERVICE_BUND_FIXTURE });
const adapter = new RssAdapter(prisma);
const records = await adapter.fetchTenders('2026-07-23');
expect(records.length).toBeGreaterThan(0);
expect(records.every((r) => r.ownerTenantId === 'tenant-a')).toBe(true);
});
it('a feed row without a tenantId (platform-wide) produces ONLY untagged records', async () => {
const feeds = [
{
id: 'f1',
url: 'https://platform.invalid/rss.xml',
label: 'platform-feed',
isActive: true,
userId: null,
tenantId: null,
},
];
const prisma = makeFakePrisma(feeds);
stubFetchResolvingXml({ 'https://platform.invalid/rss.xml': SERVICE_BUND_FIXTURE });
const adapter = new RssAdapter(prisma);
const records = await adapter.fetchTenders('2026-07-23');
expect(records.length).toBeGreaterThan(0);
expect(records.every((r) => r.ownerTenantId === undefined)).toBe(true);
});
it('two feeds of different owners are both fetched in one run; the first failing never blocks the second\'s tagged records (D-01)', async () => {
const feeds = [
{
id: 'f1',
url: 'https://broken-owner.invalid/rss.xml',
label: 'broken-owner-feed',
isActive: true,
userId: 'user-a',
tenantId: 'tenant-a',
},
{
id: 'f2',
url: 'https://ok-owner.invalid/rss.xml',
label: 'ok-owner-feed',
isActive: true,
userId: 'user-b',
tenantId: 'tenant-b',
},
];
const prisma = makeFakePrisma(feeds);
stubFetchResolvingXml({
// 'broken-owner.invalid' deliberately absent -> stub resolves 404 -> throws
'https://ok-owner.invalid/rss.xml': SERVICE_BUND_FIXTURE,
});
const adapter = new RssAdapter(prisma);
const records = await adapter.fetchTenders('2026-07-23');
expect(records.length).toBeGreaterThan(0);
expect(records.every((r) => r.ownerTenantId === 'tenant-b')).toBe(true);
});
});
});
it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => {
+25 -4
View File
@@ -50,6 +50,15 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
* filter — RSS has no day-batch concept; it is polled every scheduler
* tick via `pollGranularity: 'tick'` (D-15, wired in
* `tender-ingestion.service.ts`), not gated by the day-cursor at all.
*
* Phase 17, Plan 02 (D-02/D-06): the fan-out now resolves BOTH
* platform-wide (`userId`/`tenantId` null) and personal (`userId` set)
* feeds in the same sweep — the mechanics above are unchanged. Records
* from a feed that carries a `tenantId` are tagged with the same D-13
* `ownerTenantId` origin marking `EmailAlertAdapter` records have carried
* since Phase 14, so a personal feed's tenders stay visible only within
* the owner's tenant; records from a platform-wide feed stay unmarked and
* visible to everyone, exactly as before.
*/
const RSS_FETCH_TIMEOUT_MS = 15_000;
/** RSS feeds are normally small; an admin-supplied URL is less trusted than a hardcoded portal (RESEARCH.md Security Domain, DoS mitigation). */
@@ -71,9 +80,15 @@ export class RssAdapter implements TenderSourceAdapter {
constructor(private readonly prisma: PrismaService) {}
/**
* Internal fan-out over every active admin-managed feed (D-14/D-08,
* global — no tenantId). Catch-per-feed (D-01): a single feed that fails
* to fetch/parse is skipped (logger.warn), never aborting the rest.
* Internal fan-out over every active feed — platform-wide and personal
* alike (D-14/D-08/D-02). Catch-per-feed (D-01): a single feed that
* fails to fetch/parse is skipped (logger.warn), never aborting the
* rest, even when two feeds belong to different owners.
*
* D-06 (17-02-PLAN.md): a feed row with a `tenantId` tags every record
* it produces with that same `ownerTenantId` — the pure `parseFeed`
* mapping below stays feed-row-agnostic; the tagging happens here, in
* the loop that actually knows which row a batch of records came from.
*/
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
const feeds = await this.prisma.tenderRssFeedSource.findMany({
@@ -85,7 +100,13 @@ export class RssAdapter implements TenderSourceAdapter {
for (const feed of feeds) {
try {
const xml = await this.fetchFeedXml(feed.url);
records.push(...this.parseFeed(xml, feed.label));
const feedRecords = this.parseFeed(xml, feed.label);
if (feed.tenantId) {
for (const record of feedRecords) {
record.ownerTenantId = feed.tenantId;
}
}
records.push(...feedRecords);
} catch (error) {
this.logger.warn(
`RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`,