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:
@@ -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)', () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { EmailAlertAdapter } from './adapters/email-alert.adapter';
|
||||
import { NetServerAdapter } from './adapters/netserver.adapter';
|
||||
import { RssAdapter } from './adapters/rss.adapter';
|
||||
import { SourceRegistry } from './source-registry';
|
||||
import { seedTendersModule } from './tenders.seed';
|
||||
import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
|
||||
import { TenderDedupService } from './tender-dedup.service';
|
||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||
@@ -278,37 +278,13 @@ export class TendersModule implements OnModuleInit {
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 14, Plan 02 (D-14, RESEARCH.md Open Question 3): seed a
|
||||
// single default-active service.bund.de feed row — the one genuinely
|
||||
// national/global RSS source. subreport-elvis has no single
|
||||
// canonical URL (per-municipality instances, RESEARCH.md Pitfall 2)
|
||||
// — deliberately ZERO subreport-elvis rows seeded; admins/users add
|
||||
// the municipality feeds relevant to them via the RSS-Feeds UI
|
||||
// (Plan 14-02 Task 3).
|
||||
//
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Phase 17, Plan 02 (Task 3): the actual find-then-create logic (and
|
||||
// why "upsert on url" stopped working, D-02) lives in
|
||||
// seedServiceBundRssFeed (tenders.seed.ts) now — extracted the same
|
||||
// way seedTendersModule already is, so it is a plain testable
|
||||
// function and not only reachable via a full Nest bootstrap
|
||||
// (idempotency proven in tenders.seed.spec.ts).
|
||||
await seedServiceBundRssFeed(this.prisma);
|
||||
this.logger.log('service.bund.de default RSS feed seeded (active)');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed service.bund.de RSS feed', error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { seedTendersModule } from './tenders.seed';
|
||||
import { SERVICE_BUND_RSS_URL, seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
|
||||
|
||||
describe('seedTendersModule', () => {
|
||||
it('calls moduleRegistryService.seedModule once with tender-radar slug', async () => {
|
||||
@@ -16,3 +16,83 @@ describe('seedTendersModule', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Seeds the tender-radar module into the module registry.
|
||||
@@ -24,3 +25,45 @@ export async function seedTendersModule(
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user