fix(tenders): link DOE notices to the notice page, not the API
The doe-opendata adapter stored the OCDS document's own `uri` as sourceUrl. That is the API address of the record and serves OCDS JSON by design, so anyone following the link from the results list, the detail view, or an alert mail landed on raw JSON instead of the notice. Build the human-readable page from the notice id the row already carries instead. `/ui/de/search/details?noticeId=...` is the redirect target of `/ui/de/notices/...`, so it needs no redirect. Verified in a browser for both id shapes the feed uses -- numeric (25673764 -> "Feuerwehr-Geraetehaus Miehlen Fliesenarbeiten") and UUID (7085ba12-... -> "Holzfassade"). The page is a single-page app that answers 200 with an identical shell for any id, so this had to be checked on rendered content; a status code proves nothing. The adapter alone only fixes new ingests, so a backfill migration rewrites the rows already stored -- in Tender and in TenderSource, since the detail view lists per-source links separately. It touches only rows still pointing at /api/notices/ and only ids of a shape that was actually verified, which makes it idempotent and keeps an unexpected id from being pasted into a URL. Counted read-only against the live database beforehand: 2846 DOE rows affected, none skipped. Closes the 2026-08-05 backlog item, which was deliberately held until Phase 16 was done. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import AdmZip from 'adm-zip';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DoeOpenDataAdapter } from './doe-opendata.adapter';
|
||||
import { buildDoeNoticeUrl, DoeOpenDataAdapter } from './doe-opendata.adapter';
|
||||
|
||||
/**
|
||||
* Real, trimmed DÖE day-export fixtures (8 notices, captured live from
|
||||
@@ -129,6 +129,42 @@ describe('DoeOpenDataAdapter', () => {
|
||||
await expect(adapter.fetchTenders(TEST_PUBDAY)).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Backlog item 2026-08-05: sourceUrl used to be the OCDS document's own
|
||||
// `uri`, which is the API address and answers with JSON. Users following a
|
||||
// link from the results list, the detail view or an alert mail landed on raw
|
||||
// JSON instead of the notice.
|
||||
it('points sourceUrl at the human-readable notice page, never at the API', async () => {
|
||||
stubFetchWithFixtures();
|
||||
const adapter = new DoeOpenDataAdapter();
|
||||
|
||||
const records = await adapter.fetchTenders(TEST_PUBDAY);
|
||||
|
||||
expect(records.length).toBe(EXPECTED_TENDER_TAGGED_COUNT);
|
||||
for (const record of records) {
|
||||
expect(record.sourceUrl).toBe(
|
||||
`https://oeffentlichevergabe.de/ui/de/search/details?noticeId=${record.sourceNoticeId}`,
|
||||
);
|
||||
// The exact shape that was broken — an API address serving OCDS JSON.
|
||||
expect(record.sourceUrl).not.toContain('/api/notices/');
|
||||
expect(record.sourceUrl).not.toContain('format=ocds');
|
||||
}
|
||||
});
|
||||
|
||||
it('builds the notice URL from both id shapes the feed uses, escaping the id', () => {
|
||||
// Numeric and UUID ids both occur in the live feed; both were verified in
|
||||
// a browser on 2026-08-11 to render the correct notice.
|
||||
expect(buildDoeNoticeUrl('25673764')).toBe(
|
||||
'https://oeffentlichevergabe.de/ui/de/search/details?noticeId=25673764',
|
||||
);
|
||||
expect(buildDoeNoticeUrl('7085ba12-c7f4-4f8c-8599-2fe9e4e2737c')).toBe(
|
||||
'https://oeffentlichevergabe.de/ui/de/search/details?noticeId=7085ba12-c7f4-4f8c-8599-2fe9e4e2737c',
|
||||
);
|
||||
// An id is directory data, not something to paste into a URL unchecked.
|
||||
expect(buildDoeNoticeUrl('a b&c=d')).toBe(
|
||||
'https://oeffentlichevergabe.de/ui/de/search/details?noticeId=a%20b%26c%3Dd',
|
||||
);
|
||||
});
|
||||
|
||||
it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => {
|
||||
const source = readFileSync(
|
||||
join(__dirname, 'doe-opendata.adapter.ts'),
|
||||
|
||||
@@ -43,6 +43,27 @@ interface OcdsDocument {
|
||||
releases?: OcdsRelease[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable notice page for a DÖE notice id.
|
||||
*
|
||||
* The OCDS document's own `uri` field is the API address of the record — it
|
||||
* serves OCDS JSON by design, so a user who follows it lands on raw JSON
|
||||
* instead of the notice. The id in that URL is the same `releases[0].id` the
|
||||
* adapter already stores as sourceNoticeId, so the page can be addressed
|
||||
* without a second lookup.
|
||||
*
|
||||
* `/ui/de/search/details?noticeId=…` is the redirect target of
|
||||
* `/ui/de/notices/…` and therefore the form that needs no redirect. Verified
|
||||
* in a browser on 2026-08-11 for both id shapes that occur in the feed: the
|
||||
* numeric one (25673764 -> "Feuerwehr-Gerätehaus Miehlen Fliesenarbeiten")
|
||||
* and the UUID one (7085ba12-… -> "Holzfassade"). Note that the page is a
|
||||
* single-page app: it answers HTTP 200 with an identical shell for ANY id,
|
||||
* so a status-code check proves nothing here — only rendered content does.
|
||||
*/
|
||||
export function buildDoeNoticeUrl(noticeId: string): string {
|
||||
return `https://oeffentlichevergabe.de/ui/de/search/details?noticeId=${encodeURIComponent(noticeId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* D-02 open-tender filter (RESEARCH.md Pattern 2 / Pitfall C): positive
|
||||
* tag-presence match only. Missing/other tags (award, planning, or no tag
|
||||
@@ -124,7 +145,8 @@ export class DoeOpenDataAdapter implements TenderSourceAdapter {
|
||||
sourcePortal: 'doe-opendata',
|
||||
sourceNoticeId: release.id,
|
||||
ocid: release.ocid,
|
||||
sourceUrl: ocdsDocument.uri,
|
||||
// NOT ocdsDocument.uri — that is the API address and serves JSON.
|
||||
sourceUrl: buildDoeNoticeUrl(release.id),
|
||||
fetchedAt,
|
||||
publishedAt: ocdsDocument.publishedDate
|
||||
? new Date(ocdsDocument.publishedDate)
|
||||
|
||||
Reference in New Issue
Block a user