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) 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)', () => { 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 * filter — RSS has no day-batch concept; it is polled every scheduler
* tick via `pollGranularity: 'tick'` (D-15, wired in * tick via `pollGranularity: 'tick'` (D-15, wired in
* `tender-ingestion.service.ts`), not gated by the day-cursor at all. * `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; 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). */ /** 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) {} constructor(private readonly prisma: PrismaService) {}
/** /**
* Internal fan-out over every active admin-managed feed (D-14/D-08, * Internal fan-out over every active feed — platform-wide and personal
* global — no tenantId). Catch-per-feed (D-01): a single feed that fails * alike (D-14/D-08/D-02). Catch-per-feed (D-01): a single feed that
* to fetch/parse is skipped (logger.warn), never aborting the rest. * 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[]> { async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
const feeds = await this.prisma.tenderRssFeedSource.findMany({ const feeds = await this.prisma.tenderRssFeedSource.findMany({
@@ -85,7 +100,13 @@ export class RssAdapter implements TenderSourceAdapter {
for (const feed of feeds) { for (const feed of feeds) {
try { try {
const xml = await this.fetchFeedXml(feed.url); 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) { } catch (error) {
this.logger.warn( this.logger.warn(
`RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`, `RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`,
@@ -0,0 +1,73 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prüft das hand-geschriebene Migrations-SQL für den Wechsel von
* TenderRssFeedSource.url @unique auf @@unique([userId, url]) (Phase 17,
* Plan 02, D-02) — reiner Textabgleich ohne Datenbank, gleiches Muster wie
* doe-url-migration-sql.spec.ts / email-config-migration-sql.spec.ts.
*
* Anders als bei der TenderEmailConfig-Migration (Plan 17-01) gibt es hier
* KEINEN Bestandsdaten-Umzug: eine bereits vorhandene Zeile bekommt keinen
* Besitzer zugewiesen und bleibt damit plattformweit — genau der
* Ist-Zustand (17-CONTEXT.md, offener Punkt 2). Das ist die zentrale
* Korrektheitsbedingung dieser Migration, nicht eine Zuordnungsreihenfolge.
*/
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
function readMigrationSql(suffix: string): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
.map((entry) => entry.name);
if (dirs.length !== 1) {
throw new Error(
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
);
}
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
describe('tender_rss_feed_owner migration.sql', () => {
const sql = readMigrationSql('_tender_rss_feed_owner');
it('ergaenzt beide neuen Spalten ohne Pflichtwert', () => {
expect(sql).toMatch(/ADD COLUMN\s+"userId"\s+TEXT(?!\s+NOT NULL)/);
expect(sql).toMatch(/ADD COLUMN\s+"tenantId"\s+TEXT(?!\s+NOT NULL)/);
// Weder Spalte traegt irgendwo in der Datei eine Pflicht-Klausel.
expect(sql).not.toMatch(/ALTER COLUMN\s+"userId"\s+SET NOT NULL/);
expect(sql).not.toMatch(/ALTER COLUMN\s+"tenantId"\s+SET NOT NULL/);
});
it('hebt den alten eindeutigen Index auf der Adresse auf', () => {
expect(sql).toContain('DROP INDEX "TenderRssFeedSource_url_key"');
});
it('legt einen neuen eindeutigen Index ueber Besitzer und Adresse an', () => {
expect(sql).toContain(
'CREATE UNIQUE INDEX "TenderRssFeedSource_userId_url_key" ON "TenderRssFeedSource"("userId", "url")',
);
});
it('legt einen gewoehnlichen Index auf dem Besitzer an', () => {
expect(sql).toContain(
'CREATE INDEX "TenderRssFeedSource_userId_idx" ON "TenderRssFeedSource"("userId")',
);
});
it('enthaelt keine Anweisung, die Bestandszeilen aendert oder loescht — anders als die TenderEmailConfig-Migration (Plan 17-01) gibt es hier keinen Datenumzug', () => {
expect(sql).not.toMatch(/UPDATE\s+"TenderRssFeedSource"/);
expect(sql).not.toMatch(/DELETE FROM\s+"TenderRssFeedSource"/);
});
it('legt die neue Eindeutigkeitsregel erst NACH dem Entfernen der alten an (Reihenfolge)', () => {
const dropIdx = sql.indexOf('DROP INDEX "TenderRssFeedSource_url_key"');
const createIdx = sql.indexOf('CREATE UNIQUE INDEX "TenderRssFeedSource_userId_url_key"');
expect(dropIdx).toBeGreaterThanOrEqual(0);
expect(createIdx).toBeGreaterThan(dropIdx);
});
});
+8 -32
View File
@@ -10,7 +10,7 @@ import { EmailAlertAdapter } from './adapters/email-alert.adapter';
import { NetServerAdapter } from './adapters/netserver.adapter'; import { NetServerAdapter } from './adapters/netserver.adapter';
import { RssAdapter } from './adapters/rss.adapter'; import { RssAdapter } from './adapters/rss.adapter';
import { SourceRegistry } from './source-registry'; import { SourceRegistry } from './source-registry';
import { seedTendersModule } from './tenders.seed'; import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
import { TenderDedupService } from './tender-dedup.service'; import { TenderDedupService } from './tender-dedup.service';
import { TenderDigestScheduler } from './tender-digest.scheduler'; import { TenderDigestScheduler } from './tender-digest.scheduler';
import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderEmailConfigService } from './tender-email-config.service';
@@ -278,37 +278,13 @@ export class TendersModule implements OnModuleInit {
} }
try { try {
// Phase 14, Plan 02 (D-14, RESEARCH.md Open Question 3): seed a // Phase 17, Plan 02 (Task 3): the actual find-then-create logic (and
// single default-active service.bund.de feed row — the one genuinely // why "upsert on url" stopped working, D-02) lives in
// national/global RSS source. subreport-elvis has no single // seedServiceBundRssFeed (tenders.seed.ts) now — extracted the same
// canonical URL (per-municipality instances, RESEARCH.md Pitfall 2) // way seedTendersModule already is, so it is a plain testable
// — deliberately ZERO subreport-elvis rows seeded; admins/users add // function and not only reachable via a full Nest bootstrap
// the municipality feeds relevant to them via the RSS-Feeds UI // (idempotency proven in tenders.seed.spec.ts).
// (Plan 14-02 Task 3). await seedServiceBundRssFeed(this.prisma);
//
// Phase 17, Plan 02 (D-02): "upsert on url" no longer works — the
// unique index moved to (userId, url), and Prisma's generated
// compound-unique input type REQUIRES userId as a plain `string`
// (not `string | null | undefined`), so a platform-wide row
// (userId = null) can never be addressed through it. Switched to
// "find a platform-wide row with this url first, only create if
// none exists" — an ordinary equality condition has no such
// requirement. Still idempotent across repeated app starts.
const existingServiceBundFeed = await this.prisma.tenderRssFeedSource.findFirst({
where: {
userId: null,
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
},
});
if (!existingServiceBundFeed) {
await this.prisma.tenderRssFeedSource.create({
data: {
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
label: 'service-bund',
isActive: true,
},
});
}
this.logger.log('service.bund.de default RSS feed seeded (active)'); this.logger.log('service.bund.de default RSS feed seeded (active)');
} catch (error) { } catch (error) {
this.logger.error('Failed to seed service.bund.de RSS feed', error); this.logger.error('Failed to seed service.bund.de RSS feed', error);
+81 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { seedTendersModule } from './tenders.seed'; import { SERVICE_BUND_RSS_URL, seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
describe('seedTendersModule', () => { describe('seedTendersModule', () => {
it('calls moduleRegistryService.seedModule once with tender-radar slug', async () => { it('calls moduleRegistryService.seedModule once with tender-radar slug', async () => {
@@ -16,3 +16,83 @@ describe('seedTendersModule', () => {
expect(arg.description).toHaveProperty('en'); expect(arg.description).toHaveProperty('en');
}); });
}); });
/**
* seedServiceBundRssFeed.spec — proves the Phase 17, Plan 02 (D-02) fix:
* "upsert on url" stopped compiling once the unique index moved to
* (userId, url) — Prisma's generated compound-unique input type requires a
* plain `string` userId, which a platform-wide row (userId = null) can
* never provide. Uses "find a platform-wide row with this url first, only
* create if none exists" instead. In-memory fake, no live DB connection.
*/
function makeFakePrisma() {
const rows = new Map<string, any>();
let seq = 0;
return {
tenderRssFeedSource: {
findFirst: async ({ where }: any) => {
for (const row of rows.values()) {
if (
row.userId === where.userId &&
row.url === where.url
) {
return row;
}
}
return null;
},
create: async ({ data }: any) => {
seq += 1;
const row = { id: `feed-${seq}`, userId: null, tenantId: null, ...data };
rows.set(row.id, row);
return row;
},
},
__rows: rows,
};
}
describe('seedServiceBundRssFeed', () => {
it('creates a single active, ownerless service.bund.de row when none exists yet', async () => {
const prisma = makeFakePrisma();
await seedServiceBundRssFeed(prisma as any);
expect(prisma.__rows.size).toBe(1);
const [row] = [...prisma.__rows.values()];
expect(row.url).toBe(SERVICE_BUND_RSS_URL);
expect(row.label).toBe('service-bund');
expect(row.isActive).toBe(true);
expect(row.userId).toBeNull();
});
it('running the seed twice in a row (simulating a second app start) leaves exactly one ownerless row (D-02, idempotent)', async () => {
const prisma = makeFakePrisma();
await seedServiceBundRssFeed(prisma as any);
await seedServiceBundRssFeed(prisma as any);
expect(prisma.__rows.size).toBe(1);
const [row] = [...prisma.__rows.values()];
expect(row.userId).toBeNull();
});
it('does not touch an existing personal feed at the same URL owned by a real user', async () => {
const prisma = makeFakePrisma();
prisma.__rows.set('personal-1', {
id: 'personal-1',
userId: 'user-a',
tenantId: 'tenant-a',
url: SERVICE_BUND_RSS_URL,
label: 'mein-service-bund-feed',
isActive: true,
});
await seedServiceBundRssFeed(prisma as any);
expect(prisma.__rows.size).toBe(2);
const platformRows = [...prisma.__rows.values()].filter((r: any) => r.userId === null);
expect(platformRows).toHaveLength(1);
});
});
+43
View File
@@ -1,4 +1,5 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { ModuleRegistryService } from '../module-registry/module-registry.service';
import type { PrismaService } from '../prisma/prisma.service';
/** /**
* Seeds the tender-radar module into the module registry. * Seeds the tender-radar module into the module registry.
@@ -24,3 +25,45 @@ export async function seedTendersModule(
isSystem: true, isSystem: true,
}); });
} }
/** The one genuinely national/global RSS source (RESEARCH.md Open Question 3). */
export const SERVICE_BUND_RSS_URL =
'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml';
/**
* Seeds the single default-active service.bund.de RSS feed row — the one
* genuinely national/global RSS source (RESEARCH.md Open Question 3,
* Phase 14, Plan 02, D-14). subreport-elvis has no single canonical URL
* (per-municipality instances, RESEARCH.md Pitfall 2) — deliberately ZERO
* subreport-elvis rows are ever seeded; users add the municipality feeds
* relevant to them via the RSS-Feeds UI (Plan 14-02 Task 3).
*
* Extracted out of `TendersModule.onModuleInit` (Phase 17, Plan 02, Task 3)
* — same "logic lives in a plain testable function, the module's lifecycle
* hook only wraps it in try/catch + logging" pattern as `seedTendersModule`
* above — so the idempotency behavior below can be exercised directly by a
* unit test instead of only through a full Nest bootstrap.
*
* Phase 17, Plan 02 (D-02): "upsert on url" stopped working when the
* unique index moved to `(userId, url)` — Prisma's generated
* compound-unique input type requires `userId` as a plain `string` (not
* `string | null | undefined`), so a platform-wide row (`userId = null`)
* can never be addressed through it. This function instead FINDS a
* platform-wide row with this url first, and only creates one if none
* exists — an ordinary equality condition has no such requirement. Still
* idempotent across repeated app starts (proven in tenders.seed.spec.ts).
*/
export async function seedServiceBundRssFeed(prisma: PrismaService): Promise<void> {
const existing = await prisma.tenderRssFeedSource.findFirst({
where: { userId: null, url: SERVICE_BUND_RSS_URL },
});
if (existing) return;
await prisma.tenderRssFeedSource.create({
data: {
url: SERVICE_BUND_RSS_URL,
label: 'service-bund',
isActive: true,
},
});
}