-
Notifications
You must be signed in to change notification settings - Fork 43
test: тестово покритие ≥95% за всички workspaces (надгражда #216) #254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lyubomir-bozhinov
wants to merge
48
commits into
midt-bg:main
Choose a base branch
from
lyubomir-bozhinov:test/coverage-ratchet
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 39 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 213d554
test(config): cover regionByName + taxonomy integrity to 100%
lyubomir-bozhinov 7c34570
test(shared): cover format.ts edge branches (eik/unp, date fallbacks,…
lyubomir-bozhinov 5194624
test(ingest): cover staging, refresh, and base/ocds edge branches
lyubomir-bozhinov 2c188d9
test(db): close branch gaps in identity, keyset, home, regions, metho…
lyubomir-bozhinov 484571d
test(db): cover sitemap streaming end to end
lyubomir-bozhinov 30bd3cc
test(db): cover getCompany, getAuthority, and getContract derivations
lyubomir-bozhinov abf0c10
test(db): cover company-centred network, defaults, and hop-2 reduction
lyubomir-bozhinov 180b4ab
test(db): cover search empty-query + trend zero-year YoY and coverage…
lyubomir-bozhinov d7fc2f8
test(db): cover authorities query branches to 96%
lyubomir-bozhinov 5ef5b07
test(db): cover companies query branches to 95%
lyubomir-bozhinov 3f232c1
test(db): cover competition query branches to 95%
lyubomir-bozhinov 72c0d29
test(db): cover contracts query branches to 96%
lyubomir-bozhinov 1357043
test(db): cover details query fallback branches to 98%
lyubomir-bozhinov 4a317fd
test(db): cover network query branches to 98%
lyubomir-bozhinov 31af022
test(db): cover flows, search, and trend branches to 95%+
lyubomir-bozhinov 7f68206
test(etl): cover the EOP ingest worker and bucket pipeline to 98%
lyubomir-bozhinov 4f1870b
test(web): cover filters URL-state helpers to 97%
lyubomir-bozhinov 151ed29
test(web): cover assistant agent, report binder, and tool registry
lyubomir-bozhinov e25b7bb
test(web): cover assistant format, results, eop-fetch, rag, emit-shape
lyubomir-bozhinov 4c8b48b
test(web): cover CSV export ranges, freshness, and multipart edges
lyubomir-bozhinov 1f2c4d8
build: exclude markdown files from coverage instrumentation
lyubomir-bozhinov 97ce143
test(web): raise branch coverage to the 95 floor
lyubomir-bozhinov 81c2d96
style(config): wrap the curated-sector assertion per prettier
lyubomir-bozhinov 42a699f
chore: ratchet coverage floors to >=95 for every workspace
lyubomir-bozhinov d5cdab8
test(web): assert non-null categories in buildSectorGroup tests
lyubomir-bozhinov d136818
test(ingest): cover sparse-release OCDS nullish branches
lyubomir-bozhinov 6f0612f
test(db): close reachable query branch gaps to 98.4%
lyubomir-bozhinov 03c9d74
test(web): cover read-only SQL guard, tool, and eop-fetch branches
lyubomir-bozhinov d376fb8
chore: ratchet coverage floors up after deeper branch tests
lyubomir-bozhinov 5573655
style(db): wrap flows two-authority test per prettier
lyubomir-bozhinov e5d83a6
test: make coverage-only tests mutation-sensitive
lyubomir-bozhinov ad980b0
test(coverage): replace coverage-only assertions with mutation-sensit…
lyubomir-bozhinov 686f70e
test(etl): pin the clock in the catch-up default-today test
lyubomir-bozhinov 1504652
test(etl): restore the data-integrity invariant weakened in the fake DB
lyubomir-bozhinov e68c6f3
Merge upstream/main into test/coverage-ratchet
lyubomir-bozhinov 457268f
test(ingest): cover FX/OCDS edge branches; reconcile etl branch floor…
lyubomir-bozhinov 84d6e03
Merge upstream/main into test/coverage-ratchet (#226 related-persons)
lyubomir-bozhinov f11cca4
Merge upstream/main into test/coverage-ratchet (undici #282, cacbg ga…
lyubomir-bozhinov a07dc6f
Merge upstream/main into test/coverage-ratchet (annex/stotinki ETL wa…
lyubomir-bozhinov 6971a09
test(db,web): address review findings on the coverage ratchet (#254)
lyubomir-bozhinov 4b47ca0
test(web): tighten the coverage denominator contract and the date-cel…
lyubomir-bozhinov b27c417
Merge remote-tracking branch 'upstream/main' into test/coverage-ratchet
lyubomir-bozhinov 940296f
test(config): name both paths to the unknown procedure bucket (#254 r…
lyubomir-bozhinov 257b097
Merge upstream/main into test/coverage-ratchet (свързани лица person …
lyubomir-bozhinov c9e842b
Merge upstream/main into test/coverage-ratchet
lyubomir-bozhinov fd2bc1b
style: run prettier on the three migrated test files
lyubomir-bozhinov dece62d
test: give the fully-covered workspaces a line-floor margin
lyubomir-bozhinov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,55 +1,310 @@ | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { computeWorkerCatchupPlan, listBucketForDay } from './eop'; | ||
|
|
||
| function fakeDbFromFreshness(maxLoadedDate: string): D1Database { | ||
| const db = { | ||
| // 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'; | ||
|
|
||
| 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 { | ||
| 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&b/ocds.json</Key><Key>c<d>"'</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 })]); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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) в мока, за да не се губи тази проверка за целост на данните.