Skip to content
Open
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
69d0a34
test: coverage measurement + ratchet gate in CI
ydimitrof Jul 16, 2026
213d554
test(config): cover regionByName + taxonomy integrity to 100%
lyubomir-bozhinov Jul 16, 2026
7c34570
test(shared): cover format.ts edge branches (eik/unp, date fallbacks,…
lyubomir-bozhinov Jul 16, 2026
5194624
test(ingest): cover staging, refresh, and base/ocds edge branches
lyubomir-bozhinov Jul 16, 2026
2c188d9
test(db): close branch gaps in identity, keyset, home, regions, metho…
lyubomir-bozhinov Jul 16, 2026
484571d
test(db): cover sitemap streaming end to end
lyubomir-bozhinov Jul 16, 2026
30bd3cc
test(db): cover getCompany, getAuthority, and getContract derivations
lyubomir-bozhinov Jul 16, 2026
abf0c10
test(db): cover company-centred network, defaults, and hop-2 reduction
lyubomir-bozhinov Jul 16, 2026
180b4ab
test(db): cover search empty-query + trend zero-year YoY and coverage…
lyubomir-bozhinov Jul 16, 2026
d7fc2f8
test(db): cover authorities query branches to 96%
lyubomir-bozhinov Jul 16, 2026
5ef5b07
test(db): cover companies query branches to 95%
lyubomir-bozhinov Jul 16, 2026
3f232c1
test(db): cover competition query branches to 95%
lyubomir-bozhinov Jul 16, 2026
72c0d29
test(db): cover contracts query branches to 96%
lyubomir-bozhinov Jul 16, 2026
1357043
test(db): cover details query fallback branches to 98%
lyubomir-bozhinov Jul 16, 2026
4a317fd
test(db): cover network query branches to 98%
lyubomir-bozhinov Jul 16, 2026
31af022
test(db): cover flows, search, and trend branches to 95%+
lyubomir-bozhinov Jul 16, 2026
7f68206
test(etl): cover the EOP ingest worker and bucket pipeline to 98%
lyubomir-bozhinov Jul 16, 2026
4f1870b
test(web): cover filters URL-state helpers to 97%
lyubomir-bozhinov Jul 16, 2026
151ed29
test(web): cover assistant agent, report binder, and tool registry
lyubomir-bozhinov Jul 16, 2026
e25b7bb
test(web): cover assistant format, results, eop-fetch, rag, emit-shape
lyubomir-bozhinov Jul 16, 2026
4c8b48b
test(web): cover CSV export ranges, freshness, and multipart edges
lyubomir-bozhinov Jul 16, 2026
1f2c4d8
build: exclude markdown files from coverage instrumentation
lyubomir-bozhinov Jul 16, 2026
97ce143
test(web): raise branch coverage to the 95 floor
lyubomir-bozhinov Jul 16, 2026
81c2d96
style(config): wrap the curated-sector assertion per prettier
lyubomir-bozhinov Jul 16, 2026
42a699f
chore: ratchet coverage floors to >=95 for every workspace
lyubomir-bozhinov Jul 16, 2026
d5cdab8
test(web): assert non-null categories in buildSectorGroup tests
lyubomir-bozhinov Jul 17, 2026
d136818
test(ingest): cover sparse-release OCDS nullish branches
lyubomir-bozhinov Jul 17, 2026
6f0612f
test(db): close reachable query branch gaps to 98.4%
lyubomir-bozhinov Jul 17, 2026
03c9d74
test(web): cover read-only SQL guard, tool, and eop-fetch branches
lyubomir-bozhinov Jul 17, 2026
d376fb8
chore: ratchet coverage floors up after deeper branch tests
lyubomir-bozhinov Jul 17, 2026
5573655
style(db): wrap flows two-authority test per prettier
lyubomir-bozhinov Jul 17, 2026
e5d83a6
test: make coverage-only tests mutation-sensitive
lyubomir-bozhinov Jul 17, 2026
ad980b0
test(coverage): replace coverage-only assertions with mutation-sensit…
lyubomir-bozhinov Jul 17, 2026
686f70e
test(etl): pin the clock in the catch-up default-today test
lyubomir-bozhinov Jul 17, 2026
1504652
test(etl): restore the data-integrity invariant weakened in the fake DB
lyubomir-bozhinov Jul 17, 2026
e68c6f3
Merge upstream/main into test/coverage-ratchet
lyubomir-bozhinov Aug 3, 2026
457268f
test(ingest): cover FX/OCDS edge branches; reconcile etl branch floor…
lyubomir-bozhinov Aug 3, 2026
84d6e03
Merge upstream/main into test/coverage-ratchet (#226 related-persons)
lyubomir-bozhinov Aug 4, 2026
f11cca4
Merge upstream/main into test/coverage-ratchet (undici #282, cacbg ga…
lyubomir-bozhinov Aug 4, 2026
a07dc6f
Merge upstream/main into test/coverage-ratchet (annex/stotinki ETL wa…
lyubomir-bozhinov Aug 18, 2026
6971a09
test(db,web): address review findings on the coverage ratchet (#254)
lyubomir-bozhinov Aug 19, 2026
4b47ca0
test(web): tighten the coverage denominator contract and the date-cel…
lyubomir-bozhinov Aug 20, 2026
b27c417
Merge remote-tracking branch 'upstream/main' into test/coverage-ratchet
lyubomir-bozhinov Aug 21, 2026
940296f
test(config): name both paths to the unknown procedure bucket (#254 r…
lyubomir-bozhinov Aug 24, 2026
257b097
Merge upstream/main into test/coverage-ratchet (свързани лица person …
lyubomir-bozhinov Aug 25, 2026
c9e842b
Merge upstream/main into test/coverage-ratchet
lyubomir-bozhinov Aug 26, 2026
fd2bc1b
style: run prettier on the three migrated test files
lyubomir-bozhinov Aug 26, 2026
dece62d
test: give the fully-covered workspaces a line-floor margin
lyubomir-bozhinov Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 278 additions & 24 deletions apps/etl/src/eop.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { computeWorkerCatchupPlan, listBucketForDay, stageBaseFromBucket } from './eop';

// Keep the pure catch-up-window helpers real (computeWorkerCatchupPlan relies on them), but stub the
// bucket-key classifier, the OCDS/base record mappers, and every staging writer so the tests drive
// eop.ts's fetch/parse/stage orchestration against a controllable fetch, with no real D1 or ingest SQL.
vi.mock('@sigma/ingest', async (importOriginal) => {
const actual = await importOriginal<typeof import('@sigma/ingest')>();
return {
...actual,
classifyBucketKey: (key: string) =>
key.includes('ocds')
? 'ocds'
: key.includes('contract')
? 'contracts'
: key.includes('tender')
? 'tenders'
: key.includes('annex')
? 'annexes'
: null,
releaseToContracts: () => [{ id: 'c' }],
releaseToAmendments: () => [{ id: 'a' }],
releaseToParties: () => [{ id: 'p' }],
releaseToLots: () => [{ id: 'l' }],
mapBaseRecord: (kind: string, rec: Record<string, unknown>) =>
rec.skip ? null : { kind, ...rec },
upsertContractStaging: vi.fn(async () => {}),
upsertAmendmentStaging: vi.fn(async () => {}),
upsertPartyStaging: vi.fn(async () => {}),
upsertLotStaging: vi.fn(async () => {}),
upsertBaseContractStaging: vi.fn(
async (_db: unknown, _src: string, rows: unknown[]) => rows.length,
),
upsertBaseTenderStaging: vi.fn(
async (_db: unknown, _src: string, rows: unknown[]) => rows.length,
),
upsertBaseAmendmentStaging: vi.fn(
async (_db: unknown, _src: string, rows: unknown[]) => rows.length,
),
};
});

import {
computeWorkerCatchupPlan,
ingestBucketWindow,
listBucketForDay,
parseBucketKeys,
stageBaseFromBucket,
stageOcdsFromBucket,
type BucketListing,
} from './eop';

/**
* A response whose stream is deliberately left open, so `cancel()` on the underlying source really
Expand All @@ -24,59 +72,265 @@ function openBodyResponse(init: ResponseInit & { url?: string }): {
return { response, cancelled: () => cancelled };
}

function fakeDbFromFreshness(maxLoadedDate: string): D1Database {
const db = {
const fakeDb = {} as D1Database;

// A fetch stub that dispatches on the request URL. `text` responses serve bucket XML; `json` responses
// serve object payloads. `url: ''` means no redirect, so assertAllowedFinalHost falls back to the
// request host and passes.
function stubFetch(
handler: (url: string) => { status?: number; body?: string; finalUrl?: string },
) {
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(input);
const { status = 200, body = '', finalUrl = '' } = handler(url);
const res = new Response(body, { status });
Object.defineProperty(res, 'url', { value: finalUrl });
return res;
}) as unknown as typeof fetch,
);
}

afterEach(() => {
vi.unstubAllGlobals();
});

function fakeDbFromFreshness(maxLoadedDate: string | null): D1Database {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Новият fakeDbFromFreshness игнорира подадения SQL и винаги връща реда за freshness. Предишната версия хвърляше грешка при sql.includes('raw_contracts') (raw staging should not be read for planning), с което тестът налагаше инварианта, че планировчикът чете обслужената freshness, а не изтекло raw staging. Сега тестът „plans an uncapped window straight from served freshness“ ще мине дори ако продукционният код регресира и започне да чете raw_contracts. Предложение: запазете негативната проверка (хвърляне при четене на raw staging) в мока, за да не се губи тази проверка за целост на данните.

return {
prepare(sql: string) {
if (sql.includes('raw_contracts')) {
throw new Error('raw staging should not be read for planning');
// Data-integrity invariant: the catch-up planner must derive the max-loaded date from served
// freshness (data_freshness), never from raw staging (raw_*). Throw on anything else so a
// regression that reads raw staging for planning fails loudly instead of passing silently.
if (!sql.includes('data_freshness')) {
throw new Error(`planning must read data_freshness, not: ${sql}`);
}
return {
async first() {
return { max_loaded_date: maxLoadedDate };
},
};
},
};
return db as unknown as D1Database;
} as unknown as D1Database;
}

describe('parseBucketKeys', () => {
it('extracts and XML-decodes every <Key> entry', () => {
const xml = '<Key>a&amp;b/ocds.json</Key><Key>c&lt;d&gt;&quot;&apos;</Key>';
expect(parseBucketKeys(xml)).toEqual(['a&b/ocds.json', 'c<d>"\'']);
});

it('returns an empty list when there are no keys', () => {
expect(parseBucketKeys('<ListBucketResult />')).toEqual([]);
});
});

describe('computeWorkerCatchupPlan', () => {
it('plans from served freshness and ignores leaked raw staging', async () => {
it('plans an uncapped window straight from served freshness', async () => {
const plan = await computeWorkerCatchupPlan(fakeDbFromFreshness('2026-06-01'), {
today: '2026-06-07',
lookbackDays: 3,
maxWindowDays: 21,
});
expect(plan).toMatchObject({ from: '2026-05-29', to: '2026-06-07', capped: false });
expect(plan.maxLoadedDate).toBe('2026-06-01');
});

expect(plan.from).toBe('2026-05-29');
it('caps an over-wide window to the most recent maxWindowDays and flags it', async () => {
const plan = await computeWorkerCatchupPlan(fakeDbFromFreshness('2026-01-01'), {
today: '2026-06-07',
lookbackDays: 3,
maxWindowDays: 5,
});
expect(plan.capped).toBe(true);
expect(plan.from).toBe('2026-06-03'); // today − (5 − 1)
expect(plan.to).toBe('2026-06-07');
expect(plan.maxLoadedDate).toBe('2026-06-01');
expect(plan.originalGapDays).toBeGreaterThan(5);
expect(plan.originalFrom).not.toBe(plan.from);
});

it('falls back to a null max-loaded date when freshness is empty', async () => {
const plan = await computeWorkerCatchupPlan(fakeDbFromFreshness(null), { today: '2026-06-07' });
expect(plan.maxLoadedDate).toBeNull();
});

it('defaults today to the current UTC date when no override is given', async () => {
// Pin the clock so the `opts.today ?? new Date()...` default resolves to a known date: the window
// must end exactly on that date, not merely be date-shaped (a shape check would survive a mutation
// to any hard-coded ISO string).
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-10T09:30:00Z'));
try {
const plan = await computeWorkerCatchupPlan(fakeDbFromFreshness('2026-06-01'), {});
expect(plan.to).toBe('2026-06-10');
} finally {
vi.useRealTimers();
}
});
});

describe('EOP fetch host allowlist', () => {
afterEach(() => {
vi.unstubAllGlobals();
describe('listBucketForDay', () => {
it('returns null for a 403 or 404 bucket', async () => {
stubFetch(() => ({ status: 404 }));
expect(await listBucketForDay('2026-06-01')).toBeNull();
stubFetch(() => ({ status: 403 }));
expect(await listBucketForDay('2026-06-01')).toBeNull();
});

it('rejects bucket listing redirects to a different final host', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
const response = new Response('<ListBucketResult />', { status: 200 });
Object.defineProperty(response, 'url', {
value: 'https://evil.example/open-data-2026-06-01/',
});
return response;
}) as unknown as typeof fetch,
);
it('throws on any other non-OK status', async () => {
stubFetch(() => ({ status: 500 }));
await expect(listBucketForDay('2026-06-01')).rejects.toThrow(/HTTP 500/);
});

it('classifies keys and keeps only the first per kind', async () => {
stubFetch(() => ({
body:
'<Key>x/ocds-1.json</Key><Key>x/ocds-2.json</Key>' + // second ocds ignored
'<Key>x/contracts.json</Key><Key>x/tenders.json</Key><Key>x/annexes.json</Key>' +
'<Key>x/readme.txt</Key>', // unclassified → dropped
}));
const listing = await listBucketForDay('2026-06-01', { baseUrl: 'https://storage.eop.bg' });
expect(listing?.keys).toEqual({
ocds: 'x/ocds-1.json',
contracts: 'x/contracts.json',
tenders: 'x/tenders.json',
annexes: 'x/annexes.json',
});
expect(listing?.day).toBe('2026-06-01');
});

it('rejects a bucket listing redirected to a different final host', async () => {
stubFetch(() => ({ finalUrl: 'https://evil.example/open-data-2026-06-01/' }));
await expect(listBucketForDay('2026-06-01')).rejects.toThrow(
/blocked redirected EOP fetch from storage\.eop\.bg to evil\.example/,
);
});
});

const listingWith = (keys: BucketListing['keys']): BucketListing => ({
day: '2026-06-01',
bucketUrl: 'https://storage.eop.bg/open-data-2026-06-01/',
keys,
});

describe('stageOcdsFromBucket', () => {
it('stages empty tables and reports zeros when there is no OCDS key', async () => {
const counts = await stageOcdsFromBucket(fakeDb, listingWith({}), '2026-06-01T00:00:00Z');
expect(counts).toEqual({ ocdsContracts: 0, ocdsAmendments: 0, parties: 0, lots: 0 });
});

it('maps releases from a plain OCDS package', async () => {
stubFetch(() => ({ body: JSON.stringify({ releases: [{}], publishedDate: '2026-06-01' }) }));
const counts = await stageOcdsFromBucket(
fakeDb,
listingWith({ ocds: 'x/ocds.json' }),
'2026-06-01T00:00:00Z',
);
expect(counts).toEqual({ ocdsContracts: 1, ocdsAmendments: 1, parties: 1, lots: 1 });
});

it('unwraps a { data: { releases } } envelope too', async () => {
stubFetch(() => ({
body: JSON.stringify({ data: { releases: [{}], publishedDate: '2026-06-02' } }),
}));
const counts = await stageOcdsFromBucket(
fakeDb,
listingWith({ ocds: 'x/ocds.json' }),
'2026-06-01T00:00:00Z',
);
expect(counts.ocdsContracts).toBe(1);
});

it('tolerates a package with neither releases nor data', async () => {
stubFetch(() => ({ body: JSON.stringify({ something: 'else' }) }));
const counts = await stageOcdsFromBucket(
fakeDb,
listingWith({ ocds: 'x/ocds.json' }),
'2026-06-01T00:00:00Z',
);
expect(counts).toEqual({ ocdsContracts: 0, ocdsAmendments: 0, parties: 0, lots: 0 });
});

it('throws when the OCDS object fetch is not OK', async () => {
stubFetch(() => ({ status: 500 }));
await expect(
stageOcdsFromBucket(fakeDb, listingWith({ ocds: 'x/ocds.json' }), '2026-06-01T00:00:00Z'),
).rejects.toThrow(/HTTP 500/);
});
});

describe('stageBaseFromBucket', () => {
it('maps and counts base contracts, tenders, and annexes, dropping skipped records', async () => {
stubFetch((url) => {
if (url.includes('contracts')) return { body: JSON.stringify([{ a: 1 }, { skip: true }]) };
if (url.includes('tenders')) return { body: JSON.stringify([{ b: 1 }]) };
if (url.includes('annexes')) return { body: JSON.stringify([{ c: 1 }, { c: 2 }]) };
return { body: '[]' };
});
const counts = await stageBaseFromBucket(
fakeDb,
listingWith({
contracts: 'contracts.json',
tenders: 'tenders.json',
annexes: 'annexes.json',
}),
'2026-06-01T00:00:00Z',
);
expect(counts).toEqual({ baseContracts: 1, baseTenders: 1, baseAmendments: 2 });
});

it('reports zeros when the bucket carries no base keys', async () => {
const counts = await stageBaseFromBucket(fakeDb, listingWith({}), '2026-06-01T00:00:00Z');
expect(counts).toEqual({ baseContracts: 0, baseTenders: 0, baseAmendments: 0 });
});

it('throws when an object payload is not a JSON array', async () => {
stubFetch(() => ({ body: JSON.stringify({ not: 'an array' }) }));
await expect(
stageBaseFromBucket(
fakeDb,
listingWith({ contracts: 'contracts.json' }),
'2026-06-01T00:00:00Z',
),
).rejects.toThrow(/is not an array/);
});
});

describe('ingestBucketWindow', () => {
it('walks each day, recording a not-found day and staging a found day', async () => {
// Day 1 (2026-06-01) → 404 (missing). Day 2 (2026-06-02) → a bucket with an OCDS + contracts key.
stubFetch((url) => {
if (url.includes('open-data-2026-06-01')) return { status: 404 };
if (url.includes('open-data-2026-06-02/')) {
// the bucket listing itself
if (url.endsWith('open-data-2026-06-02/'))
return { body: '<Key>2026-06-02/ocds.json</Key><Key>2026-06-02/contracts.json</Key>' };
if (url.includes('contracts.json')) return { body: JSON.stringify([{ a: 1 }]) };
if (url.includes('ocds.json')) return { body: JSON.stringify({ releases: [{}] }) };
}
return { body: '[]' };
});
const results = await ingestBucketWindow(
fakeDb,
{ from: '2026-06-01', to: '2026-06-02' },
{ fetchedAt: '2026-06-02T00:00:00Z' },
);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ day: '2026-06-01', found: false, baseContracts: 0 });
expect(results[1]).toMatchObject({
day: '2026-06-02',
found: true,
baseContracts: 1,
ocdsContracts: 1,
});
});

it('defaults fetchedAt to now when the caller omits it', async () => {
stubFetch(() => ({ status: 404 })); // single missing day → no staging, just the default fetchedAt
const results = await ingestBucketWindow(fakeDb, { from: '2026-06-01', to: '2026-06-01' });
expect(results).toEqual([expect.objectContaining({ day: '2026-06-01', found: false })]);
});
});
describe('EOP responses the ingest walks away from', () => {
afterEach(() => {
vi.unstubAllGlobals();
Expand Down
Loading