diff --git a/apps/etl/src/eop.test.ts b/apps/etl/src/eop.test.ts index 79c6b1393..6e932ab06 100644 --- a/apps/etl/src/eop.test.ts +++ b/apps/etl/src/eop.test.ts @@ -1,6 +1,54 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { fakeD1 } from '@sigma/test-support'; -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(); + 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) => + 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 @@ -25,60 +73,259 @@ function openBodyResponse(init: ResponseInit & { url?: string }): { return { response, cancelled: () => cancelled }; } -function fakeDbFromFreshness(maxLoadedDate: string): D1Database { - return fakeD1([ - { - when: 'raw_contracts', - first: () => { - throw new Error('raw staging should not be read for planning'); - }, - }, - { when: [], first: { max_loaded_date: maxLoadedDate } }, - ]).db; +// A double with no routes at all: every one of these tests stubs the staging writers, so nothing +// may reach D1. Any query that does hits the unmatched-route throw instead of a silent empty answer. +const fakeDb = fakeD1([]).db; + +// 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 { + // Data-integrity invariant: the catch-up planner must derive the max-loaded date from served + // freshness (data_freshness), never from raw staging (raw_*). data_freshness is the ONLY route, so + // a regression that reads raw staging for planning throws rather than passing silently. + return fakeD1([{ when: 'data_freshness', first: { max_loaded_date: maxLoadedDate } }]).db; } +describe('parseBucketKeys', () => { + it('extracts and XML-decodes every entry', () => { + const xml = 'a&b/ocds.jsonc<d>"''; + expect(parseBucketKeys(xml)).toEqual(['a&b/ocds.json', 'c"\'']); + }); + + it('returns an empty list when there are no keys', () => { + expect(parseBucketKeys('')).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('', { 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: + 'x/ocds-1.jsonx/ocds-2.json' + // second ocds ignored + 'x/contracts.jsonx/tenders.jsonx/annexes.json' + + 'x/readme.txt', // 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/, ); }); }); -describe('EOP responses the ingest walks away from', () => { - afterEach(() => { - vi.unstubAllGlobals(); +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: '2026-06-02/ocds.json2026-06-02/contracts.json' }; + 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', () => { + // No local afterEach: the file-level one above already unstubs globals after every test. it('releases the body of a missing bucket instead of leaving the stream open', async () => { const { response, cancelled } = openBodyResponse({ status: 403 }); vi.stubGlobal('fetch', vi.fn(async () => response) as unknown as typeof fetch); diff --git a/apps/etl/src/index.control-flow.test.ts b/apps/etl/src/index.control-flow.test.ts new file mode 100644 index 000000000..ebbdf7b87 --- /dev/null +++ b/apps/etl/src/index.control-flow.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fakeD1 } from '@sigma/test-support'; + +// index.test.ts covers the FX/derive path end-to-end against a real SQLite (#158). This file isolates +// the RefreshWorkflow.run()/scheduled() *control flow* — plan → capped warning → zero-ingest +// short-circuit → derive loop → integrity gate → finally-drop — by mocking the platform base class, +// the Workflow error type, the build-time `.sql` imports, the ingest helpers, the eop bucket walk, and +// the served integrity gate, so each orchestration branch is asserted without any real D1 or network. +vi.mock('cloudflare:workers', () => ({ + WorkflowEntrypoint: class { + env: unknown; + ctx: unknown; + constructor(ctx: unknown, env: unknown) { + this.ctx = ctx; + this.env = env; + } + }, +})); +vi.mock('cloudflare:workflows', () => ({ + NonRetryableError: class NonRetryableError extends Error {}, +})); +vi.mock('../../../scripts/refresh-slice.sql', () => ({ default: 'REFRESH_SLICE_SQL' })); +vi.mock('../../../scripts/work-staging-schema.sql', () => ({ default: 'WORK_STAGING_SCHEMA_SQL' })); + +type GateLog = { info: (e: object) => void; warn: (e: object) => void; error: (e: object) => void }; + +// Hoisted so the vi.mock factories (themselves hoisted above the imports) can close over them. +const { ingest, eop, integrity } = vi.hoisted(() => ({ + ingest: { + createTransientStaging: vi.fn(async () => {}), + dropTransientStaging: vi.fn(async () => {}), + refreshDerivedContractCount: vi.fn(async () => 42), + refreshSliceStatementGroups: vi.fn(() => [{ name: 'g1', statements: ['a', 'b'] }]), + runRefreshSliceStatementGroup: vi.fn(async () => {}), + loadFxRates: vi.fn(async () => ({ + inserted: 0, + fetched: 0, + skipped: 0, + warnings: [] as string[], + uncovered: [] as string[], + })), + }, + eop: { + computeWorkerCatchupPlan: vi.fn(), + ingestBucketWindow: vi.fn(), + }, + integrity: { + runServedIntegrityGate: vi.fn(async (_db: unknown, _log: GateLog) => {}), + }, +})); +vi.mock('@sigma/ingest', () => ingest); +vi.mock('./eop', () => eop); +vi.mock('./integrity', () => integrity); + +import worker, { RefreshWorkflow } from './index'; + +const PLAN = { + maxLoadedDate: '2026-06-01', + from: '2026-05-29', + to: '2026-06-07', + gapDays: 10, + capped: false, + originalFrom: '2026-05-29', + originalGapDays: 10, +}; + +const dayResult = (over: Partial> = {}) => ({ + day: '2026-06-07', + found: true, + baseContracts: 0, + baseTenders: 0, + baseAmendments: 0, + ocdsContracts: 0, + ocdsAmendments: 0, + parties: 0, + lots: 0, + ...over, +}); + +// A step runner that simply executes each step body inline and records the step names. +function fakeStep(names: string[]) { + return { + do: async (name: string, fn: () => Promise): Promise => { + names.push(name); + return fn(); + }, + }; +} + +// Every ingest/refresh writer is mocked in this file, so the binding must never be touched. A +// route-less double throws on any query instead of quietly answering one. One shared instance, so +// the assertions below can name the exact handle the workflow was expected to pass down. +const DB = fakeD1([]).db; + +function makeWorkflow() { + return new RefreshWorkflow({} as never, { DB, REFRESH: {} as Workflow }); +} + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('RefreshWorkflow.run — control flow', () => { + it('runs the full pipeline: stage, ingest, load FX, derive slice groups, gate, count, and drop', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([ + // All seven staged-row counts are non-zero (distinct values) so `staged` guards every term of + // the sum — including baseAmendments/ocdsAmendments, which no other case exercises. + dayResult({ + baseContracts: 3, + baseTenders: 6, + baseAmendments: 5, + ocdsContracts: 2, + ocdsAmendments: 7, + parties: 1, + lots: 4, + }), + ]); + const wf = makeWorkflow(); + const names: string[] = []; + + const result = await wf.run( + { payload: { today: '2026-06-07' } } as never, + fakeStep(names) as never, + ); + + expect(result).toMatchObject({ from: '2026-05-29', days: 1, staged: 28, derived: 42 }); + expect(ingest.createTransientStaging).toHaveBeenCalledWith(DB, 'WORK_STAGING_SCHEMA_SQL'); + expect(ingest.loadFxRates).toHaveBeenCalledOnce(); // FX loaded before derive (#158) + expect(ingest.runRefreshSliceStatementGroup).toHaveBeenCalledTimes(1); + expect(ingest.refreshDerivedContractCount).toHaveBeenCalledOnce(); + expect(integrity.runServedIntegrityGate).toHaveBeenCalledOnce(); + expect(names).toContain('load-fx'); + expect(names).toContain('derive-slice:g1'); + expect(names).toContain('integrity-gate'); + expect(names).toContain('drop-transient-staging'); // finally always drops + }); + + it('warns when FX loading reports uncovered currency pairs', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([dayResult({ baseContracts: 1 })]); + ingest.loadFxRates.mockResolvedValueOnce({ + inserted: 1, + fetched: 2, + skipped: 0, + warnings: ['frankfurter slow'], + uncovered: ['2026-06-05:USD'], + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wf = makeWorkflow(); + + await wf.run({ payload: {} } as never, fakeStep([]) as never); + + expect(warn.mock.calls.some((c) => String(c[0]).includes('etl_fx_uncovered'))).toBe(true); + }); + + it('wires the integrity-gate logger to structured console info/warn/error', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([dayResult({ baseContracts: 1 })]); + // Drive all three logger callbacks the run() passes into the gate, so each console wrapper runs. + integrity.runServedIntegrityGate.mockImplementationOnce(async (_db: unknown, log: GateLog) => { + log.info({ event: 'gate_info' }); + log.warn({ event: 'gate_warn' }); + log.error({ event: 'gate_error' }); + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const wf = makeWorkflow(); + + await wf.run({ payload: {} } as never, fakeStep([]) as never); + + expect(logSpy.mock.calls.some((c) => String(c[0]).includes('"event":"gate_info"'))).toBe(true); + expect(warnSpy.mock.calls.some((c) => String(c[0]).includes('"event":"gate_warn"'))).toBe(true); + expect(errSpy.mock.calls.some((c) => String(c[0]).includes('"event":"gate_error"'))).toBe(true); + }); + + it('defaults the payload to an empty object when the event carries none', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([dayResult({ baseTenders: 1 })]); + const wf = makeWorkflow(); + + const result = await wf.run({} as never, fakeStep([]) as never); + expect(result.staged).toBe(1); + expect(eop.computeWorkerCatchupPlan).toHaveBeenCalledWith(DB, { + today: undefined, + lookbackDays: undefined, + maxWindowDays: undefined, + }); + }); + + it('logs a capped warning when the plan window was truncated', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue({ + ...PLAN, + capped: true, + from: '2026-05-18', + gapDays: 21, + originalGapDays: 40, + }); + eop.ingestBucketWindow.mockResolvedValue([dayResult({ baseContracts: 5 })]); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wf = makeWorkflow(); + + await wf.run({ payload: {} } as never, fakeStep([]) as never); + + const capped = warn.mock.calls + .map((c) => String(c[0])) + .find((s) => s.includes('etl_window_capped')); + expect(capped).toBeDefined(); + expect(capped).toContain('"originalGapDays":40'); + }); + + it('short-circuits with a zero-ingest warning and still drops staging when nothing staged', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([dayResult()]); // all counts 0 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wf = makeWorkflow(); + const names: string[] = []; + + const result = await wf.run({ payload: {} } as never, fakeStep(names) as never); + + expect(result).toMatchObject({ days: 1, staged: 0, derived: 0 }); + expect(ingest.loadFxRates).not.toHaveBeenCalled(); // no FX/derive on empty ingest + expect(ingest.runRefreshSliceStatementGroup).not.toHaveBeenCalled(); + expect(warn.mock.calls.some((c) => String(c[0]).includes('etl_zero_ingest'))).toBe(true); + expect(names).toContain('drop-transient-staging'); // finally still runs + }); + + it('fails the run non-retryably when the served integrity gate throws, still dropping staging', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([dayResult({ baseContracts: 2 })]); + integrity.runServedIntegrityGate.mockRejectedValueOnce(new Error('reconciliation drift')); + const wf = makeWorkflow(); + const names: string[] = []; + + await expect(wf.run({ payload: {} } as never, fakeStep(names) as never)).rejects.toThrow( + 'reconciliation drift', + ); + expect(names).toContain('integrity-gate'); + expect(names).toContain('drop-transient-staging'); // finally runs on the gate-failure path + expect(ingest.dropTransientStaging).toHaveBeenCalled(); + }); + + it('stringifies a non-Error gate rejection into the NonRetryableError message', async () => { + // The gate catch wraps `err instanceof Error ? err.message : String(err)`; a thrown non-Error + // exercises the String(err) side that an Error rejection cannot. + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockResolvedValue([dayResult({ baseContracts: 2 })]); + integrity.runServedIntegrityGate.mockRejectedValueOnce('raw string fault'); + const wf = makeWorkflow(); + + await expect(wf.run({ payload: {} } as never, fakeStep([]) as never)).rejects.toThrow( + 'raw string fault', + ); + expect(ingest.dropTransientStaging).toHaveBeenCalled(); // finally still drops + }); + + it('drops staging even when ingestion throws', async () => { + eop.computeWorkerCatchupPlan.mockResolvedValue(PLAN); + eop.ingestBucketWindow.mockRejectedValue(new Error('bucket down')); + const wf = makeWorkflow(); + const names: string[] = []; + + await expect(wf.run({ payload: {} } as never, fakeStep(names) as never)).rejects.toThrow( + 'bucket down', + ); + expect(names).toContain('drop-transient-staging'); // finally runs on the error path + expect(ingest.dropTransientStaging).toHaveBeenCalled(); + }); +}); + +describe('scheduled handler', () => { + it('kicks one durable refresh run and logs its id', async () => { + const create = vi.fn(async () => ({ id: 'wf-123' })); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const env = { DB: fakeD1([]).db, REFRESH: { create } as unknown as Workflow }; + + await worker.scheduled?.({} as never, env); + + expect(create).toHaveBeenCalledOnce(); + expect(log.mock.calls.some((c) => String(c[0]).includes('"id":"wf-123"'))).toBe(true); + }); +}); diff --git a/apps/web/app/components/ConflictDetail.test.tsx b/apps/web/app/components/ConflictDetail.test.tsx new file mode 100644 index 000000000..7d2fb30db --- /dev/null +++ b/apps/web/app/components/ConflictDetail.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +// ConflictDetail is the rich per-link case detail. It was lifted out of the retired ConflictCards in +// #312, but the tests that covered its thinner row shapes lived in conflicts.render.test.tsx and went +// away with the card LIST — /conflicts is a person table now. The component survived; its edge +// branches did not keep their cover. This file restores it against the component directly. +// +// Every case here is a shape the feed really produces: a declaration with no usable period, a +// seat/ЕИК confirmation that cites no register act, a contract with no number, an authority that +// never resolved, and a winner whose amounts are all NULL. +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRoutesStub } from 'react-router'; +import type { ConflictContract, ConflictContractFacts, ConflictLink } from '@sigma/api-contract'; +import { ConflictDetail } from './ConflictDetail'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function link(over: Partial = {}): ConflictLink { + return { + linkKey: 'person:ivan|111', + officialSlug: 'aXZhbg', + official: 'Иван Петров', + institution: 'Община Тест', + company: 'ТРЕЙС ГРУП ХОЛД АД', + eik: '111', + relation: 'owns', + contemporaneous: true, + ownInstitution: true, + firstDeclaredYear: '2019', + lastDeclaredYear: '2023', + matchMethod: 'exact_name_key', + contractCount: 2, + contractValueEur: 88_000_000, + contemporaneousContractCount: 1, + contemporaneousValueEur: 30_000_000, + firstContractYear: '2020', + lastContractYear: '2024', + sourceUrl: 'https://register.cacbg.bg/2024/x.xml', + evidenceKind: 'document', + registryRole: 'owner', + registryEntryNumber: '20110502101007', + registryEntryDate: '2011-05-02', + registryLookupDate: '2026-08-05', + ...over, + }; +} + +function facts(over: Partial = {}): ConflictContractFacts { + const { temporal: _drop, ...rest } = { + contractSlug: 'e:abc', + signedAt: '2021-05-01', + authority: 'Община Пловдив', + authorityId: 'auth1', + authorityTotalEur: 5_000_000, + contractKind: 'Услуги', + procedureType: 'открита процедура', + subject: 'Ремонт на улици', + contractNumber: 'Д-1', + amountEur: 1_000_000, + temporal: 'contemporaneous' as const, + ...over, + }; + return rest; +} + +let container: HTMLDivElement; +let root: Root; +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +async function render(links: ConflictLink[], byEik: Record) { + const Stub = createRoutesStub([ + { + path: '/x', + Component: () => , + }, + { path: '/companies/:eik', Component: () => null }, + { path: '/contracts/:id', Component: () => null }, + { path: '/', Component: () => null }, + ]); + await act(async () => { + root.render(); + }); +} + +const text = () => container.textContent ?? ''; + +describe('ConflictDetail — provenance on the thinner link shapes', () => { + it('cites no act entry for a seat/ЕИК confirmation, and prints no bare „№"', async () => { + // A 'confirmed' seal identifies the COMPANY from declared data; nobody was found in a register + // act, so there is no entry number or date to cite. Printing the separators anyway would imply a + // document behind the claim that does not exist. + await render( + [ + link({ + evidenceKind: 'confirmed', + registryRole: null, + registryEntryNumber: null, + registryEntryDate: null, + }), + ], + { '111': [facts()] }, + ); + const evidence = container.querySelector('.cc-evidence')!.textContent ?? ''; + expect(evidence).toContain('потвърдена'); + expect(evidence).not.toContain('№'); + expect(evidence).not.toContain('вписване'); + expect(evidence).toContain('справка'); // lookup_date is NOT NULL — it always says when we looked + }); + + it('renders „—" for a link with no source declaration URL', async () => { + await render([link({ sourceUrl: null })], { '111': [facts()] }); + const src = container.querySelector('.cc-source, .conflict-detail')!; + expect(src.textContent).toContain('—'); + expect(container.querySelector('a[href^="https://register.cacbg.bg"]')).toBeNull(); + }); + + it('omits the declared-period line entirely when the declaration carries no usable years', async () => { + await render([link({ firstDeclaredYear: null, lastDeclaredYear: null })], { + '111': [facts()], + }); + expect(text()).not.toContain('Деклариран период'); + }); +}); + +describe('ConflictDetail — authority shares that cannot be plotted', () => { + it('labels a sub-threshold capture „под 0,1%" and plots no bar inside the track', async () => { + await render([link()], { + '111': [facts({ amountEur: 1_000, authorityTotalEur: 500_000_000 })], + }); + expect(text()).toContain('под 0,1%'); + expect(container.querySelector('.auth-bar')).not.toBeNull(); // the track renders… + expect(container.querySelector('.auth-bar i')).toBeNull(); // …but nothing is filled in + expect(container.querySelector('.auth-share-pct.is-muted')).not.toBeNull(); + }); + + it('renders „—" and no track at all when the authority total is unknown', async () => { + // No denominator → no ratio. A „0%" here would read as "won nothing from this body", which is a + // different and false claim from "we do not know what this body spent in total". + await render([link()], { '111': [facts({ authorityTotalEur: null })] }); + const pctCell = container.querySelector('.auth-share-pct')!; + expect(pctCell.textContent).toBe('—'); + expect(pctCell.className).toContain('is-muted'); + expect(container.querySelector('.auth-bar')).toBeNull(); + }); + + it('says the sum is unavailable rather than printing „0 €" when every amount is NULL', async () => { + await render([link()], { + '111': [facts({ amountEur: null, authorityTotalEur: null })], + }); + expect(text()).toContain('сума не е налична'); + expect(container.querySelector('.auth-share-figures')!.textContent).not.toContain('0'); + }); +}); + +describe('ConflictDetail — timeline and contract rows on sparse data', () => { + it('plots the contract marks but no declared-period band when the window is unknown', async () => { + // A declaration can carry no usable period while the contracts matched to it are dated. The axis + // is still worth drawing; a band defaulted to 0 would render a zero-width marker at the left edge + // that reads as „the period starts at the beginning of time". + await render([link({ firstDeclaredYear: null, lastDeclaredYear: null })], { + '111': [facts({ signedAt: '2022-07-01' })], + }); + expect(container.querySelector('.tl-mark')).not.toBeNull(); + expect(container.querySelector('.tl-band')).toBeNull(); + }); + + it('falls back to a generic label and an index key for a contract with no number', async () => { + // contractNumber is the React key for in-window rows; two unnumbered contracts must still render + // as two distinct rows rather than collapsing onto one key. + await render([link()], { + '111': [ + facts({ contractSlug: 'e:n1', contractNumber: null, signedAt: '2020-02-02' }), + facts({ contractSlug: 'e:n2', contractNumber: null, signedAt: '2020-03-03' }), + ], + }); + expect(container.querySelectorAll('.contract-list li').length).toBe(2); + expect(text()).toContain('договор'); + expect(text()).not.toContain('№ null'); + }); + + it('renders „—" for a contract whose awarding body never resolved', async () => { + // getLinkContracts maps a NULL joined authority to '' (never null), so '' is the real shape. + await render([link()], { '111': [facts({ authority: '', authorityId: 'a:bare' })] }); + expect(container.querySelector('.contract-authority')!.textContent).toBe('—'); + }); + + it('marks only in-window contracts as conflicting, leaving outside ones unflagged', async () => { + // The `conflict` flag drives the row's modifier class. An outside-window contract is disclosed + // but never asserted as a conflict — the distinction the whole surface rests on. + await render([link({ firstDeclaredYear: '2019', lastDeclaredYear: '2023' })], { + '111': [ + facts({ contractSlug: 'e:in', contractNumber: 'Д-IN', signedAt: '2021-05-01' }), + facts({ contractSlug: 'e:out', contractNumber: 'Д-OUT', signedAt: '2025-05-01' }), + ], + }); + expect(container.querySelectorAll('.contract-list li').length).toBeGreaterThanOrEqual(2); + expect(text()).toContain('Д-IN'); + expect(text()).toContain('Д-OUT'); + }); +}); diff --git a/apps/web/app/components/Pagination.test.tsx b/apps/web/app/components/Pagination.test.tsx new file mode 100644 index 000000000..0d0fa78a5 --- /dev/null +++ b/apps/web/app/components/Pagination.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +// Keyset paging renders a LINK when a neighbouring page exists and a disabled SPAN when it does not — the +// only two states, and the page-level render tests only ever hit whichever one their fixture happens to be +// on. The disabled arm matters on its own: a would either throw or navigate to the current +// URL, and a bare without aria-disabled reads to a screen reader as unlabelled text. +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRoutesStub } from 'react-router'; +import { Pagination } from './Pagination'; +import type { PageNav } from '../lib/filters'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +async function render(nav: PageNav, unit?: string) { + const Stub = createRoutesStub([ + { path: '/', Component: () => }, + ]); + await act(async () => { + root.render(); + }); +} + +const nav = (over: Partial = {}): PageNav => ({ + page: 2, + pageCount: 5, + prevHref: '/x?page=1', + nextHref: '/x?page=3', + ...over, +}); + +describe('Pagination', () => { + it('links both neighbours from a middle page, with rel hints', async () => { + await render(nav()); + const prev = container.querySelector('a[rel="prev"]') as HTMLAnchorElement; + const next = container.querySelector('a[rel="next"]') as HTMLAnchorElement; + expect(prev.getAttribute('href')).toBe('/x?page=1'); + expect(next.getAttribute('href')).toBe('/x?page=3'); + expect(container.querySelector('.disabled')).toBeNull(); + }); + + it('renders an aria-disabled span instead of a link at each end of the range', async () => { + await render(nav({ page: 1, prevHref: null })); + expect(container.querySelector('a[rel="prev"]')).toBeNull(); + expect(container.querySelector('.disabled')!.getAttribute('aria-disabled')).toBe('true'); + expect(container.querySelector('a[rel="next"]')).not.toBeNull(); + + await render(nav({ page: 5, nextHref: null })); + expect(container.querySelector('a[rel="next"]')).toBeNull(); + expect(container.querySelector('.disabled')!.textContent).toContain('Следваща'); + }); + + it('appends the unit only when one is given', async () => { + await render(nav(), 'договора'); + expect(container.textContent).toContain('по 20 на страница (договора)'); + + await render(nav()); + expect(container.textContent).toContain('по 20 на страница'); + expect(container.textContent).not.toContain('()'); + }); +}); diff --git a/apps/web/app/components/ScrollToTop.test.tsx b/apps/web/app/components/ScrollToTop.test.tsx index 898114236..5c6fe62fe 100644 --- a/apps/web/app/components/ScrollToTop.test.tsx +++ b/apps/web/app/components/ScrollToTop.test.tsx @@ -26,6 +26,7 @@ function scrollTo(y: number) { describe('ScrollToTop', () => { let container: HTMLDivElement; let root: Root; + let originalRaf: typeof window.requestAnimationFrame; beforeEach(() => { container = document.createElement('div'); @@ -33,6 +34,7 @@ describe('ScrollToTop', () => { root = createRoot(container); mockMatchMedia(false); window.scrollTo = vi.fn(); + originalRaf = window.requestAnimationFrame; // saved so the global override doesn't leak window.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; @@ -45,6 +47,7 @@ describe('ScrollToTop', () => { root.unmount(); }); container.remove(); + window.requestAnimationFrame = originalRaf; vi.restoreAllMocks(); }); @@ -88,6 +91,33 @@ describe('ScrollToTop', () => { expect(getButton().className).not.toContain('is-visible'); }); + it('coalesces a burst of scroll events into a single frame (ignores events while one is pending)', () => { + // Hold the rAF callback instead of running it synchronously so a second scroll lands while a + // frame is still pending. The counting spy is the load-bearing assertion: the burst must schedule + // exactly ONE frame — without the `if (ticking) return` guard the second scroll schedules a second. + let pending: FrameRequestCallback | null = null; + const raf = vi.fn((cb: FrameRequestCallback) => { + pending = cb; + return 1; + }); + window.requestAnimationFrame = raf; + act(() => { + root.render(); + }); + raf.mockClear(); // ignore any frame from the initial mount + act(() => { + scrollTo(500); // schedules a frame (ticking = true) + }); + act(() => { + scrollTo(600); // frame still pending → coalesced, must NOT schedule another + }); + expect(raf).toHaveBeenCalledTimes(1); // the two-scroll burst collapsed to a single rAF + act(() => { + pending?.(0); // flush the single frame + }); + expect(getButton().className).toContain('is-visible'); + }); + it('calls window.scrollTo with smooth behavior on click by default', () => { act(() => { root.render(); diff --git a/apps/web/app/components/ui.test.tsx b/apps/web/app/components/ui.test.tsx new file mode 100644 index 000000000..f72060920 --- /dev/null +++ b/apps/web/app/components/ui.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +// ui.tsx holds the shared editorial primitives. Every one takes an optional prop that switches a class +// or drops a subtree; those are exactly the branches the page-level render tests never reach (they only +// ever exercise whichever variant that page happens to use). Render each primitive both ways. +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { Callout, Chip, ExternalEikLink, Flag, OwnershipChip, Section, ShareBar } from './ui'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render(node: React.ReactNode) { + act(() => { + root.render(node); + }); + return container; +} + +describe('Chip', () => { + it('emits a bare chip class with no tone and a toned modifier with one', () => { + expect(render(плайн).querySelector('span')!.className).toBe('chip'); + expect(render(силен).querySelector('span')!.className).toBe( + 'chip chip-strong', + ); + expect(render(прозорец).querySelector('span')!.className).toBe( + 'chip chip-window', + ); + }); +}); + +describe('ExternalEikLink', () => { + it('URL-encodes the ЕИК, opens in a new tab safely, and appends an optional class', () => { + const a = render().querySelector('a')!; + expect(a.getAttribute('href')).toContain('uic=123%20456'); // encoded, not raw + expect(a.getAttribute('target')).toBe('_blank'); + expect(a.getAttribute('rel')).toBe('noopener noreferrer'); // no reverse-tabnabbing + expect(a.className).toBe('external-eik-link'); + expect(a.getAttribute('aria-label')).toContain('123 456'); + + const withClass = render().querySelector('a')!; + expect(withClass.className).toBe('external-eik-link inline'); + }); +}); + +describe('OwnershipChip', () => { + it('renders nothing when the ownership kind is absent', () => { + expect(render().querySelector('span')).toBeNull(); + expect(render().querySelector('span')).toBeNull(); + }); + + it('labels each ownership kind in Bulgarian', () => { + expect(render().textContent).toBe('държавно'); + expect(render().textContent).toBe('общинско'); + expect(render().textContent).toBe('държавно-общинско'); + }); +}); + +describe('Flag', () => { + it('emits a bare flag with no variant and a suffixed class with one', () => { + expect(render(гол).querySelector('span')!.className).toBe('flag'); + for (const variant of ['soft', 'info', 'neutral'] as const) { + expect(render(x).querySelector('span')!.className).toBe( + `flag ${variant}`, + ); + } + }); +}); + +describe('ShareBar', () => { + it('clamps the fill width to 0–100% for out-of-range ratios', () => { + // jsdom re-serialises the CSS length, so compare the numeric percentage, not the raw string. + const fill = (ratio: number) => + parseFloat( + (render().querySelector('.share-bar i') as HTMLElement).style + .width, + ); + expect(fill(-0.5)).toBe(0); // negative clamps to the floor + expect(fill(2)).toBe(100); // over-unity clamps to the ceiling + expect(fill(0.253)).toBe(25.3); // in range, one decimal + }); + + it('paints the warn variant and adds a screen-reader-only note only when warn is set', () => { + const plain = render(); + expect(plain.querySelector('.share-bar')!.className).toBe('share-bar'); + expect(plain.querySelector('.sr-only')).toBeNull(); + + const warned = render(); + expect(warned.querySelector('.share-bar')!.className).toBe('share-bar warn'); + expect(warned.querySelector('.sr-only')!.textContent).toContain('висок дял'); + }); +}); + +describe('Callout', () => { + it('omits the heading entirely when no title is given', () => { + const el = render(тяло); + expect(el.querySelector('h2, h3')).toBeNull(); + expect(el.querySelector('div')!.className).toBe('callout'); + }); + + it('defaults the title to h3 and honours an explicit h2 (heading order)', () => { + expect(render(тяло).querySelector('h3')).not.toBeNull(); + expect( + render( + + тяло + , + ).querySelector('h2'), + ).not.toBeNull(); + }); + + it('suffixes the variant class when set', () => { + expect( + render( + + тяло + , + ).querySelector('div')!.className, + ).toBe('callout warning'); + }); +}); + +describe('Section', () => { + it('wires the heading id to aria-labelledby and drops the hint when absent', () => { + const el = render( +
+ тяло +
, + ); + expect(el.querySelector('section')!.getAttribute('aria-labelledby')).toBe('sec-1'); + expect(el.querySelector('h2')!.id).toBe('sec-1'); + expect(el.querySelector('.section-hint')).toBeNull(); + }); + + it('renders the hint paragraph when provided', () => { + const el = render( +
+ тяло +
, + ); + expect(el.querySelector('.section-hint')!.textContent).toBe('пояснение'); + }); +}); diff --git a/apps/web/app/lib/assistant/agent.test.ts b/apps/web/app/lib/assistant/agent.test.ts index 76a573347..985806cc2 100644 --- a/apps/web/app/lib/assistant/agent.test.ts +++ b/apps/web/app/lib/assistant/agent.test.ts @@ -1,5 +1,28 @@ -import { describe, expect, it } from 'vitest'; -import { resolveMaxSteps } from './agent'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeD1 } from '@sigma/test-support'; + +// agent.ts is thin Vercel-AI-SDK wiring. Mock the SDK and provider so the tests can assert the wiring +// (model/base-URL resolution, tool-set assembly, stream Response + onError message) without a live +// BgGPT call. resolveMaxSteps is pure and needs no mocks. +const { streamTextMock, createOpenAIMock, chatMock } = vi.hoisted(() => { + const chatMock = vi.fn((model: string) => ({ model })); + return { + chatMock, + createOpenAIMock: vi.fn(() => ({ chat: chatMock })), + streamTextMock: vi.fn(), + }; +}); +vi.mock('@ai-sdk/openai', () => ({ createOpenAI: createOpenAIMock })); +vi.mock('ai', () => ({ + convertToModelMessages: vi.fn(async (m: unknown) => m), + jsonSchema: vi.fn((s: unknown) => s), + stepCountIs: vi.fn((n: number) => ({ stopAt: n })), + streamText: (o: unknown) => streamTextMock(o), + tool: (def: unknown) => def, +})); + +import { resolveMaxSteps, runAssistant } from './agent'; +import { ASSISTANT_TOOLS } from './tools'; describe('resolveMaxSteps', () => { it('uses the default for a missing or non-numeric value', () => { @@ -23,3 +46,67 @@ describe('resolveMaxSteps', () => { expect(resolveMaxSteps('4.9')).toBe(4); }); }); + +describe('runAssistant (SDK wiring)', () => { + // The SDK is mocked here; nothing in these tests may reach D1. A route-less double throws on + // any query rather than silently answering one. + const ctx = { db: fakeD1([]).db, results: [] }; + + beforeEach(() => { + vi.clearAllMocks(); + streamTextMock.mockReturnValue({ + // Exercise onError so its Bulgarian degradation message is covered. + toUIMessageStreamResponse: (cfg: { onError: (e: unknown) => string }) => + new Response(String(cfg.onError(new Error('boom')))), + }); + }); + + it('wires BgGPT through the AI Gateway and returns a UI-message stream Response', async () => { + const res = await runAssistant({ + env: { + BGGPT_API_KEY: 'k', + AI_GATEWAY_BASE_URL: 'https://gw.example/v1', + BGGPT_MODEL: 'custom-model', + MAX_STEPS: '3', + }, + ctx, + messages: [], + }); + expect(res).toBeInstanceOf(Response); + expect(await res.text()).toContain('временно не е достъпен'); // onError message surfaced + expect(createOpenAIMock).toHaveBeenCalledWith({ + baseURL: 'https://gw.example/v1', + apiKey: 'k', + }); + expect(chatMock).toHaveBeenCalledWith('custom-model'); + const opts = streamTextMock.mock.calls[0]![0]; + expect(opts.stopWhen).toEqual({ stopAt: 3 }); + expect(opts.maxRetries).toBe(1); + expect(opts.maxOutputTokens).toBe(4096); + }); + + it('falls back to the default base URL and model when env omits them', async () => { + await runAssistant({ env: { BGGPT_API_KEY: 'k' }, ctx, messages: [] }); + expect(createOpenAIMock).toHaveBeenCalledWith({ + baseURL: 'https://api.bggpt.ai/v1', + apiKey: 'k', + }); + expect(chatMock).toHaveBeenCalledWith('bggpt-gemma-3-27b-fp8'); + }); + + it('assembles every registry tool plus the terminal emit_report tool', async () => { + await runAssistant({ env: { BGGPT_API_KEY: 'k' }, ctx, messages: [] }); + const tools = streamTextMock.mock.calls[0]![0].tools; + expect(tools.emit_report).toBeDefined(); + for (const t of ASSISTANT_TOOLS) expect(tools[t.name]).toBeDefined(); + + // Invoke a regular tool's execute closure (covers the input ?? {} default); tolerate the runtime + // error the real tool throws against the empty fake ctx — only the wiring is under test here. + await tools[ASSISTANT_TOOLS[0]!.name].execute(undefined).catch(() => {}); + + // emit_report.execute → finalizeReport; invalid input returns the validation-error branch. + const r = await tools.emit_report.execute({ not: 'a valid report' }); + expect(r.ok).toBe(false); + expect(Array.isArray(r.errors)).toBe(true); + }); +}); diff --git a/apps/web/app/lib/assistant/emit-report-schema.test.ts b/apps/web/app/lib/assistant/emit-report-schema.test.ts index 4880fed9e..4fa03309c 100644 --- a/apps/web/app/lib/assistant/emit-report-schema.test.ts +++ b/apps/web/app/lib/assistant/emit-report-schema.test.ts @@ -112,3 +112,57 @@ describe('EMIT_REPORT_JSON_SCHEMA', () => { expect(EMIT_REPORT_JSON_SCHEMA.required).toEqual(['title', 'question', 'blocks']); }); }); + +describe('validateEmitShape — remaining block types and negatives', () => { + it('accepts callout, flows, and timeseries blocks', () => { + const r = validateEmitShape({ + title: 't', + question: 'q', + blocks: [ + { type: 'callout', title: 'Внимание', md: 'бележка' }, + { type: 'flows', resultId: 'R1', fromCol: 'f', toCol: 't', valueCol: 'v' }, + { type: 'timeseries', resultId: 'R1', periodCol: 'p', valueCol: 'v' }, + ], + }); + expect(r.ok).toBe(true); + }); + + it('rejects a non-object input and a non-string question', () => { + expect(validateEmitShape(null).ok).toBe(false); + expect(validateEmitShape('nope').ok).toBe(false); + expect(validateEmitShape({ title: 't', question: 5, blocks: [] }).ok).toBe(false); + }); + + it('rejects flows and timeseries blocks missing their columns', () => { + expect( + validateEmitShape({ + title: 't', + question: '', + blocks: [{ type: 'flows', resultId: 'R1', fromCol: 'f' }], + }).ok, + ).toBe(false); + expect( + validateEmitShape({ + title: 't', + question: '', + blocks: [{ type: 'timeseries', resultId: 'R1', periodCol: 'p' }], + }).ok, + ).toBe(false); + }); + + it('rejects totals/facts with non-array items and a table with non-array columns', () => { + expect( + validateEmitShape({ title: 't', question: '', blocks: [{ type: 'totals', items: 'x' }] }).ok, + ).toBe(false); + expect( + validateEmitShape({ title: 't', question: '', blocks: [{ type: 'facts', items: 'x' }] }).ok, + ).toBe(false); + expect( + validateEmitShape({ + title: 't', + question: '', + blocks: [{ type: 'table', resultId: 'R1', columns: 'nope' }], + }).ok, + ).toBe(false); + }); +}); diff --git a/apps/web/app/lib/assistant/eop-fetch.test.ts b/apps/web/app/lib/assistant/eop-fetch.test.ts index 8dfacc84d..47da262e2 100644 --- a/apps/web/app/lib/assistant/eop-fetch.test.ts +++ b/apps/web/app/lib/assistant/eop-fetch.test.ts @@ -77,3 +77,50 @@ describe('fetchEopDay', () => { expect(read).toBe(false); // body was never buffered into Worker memory }); }); + +describe('eop-fetch — parse and error branches', () => { + it('coalesces a null raw date to an empty string (format rejection)', () => { + expect(validateEopDate(null as unknown as string).ok).toBe(false); + }); + + it('wraps a non-array JSON payload into a single-element rows array', async () => { + const fetchImpl: FetchImpl = async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify({ a: 1 }), + }); + const files = await fetchEopDay('2024-01-15', fetchImpl); + expect(files.every((f) => f.rows && f.rows.length === 1)).toBe(true); + }); + + it('surfaces a thrown fetch as a per-file error, not a throw', async () => { + const fetchImpl: FetchImpl = async () => { + throw new Error('network boom'); + }; + const files = await fetchEopDay('2024-01-15', fetchImpl); + expect(files.every((f) => f.error === 'network boom')).toBe(true); + }); + + it('labels a non-Error thrown value with the generic fetch-error message', async () => { + // A thrown string (not an Error instance) exercises the `: 'fetch error'` fallback. + const fetchImpl: FetchImpl = async () => { + throw 'socket reset'; + }; + const files = await fetchEopDay('2024-01-15', fetchImpl); + expect(files.every((f) => f.error === 'fetch error')).toBe(true); + }); +}); + +describe('eop-fetch — invalid JSON', () => { + it('reports an unparseable body as a per-file error', async () => { + const fetchImpl: FetchImpl = async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => 'not json{', + }); + const files = await fetchEopDay('2024-01-15', fetchImpl); + expect(files.every((f) => f.error === 'невалиден JSON')).toBe(true); + }); +}); diff --git a/apps/web/app/lib/assistant/rag.test.ts b/apps/web/app/lib/assistant/rag.test.ts index 699fe48d4..974d35682 100644 --- a/apps/web/app/lib/assistant/rag.test.ts +++ b/apps/web/app/lib/assistant/rag.test.ts @@ -106,3 +106,61 @@ describe('semanticSearch', () => { ); }); }); + +describe('rag — embed mismatch and metadata mapping', () => { + const runner = (data: unknown): EmbeddingRunner => + ({ run: async () => ({ data }) }) as unknown as EmbeddingRunner; + const index = ( + matches: { id: string; score: number; metadata?: Record }[], + ): VectorIndex => + ({ upsert: async () => ({}), query: async () => ({ matches }) }) as unknown as VectorIndex; + + it('throws when the provider returns the wrong number of vectors', async () => { + // 1 vector back for 2 input texts → the index-alignment invariant is violated. + await expect(embed(runner([vec()]), ['a', 'b'])).rejects.toThrow(/expected 2 vectors/); + }); + + it('retrieveSchemaContext returns only non-empty chunk texts from the matches', async () => { + const out = await retrieveSchemaContext( + runner([vec()]), + index([ + { id: '1', score: 0.9, metadata: { text: 'chunk A' } }, + { id: '2', score: 0.8, metadata: {} }, // no text → filtered out + ]), + 'въпрос', + ); + expect(out).toEqual(['chunk A']); + }); + + it('semanticSearch maps hit metadata, defaulting missing fields to empty strings', async () => { + const hits = await semanticSearch( + runner([vec()]), + index([ + { id: 'e1', score: 0.7, metadata: { kind: 'company', ref: 'eik:1', title: 'Тест' } }, + { id: 'e2', score: 0.5 }, // no metadata → defaults + ]), + 'заявка', + ); + expect(hits[0]).toEqual({ kind: 'company', ref: 'eik:1', title: 'Тест', score: 0.7 }); + expect(hits[1]).toEqual({ kind: '', ref: '', title: '', score: 0.5 }); + }); +}); + +describe('rag — provider-anomaly and empty-vector guards', () => { + const runner = (data: unknown): EmbeddingRunner => + ({ run: async () => ({ data }) }) as unknown as EmbeddingRunner; + const emptyIndex = () => + ({ upsert: async () => ({}), query: async () => ({ matches: [] }) }) as unknown as VectorIndex; + + it('embed reports "none" when the provider returns a non-array', async () => { + await expect(embed(runner('nope'), ['a'])).rejects.toThrow(/got none/); + }); + + it('retrieveSchemaContext returns [] when the embedding vector is missing', async () => { + expect(await retrieveSchemaContext(runner([undefined]), emptyIndex(), 'q')).toEqual([]); + }); + + it('semanticSearch returns [] when the embedding vector is missing', async () => { + expect(await semanticSearch(runner([undefined]), emptyIndex(), 'q')).toEqual([]); + }); +}); diff --git a/apps/web/app/lib/assistant/render-format.test.ts b/apps/web/app/lib/assistant/render-format.test.ts index 71f194bf3..bc991895d 100644 --- a/apps/web/app/lib/assistant/render-format.test.ts +++ b/apps/web/app/lib/assistant/render-format.test.ts @@ -48,3 +48,18 @@ describe('entityHref', () => { expect(href).not.toMatch(/[#?&]/); // no fragment/query/param can be injected via a malformed id }); }); + +describe('formatCell — date branch', () => { + it('renders a null date value as a literal em-dash, not „null"', () => { + // Pinned to the character, not to `date(null)`: comparing the two only proves formatCell delegates, + // and would still pass if the shared formatter started returning something else entirely. + expect(formatCell(null, 'date')).toBe('—'); + }); + + it('formats a present date through the shared formatter rather than echoing the ISO string', () => { + // The null case alone cannot tell delegation from a hard-coded em-dash — a formatCell that always + // returned '—' for 'date' would pass it. This is the assertion that fixes the non-null path. + expect(formatCell('2026-03-05', 'date')).toBe('05.03.2026'); + expect(formatCell(20260305, 'date')).toBe('20260305'); // unparseable → echoed, never a fake date + }); +}); diff --git a/apps/web/app/lib/assistant/report-schema.test.ts b/apps/web/app/lib/assistant/report-schema.test.ts index 69f8c250a..11d2e866a 100644 --- a/apps/web/app/lib/assistant/report-schema.test.ts +++ b/apps/web/app/lib/assistant/report-schema.test.ts @@ -677,3 +677,157 @@ describe('ultra review fixes (review #80)', () => { expect(out.report.blocks[0].points).toEqual([{ label: 'c', value: 42 }]); }); }); + +describe('bindReport — flows block', () => { + const flowResults: QueryResult[] = [ + { + handle: 'F1', + columns: ['from_name', 'to_name', 'amount'], + rows: [ + ['Мин. финанси', 'Алфа ООД', 500000], + ['Мин. финанси', 'Бета ЕООД', null], // null value → edge skipped + ], + }, + ]; + + it('builds edges from the result, dropping rows with a null value', () => { + const out = bindReport( + emit([ + { + type: 'flows', + resultId: 'F1', + fromCol: 'from_name', + toCol: 'to_name', + valueCol: 'amount', + }, + ]), + flowResults, + ); + expect(out.ok).toBe(true); + if (out.ok) { + const b = out.report.blocks[0]!; + expect(b.type).toBe('flows'); + if (b.type === 'flows') + expect(b.edges).toEqual([{ from: 'Мин. финанси', to: 'Алфа ООД', valueEur: 500000 }]); + } + }); + + it('renders an empty flows block for a 0-row result', () => { + const out = bindReport( + emit([{ type: 'flows', resultId: 'E', fromCol: 'a', toCol: 'b', valueCol: 'c' }]), + [{ handle: 'E', columns: [], rows: [] }], + ); + expect(out.ok).toBe(true); + if (out.ok) expect(out.report.blocks[0]).toMatchObject({ type: 'flows', edges: [] }); + }); + + it('rejects a flows block that references a missing column', () => { + const out = bindReport( + emit([ + { type: 'flows', resultId: 'F1', fromCol: 'nope', toCol: 'to_name', valueCol: 'amount' }, + ]), + flowResults, + ); + expect(out.ok).toBe(false); + }); + + it('rejects a chart block that references an unknown result handle', () => { + const out = bindReport( + emit([{ type: 'flows', resultId: 'GHOST', fromCol: 'a', toCol: 'b', valueCol: 'c' }]), + flowResults, + ); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.join(' ')).toContain('unknown result handle'); + }); +}); + +describe('bindReport — facts sub-line and title guards', () => { + it('sanitises a non-numeric facts sub-line', () => { + const out = bindReport( + emit([ + { + type: 'facts', + items: [ + { + term: 'Възложител', + ref: { resultId: 'R1', row: 0, col: 'authority' }, + sub: 'държавен', + }, + ], + }, + ]), + results, + ); + expect(out.ok).toBe(true); + if (out.ok) { + const b = out.report.blocks[0]!; + if (b.type === 'facts') expect(b.items[0]!.sub).toBe('държавен'); + } + }); + + it('rejects a facts sub-line carrying a material number', () => { + const out = bindReport( + emit([ + { + type: 'facts', + items: [ + { + term: 'Х', + ref: { resultId: 'R1', row: 0, col: 'authority' }, + sub: 'струва 5 млн лв', + }, + ], + }, + ]), + results, + ); + expect(out.ok).toBe(false); + }); + + it('rejects an empty (whitespace-only) report title', () => { + const out = bindReport( + { title: ' ', question: 'въпрос', blocks: [{ type: 'text', md: 'нещо' }] }, + results, + ); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.errors.join(' ')).toContain('title is empty'); + }); +}); + +describe('bindReport — chart block edges', () => { + it('renders empty bar and timeseries blocks for 0-row results', () => { + const empty: QueryResult[] = [{ handle: 'Z', columns: [], rows: [] }]; + const bar = bindReport( + emit([{ type: 'bar', resultId: 'Z', labelCol: 'a', valueCol: 'b' }]), + empty, + ); + expect(bar.ok).toBe(true); + if (bar.ok) expect(bar.report.blocks[0]).toMatchObject({ type: 'bar', points: [] }); + const ts = bindReport( + emit([{ type: 'timeseries', resultId: 'Z', periodCol: 'a', valueCol: 'b' }]), + empty, + ); + expect(ts.ok).toBe(true); + if (ts.ok) expect(ts.report.blocks[0]).toMatchObject({ type: 'timeseries', points: [] }); + }); + + it('coerces null flow endpoints to "" and keeps a null timeseries period', () => { + const r: QueryResult[] = [ + { handle: 'N', columns: ['f', 't', 'v', 'p'], rows: [[null, null, 100, null]] }, + ]; + const flows = bindReport( + emit([{ type: 'flows', resultId: 'N', fromCol: 'f', toCol: 't', valueCol: 'v' }]), + r, + ); + expect(flows.ok).toBe(true); + if (flows.ok && flows.report.blocks[0]!.type === 'flows') + expect(flows.report.blocks[0]!.edges[0]).toEqual({ from: '', to: '', valueEur: 100 }); + const ts = bindReport( + emit([{ type: 'timeseries', resultId: 'N', periodCol: 'p', valueCol: 'v' }]), + r, + ); + expect(ts.ok).toBe(true); + if (ts.ok && ts.report.blocks[0]!.type === 'timeseries') + expect(ts.report.blocks[0]!.points[0]).toEqual({ period: null, value: 100 }); + }); +}); diff --git a/apps/web/app/lib/assistant/sql-guard.test.ts b/apps/web/app/lib/assistant/sql-guard.test.ts index 5199deceb..a818c312f 100644 --- a/apps/web/app/lib/assistant/sql-guard.test.ts +++ b/apps/web/app/lib/assistant/sql-guard.test.ts @@ -124,6 +124,29 @@ describe('assertReadOnlySelect', () => { // a REAL trailing line comment is still stripped (single statement, leading SELECT preserved) expect(assertReadOnlySelect('SELECT id FROM contracts -- top earners').ok).toBe(true); }); + + it('rejects an empty or comment-only query', () => { + const empty = assertReadOnlySelect(''); + expect(empty.ok).toBe(false); + if (!empty.ok) expect(empty.reason).toMatch(/empty/); + expect(assertReadOnlySelect(' /* nothing */ ').ok).toBe(false); + expect(assertReadOnlySelect('-- only a comment').ok).toBe(false); + }); + + it('rejects a forbidden keyword hidden inside a single SELECT/WITH statement', () => { + // A CTE prefix keeps it a single statement that passes the leading-token check, so the cheap + // whole-word keyword layer (not just the AST guard) has to catch the trailing write. + const r = assertReadOnlySelect('WITH x AS (SELECT 1 AS n) DELETE FROM contracts'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/forbidden keyword/); + }); + + it('rejects the sqlite_master / sqlite_schema catalog tables', () => { + const r = assertReadOnlySelect('SELECT name FROM sqlite_master'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/catalog/); + expect(assertReadOnlySelect('SELECT * FROM sqlite_schema').ok).toBe(false); + }); }); describe('enforceLimit', () => { diff --git a/apps/web/app/lib/assistant/tool-results.test.ts b/apps/web/app/lib/assistant/tool-results.test.ts index 3ae8cbcc6..34189a544 100644 --- a/apps/web/app/lib/assistant/tool-results.test.ts +++ b/apps/web/app/lib/assistant/tool-results.test.ts @@ -58,3 +58,22 @@ describe('forModel', () => { expect(view).toContain(JSON.stringify([[injected]])); // verbatim, inside the data payload }); }); + +describe('tool-results — missing cells and truncation flag', () => { + it('nulls a cell for a column absent from a later row', () => { + const qr = toQueryResult('R1', [ + { a: 1, b: 5 }, + { a: 2 }, // b missing → null + ]); + expect(qr.columns).toEqual(['a', 'b']); + expect(qr.rows).toEqual([ + [1, 5], + [2, null], + ]); + }); + + it('marks a truncated result in the model-facing header', () => { + const out = forModel({ handle: 'R2', columns: ['a'], rows: [[1]], truncated: true }); + expect(out).toContain('отрязани'); + }); +}); diff --git a/apps/web/app/lib/assistant/tools.test.ts b/apps/web/app/lib/assistant/tools.test.ts index 214610220..95d5d976f 100644 --- a/apps/web/app/lib/assistant/tools.test.ts +++ b/apps/web/app/lib/assistant/tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { fakeD1 } from '@sigma/test-support'; +import { fakeD1, throwingD1 } from '@sigma/test-support'; import { ASSISTANT_TOOLS, DEFAULT_ROWS_READ_BUDGET, @@ -178,3 +178,157 @@ describe('resolveRowsReadBudget', () => { expect(resolveRowsReadBudget('999999999')).toBe(50_000_000); }); }); + +/** + * A context whose statement answers with a deliberately incomplete D1 response. fakeD1 always + * returns a well-formed `{ results, meta }` — right for query tests, but it cannot reach run_sql's + * driver-shape fallbacks (`results ?? []`, `meta?.rows_read ?? 0`), which exist precisely because a + * driver may not send those. The double still owns prepare() and still records the SQL; only all() + * is overridden. + */ +function driverCtx(all: () => Promise, extra: Partial = {}): ToolContext { + const fake = fakeD1([{ when: [], all: [] }]); + const inner = fake.db.prepare.bind(fake.db); + fake.db.prepare = ((sql: string) => { + const stmt = inner(sql); + const self = { + ...stmt, + bind(...args: unknown[]) { + stmt.bind(...args); + return self; + }, + all, + }; + return self; + }) as typeof fake.db.prepare; + return { db: fake.db, results: [], ...extra }; +} + +describe('run_sql — guard and error paths', () => { + it('rejects a structurally-valid SELECT over a non-allowlisted table (AST guard)', async () => { + // Passes the cheap structural read-only check, then the AST guard rejects the raw_* mirror. + const c = ctx(); + const out = await runTool('run_sql', { sql: 'SELECT id FROM raw_contracts' }, c); + expect(out).toMatch(/отхвърлена/); + expect(c.results).toHaveLength(0); + }); + + it('tolerates a driver that omits rows-read meta and an unset turn counter', async () => { + const c = driverCtx(async () => ({ results: [{ n: 1 }] })); // no meta block + // rowsRead intentionally unset on the context → the `?? 0` fallback + const out = await runTool('run_sql', { sql: 'SELECT n FROM contracts' }, c); + expect(out).toContain('R1'); + expect(c.rowsRead).toBe(0); // meta absent → +0 + }); + + it('tolerates a driver that returns no results array at all (results ?? [])', async () => { + const c = driverCtx(async () => ({})); // neither results nor meta + const out = await runTool('run_sql', { sql: 'SELECT n FROM contracts' }, c); + expect(out).toContain('R1'); // an empty result set, still handled + expect(c.results[0]).toMatchObject({ rows: [] }); + }); + + it('returns a generic error (never the raw D1 message) when the query throws', async () => { + const c: ToolContext = { + db: throwingD1(new Error('D1 internal: table x locked')).db, + results: [], + rowsRead: 0, + }; + const out = await runTool('run_sql', { sql: 'SELECT 1 AS n FROM contracts' }, c); + expect(out).toBe('Грешка при изпълнение на заявката.'); + expect(out).not.toContain('D1 internal'); + }); +}); + +describe('semantic_search — hits', () => { + function vectorCtx( + matches: { id: string; score: number; metadata?: Record }[], + ): ToolContext { + return { + db: fakeD1([]).db, // no tool here touches D1 + results: [], + ai: { run: async () => ({ data: [[0.1, 0.2, 0.3]] }) } as unknown as NonNullable< + ToolContext['ai'] + >, + vectorize: { + upsert: async () => ({}), + query: async () => ({ matches }), + } as unknown as NonNullable, + }; + } + + it('formats semantic hits with kind, ref, title and score', async () => { + const c = vectorCtx([ + { + id: 'entity:1', + score: 0.912, + metadata: { kind: 'company', ref: 'eik:1', title: 'Тест ООД' }, + }, + ]); + const out = await runTool('semantic_search', { query: 'детски градини' }, c); + expect(out).toContain('company eik:1 — Тест ООД (0.912)'); + }); + + it('reports no matches when the vector index returns none', async () => { + const out = await runTool('semantic_search', { query: 'нищо' }, vectorCtx([])); + expect(out).toMatch(/Няма семантични съвпадения/); + }); +}); + +describe('eop_fetch', () => { + it('rejects a malformed date without fetching', async () => { + const out = await runTool('eop_fetch', { date: 'nonsense' }, ctx()); + expect(out).toMatch(/Невалидна дата/); + }); + + it('summarises per-file row counts and errors, flagging the data as non-bindable', async () => { + let call = 0; + const c: ToolContext = { + db: fakeD1([]).db, // eop_fetch never touches D1 + results: [], + fetchImpl: async () => { + call++; + // First file: a valid JSON array; the rest: a 403 surfaced as a per-file error. + return call === 1 + ? { + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => JSON.stringify([{ a: 1 }, { a: 2 }]), + } + : { ok: false, status: 403, headers: { get: () => null }, text: async () => '' }; + }, + }; + const out = await runTool('eop_fetch', { date: '2024-01-15' }, c); + expect(out).toMatch(/2 реда/); // the valid file's row count + expect(out).toMatch(/грешка \(HTTP 403\)/); // a missing file surfaced as an error + expect(out).toContain('не могат да се подават към emit_report'); // non-bindable note + }); + + it('falls back to the global fetch when the context supplies no fetch impl', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => ({ + ok: false, + status: 403, + headers: { get: () => null }, + text: async () => '', + })) as unknown as typeof fetch; + try { + const out = await runTool('eop_fetch', { date: '2024-01-15' }, ctx()); // ctx() has no fetchImpl + expect(out).toMatch(/HTTP 403/); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe('source_link', () => { + it('returns official deep links for a tender id', async () => { + const out = await runTool('source_link', { eopTenderId: '00123-2024-0007' }, ctx()); + expect(out).toMatch(/https?:\/\//); + }); + + it('reports no links when the input yields none', async () => { + expect(await runTool('source_link', {}, ctx())).toMatch(/Няма налични официални линкове/); + }); +}); diff --git a/apps/web/app/lib/cache.test.ts b/apps/web/app/lib/cache.test.ts new file mode 100644 index 000000000..1e0056275 --- /dev/null +++ b/apps/web/app/lib/cache.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { publicCache } from './cache'; + +describe('publicCache', () => { + it('builds a public edge Cache-Control with the default stale-while-revalidate window', () => { + expect(publicCache(120)).toBe('public, s-maxage=120, stale-while-revalidate=86400'); + }); + + it('honours an explicit stale-while-revalidate override', () => { + expect(publicCache(600, 30)).toBe('public, s-maxage=600, stale-while-revalidate=30'); + }); +}); diff --git a/apps/web/app/lib/conflicts.test.ts b/apps/web/app/lib/conflicts.test.ts index 9e40c951f..c2e208651 100644 --- a/apps/web/app/lib/conflicts.test.ts +++ b/apps/web/app/lib/conflicts.test.ts @@ -12,6 +12,7 @@ import { contractYear, contractYearsLabel, contractsCountLabel, + declaredStakeNoun, fundsCellLabel, fundsMagnitude, hasContemporaneousContracts, @@ -540,6 +541,55 @@ describe('authorityShareDisplay', () => { }); }); +describe('temporalLabel — unknown feed value', () => { + it('falls back to the raw value when the temporal tag is not one of the known keys', () => { + // The label map is keyed on the four known tags; an unrecognised value from the feed must surface + // verbatim rather than rendering "undefined" in the UI. + expect(temporalLabel('sideways' as ConflictContract['temporal'])).toBe('sideways'); + }); +}); + +describe('contractTimeline — unusable signing years', () => { + it('ignores contracts whose signed_at year is non-numeric or zero', () => { + // parseYear rejects a non-finite year and a year 0 — both would otherwise plot a bogus point at the + // axis origin. With no usable year left, the timeline is null and the caller renders no axis at all. + const tl = contractTimeline(link(), [ + contract({ signedAt: 'not-a-date' }), + contract({ signedAt: '0000-01-01' }), + ]); + expect(tl).toBeNull(); + }); +}); + +describe('authorityShares — ordering when the denominator is unknown', () => { + const row = (authorityId: string, authority: string, amountEur: number, total: number | null) => + contract({ authorityId, authority, amountEur, authorityTotalEur: total }); + + it('ranks plottable shares above unknown-denominator ones, and breaks ties by money', () => { + const shares = authorityShares([ + row('a:nd1', 'Без общо 1', 100_000, null), // ratio null + row('a:bar', 'С дял', 1_000_000, 10_000_000), // ratio 0.1 → plottable + row('a:nd2', 'Без общо 2', 900_000, null), // ratio null, richer than nd1 + ]); + // Plottable first; the two null-ratio rows keep a stable money-descending order between themselves. + expect(shares.map((s) => s.authorityId)).toEqual(['a:bar', 'a:nd2', 'a:nd1']); + }); + + it('orders two plottable shares by ratio, falling back to money on an exact tie', () => { + const shares = authorityShares([ + row('a:small', 'Малък дял', 1_000_000, 100_000_000), // 0.01 + row('a:big', 'Голям дял', 5_000_000, 10_000_000), // 0.5 + ]); + expect(shares.map((s) => s.authorityId)).toEqual(['a:big', 'a:small']); + + const tied = authorityShares([ + row('a:t1', 'Равен 1', 1_000_000, 10_000_000), // 0.1 + row('a:t2', 'Равен 2', 2_000_000, 20_000_000), // 0.1 — same ratio, more money + ]); + expect(tied.map((s) => s.authorityId)).toEqual(['a:t2', 'a:t1']); // money breaks the tie + }); +}); + describe('registryEvidenceLabel', () => { // The wording is load-bearing. The register records a ROLE; it does not certify that the official owns // anything — that claim comes from their own declaration and is rendered separately. A label that said @@ -968,3 +1018,28 @@ describe('markContracts (derive per-link temporal + contemporaneous-first)', () expect(markContracts(shared, '2010', '2014')[0]!.temporal).toBe('after'); }); }); + +describe('declaredStakeNoun — page prose must not out-claim the cards', () => { + const self = { relation: 'owns' }; + const family = { relation: 'related' }; + + it('says „собствен дял" only when every link really is the official\'s own', () => { + expect(declaredStakeNoun([self])).toBe('собствен дял'); + expect(declaredStakeNoun([self, { relation: 'partner' }])).toBe('собствен дял'); + expect(declaredStakeNoun([])).toBe('собствен дял'); // no links → nothing to qualify + }); + + it('says „дял на свързано лице" on a family-only page', () => { + // Asserting an OWN stake above cards that read „свързано лице" is a false claim about a named + // individual — the second source of truth this function exists to remove. + expect(declaredStakeNoun([family])).toBe('дял на свързано лице'); + expect(declaredStakeNoun([family, family])).toBe('дял на свързано лице'); + }); + + it('falls back to the neutral wording on a MIXED page, the only phrasing true of every card', () => { + // Either single-sided noun would be false about half the cards on the page. + const mixed = 'деклариран дял — собствен или на свързано лице'; + expect(declaredStakeNoun([self, family])).toBe(mixed); + expect(declaredStakeNoun([family, self])).toBe(mixed); // order must not decide the claim + }); +}); diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts index 9b69cd201..5ba2fd85a 100644 --- a/apps/web/app/lib/csv-export.test.ts +++ b/apps/web/app/lib/csv-export.test.ts @@ -99,9 +99,23 @@ class InMemoryR2 { } const range = this.rangeFrom(options?.range, stored.bytes.length); - const bytes = range - ? stored.bytes.slice(range.offset, range.offset + range.length) - : stored.bytes.slice(); + let bytes = stored.bytes.slice(); + if (range) { + // Resolve the R2-native range shape to a concrete slice (mirrors csv-export's rangeInfo). + let start = 0; + let length = stored.bytes.length; + if ('offset' in range && range.offset !== undefined) { + start = range.offset; + length = range.length ?? stored.bytes.length - start; + } else if ('suffix' in range) { + length = Math.min(range.suffix, stored.bytes.length); + start = stored.bytes.length - length; + } else if ('length' in range && range.length !== undefined) { + start = 0; + length = range.length; + } + bytes = stored.bytes.slice(start, start + length); + } return this.objectWithBody(key, stored, bytes, range); }); @@ -185,7 +199,7 @@ class InMemoryR2 { key: string, stored: StoredObject, bytes: Uint8Array, - range?: { offset: number; length: number }, + range?: R2Range, ): R2ObjectBody { return { ...this.objectWithoutBody(key, stored), @@ -200,16 +214,22 @@ class InMemoryR2 { } as unknown as R2ObjectBody; } - private rangeFrom( - range: R2GetOptions['range'] | undefined, - size: number, - ): { offset: number; length: number } | undefined { + private rangeFrom(range: R2GetOptions['range'] | undefined, size: number): R2Range | undefined { if (range === undefined) return undefined; if (range instanceof Headers) { const header = range.get('range'); if (header === null) return { offset: 0, length: size }; + // `bytes=-N` → an R2 suffix range; `bytes=A-` → an offset-only (open-ended) range; `bytes=A-B` + // → offset+length. Real R2 surfaces each shape on obj.range, so echo the native shape here. + const suffix = /^bytes=-(\d+)$/.exec(header); + if (suffix) return { suffix: Number(suffix[1]) }; + const open = /^bytes=(\d+)-$/.exec(header); + if (open) { + const start = Number(open[1]); + return start >= size ? undefined : { offset: start }; + } const match = /^bytes=(\d+)-(\d+)$/.exec(header); if (!match) return undefined; @@ -510,3 +530,112 @@ describe('servedCsvExport', () => { expect((await response.arrayBuffer()).byteLength).toBe(largeBody.byteLength); }); }); + +describe('csv-export — remaining branches', () => { + it('treats a non-string q as a filter and a null q as unfiltered', () => { + expect(isUnfilteredCsvExport({ q: 123 })).toBe(false); // non-string, present → filtered + expect(isUnfilteredCsvExport({ q: null })).toBe(true); // explicit null → unfiltered + expect(isUnfilteredCsvExport({ q: ' ' })).toBe(true); // whitespace-only → unfiltered + }); + + it('falls back to the v0 freshness version when home_totals has no refreshed_at', async () => { + const r2 = new InMemoryR2(); + const stream = vi.fn(() => csvResponse()); + await (await serve(r2, stream, { refreshedAt: null })).text(); + expect(r2.createMultipartUpload).toHaveBeenCalledWith('csv/contracts/v0', expect.anything()); + }); + + it('stores an empty unfiltered export without uploading a zero-length part', async () => { + const r2 = new InMemoryR2(); + const stream = vi.fn(() => csvResponse('')); // empty body → uploadBufferedPart sees buffered 0 + const response = await serve(r2, stream); + expect(response.status).toBe(200); + expect(await response.text()).toBe(''); + }); + + it('skips an empty stream chunk while buffering multipart parts', async () => { + const r2 = new InMemoryR2(); + const stream = vi.fn( + () => + new Response( + new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(0)); // empty chunk → the `!value?.byteLength` continue + c.enqueue(encoder.encode('data\n')); + c.close(); + }, + }), + { headers: { 'Content-Type': CSV_CONTENT_TYPE } }, + ), + ); + const response = await serve(r2, stream); + expect(response.status).toBe(200); + expect(await response.text()).toBe('data\n'); + }); +}); + +describe('putStreamMultipart — abort on failure', () => { + it('aborts the multipart upload and rethrows when a part upload fails', async () => { + const abort = vi.fn(async () => {}); + const bucket = { + get: vi.fn(async () => null), + createMultipartUpload: vi.fn(async () => ({ + uploadPart: async () => { + throw new Error('part failed'); + }, + complete: async () => {}, + abort, + })), + } as unknown as R2Bucket; + await expect( + servedCsvExport({ + env: { DB: fakeDb(REFRESHED_AT), CSV_CACHE: bucket }, + request: new Request('http://local/contracts.csv'), + route: 'contracts', + params: { sort: 'value-desc' }, + stream: () => csvResponse(), + }), + ).rejects.toThrow('part failed'); + expect(abort).toHaveBeenCalled(); // best-effort cleanup ran before the rethrow + }); +}); + +describe('servedCsvExport — R2 range shapes', () => { + async function prime(r2: InMemoryR2): Promise { + await ( + await serve( + r2, + vi.fn(() => csvResponse()), + ) + ).text(); + } + + it('serves a suffix range (bytes=-N) as the trailing bytes', async () => { + const r2 = new InMemoryR2(); + await prime(r2); + const size = encoder.encode(CSV_BODY).length; + const response = await serve( + r2, + vi.fn(() => csvResponse('x')), + { request: new Request('http://local/contracts.csv', { headers: { Range: 'bytes=-4' } }) }, + ); + expect(response.status).toBe(206); + expect(response.headers.get('Content-Range')).toBe(`bytes ${size - 4}-${size - 1}/${size}`); + expect(response.headers.get('Content-Length')).toBe('4'); + expect(await response.text()).toBe(CSV_BODY.slice(-4)); + }); + + it('serves an open-ended range (bytes=A-) to the end of the object', async () => { + const r2 = new InMemoryR2(); + await prime(r2); + const size = encoder.encode(CSV_BODY).length; + const response = await serve( + r2, + vi.fn(() => csvResponse('x')), + { request: new Request('http://local/contracts.csv', { headers: { Range: 'bytes=10-' } }) }, + ); + expect(response.status).toBe(206); + expect(response.headers.get('Content-Range')).toBe(`bytes 10-${size - 1}/${size}`); + expect(await response.text()).toBe(CSV_BODY.slice(10)); + }); +}); diff --git a/apps/web/app/lib/eopSource.test.ts b/apps/web/app/lib/eopSource.test.ts new file mode 100644 index 000000000..badaf723f --- /dev/null +++ b/apps/web/app/lib/eopSource.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { eopSourceFiles } from './eopSource'; + +describe('eopSourceFiles', () => { + it('returns [] for a missing date', () => { + expect(eopSourceFiles(null)).toEqual([]); + expect(eopSourceFiles(undefined)).toEqual([]); + expect(eopSourceFiles('')).toEqual([]); + }); + + it('returns [] when the value is not a plain YYYY-MM-DD day', () => { + expect(eopSourceFiles('not-a-date')).toEqual([]); + expect(eopSourceFiles('2024/01/15')).toEqual([]); + }); + + it('builds the three base file links for a pre-OCDS day, keyed on DD.MM.YYYY', () => { + const files = eopSourceFiles('2024-01-15'); + expect(files.map((f) => f.label)).toEqual(['Договори', 'Поръчки', 'Анекси']); + expect(files[0]!.url).toContain('https://storage.eop.bg/open-data-2024-01-15/'); + // The Bulgarian noun and DD.MM.YYYY date are embedded (URL-encoded) in the object key. + expect(decodeURIComponent(files[0]!.url)).toContain( + 'Автоматично генерирани данни за договори, публикувани в ЦАИС ЕОП на 15.01.2024.json', + ); + }); + + it('accepts a full ISO timestamp by slicing to the day', () => { + const files = eopSourceFiles('2024-01-15T09:30:00Z'); + expect(files).toHaveLength(3); + expect(files[0]!.url).toContain('open-data-2024-01-15'); + }); + + it('appends the OCDS export on/after the 2026-01-01 cutoff', () => { + const files = eopSourceFiles('2026-01-01'); + expect(files.map((f) => f.label)).toEqual([ + 'Договори', + 'Поръчки', + 'Анекси', + 'Обявления (OCDS)', + ]); + expect(decodeURIComponent(files[3]!.url)).toContain('съгласно стандарт OCDS.json'); + }); + + it('omits the OCDS export the day before the cutoff', () => { + expect(eopSourceFiles('2025-12-31')).toHaveLength(3); + }); +}); diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts index 158f16093..8bb6d04ba 100644 --- a/apps/web/app/lib/filters.test.ts +++ b/apps/web/app/lib/filters.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CPV_SECTORS } from '@sigma/config'; import { authorityListFilters, + buildSectorGroup, companyListFilters, contractListFilters, getMulti, @@ -10,8 +11,11 @@ import { pageNav, PARAM_ORDER, searchHref, + singleSelectFilters, + sortHref, withParams, } from './filters'; +import { categoryForDivision } from '@sigma/config'; import { CANONICAL_QUERY_PARAMS } from './query-params'; const sp = (q: string) => new URLSearchParams(q); @@ -372,3 +376,102 @@ describe('withParams', () => { expect(PARAM_ORDER.filter((key) => !CANONICAL_QUERY_PARAMS.has(key))).toEqual([]); }); }); + +describe('singleSelectFilters', () => { + const known = CPV_SECTORS[0]!.code; + + it('passes through a valid sector, year, funding, and top', () => { + const f = singleSelectFilters(sp(`sector=${known}&year=2024&funding=eu&top=50`), [ + '2024', + '2023', + ]); + expect(f).toMatchObject({ + sector: known, + year: '2024', + funding: 'eu', + top: 50, + unknownSector: false, + unknownYear: false, + }); + }); + + it('flags and drops an unknown sector', () => { + const f = singleSelectFilters(sp('sector=ZZ')); + expect(f.sector).toBeNull(); + expect(f.unknownSector).toBe(true); + }); + + it('flags and drops a year outside the coverage window', () => { + const f = singleSelectFilters(sp('year=1999'), ['2024', '2023']); + expect(f.year).toBeNull(); + expect(f.unknownYear).toBe(true); + }); + + it('never flags a year when no coverage window is supplied', () => { + const f = singleSelectFilters(sp('year=1999')); // years=[] → unknownYear always false + expect(f.unknownYear).toBe(false); + expect(f.year).toBe('1999'); + }); + + it('defaults funding to „all" and top to 20, and keeps national', () => { + expect(singleSelectFilters(sp('')).funding).toBe('all'); + expect(singleSelectFilters(sp('funding=bogus&top=99')).top).toBe(20); + expect(singleSelectFilters(sp('funding=national')).funding).toBe('national'); + }); +}); + +describe('buildSectorGroup', () => { + const withCat = CPV_SECTORS.filter((s) => categoryForDivision(s.code)); + + it('groups facet sectors under their CPV category and sums present counts', () => { + const a = withCat[0]!; + const group = buildSectorGroup([{ value: a.code, label: a.label, count: 3 }], [a.code]); + expect(group.key).toBe('sector'); + expect(group.type).toBe('checkbox'); + expect(group.selected).toEqual([a.code]); + const cat = group.categories!.find((c) => c.options.some((o) => o.value === a.code))!; + expect(cat.count).toBe(3); // all option counts present → category count summed + expect(cat.options.find((o) => o.value === a.code)).toMatchObject({ label: a.label, count: 3 }); + }); + + it('omits the category count when any sector count is missing', () => { + const a = withCat[0]!; + const group = buildSectorGroup([{ value: a.code, label: a.label }], []); // no count + const cat = group.categories!.find((c) => c.options.some((o) => o.value === a.code))!; + expect(cat.count).toBeUndefined(); + expect(cat.options.find((o) => o.value === a.code)!.count).toBeUndefined(); + }); + + it('skips a facet sector with no CPV category', () => { + const group = buildSectorGroup([{ value: 'ZZ', label: 'Bogus', count: 1 }], []); + expect(group.categories!.every((c) => c.options.every((o) => o.value !== 'ZZ'))).toBe(true); + }); +}); + +describe('sortHref', () => { + it('swaps the sort and resets the cursor/page to page one', () => { + expect(sortHref(sp('sort=old&cursor=abc&page=3'), 'value-asc')).toBe('?sort=value-asc'); + }); +}); + +describe('filters — remaining branch coverage', () => { + it('authorityListFilters leaves eu null when the param is absent', () => { + expect(authorityListFilters(sp('type=x')).eu).toBeNull(); + }); + + it('withParams drops null overrides and appends array values, skipping empty elements', () => { + expect(withParams(sp('sort=x'), { sort: null })).toBe(''); // null override → dropped + expect(withParams(sp(''), { year: ['2024', '', '2023'] })).toBe('?year=2024&year=2023'); + }); + + it('withParams drops an empty-valued base param and returns "" when nothing survives', () => { + expect(withParams(sp('year='), {})).toBe(''); + }); + + it('pageNav defaults the display page when the cursor is present but page is missing or NaN', () => { + const nav = (q: string) => + pageNav({ base: sp(q), total: 100, pageSize: 25, nextCursor: 'n', prevCursor: 'p' }); + expect(nav('cursor=x').page).toBe(1); // page absent → ?? '1' + expect(nav('cursor=x&page=abc').page).toBe(1); // NaN → || 1 + }); +}); diff --git a/apps/web/app/lib/retry.test.ts b/apps/web/app/lib/retry.test.ts index bab457292..a2ea9224e 100644 --- a/apps/web/app/lib/retry.test.ts +++ b/apps/web/app/lib/retry.test.ts @@ -41,4 +41,31 @@ describe('withDbRetry', () => { await expect(withDbRetry(fn, 0)).rejects.toBe(err); expect(fn).toHaveBeenCalledTimes(1); }); + + it('logs a non-Error rejection verbatim and uses the default backoff once past the table', async () => { + // A non-Error thrown value exercises the `: error` log branch, and a 4th attempt indexes past + // the two-entry BACKOFF_MS table, exercising the `?? 150` fallback. + // Fake timers so the three real backoff sleeps don't add ~350ms of wall time to the suite. + vi.useFakeTimers(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const fn = vi + .fn() + .mockRejectedValueOnce('string fault') + .mockRejectedValueOnce('string fault') + .mockRejectedValueOnce('string fault') + .mockResolvedValue('ok'); + + const result = withDbRetry(fn, 4); + await vi.runAllTimersAsync(); // flush the queued backoff delays + await expect(result).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(4); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('read failed'), 'string fault'); + // Backoffs: BACKOFF_MS[0]=50, [1]=150, then index 2 is past the table → the `?? 150` fallback. + const delays = setTimeoutSpy.mock.calls.map((c) => c[1]); + expect(delays).toEqual([50, 150, 150]); + setTimeoutSpy.mockRestore(); + warn.mockRestore(); + vi.useRealTimers(); + }); }); diff --git a/apps/web/app/lib/riskLogic.test.ts b/apps/web/app/lib/riskLogic.test.ts index 11d512927..633f70575 100644 --- a/apps/web/app/lib/riskLogic.test.ts +++ b/apps/web/app/lib/riskLogic.test.ts @@ -34,6 +34,22 @@ describe('evaluateRiskIndicators', () => { expect(flags).toEqual([{ type: 'eu_no_competition' }]); }); + it('emits no competition flag when the bid count is unknown (bidsReceived null)', () => { + // bidsReceived null → the `!= null` guard is false, so `admitted` stays null and neither + // `admitted === 1` competition check can match. + const flags = evaluateRiskIndicators(buildContract({ bidsReceived: null, bidsRejected: 0 })); + expect(flags).not.toContainEqual({ type: 'no_competition' }); + expect(flags).not.toContainEqual({ type: 'eu_no_competition' }); + }); + + it('treats a missing bidsRejected as zero rejected', () => { + // bidsRejected null → the `|| 0` fallback: 1 received − 0 rejected = 1 admitted. + const flags = evaluateRiskIndicators( + buildContract({ bidsReceived: 1, bidsRejected: null, euFunded: false }), + ); + expect(flags).toContainEqual({ type: 'no_competition' }); + }); + it('does not trigger competition flags when > 1 bid is admitted', () => { const contract = buildContract({ bidsReceived: 2, bidsRejected: 0 }); const flags = evaluateRiskIndicators(contract); diff --git a/apps/web/app/lib/security.test.ts b/apps/web/app/lib/security.test.ts index 893bc8c38..651f855b8 100644 --- a/apps/web/app/lib/security.test.ts +++ b/apps/web/app/lib/security.test.ts @@ -20,6 +20,13 @@ describe('securityHeaders CSP', () => { expect(csp).not.toContain('nonce-'); }); + it('omits the CSP on the edge-cached variant outside production', () => { + // nonceLessSecurityHeaders only attaches the CSP in prod; dev keeps the base headers CSP-free. + const dev = nonceLessSecurityHeaders(["'sha256-abc'"], false); + expect(dev.get('Content-Security-Policy')).toBeNull(); + expect(dev.get('X-Content-Type-Options')).toBe('nosniff'); + }); + it('omits the CSP outside production but keeps the base hardening headers', () => { const dev = securityHeaders('n', false); expect(dev.get('Content-Security-Policy')).toBeNull(); diff --git a/apps/web/app/routes/conflict.headers.test.ts b/apps/web/app/routes/conflict.headers.test.ts new file mode 100644 index 000000000..46d2dc80d --- /dev/null +++ b/apps/web/app/routes/conflict.headers.test.ts @@ -0,0 +1,48 @@ +// The свързани-лица routes' cache policy, tested at the export rather than inferred from the page body. +// These pages name individuals, so how long an intermediary is allowed to hold them is part of the ADR-0020 +// surface, not an implementation detail: a wrong TTL keeps a corrected or withdrawn link on an edge cache +// after it has been pulled from the database. +import { describe, expect, it } from 'vitest'; +import { headers as officialHeaders } from './conflict.official'; +import { headers as companyHeaders } from './conflict.company'; +import { headers as methodologyHeaders } from './conflict.methodology'; +import { headers as leaderboardHeaders, meta as leaderboardMeta } from './conflicts'; +import { meta as methodologyMeta } from './conflict.methodology'; + +const ONE_HOUR = /s-maxage=3600/; + +describe('свързани-лица cache headers', () => { + it('caches every conflict page publicly for an hour', () => { + for (const h of [officialHeaders, companyHeaders, methodologyHeaders]) { + const cc = h()['Cache-Control']; + expect(cc).toMatch(ONE_HOUR); + expect(cc).toMatch(/public/); + } + }); + + it('the leaderboard honours a Cache-Control the loader set, and falls back when it set none', () => { + // The loader may shorten the TTL (e.g. on a soft-failed read of an un-migrated env); the route must not + // overwrite that with its own hour. With no loader header at all, the hour is the floor. + expect( + leaderboardHeaders({ loaderHeaders: new Headers({ 'Cache-Control': 'no-store' }) } as never)[ + 'Cache-Control' + ], + ).toBe('no-store'); + expect(leaderboardHeaders({ loaderHeaders: new Headers() } as never)['Cache-Control']).toMatch( + ONE_HOUR, + ); + }); +}); + +describe('indexing split between the naming pages and the methodology', () => { + it('keeps the leaderboard out of search indexes', () => { + const tags = leaderboardMeta({ matches: [], params: {} } as never); + expect(tags).toContainEqual({ name: 'robots', content: 'noindex' }); + }); + + it('leaves the methodology page indexable — it is the libel defence, not a naming page', () => { + const tags = methodologyMeta({ matches: [], params: {} } as never); + expect(tags.some((t) => t.name === 'robots' && t.content === 'noindex')).toBe(false); + expect(tags.some((t) => t.title?.includes('методология'))).toBe(true); + }); +}); diff --git a/apps/web/app/routes/conflict.pages.render.test.tsx b/apps/web/app/routes/conflict.pages.render.test.tsx index 1a914e359..40442111b 100644 --- a/apps/web/app/routes/conflict.pages.render.test.tsx +++ b/apps/web/app/routes/conflict.pages.render.test.tsx @@ -204,6 +204,22 @@ describe('/conflicts/official/:id — render', () => { expect(JSON.stringify(tags)).toContain('Иван Петров'); expect(tags).toContainEqual({ name: 'robots', content: 'noindex' }); }); + + it('meta() falls back to a generic noun when the loader data is absent (error boundary render)', () => { + // On a thrown 404 the route still renders its meta with `data` undefined; the title must degrade to + // the generic noun rather than interpolating "undefined" into a public page title. + const tags = officialMeta({ data: undefined, matches: [], params: { id: 'aXZhbg' } } as never); + expect(JSON.stringify(tags)).toContain('Длъжностно лице'); + expect(JSON.stringify(tags)).not.toContain('undefined'); + }); + + it('falls back to a generic kicker when the person has no institution on the first link', async () => { + await mount(ConflictOfficial as never, { + official: 'Иван Петров', + links: [], // no links → links[0] is undefined → the kicker takes its fallback + }); + expect(text()).toContain('Длъжностно лице'); + }); }); describe('Trade Register evidence on the detail page (#279, ADR-0033)', () => { @@ -358,6 +374,12 @@ describe('/conflicts/company/:eik — render', () => { expect(JSON.stringify(tags)).toContain('ТРЕЙС ГРУП ХОЛД АД'); expect(tags).toContainEqual({ name: 'robots', content: 'noindex' }); }); + + it('meta() falls back to a generic noun when the loader data is absent (error boundary render)', () => { + const tags = companyMeta({ data: undefined, matches: [], params: { eik: '111' } } as never); + expect(JSON.stringify(tags)).toContain('Дружество'); + expect(JSON.stringify(tags)).not.toContain('undefined'); + }); }); describe('/conflicts/methodology — render', () => { diff --git a/apps/web/app/routes/conflicts.loaders.test.ts b/apps/web/app/routes/conflicts.loaders.test.ts index 8ab45d54c..931fdc27b 100644 --- a/apps/web/app/routes/conflicts.loaders.test.ts +++ b/apps/web/app/routes/conflicts.loaders.test.ts @@ -160,6 +160,14 @@ describe('contracts resource loader (/conflicts/link/:scope/:slug/:eik/contracts expect(q.getLinkContracts).not.toHaveBeenCalled(); }); + it('404s when the route params are absent entirely (not merely blank)', async () => { + // React Router types :scope/:slug/:eik as optional; an absent key takes the `?? ''` fallback rather + // than reaching the DB with `undefined` interpolated into the link_key. + q.personIdFromSlug.mockReturnValue(null); + await expectStatus(call(contractsLoader, {}), 404); + expect(q.getLinkContracts).not.toHaveBeenCalled(); + }); + it('builds a SELF link_key (personId|eik) — never collapses with the family key', async () => { q.personIdFromSlug.mockReturnValue('person:1'); q.getLinkContracts.mockResolvedValue([{ contractNumber: 'A-1' }]); diff --git a/apps/web/app/routes/search.suggest.test.tsx b/apps/web/app/routes/search.suggest.test.tsx new file mode 100644 index 000000000..90f860bbc --- /dev/null +++ b/apps/web/app/routes/search.suggest.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fakeD1 } from '@sigma/test-support'; +import type { SearchGroup, SearchResults } from '@sigma/api-contract'; + +const { searchMock } = vi.hoisted(() => ({ searchMock: vi.fn() })); +// The route now wraps the env in getDb() before calling search (#225, the read-only D1 chokepoint for +// #199); stub it as a pass-through so search still receives a non-null handle and the call-args +// assertions hold. +vi.mock('@sigma/db', () => ({ search: searchMock, getDb: (env: unknown) => env })); + +import { loader, trimGroup } from './search.suggest'; + +function hit(id: string) { + return { + kind: 'contract', + ref: id, + title: id, + subtitle: '', + } as unknown as SearchGroup['hits'][number]; +} + +function group(kind: string, n: number): SearchGroup { + return { kind, hits: Array.from({ length: n }, (_, i) => hit(`${kind}-${i}`)) } as SearchGroup; +} + +describe('trimGroup', () => { + it('returns the group unchanged when at or under the per-group cap', () => { + const g = group('company', 4); + expect(trimGroup(g)).toBe(g); // same reference — no copy + }); + + it('caps a group to the first SUGGEST_PER_GROUP hits', () => { + const trimmed = trimGroup(group('company', 9)); + expect(trimmed.hits).toHaveLength(4); + expect(trimmed.hits.map((h) => h.ref)).toEqual([ + 'company-0', + 'company-1', + 'company-2', + 'company-3', + ]); + }); +}); + +describe('loader', () => { + function ctx() { + // search() is mocked, so the binding is only ever passed along, never queried — a route-less + // double turns any real query into a failure instead of a silent empty answer. + return { cloudflare: { env: { DB: fakeD1([]).db } } } as never; + } + + it('runs the ranked FTS query, trims every group, and sets the JSON + short-cache headers', async () => { + const results: SearchResults = { + query: 'ео', + groups: [group('company', 6), group('authority', 2)], + } as SearchResults; + searchMock.mockResolvedValueOnce(results); + + const res = await loader({ + request: new Request('https://x/search/suggest?q=ео'), + context: ctx(), + } as never); + + expect(searchMock).toHaveBeenCalledWith(expect.anything(), 'ео'); + const payload = res.data as SearchResults; + expect(payload.groups[0]!.hits).toHaveLength(4); // trimmed + expect(payload.groups[1]!.hits).toHaveLength(2); // untouched + expect(res.init?.headers).toMatchObject({ + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, s-maxage=120, stale-while-revalidate=86400', + }); + }); + + it('defaults the query to an empty string when q is absent', async () => { + searchMock.mockResolvedValueOnce({ query: '', groups: [] } as SearchResults); + await loader({ request: new Request('https://x/search/suggest'), context: ctx() } as never); + expect(searchMock).toHaveBeenCalledWith(expect.anything(), ''); + }); +}); diff --git a/apps/web/workers/app.harden.test.ts b/apps/web/workers/app.harden.test.ts new file mode 100644 index 000000000..0cdb7078f --- /dev/null +++ b/apps/web/workers/app.harden.test.ts @@ -0,0 +1,109 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Exercises the response-hardening path in app.ts that app.cache.test.ts doesn't reach: the +// nonce → hash CSP swap for an edge-cacheable HTML response (hardenResponse's `nonce !== null` +// branch), the OPTIONS short-circuit, and a cacheable response with no Content-Type header. + +const NONCE = 'abc123nonce'; +// A cacheable, anonymous HTML response carrying a per-request nonce CSP and one nonce-bearing +// framework script — the exact shape entry.server.tsx emits and hardenResponse must re-hash. +const HTML_WITH_NONCE = `

hi

`; + +vi.mock('react-router', () => ({ + createRequestHandler: () => async (request: Request) => { + const url = new URL(request.url); + if (url.pathname === '/no-content-type') { + // Cacheable (s-maxage) but no Content-Type → isHtml's `?? ''` fallback, nonce stays null. + return new Response('raw', { + status: 200, + headers: { 'Cache-Control': 'public, s-maxage=60' }, + }); + } + return new Response(HTML_WITH_NONCE, { + status: 200, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'public, s-maxage=1800', + 'Content-Security-Policy': `default-src 'self'; script-src 'self' 'nonce-${NONCE}'`, + }, + }); + }, +})); +vi.mock('virtual:react-router/server-build', () => ({})); +vi.mock('./request-log', () => ({ + withRequestLog: ( + request: Request, + env: unknown, + ctx: unknown, + handler: (r: Request, e: unknown, c: unknown) => Promise, + ) => handler(request, env, ctx), +})); +vi.mock('./aggregation-rate-limit', () => ({ rateLimitAggregationRoute: async () => null })); +vi.mock('./assistant-rate-limit', () => ({ rateLimitAssistantRoute: async () => null })); +vi.mock('./csv-rate-limit', () => ({ rateLimitCsvExport: async () => null })); +vi.mock('./search-rate-limit', () => ({ rateLimitSearchRoute: async () => null })); + +const store = new Map(); +const fakeCache = { + async match(req: Request) { + const hit = store.get(req.url); + return hit ? hit.clone() : undefined; + }, + async put(req: Request, res: Response) { + store.set(req.url, res); + }, +}; + +let worker: { fetch: (r: Request, env: unknown, ctx: unknown) => Promise }; + +beforeAll(async () => { + vi.stubGlobal('caches', { default: fakeCache }); + worker = ((await import('./app')) as { default: typeof worker }).default; +}); + +beforeEach(() => store.clear()); + +function run(url: string, init?: RequestInit) { + const waits: Promise[] = []; + const ctx = { + waitUntil: (p: Promise) => void waits.push(p), + passThroughOnException: () => {}, + }; + return worker.fetch(new Request(url, init), {}, ctx).then(async (res) => { + const body = await res.clone().text(); + await Promise.all(waits); + return { res, body }; + }); +} + +describe('app.ts response hardening', () => { + it('swaps the per-request nonce CSP for a hash-based one on a cacheable HTML response (prod)', async () => { + // Force prod so hardenResponse's `nonce !== null` branch actually rewrites the CSP: the SSR nonce + // must be gone and the trusted framework script re-authorized by its sha256 hash instead. (Under + // the default dev env nonceLessSecurityHeaders emits no CSP, so the swap would be unobservable and + // the test would pass even if the branch were skipped — hence the stub.) + vi.stubEnv('PROD', true); + try { + const { res, body } = await run('https://x/'); + expect(res.headers.get('X-Edge-Cache')).toBe('MISS'); + expect(body).toContain('window.__d=1;'); // body preserved through the buffered re-read + const csp = res.headers.get('Content-Security-Policy') ?? ''; + expect(csp).not.toContain(`nonce-${NONCE}`); // the replayable nonce is gone + expect(csp).toContain("'sha256-"); // replaced by the framework script's hash + } finally { + vi.unstubAllEnvs(); + } + }); + + it('short-circuits an OPTIONS preflight before the loader', async () => { + const { res } = await run('https://x/anything', { method: 'OPTIONS' }); + expect(res.headers.get('Allow')).toContain('GET'); + }); + + it('handles a cacheable response with no Content-Type (isHtml fallback) without hardening', async () => { + const { res, body } = await run('https://x/no-content-type'); + expect(body).toBe('raw'); + expect(res.headers.get('X-Edge-Cache')).toBe('MISS'); + expect(res.headers.get('Content-Security-Policy')).toBeNull(); + }); +}); diff --git a/apps/web/workers/conflicts-rate-limit.test.ts b/apps/web/workers/conflicts-rate-limit.test.ts index 941c5903d..aca5b6616 100644 --- a/apps/web/workers/conflicts-rate-limit.test.ts +++ b/apps/web/workers/conflicts-rate-limit.test.ts @@ -71,6 +71,24 @@ describe('rateLimitConflictsRoute', () => { expect(limit).not.toHaveBeenCalled(); }); + it('does not limit non-GET/HEAD methods (the budget is for read scraping)', async () => { + // The limiter guards enumeration of the published surface, which is read-only. A POST/OPTIONS to the + // same path is not a scrape vector and must pass through untouched rather than consuming the budget. + for (const method of ['POST', 'OPTIONS', 'DELETE']) { + const { limiter, limit } = rateLimiter(false); + const response = await rateLimitConflictsRoute( + new Request('http://local/conflicts', { + method, + headers: { 'CF-Connecting-IP': '203.0.113.40' }, + }), + { CONFLICTS_RATE_LIMITER: limiter }, + false, + ); + expect(response, method).toBeNull(); + expect(limit, method).not.toHaveBeenCalled(); + } + }); + it('does not limit unrelated paths', async () => { const { limiter, limit } = rateLimiter(false); await expect( diff --git a/coverage-baseline.json b/coverage-baseline.json index a8f22a110..187927cce 100644 --- a/coverage-baseline.json +++ b/coverage-baseline.json @@ -2,31 +2,31 @@ "tolerance": 0.5, "workspaces": { "apps/etl": { - "lines": 74, - "branches": 58.2 + "lines": 99, + "branches": 97.6 }, "apps/web": { - "lines": 91, - "branches": 82.4 + "lines": 99.4, + "branches": 96 }, "packages/config": { - "lines": 92.8, - "branches": 72.2 + "lines": 99, + "branches": 100 }, "packages/db": { - "lines": 94.5, - "branches": 79.3 + "lines": 99, + "branches": 98.3 }, "packages/ingest": { - "lines": 86.3, - "branches": 80.4 + "lines": 99, + "branches": 98.7 }, "packages/shared": { - "lines": 95.5, - "branches": 80.8 + "lines": 98.8, + "branches": 98.3 }, "packages/test-support": { - "lines": 100, + "lines": 99, "branches": 100 } } diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 1ec402706..fce4f3c21 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from 'vitest'; import { + BG_REGIONS, CLASSIFIED_PROCEDURE_TYPES, CPV_CATEGORIES, CPV_SECTORS, + ENTITY_TYPES, EU_SCOREBOARD, NON_COMPETITIVE_PROCEDURE_TYPES, + PROCEDURE_GROUPS, + PROCEDURE_UNKNOWN_KEY, categoryForDivision, procedureGroup, rateLowerIsBetter, + regionByName, } from './index'; describe('CPV_CATEGORIES', () => { @@ -100,4 +105,169 @@ describe('rateLowerIsBetter (EU Single Market Scoreboard)', () => { expect(rateLowerIsBetter(0.1, EU_SCOREBOARD.singleBidder)).toBe('good'); expect(rateLowerIsBetter(0.2, EU_SCOREBOARD.singleBidder)).toBe('bad'); }); + + it('rates the entire open interval between the bands as mid', () => { + // Any value strictly inside (good, bad) must be 'mid' — the middle branch, + // exercised just above good and just below bad so a `<`/`<=` flip is caught. + expect(rateLowerIsBetter(0.101, EU_SCOREBOARD.singleBidder)).toBe('mid'); + expect(rateLowerIsBetter(0.199, EU_SCOREBOARD.singleBidder)).toBe('mid'); + expect(rateLowerIsBetter(0.0501, EU_SCOREBOARD.directAward)).toBe('mid'); + expect(rateLowerIsBetter(0.0999, EU_SCOREBOARD.directAward)).toBe('mid'); + }); +}); + +describe('categoryForDivision (edge inputs)', () => { + it('returns null for values with no leading digits after non-digit stripping', () => { + // Truthy strings that reduce to '' after \D removal must miss the map, not throw. + expect(categoryForDivision(' ')).toBeNull(); + expect(categoryForDivision('abc')).toBeNull(); + expect(categoryForDivision('-')).toBeNull(); + }); + + it('reads the division from the first two digits only, ignoring the rest', () => { + // '45' resolves; the trailing digits/letters of a full CPV code are irrelevant. + expect(categoryForDivision('45')?.key).toBe('construction'); + expect(categoryForDivision('4599zzz')?.key).toBe('construction'); + // A separator before the division must not shift the 2-digit window: \D is stripped first. + expect(categoryForDivision('CPV-45')?.key).toBe('construction'); + }); + + it('returns null for a well-formed division that is not in any category', () => { + expect(categoryForDivision('01')).toBeNull(); // no such division in CPV_CATEGORIES + expect(categoryForDivision(undefined)).toBeNull(); + }); +}); + +describe('procedureGroup (edge inputs)', () => { + it('trims surrounding whitespace before lookup', () => { + expect(procedureGroup(' Пряко договаряне ').key).toBe('direct'); + expect(procedureGroup('\tОткрита процедура\n').key).toBe('open'); + }); + + it('falls back via the exported key constant, not a hard-coded literal', () => { + // Same destination, two different paths through procedureGroup — worth naming, because the + // whitespace case is easy to misread as hitting the nullish guard: + // ' ' → truthy, so it PASSES `if (!procedureType)` and reaches the map lookup, where + // `.get('')` misses and `?? PROCEDURE_UNKNOWN` supplies the fallback. + // undefined/null → falsy, caught by the guard before any lookup. + // (The map-miss branch is also covered on its own by the unrecognised-type case above.) + // Asserted against PROCEDURE_UNKNOWN_KEY, not a hard-coded 'unknown'. + expect(procedureGroup(' ').key).toBe(PROCEDURE_UNKNOWN_KEY); + expect(procedureGroup(undefined).key).toBe(PROCEDURE_UNKNOWN_KEY); + expect(procedureGroup(null).key).toBe(PROCEDURE_UNKNOWN_KEY); + expect(PROCEDURE_UNKNOWN_KEY).toBe('unknown'); + }); +}); + +describe('regionByName', () => { + it('resolves an authority region name to its NUTS3 region', () => { + expect(regionByName('София (столица)')).toMatchObject({ + nuts3: 'BG411', + name: 'София (столица)', + nuts2: 'BG41', + nuts2Name: 'Югозападен', + }); + expect(regionByName('Пловдив')?.nuts3).toBe('BG421'); + // The two same-named-stem regions must stay distinct. + expect(regionByName('София')?.nuts3).toBe('BG412'); + }); + + it('trims surrounding whitespace before matching', () => { + expect(regionByName(' Варна ')?.nuts3).toBe('BG331'); + expect(regionByName('\nБургас\t')?.nuts3).toBe('BG341'); + }); + + it('returns null for missing, empty, or unknown names', () => { + expect(regionByName(null)).toBeNull(); + expect(regionByName(undefined)).toBeNull(); + expect(regionByName('')).toBeNull(); + expect(regionByName('Атлантида')).toBeNull(); + // A trailing space around an unknown name still misses, not throws. + expect(regionByName(' Няма такъв ')).toBeNull(); + }); + + it('round-trips every configured region through its verbatim name', () => { + for (const region of BG_REGIONS) { + expect(regionByName(region.name)).toBe(region); + } + }); +}); + +// ── Taxonomy integrity: these guard the deterministic maps the whole app trusts. +// A duplicate key or a broken partition here silently mis-classifies contracts, so +// each invariant is asserted, not assumed. ────────────────────────────────────────── +describe('taxonomy integrity', () => { + it('CPV_SECTORS has 45 unique 2-digit division codes in ascending order', () => { + const codes = CPV_SECTORS.map((s) => s.code); + expect(codes).toHaveLength(45); + expect(new Set(codes).size).toBe(45); + expect([...codes]).toEqual([...codes].sort()); + for (const code of codes) expect(code).toMatch(/^\d{2}$/); + }); + + it('every curated sector carries a short display name', () => { + for (const sector of CPV_SECTORS.filter((s) => s.curated)) { + expect(sector.short, `curated ${sector.code} needs a short name`).toBeTruthy(); + } + expect( + CPV_SECTORS.filter((s) => s.curated) + .map((s) => s.code) + .sort(), + ).toEqual(['15', '45']); + }); + + it('CPV_CATEGORIES keys are unique and every division is a known sector', () => { + const keys = CPV_CATEGORIES.map((c) => c.key); + expect(new Set(keys).size).toBe(keys.length); + const sectorCodes = new Set(CPV_SECTORS.map((s) => s.code)); + for (const category of CPV_CATEGORIES) { + for (const division of category.divisions) { + expect(sectorCodes.has(division), `division ${division} is not a CPV sector`).toBe(true); + } + } + }); + + it('PROCEDURE_GROUPS assign each procedure type to exactly one group', () => { + const seen = new Map(); + for (const group of PROCEDURE_GROUPS) { + expect(group.types.length, `group ${group.key} has no types`).toBeGreaterThan(0); + expect(group.color, `group ${group.key} has no colour`).toBeTruthy(); + for (const type of group.types) { + expect(seen.has(type), `type "${type}" is in two groups`).toBe(false); + seen.set(type, group.key); + } + } + // group keys are unique too + const keys = PROCEDURE_GROUPS.map((g) => g.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('classified types are exactly the competitive ∪ non-competitive types, no neutrals', () => { + const competitiveTypes = PROCEDURE_GROUPS.filter((g) => g.competitive !== null).flatMap( + (g) => g.types, + ); + expect([...CLASSIFIED_PROCEDURE_TYPES].sort()).toEqual([...competitiveTypes].sort()); + // The direct-award list is precisely the competitive===false types. + const nonCompetitive = PROCEDURE_GROUPS.filter((g) => g.competitive === false).flatMap( + (g) => g.types, + ); + expect([...NON_COMPETITIVE_PROCEDURE_TYPES].sort()).toEqual([...nonCompetitive].sort()); + }); + + it('BG_REGIONS has 28 области with unique NUTS3 ids and names', () => { + expect(BG_REGIONS).toHaveLength(28); + expect(new Set(BG_REGIONS.map((r) => r.nuts3)).size).toBe(28); + expect(new Set(BG_REGIONS.map((r) => r.name)).size).toBe(28); + for (const region of BG_REGIONS) { + expect(region.nuts3).toMatch(/^BG\d{3}$/); + expect(region.nuts2).toMatch(/^BG\d{2}$/); + expect(region.nuts3.startsWith(region.nuts2)).toBe(true); + } + }); + + it('ENTITY_TYPES labels both real bidder kinds', () => { + expect(Object.keys(ENTITY_TYPES).sort()).toEqual(['company', 'consortium']); + expect(ENTITY_TYPES.company).toBeTruthy(); + expect(ENTITY_TYPES.consortium).toBeTruthy(); + }); }); diff --git a/packages/db/src/queries/authorities.test.ts b/packages/db/src/queries/authorities.test.ts index ca479d057..f58852286 100644 --- a/packages/db/src/queries/authorities.test.ts +++ b/packages/db/src/queries/authorities.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { fakeD1, type FakeD1 } from '@sigma/test-support'; -import { getAuthorityFacets, listAuthorities, streamAuthoritiesCsv } from './authorities'; +import { + getAuthorityFacets, + listAuthorities, + normalizeAuthoritySort, + streamAuthoritiesCsv, +} from './authorities'; const authorityRow = { authority_id: 'auth:000695089', @@ -37,6 +42,10 @@ function fakeDb(): D1Database { ]).db; } +// A SQL-recording fake for the branch-selection tests: it answers either table source with one +// authority row, so the assertion is about *which* source ran. Deliberately not fakeDb() above — its +// facet route would hijack the base-aggregation page query (which also carries `type_group` + +// `GROUP BY`) and feed back a facet-shaped row that toAuthorityListItem cannot map. function spyDb(): FakeD1 { return fakeD1([ { when: 'FROM authority_totals', all: [authorityRow] }, @@ -55,6 +64,17 @@ describe('listAuthorities', () => { expect(page.items[0]!.spentEur).toBe(1000000); }); + it('defaults the page size and tolerates a missing total row', async () => { + const db = fakeD1([ + { when: 'FROM authority_totals', all: [authorityRow] }, + { when: 'FROM (', all: [authorityRow] }, + { when: 'COUNT(*) AS n', first: null }, // COUNT(*) row absent + ]).db; + const page = await listAuthorities(db, {}); // no pageSize → default of 25 + expect(page.items).toHaveLength(1); + expect(page.total).toBe(0); // null total row → 0 + }); + it('falls back to the default sort instead of throwing for an invalid sort key', async () => { await expect( listAuthorities(fakeDb(), { sort: 'invalid' as never, pageSize: 10 }), @@ -119,3 +139,209 @@ describe('streamAuthoritiesCsv', () => { expect(response.headers.get('Content-Disposition')).toContain('sigma-authorities.csv'); }); }); + +describe('listAuthorities — base-source and entity-where branches', () => { + it('adds year, EU, and single-sector predicates to the base aggregation', async () => { + const { db, sql } = spyDb(); + await listAuthorities(db, { sectors: ['45'], years: ['2024'], eu: 'eu', pageSize: 10 }); + const base = sql.find(usesBaseAggregation)!; + expect(base).toContain('substr(t.cpv_code, 1, 2) IN'); + expect(base).toContain('substr(c.signed_at, 1, 4) IN'); + expect(base).toContain('c.eu_funded = 1'); + expect(base).toContain('? AS primary_sector'); // single sector → bound value, not NULL + }); + + it('uses NULL primary_sector for a multi-sector filter', async () => { + const { db, sql } = spyDb(); + await listAuthorities(db, { sectors: ['45', '33'], pageSize: 10 }); + expect(sql.find(usesBaseAggregation)).toContain('NULL AS primary_sector'); + }); + + it('uses the national (non-EU) funding predicate', async () => { + const { db, sql } = spyDb(); + await listAuthorities(db, { eu: 'national', pageSize: 10 }); + expect(sql.find(usesBaseAggregation)).toContain('c.eu_funded IS NULL OR c.eu_funded = 0'); + }); + + it('applies a strict-subset type filter and a text query to the entity WHERE', async () => { + const { db, sql } = spyDb(); + await listAuthorities(db, { types: ['министерство', 'община'], q: 'софия', pageSize: 10 }); + const page = sql.find((s) => s.includes('sort_value'))!; + expect(page).toContain('type_group IN'); + expect(page).toContain('search_index MATCH'); + }); + + it('does not filter by type when all 7 buckets are selected', async () => { + const { db, sql } = spyDb(); + await listAuthorities(db, { types: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], pageSize: 10 }); + expect(sql.every((s) => !s.includes('type_group IN'))).toBe(true); + }); + + it('slices to pageSize and reports a next cursor when the query overflows the page', async () => { + // results.length > pageSize is the hasMore signal: the extra row is dropped from `items` but drives + // pagination. Two rows with a pageSize of one exercises that branch and the forward-cursor assembly. + const rows = [ + { ...authorityRow, authority_id: 'auth:1', sort_value: 200 }, + { ...authorityRow, authority_id: 'auth:2', sort_value: 100 }, + ]; + const db = fakeD1([ + { when: 'FROM authority_totals', all: rows }, + { when: 'FROM (', all: rows }, + { when: 'COUNT(*) AS n', first: { n: 5 } }, + ]).db; + const page = await listAuthorities(db, { pageSize: 1 }); + expect(page.items).toHaveLength(1); // the overflow row is not emitted + expect(page.total).toBe(5); + expect(page.nextCursor).toBeTruthy(); // hasMore → a forward cursor is produced + }); +}); + +describe('listAuthorities — backward pagination', () => { + it('emits a backward page in reversed fetch order (before-cursor → reverse)', async () => { + // 3 rows, pageSize 2 → a FULL page (pageSize 1 would make slice+reverse a no-op and hide a broken + // reverse). Walk forward to mint a before-cursor, feed it back: keyset sets reverse=true and the + // page is emitted in reversed fetch order. Asserting the order flip guards `rows.reverse()` itself. + const rows = [ + { ...authorityRow, authority_id: 'auth:1', sort_value: 300 }, + { ...authorityRow, authority_id: 'auth:2', sort_value: 200 }, + { ...authorityRow, authority_id: 'auth:3', sort_value: 100 }, + ]; + const db = fakeD1([ + { when: 'FROM authority_totals', all: rows }, + { when: 'FROM (', all: rows }, + { when: 'COUNT(*) AS n', first: { n: 3 } }, + ]).db; + const fwd = await listAuthorities(db, { pageSize: 2 }); + const mid = await listAuthorities(db, { pageSize: 2, cursor: fwd.nextCursor! }); + expect(mid.prevCursor).toBeTruthy(); + const back = await listAuthorities(db, { pageSize: 2, cursor: mid.prevCursor! }); + expect(back.items.map((i) => i.slug)).toEqual( + [...fwd.items].reverse().map((i) => i.slug), // reversed vs the forward page + ); + expect(back.items).toHaveLength(2); + }); +}); + +describe('normalizeAuthoritySort', () => { + it('passes through a known sort key and collapses everything else to „spent"', () => { + expect(normalizeAuthoritySort('count')).toBe('count'); + expect(normalizeAuthoritySort('avg')).toBe('avg'); + expect(normalizeAuthoritySort('name')).toBe('name'); + expect(normalizeAuthoritySort('spent')).toBe('spent'); + expect(normalizeAuthoritySort('bogus')).toBe('spent'); // unknown → default + expect(normalizeAuthoritySort(null)).toBe('spent'); // null → default + expect(normalizeAuthoritySort(undefined)).toBe('spent'); // undefined → default + // A prototype key must not slip through the `in` check as a real sort. + expect(normalizeAuthoritySort('toString')).toBe('spent'); + }); +}); + +describe('getAuthorityFacets — sector sort', () => { + it('orders the sector facets by descending value', async () => { + // Two non-zero sectors returned out of value-order forces the `.sort((a,b) => b.count - a.count)` + // comparator to actually reorder (a single row would never invoke it). + const db = fakeD1([ + { when: ['type_group', 'GROUP BY'], all: [] }, + { + when: 'sector_totals', + all: [ + { division: '45', value_eur: 100 }, // smaller first → must be reordered below + { division: '33', value_eur: 900 }, + ], + }, + ]).db; + const facets = await getAuthorityFacets(db); + expect(facets.sectors.map((s) => s.value)).toEqual(['33', '45']); // 900 before 100 + }); +}); + +describe('getAuthorityFacets — unmapped type label', () => { + it('labels an unrecognised type_group as „друго"', async () => { + // A NULL that leaks past the SQL COALESCE(type_group,'друго') → typeLabel(null) is null → the + // `?? 'друго'` fallback owns the label. (Defensive; the real query can't emit NULL here.) + const db = fakeD1([ + { when: ['type_group', 'GROUP BY'], all: [{ type_group: null, n: 4 }] }, + { when: 'sector_totals', all: [] }, + ]).db; + const facets = await getAuthorityFacets(db); + expect(facets.types[0]).toMatchObject({ value: null, label: 'друго', count: 4 }); + expect(facets.sectors).toEqual([]); // no sector_totals rows + }); +}); + +describe('streamAuthoritiesCsv — streamed body', () => { + it('streams a BOM header + one row per authority (auth: stripped, avg rounded) and closes', async () => { + const rows = [ + { + authority_id: 'auth:000695089', + name: 'Министерство', + type_group: 'министерство', + settlement: 'София', + region: 'Столична', + spent_eur: 1000000, + contracts: 100, + suppliers: 30, + avg_eur: 10000.7, + }, + ]; + let served = false; + const serve = () => { + if (served) return []; + served = true; + return rows; + }; + const db = fakeD1([ + { when: 'FROM authority_totals', all: serve }, + { when: 'FROM (', all: serve }, + ]).db; + // The BOM lives in the bytes for Excel; Response.text()'s UTF-8 decode strips a leading BOM, so + // assert it at the byte layer and read the content from the (BOM-stripped) decoded text. + const bytes = new Uint8Array(await streamAuthoritiesCsv(db, {}).arrayBuffer()); + expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]); // UTF-8 BOM + const csv = new TextDecoder().decode(bytes); + expect(csv.startsWith('eik,name,type_group')).toBe(true); + expect(csv).toContain('000695089'); // auth: prefix stripped + expect(csv).toContain('10001'); // avg_eur rounded + expect(csv.endsWith('\n')).toBe(true); + }); + + it('closes immediately when there are no rows', async () => { + const db = fakeD1([ + { when: 'FROM authority_totals', all: [] }, + { when: 'FROM (', all: [] }, + ]).db; + const bytes = new Uint8Array(await streamAuthoritiesCsv(db, {}).arrayBuffer()); + expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]); // BOM still emitted + expect(new TextDecoder().decode(bytes)).toBe( + 'eik,name,type_group,settlement,region,spent_eur,contracts,suppliers,avg_eur\n', + ); + }); + + it('paginates across the CHUNK boundary and folds a type filter into the WHERE', async () => { + // A full first chunk (results.length === CHUNK) must NOT terminate the stream — the pull loop has to + // fetch again. The type filter proves ew.sql is joined into the keyset conditions for the CSV source. + const CHUNK = 2000; + const first = Array.from({ length: CHUNK }, (_, i) => ({ + authority_id: `auth:${String(i).padStart(6, '0')}`, + name: 'Ведомство', + type_group: 'министерство', + settlement: 'София', + region: 'Столична', + spent_eur: 1, + contracts: 1, + suppliers: 1, + avg_eur: 1, + })); + let calls = 0; + const page = () => (calls++ === 0 ? first : []); + const fake = fakeD1([ + { when: 'FROM authority_totals', all: page }, + { when: 'FROM (', all: page }, + ]); + const db = fake.db; + const csv = await streamAuthoritiesCsv(db, { types: ['министерство'] }).text(); + expect(csv.match(/\n/g)!).toHaveLength(CHUNK + 1); // header + CHUNK rows + expect(calls).toBe(2); // the === CHUNK page did not close; a second pull ran + expect(fake.sql.some((s) => s.includes('type_group IN'))).toBe(true); // ew.sql folded in + }); +}); diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts index cc3ff40e9..34ae03569 100644 --- a/packages/db/src/queries/companies.test.ts +++ b/packages/db/src/queries/companies.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { fakeD1, type FakeD1Call } from '@sigma/test-support'; -import { listCompanies, streamCompaniesCsv, type CompanyListParams } from './companies'; +import { fakeD1, type FakeD1, type FakeD1Call } from '@sigma/test-support'; +import { + getCompanyFacets, + listCompanies, + normalizeCompanySort, + streamCompaniesCsv, + type CompanyListParams, +} from './companies'; import type { CompanyTotalsRow } from './rows'; const filteredRows: (CompanyTotalsRow & { sort_value: number })[] = [ @@ -149,3 +155,178 @@ describe('prototype-key params (untrusted query values)', () => { expect(sql.some((s) => s.includes('contracts = 1'))).toBe(true); }); }); + +// A SQL-recording fake that answers either source with one company row — for asserting *which* +// predicates the source/entity-where builders emit, independent of the row-filtering fakeDb above. +function capDb(): FakeD1 { + return fakeD1([ + { when: 'FROM (', all: [filteredRows[0]!] }, + { when: 'FROM company_totals', all: [filteredRows[0]!] }, + { when: 'COUNT(*)', first: { n: 1 } }, + ]); +} + +describe('listCompanies — backward pagination', () => { + it('emits a backward page in reversed fetch order (before-cursor → reverse)', async () => { + // 3 rows, pageSize 2 → a full page so the reverse is observable (pageSize 1 would hide it). + const rows = [ + { ...filteredRows[0]!, bidder_id: 'eik:1', sort_value: 300 }, + { ...filteredRows[0]!, bidder_id: 'eik:2', sort_value: 200 }, + { ...filteredRows[0]!, bidder_id: 'eik:3', sort_value: 100 }, + ]; + const db = fakeD1([ + { when: 'FROM (', all: rows }, + { when: 'FROM company_totals', all: rows }, + { when: 'COUNT(*)', first: { n: 3 } }, + ]).db; + const fwd = await listCompanies(db, { pageSize: 2 }); + const mid = await listCompanies(db, { pageSize: 2, cursor: fwd.nextCursor! }); + expect(mid.prevCursor).toBeTruthy(); + const back = await listCompanies(db, { pageSize: 2, cursor: mid.prevCursor! }); + expect(back.items.map((i) => i.slug)).toEqual([...fwd.items].reverse().map((i) => i.slug)); + expect(back.items).toHaveLength(2); + }); +}); + +describe('normalizeCompanySort', () => { + it('passes through known keys and collapses everything else to „won"', () => { + expect(normalizeCompanySort('count')).toBe('count'); + expect(normalizeCompanySort('authorities')).toBe('authorities'); + expect(normalizeCompanySort('name')).toBe('name'); + expect(normalizeCompanySort('won')).toBe('won'); + expect(normalizeCompanySort('bogus')).toBe('won'); // unknown → default + expect(normalizeCompanySort(null)).toBe('won'); + expect(normalizeCompanySort(undefined)).toBe('won'); + expect(normalizeCompanySort('toString')).toBe('won'); // prototype key is not a sort + }); +}); + +describe('listCompanies — source and entity-where branches', () => { + it('adds year, EU, and single-sector predicates to the base aggregation', async () => { + const { db, sql } = capDb(); + await listCompanies(db, { sectors: ['45'], years: ['2024'], eu: 'eu', pageSize: 10 }); + const base = sql.find((s) => s.includes('FROM (') && s.includes('substr(t.cpv_code, 1, 2)'))!; + expect(base).toContain('substr(c.signed_at, 1, 4) IN'); + expect(base).toContain('c.eu_funded = 1'); + expect(base).toContain('? AS primary_sector'); // single sector → bound value + }); + + it('builds the base aggregation from a non-sector filter, omitting the CPV predicate', async () => { + // needsBase is triggered by the year filter alone; with no sectors the `if (p.sectors?.length)` + // else-branch runs → no CPV predicate is emitted, but the year predicate still is. + const { db, sql } = capDb(); + await listCompanies(db, { years: ['2024'], pageSize: 10 }); + const base = sql.find((s) => s.includes('FROM ('))!; + expect(base).toContain('substr(c.signed_at, 1, 4) IN'); + expect(base).not.toContain('substr(t.cpv_code, 1, 2) IN'); + }); + + it('uses NULL primary_sector for a multi-sector filter and the national funding predicate', async () => { + const { db, sql } = capDb(); + await listCompanies(db, { sectors: ['45', '72'], eu: 'national', pageSize: 10 }); + const base = sql.find((s) => s.includes('FROM ('))!; + expect(base).toContain('NULL AS primary_sector'); + expect(base).toContain('c.eu_funded IS NULL OR c.eu_funded = 0'); + }); + + it('applies a single-kind filter and a text query, but not a kind filter for both kinds', async () => { + const one = capDb(); + await listCompanies(one.db, { kinds: ['company'], q: 'софия', pageSize: 10 }); + const page = one.sql.find((s) => s.includes('sort_value'))!; + expect(page).toContain('kind = ?'); + expect(page).toContain('search_index MATCH'); + + const both = capDb(); + await listCompanies(both.db, { kinds: ['company', 'consortium'], pageSize: 10 }); + expect(both.sql.every((s) => !s.includes('kind = ?'))).toBe(true); + }); + + it('defaults the page size and tolerates a missing total row', async () => { + const db = fakeD1([ + { when: 'FROM (', all: [filteredRows[0]!] }, + { when: 'FROM company_totals', all: [filteredRows[0]!] }, + { when: 'COUNT(*)', first: null }, // COUNT(*) row absent + ]).db; + const page = await listCompanies(db, {}); // no pageSize → default of 25 + expect(page.items).toHaveLength(1); + expect(page.total).toBe(0); + }); + + it('slices to pageSize and reports a next cursor when the query overflows the page', async () => { + const rows = [ + { ...filteredRows[0]!, bidder_id: 'eik:1', sort_value: 200 }, + { ...filteredRows[0]!, bidder_id: 'eik:2', sort_value: 100 }, + ]; + const db = fakeD1([ + { when: 'FROM (', all: rows }, + { when: 'FROM company_totals', all: rows }, + { when: 'COUNT(*)', first: { n: 9 } }, + ]).db; + const page = await listCompanies(db, { pageSize: 1 }); + expect(page.items).toHaveLength(1); // overflow row dropped + expect(page.total).toBe(9); + expect(page.nextCursor).toBeTruthy(); + }); +}); + +describe('getCompanyFacets', () => { + it('maps the two entity kinds (missing kind → 0) and sorts sectors by descending value', async () => { + const db = fakeD1([ + { when: 'GROUP BY kind', all: [{ kind: 'company', n: 7 }] }, // consortium absent → 0 + { + when: 'sector_totals', + all: [ + { division: '45', value_eur: 100 }, // out of order → must be reordered below + { division: '72', value_eur: 900 }, + ], + }, + ]).db; + const facets = await getCompanyFacets(db); + const company = facets.kinds.find((k) => k.value === 'company')!; + const consortium = facets.kinds.find((k) => k.value === 'consortium')!; + expect(company.count).toBe(7); + expect(consortium.count).toBe(0); // byKind.get(k) ?? 0 fallback + expect(facets.sectors.map((s) => s.value)).toEqual(['72', '45']); // 900 before 100 + }); + + it('drops zero-value sectors from the facet', async () => { + const db = fakeD1([ + { when: 'GROUP BY kind', all: [] }, + { when: 'sector_totals', all: [] }, // no rows → every count 0 → filtered out + ]).db; + const facets = await getCompanyFacets(db); + expect(facets.sectors).toEqual([]); + }); +}); + +describe('streamCompaniesCsv — body edges', () => { + it('emits header only when there are no rows', async () => { + const db = fakeD1([ + { when: 'FROM (', all: [] }, + { when: 'FROM company_totals', all: [] }, + ]).db; + const bytes = new Uint8Array(await streamCompaniesCsv(db, {}).arrayBuffer()); + expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]); // UTF-8 BOM + expect(new TextDecoder().decode(bytes)).toBe( + 'eik,name,kind,settlement,won_eur,contracts,authorities,primary_sector\n', + ); + }); + + it('continues past a full first chunk instead of closing at the CHUNK boundary', async () => { + const CHUNK = 2000; + const first = Array.from({ length: CHUNK }, (_, i) => ({ + ...filteredRows[0]!, + bidder_id: `eik:${String(i).padStart(9, '0')}`, + eik: String(i).padStart(9, '0'), + })); + let calls = 0; + const page = () => (calls++ === 0 ? first : []); + const db = fakeD1([ + { when: 'FROM (', all: page }, + { when: 'FROM company_totals', all: page }, + ]).db; + const csv = await streamCompaniesCsv(db, {}).text(); + expect(csv.match(/\n/g)!).toHaveLength(CHUNK + 1); // header + CHUNK rows + expect(calls).toBe(2); // === CHUNK page did not close; a second pull ran + }); +}); diff --git a/packages/db/src/queries/competition.test.ts b/packages/db/src/queries/competition.test.ts index 623884b26..1b7194d17 100644 --- a/packages/db/src/queries/competition.test.ts +++ b/packages/db/src/queries/competition.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { fakeD1, type FakeD1, type FakeD1Call } from '@sigma/test-support'; -import { getCompetition } from './competition'; +import { + getAuthorityProcedureCompetition, + getAuthoritySingleOffer, + getCompetition, + getCompetitionSummary, +} from './competition'; // The query layer is pure SQL-building over D1; tests use a fake D1 that returns canned rows keyed by // SQL markers (same approach as companies.test.ts). They verify the JS-side math (shares, HHI mapping, @@ -216,6 +221,83 @@ describe('getCompetition', () => { expect(bySingleOffer).toEqual([]); }); + it('handles a degenerate corpus: null totals row, zero-contract rows, empty classified set', async () => { + // Sweeps the zero-guard false branches: `contracts > 0 ? … : 0`, `classified > 0 ? … : 0`, + // `classifiedContracts > 0 ? … : 0`, the `row?.x ?? 0` nullish fallbacks, and the year scope filter. + const db = fakeD1([ + { when: CORPUS_TOTALS, first: null }, // totals row missing → every `row?.x ?? 0` falls back + { when: 'FROM sector_totals', all: [] }, + { when: 'FROM flow_pairs', all: [] }, + { when: 'JOIN bidders b', all: [] }, + { when: 'WITH pair AS', all: [] }, + { + when: 'GROUP BY t.procedure_type', + all: [ + { procedure_type: 'Покана до определени лица', contracts: 1, value_eur: 0 }, // neutral + { procedure_type: 'неизвестна', contracts: 1, value_eur: 0 }, // unknown → 0 classified + ], + }, + { + when: 'TRIM(t.procedure_type) IN (', + all: [ + { + authority_id: 'auth:9', + name: 'X', + type_group: null, + classified: 0, + non_competitive: 0, + value_eur: 0, + }, + ], + }, + { + when: SINGLE_OFFER_BOARD, + all: [ + { + authority_id: 'auth:9', + name: 'X', + type_group: null, + contracts: 0, + single_offer: 0, + value_eur: 0, + }, + ], + }, + ]).db; + const data = await getCompetition(db, { year: '2024' }); + expect(data.totals.singleOfferShare).toBe(0); + expect(data.totals.valueEur).toBe(0); + expect(data.bySingleOffer[0]?.singleOfferShare).toBe(0); // r.contracts 0 → 0 + expect(data.byDirectAward[0]?.nonCompetitiveShare).toBe(0); // r.classified 0 → 0 + expect(data.procedure.nonCompetitiveShare).toBe(0); // classifiedContracts 0 → 0 + expect(data.procedure.nonCompetitiveValueShare).toBe(0); // classifiedValueEur 0 → 0 + expect(data.scope.year).toBe(2024); // year scoped through Number() + }); + + it('offers exactly two leaderboard sizes — MAX_TOP on an exact request, otherwise the default', async () => { + // Not a clamp: the source is `p.top === MAX_TOP ? MAX_TOP : DEFAULT_TOP`, so anything that is not + // exactly 50 falls back to 20 rather than being reduced to 50. That fallback is the half that + // matters — it is what stops a caller from naming its own leaderboard size (and its own LIMIT). + expect((await getCompetition(fakeDb().db, { top: 50 })).scope.top).toBe(50); + + for (const top of [999, 51, 35, 0, -1, Number.NaN]) { + expect((await getCompetition(fakeDb().db, { top })).scope.top).toBe(20); + } + expect((await getCompetition(fakeDb().db, {})).scope.top).toBe(20); // omitted → default + }); + + it('scopes every panel by EU funding', async () => { + const calls = fakeDb(); + await getCompetition(calls.db, { funding: 'eu' }); + expect(calls.sql.some((s) => s.includes('c.eu_funded = 1'))).toBe(true); + }); + + it('scopes every panel by national funding', async () => { + const calls = fakeDb(); + await getCompetition(calls.db, { funding: 'national' }); + expect(calls.sql.some((s) => s.includes('c.eu_funded IS NULL OR c.eu_funded = 0'))).toBe(true); + }); + it('scopes competition indicators by authorityId', async () => { const national = await getCompetition(scopedFakeDb().db, { minContracts: 1 }); const calls = scopedFakeDb(); @@ -254,3 +336,47 @@ describe('getCompetition', () => { expect(calls.calls.filter((c) => c.binds.includes('auth:111'))).toHaveLength(6); }); }); + +describe('authority-detail wrappers', () => { + it('getAuthoritySingleOffer returns the single-offer totals for one authority', async () => { + const calls = fakeDb(); + const totals = await getAuthoritySingleOffer(calls.db, 'auth:111'); + expect(totals.singleOfferShare).toBeCloseTo(0.3); // 3 / 10 from TOTALS + expect(calls.sql.some((s) => s.includes('t.authority_id = ?'))).toBe(true); // scoped + }); + + it('getAuthorityProcedureCompetition folds the procedure mix for one authority', async () => { + const calls = fakeDb(); + const proc = await getAuthorityProcedureCompetition(calls.db, 'auth:111'); + expect(proc).toMatchObject({ classifiedContracts: 8, nonCompetitiveContracts: 2 }); + expect(proc.nonCompetitiveShare).toBeCloseTo(0.25); // 2 / 8 + expect(calls.sql.some((s) => s.includes('t.authority_id = ?'))).toBe(true); + }); +}); + +describe('getCompetitionSummary', () => { + it('returns totals and the single most-concentrated authority (default params)', async () => { + const summary = await getCompetitionSummary(fakeDb().db); + expect(summary.totals.singleOfferShare).toBeCloseTo(0.3); + expect(summary.topConcentration).toMatchObject({ slug: '222', hhi: 0.7 }); + }); + + it('yields a null topConcentration when no authority qualifies', async () => { + const emptyDb = fakeD1([ + { + when: CORPUS_TOTALS, + first: { contracts: 0, single_offer: 0, value_eur: 0, single_value_eur: 0 }, + }, + { when: 'FROM sector_totals', all: [] }, + { when: 'FROM flow_pairs', all: [] }, + { when: 'JOIN bidders b', all: [] }, + { when: 'WITH pair AS', all: [] }, + { when: 'GROUP BY t.procedure_type', all: [] }, + { when: 'TRIM(t.procedure_type) IN (', all: [] }, + { when: SINGLE_OFFER_BOARD, all: [] }, + ]).db; + const summary = await getCompetitionSummary(emptyDb, { minContracts: 5 }); + expect(summary.topConcentration).toBeNull(); // byConcentration[0] ?? null + expect(summary.totals.contracts).toBe(0); + }); +}); diff --git a/packages/db/src/queries/contracts.test.ts b/packages/db/src/queries/contracts.test.ts index 0315c2000..fbba2cc8b 100644 --- a/packages/db/src/queries/contracts.test.ts +++ b/packages/db/src/queries/contracts.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { fakeD1 } from '@sigma/test-support'; -import { getContractFacets, listContracts, normalizeContractSort } from './contracts'; +import { fakeD1, type FakeD1 } from '@sigma/test-support'; +import { + contractsSummary, + getContractFacets, + listContracts, + listSingleOfferContracts, + normalizeContractSort, + streamContractsCsv, +} from './contracts'; describe('normalizeContractSort', () => { it('passes known sort keys through', () => { @@ -34,20 +41,41 @@ const contractRow = { sort_value: 1000, }; +/** The list page and the COUNT/SUM aggregate that goes with it, as two separately breakable routes. */ +function listDb( + rows: object[] = [contractRow], + summary: { total: number; eur: number; suspect: number } | null = { + total: 1, + eur: 1000, + suspect: 0, + }, +): FakeD1 { + // `1=0` is the guard the list query uses for an input it could not decode — the page it returns + // must be empty, and the count that goes with it zero. Its routes come first so the guard wins. + return fakeD1([ + { when: '1=0', all: [] }, + { when: '1=0', first: { total: 0, eur: 0, suspect: 0 } }, + { when: 'COUNT(*) AS total', first: summary }, + { when: 'FROM contracts c', all: rows }, + ]); +} + +function fakeDb(): D1Database { + return listDb().db; +} + // The three facet reads: the precomputed rollup, the CPV-division count, and the signed-year buckets. const FACET_ROLLUP = 'FROM facet_counts'; const FACET_SECTORS = 'substr(t.cpv_code, 1, 2)'; const FACET_YEARS = 'GROUP BY key'; -function fakeDb(): D1Database { - // `1=0` is the guard the list query uses for an input it could not decode — the page it returns - // must be empty, and the count that goes with it zero. +/** The three facet reads, each answerable — and therefore breakable — on its own. */ +function facetDb(parts: { rollup?: unknown[]; sectors?: unknown[]; years?: unknown[] }): FakeD1 { return fakeD1([ - { when: '1=0', all: [] }, - { when: '1=0', first: { total: 0, eur: 0, suspect: 0 } }, - { when: 'FROM contracts c', all: [contractRow] }, - { when: 'FROM contracts c', first: { total: 1, eur: 1000, suspect: 0 } }, - ]).db; + { when: FACET_ROLLUP, all: parts.rollup ?? [] }, + { when: FACET_SECTORS, all: parts.sectors ?? [] }, + { when: FACET_YEARS, all: parts.years ?? [] }, + ]); } describe('listContracts', () => { @@ -69,17 +97,238 @@ describe('listContracts', () => { listContracts(fakeDb(), { valueBucket: 'toString', pageSize: 10 }), ).resolves.toBeDefined(); }); + + it('accepts a caller-supplied summary override without a COUNT/SUM scan', async () => { + // No aggregate route at all: with an override the COUNT/SUM scan must never run, so a double + // that throws on it asserts the skip more directly than counting first() calls would. + const fake = fakeD1([{ when: 'FROM contracts c', all: [contractRow] }]); + const page = await listContracts( + fake.db, + { pageSize: 10 }, + { total: 42, valueEur: 999, suspect: 3 }, + ); + expect(page.total).toBe(42); + expect(page.valueEur).toBe(999); + expect(page.suspect).toBe(3); + expect(fake.sql.every((sql) => !sql.includes('COUNT(*) AS total'))).toBe(true); + }); + + it('slices to pageSize and emits a next cursor when the page overflows', async () => { + const rows = [ + { ...contractRow, id: 'c:1', sort_value: 200 }, + { ...contractRow, id: 'c:2', sort_value: 100 }, + ]; + const db = listDb(rows, { total: 2, eur: 2000, suspect: 0 }).db; + const page = await listContracts(db, { pageSize: 1 }); + expect(page.items).toHaveLength(1); // overflow row dropped + expect(page.nextCursor).toBeTruthy(); + }); }); -describe('getContractFacets', () => { - it('counts sectors from the same CPV division expression used by list filters', async () => { - const fake = fakeD1([ - { when: FACET_ROLLUP, all: [] }, - { when: FACET_SECTORS, all: [{ division: '45', contracts: 7 }] }, - { when: FACET_YEARS, all: [] }, - ]); +// A SQL-recording double over the standard list rows — for asserting which predicates buildFilters +// emits. `listDb()` already records every statement, so the spy is just its FakeD1 handle. +const spyDb = (): FakeD1 => listDb(); + +describe('buildFilters (via listContracts)', () => { + it('emits every filter predicate for a fully-specified query', async () => { + const { db, sql } = spyDb(); + await listContracts(db, { + years: ['2024', 'unknown'], // real years OR the unknown-date complement + sectors: ['45'], + procedureGroups: ['open'], + valueBucket: '100k-1m', // bounded → two-sided predicate + eu: 'eu', + bids: 'one', + authority: '123456789', + bidder: '111111111', // a bare ЕИК decodes to eik:… + q: 'ремонт', + pageSize: 10, + }); + const page = sql.find((s) => s.includes('sort_value'))!; + expect(page).toContain('substr(c.signed_at, 1, 4) IN'); // real year + expect(page).toContain('c.signed_at IS NULL OR NOT'); // unknown-date complement + expect(page).toContain('substr(t.cpv_code, 1, 2) IN'); // sector + expect(page).toContain('t.procedure_type IN'); // procedure group expanded to types + expect(page).toContain('c.amount_eur >= ? AND c.amount_eur < ?'); // bounded value bucket + expect(page).toContain('c.eu_funded = 1'); // EU + expect(page).toContain('c.bids_received = 1'); // single-offer + expect(page).toContain('t.authority_id = ?'); // authority + expect(page).toContain('c.bidder_id = ?'); // decoded bidder + expect(page).toContain('search_index MATCH'); // full-text + }); + + it('emits an open-ended predicate for the top value bucket and the national EU predicate', async () => { + const { db, sql } = spyDb(); + await listContracts(db, { valueBucket: 'gt100m', eu: 'national', pageSize: 10 }); + const page = sql.find((s) => s.includes('sort_value'))!; + expect(page).toContain('c.amount_eur >= ?'); + expect(page).not.toContain('c.amount_eur < ?'); // no upper bound + expect(page).toContain('c.eu_funded IS NULL OR c.eu_funded = 0'); + }); + + it('drops a procedure group with no mapped types without emitting an IN ()', async () => { + const { db, sql } = spyDb(); + await listContracts(db, { procedureGroups: ['not-a-real-group'], pageSize: 10 }); + expect(sql.every((s) => !s.includes('t.procedure_type IN'))).toBe(true); + }); - const facets = await getContractFacets(fake.db); + it('produces a WHERE-less query when no filters are set', async () => { + const { db, sql } = spyDb(); + await listContracts(db, { pageSize: 10 }); + // The page query still has a keyset ORDER BY, but no filter WHERE fragment. + const page = sql.find((s) => s.includes('sort_value'))!; + expect(page).not.toContain('substr(t.cpv_code'); + }); + + it('filters to the unknown-date bucket alone (no real-year clause)', async () => { + const { db, sql } = spyDb(); + await listContracts(db, { years: ['unknown'], pageSize: 10 }); + const page = sql.find((s) => s.includes('sort_value'))!; + expect(page).toContain('c.signed_at IS NULL OR NOT'); + expect(page).not.toContain('substr(c.signed_at, 1, 4) IN'); // realYears empty → no IN clause + }); + + it('maps a CPV-less row to a null sector and defaults the page size', async () => { + const db = listDb([{ ...contractRow, cpv_code: null }]).db; + const page = await listContracts(db, {}); // no pageSize → default of 15 + expect(page.items[0]!.sectorCode).toBeNull(); // r.cpv_code ? … : null + }); + + it('emits a backward page in reversed fetch order (before-cursor → reverse)', async () => { + // 3 rows, pageSize 2 → a full page so the reverse is observable (pageSize 1 would hide it). + const rows = [ + { ...contractRow, id: 'c:1', sort_value: 300 }, + { ...contractRow, id: 'c:2', sort_value: 200 }, + { ...contractRow, id: 'c:3', sort_value: 100 }, + ]; + const db = listDb(rows, { total: 3, eur: 1000, suspect: 0 }).db; + const fwd = await listContracts(db, { pageSize: 2 }); + const mid = await listContracts(db, { pageSize: 2, cursor: fwd.nextCursor! }); + expect(mid.prevCursor).toBeTruthy(); + const back = await listContracts(db, { pageSize: 2, cursor: mid.prevCursor! }); + expect(back.items.map((i) => i.id)).toEqual([...fwd.items].reverse().map((i) => i.id)); + expect(back.items).toHaveLength(2); + }); +}); + +describe('contractsSummary', () => { + it('returns zeroed totals when the aggregate row is missing', async () => { + const db = fakeD1([{ when: 'COUNT(*) AS total', first: null }]).db; // no aggregate row + const summary = await contractsSummary(db, {}); + expect(summary).toEqual({ total: 0, valueEur: 0, suspect: 0 }); + }); +}); + +describe('listSingleOfferContracts', () => { + it('orders by value in value mode and by date in recent mode', async () => { + const capture = (): FakeD1 => fakeD1([{ when: 'FROM contracts c', all: [contractRow] }]); + const v = capture(); + const items = await listSingleOfferContracts(v.db, 'value', 5); + expect(items).toHaveLength(1); + expect(v.sql[0]).toContain('ORDER BY c.amount_eur DESC'); + expect(v.sql[0]).toContain('LIMIT ?'); + expect(v.calls[0]!.binds).toEqual([5]); // the explicit limit reaches the LIMIT placeholder + + const r = capture(); + await listSingleOfferContracts(r.db, 'recent'); + expect(r.sql[0]).toContain('ORDER BY COALESCE(c.signed_at, c.published_at) DESC'); + expect(r.calls[0]!.binds).toEqual([10]); // default limit + }); +}); + +describe('getContractFacets — procedure folding and EU counts', () => { + it('folds procedure facet rows into config groups, sorts sectors, and splits EU counts', async () => { + const db = facetDb({ + rollup: [ + { facet: 'procedure', key: 'Открита процедура', contracts: 5 }, // → 'open' + { facet: 'eu', key: '1', contracts: 8 }, + { facet: 'eu', key: '0', contracts: 2 }, + ], + sectors: [ + { division: '45', contracts: 3 }, // out of order → must be resorted below + { division: '72', contracts: 9 }, + ], + }).db; + const facets = await getContractFacets(db); + expect(facets.procedures.find((p) => p.value === 'open')?.count).toBe(5); + expect(facets.sectors.map((s) => s.value)).toEqual(['72', '45']); // 9 before 3 + expect(facets.eu).toEqual({ all: 10, eu: 8, national: 2 }); + }); + + it('sorts real years newest-first and sinks the unknown bucket to the end', async () => { + const db = facetDb({ + years: [ + { key: '2022', contracts: 1 }, + { key: '2024', contracts: 2 }, + { key: 'unknown', contracts: 3 }, + ], + }).db; + const facets = await getContractFacets(db); + // localeCompare orders the real years descending; both `a === unknown` and `b === unknown` + // comparator arms fire to push the unknown bucket last. + expect(facets.years.map((y) => y.value)).toEqual(['2024', '2022', 'unknown']); + }); +}); + +describe('streamContractsCsv', () => { + /** The keyset walk pulls a chunk at a time; each call serves the next page, then nothing. */ + function csvDb(pages: Record[][]): FakeD1 { + let call = 0; + return fakeD1([{ when: 'FROM contracts c', all: () => pages[call++] ?? [] }]); + } + + const csvRow = { + ...contractRow, + rowid: 1, + authority_eik: '123456789', + contractor_eik: '111111111', + }; + + it('streams a BOM header then one CSV row per contract with the raw (unescaped) id', async () => { + const bytes = new Uint8Array( + await streamContractsCsv(csvDb([[csvRow], []]).db, {}).arrayBuffer(), + ); + expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]); // UTF-8 BOM + const csv = new TextDecoder().decode(bytes); + expect(csv.split('\n')[0]).toBe( + 'id,unp,subject,authority,authority_eik,contractor,contractor_eik,kind,sector_code,procedure,signed_at,value_eur,eu_funded,bids_received', + ); + expect(csv).toContain('UNP-1'); + expect(csv).toContain('123456789'); // authority_eik column + }); + + it('emits only the header when the filtered set is empty', async () => { + const csv = await streamContractsCsv(csvDb([[]]).db, { authority: '999999999' }).text(); + expect(csv.split('\n').filter(Boolean)).toHaveLength(1); // header row only + }); + + it('renders a blank sector cell and eu flag „1" for a CPV-less EU-funded row', async () => { + const row = { ...csvRow, cpv_code: null, eu_funded: 1, amount_eur: 1000, bids_received: 3 }; + const csv = await streamContractsCsv(csvDb([[row], []]).db, {}).text(); + // trailing columns: …,value_eur,eu_funded,bids_received → 1000,1,3 (eu_funded === 1 → '1') + expect(csv).toContain(',1000,1,3'); + }); + + it('folds the filter WHERE into the keyset walk and continues past a full chunk', async () => { + const CHUNK = 1000; + const first = Array.from({ length: CHUNK }, (_, i) => ({ + ...csvRow, + id: `c:${i}`, + rowid: i + 1, + })); + const fake = csvDb([first, []]); + const csv = await streamContractsCsv(fake.db, { eu: 'eu' }).text(); + expect(csv.match(/\n/g)!).toHaveLength(CHUNK + 1); // header + CHUNK rows + expect(fake.sql.some((s) => s.includes('c.eu_funded = 1 AND c.rowid > ?'))).toBe(true); + expect(fake.sql).toHaveLength(2); // === CHUNK page did not close; a second pull ran + }); +}); + +describe('getContractFacets — sector, year and authority facets', () => { + it('counts sectors from the same CPV division expression used by list filters', async () => { + const fake = facetDb({ sectors: [{ division: '45', contracts: 7 }] }); + const db = fake.db; + const facets = await getContractFacets(db); expect(fake.sql.some((sql) => sql.includes('JOIN tenders t ON t.id = c.tender_id'))).toBe(true); expect(facets.sectors.find((sector) => sector.value === '45')?.count).toBe(7); @@ -88,19 +337,13 @@ describe('getContractFacets', () => { it('folds future signed-year buckets into unknown without hiding the rows', async () => { const currentYear = new Date().getUTCFullYear(); const futureYear = String(currentYear + 3); - const db = fakeD1([ - { when: FACET_ROLLUP, all: [] }, - { when: FACET_SECTORS, all: [] }, - { - when: FACET_YEARS, - all: [ - { key: String(currentYear), contracts: 4 }, - { key: futureYear, contracts: 1 }, - { key: 'unknown', contracts: 2 }, - ], - }, - ]).db; - + const db = facetDb({ + years: [ + { key: String(currentYear), contracts: 4 }, + { key: futureYear, contracts: 1 }, + { key: 'unknown', contracts: 2 }, + ], + }).db; const facets = await getContractFacets(db); expect(facets.years.find((year) => year.value === String(currentYear))?.count).toBe(4); @@ -110,4 +353,21 @@ describe('getContractFacets', () => { count: 3, }); }); + + it('always sinks the „Неизвестна" bucket below real years, whatever the row order', async () => { + // Multiple buckets with „unknown" NOT last force the comparator to evaluate `a.key === YEAR_UNKNOWN` + // (its first arm) as well as the `b` arm — real years descend, unknown always sorts to the bottom. + const db = facetDb({ + years: [ + { key: '2020', contracts: 1 }, + { key: 'unknown', contracts: 2 }, + { key: '2024', contracts: 3 }, + { key: '2022', contracts: 4 }, + ], + }).db; + const facets = await getContractFacets(db); + const values = facets.years.map((y) => y.value); + expect(values[values.length - 1]).toBe('unknown'); // unknown always last + expect(values.slice(0, -1)).toEqual(['2024', '2022', '2020']); // real years descend + }); }); diff --git a/packages/db/src/queries/details.test.ts b/packages/db/src/queries/details.test.ts index d739fb218..d8487b1bb 100644 --- a/packages/db/src/queries/details.test.ts +++ b/packages/db/src/queries/details.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest'; import { fakeD1 } from '@sigma/test-support'; -import { getContract } from './details'; +import { getAuthority, getCompany, getContract } from './details'; const baseContractRow = { id: 'c:1', tender_id: 't:UNP-1', - contract_subject: 'Contract subject', + contract_subject: 'Contract subject' as string | null, contract_number: null as string | null, document_number: null, - lot_id: 'lot:UNP-1:1', + lot_id: 'lot:UNP-1:1' as string | null, signed_at: '2024-01-15', published_at: '2024-01-16', contract_kind: 'services', - eu_funded: 0, + eu_funded: 0 as number | null, eu_programme: null, duration_days: null, amount_eur: 5000, @@ -27,23 +27,27 @@ const baseContractRow = { bids_rejected: 0, bids_sme: 1, bids_non_eea: 0, - subcontractor_eik: null, - subcontractor_name: null, - subcontract_value: null, + subcontractor_eik: null as string | null, + subcontractor_name: null as string | null, + subcontract_value: null as number | null, contract_currency: 'EUR', title: 'Tender subject', unp: 'UNP-1', procedure_type: 'Открита процедура', - cpv_code: '72000000', + cpv_code: '72000000' as string | null, cpv_description: 'IT services', - num_lots: 2, + num_lots: 2 as number | null, + tender_awards: 1 as number, + eop_tender_id: null as string | null, estimated_value: 10000, - tender_currency: 'EUR', + tender_currency: 'EUR' as string | null, tender_fx_rate: null as number | null, start_date: null, end_date: null, authority_id: 'auth:123456786', authority_name: 'Authority', + // c.ordering_unit_name — the unit named ON the document, which may or may not be the authority itself. + source_authority_name: null as string | null, authority_type_group: 'ministry', authority_settlement: 'Sofia', bidder_id: 'eik:111111113', @@ -122,6 +126,79 @@ describe('getContract', () => { expect(detail?.lots?.estimatedTotalEur).toBe(12000); }); + it('re-sorts lexically-collated lot labels numerically (1, 2 … 10, not 1, 10, 2)', async () => { + // SQL ORDER BY l.id collates the lot id as text, so multi-digit labels arrive lexically + // (10, 2, 1). The numeric-aware comparator must restore 1, 2, 10 — a plain localeCompare would + // give 1, 10, 2 and dropping the sort would leave 10, 2, 1, so only the numeric sort passes. + const lot = (n: string) => ({ + lot_id: `lot:UNP-1:${n}`, + title: `Lot ${n}`, + estimated_value: null, + estimated_currency: 'EUR', + cpv_code: null, + contract_id: null, + signing_value_eur: null, + estimated_fx_rate: null, + bidder_name: null, + bidder_kind: null, + bidder_id: null, + }); + const detail = await getContract( + fakeDb(baseContractRow, [lot('10'), lot('2'), lot('1')]), + 'c:1', + ); + expect(detail?.lots?.rows.map((r) => r.lotLabel)).toEqual(['1', '2', '10']); + }); + + it('collapses a consortium lot contractor name via the row kind', async () => { + // bidder_kind 'consortium' + a ';'-joined name must flow into entityName so the lot row shows + // „А и др." — a mutation that ignored the row kind would leave the raw joined string. + const detail = await getContract( + fakeDb(baseContractRow, [ + { + lot_id: 'lot:UNP-1:1', + title: 'Lot 1', + estimated_value: null, + estimated_currency: 'EUR', + cpv_code: null, + contract_id: null, + signing_value_eur: null, + estimated_fx_rate: null, + bidder_name: 'А ООД; Б ЕООД', + bidder_kind: 'consortium', + bidder_id: 'eik:111111111', + }, + ]), + 'c:1', + ); + expect(detail?.lots?.rows[0]?.contractorName).toBe('А ООД и др.'); + }); + + it('defaults a lot with no currency (and no tender currency) to the BGN peg', async () => { + // estimated_currency ?? tender_currency is null → eurFromNative applies `currency || 'BGN'` and + // converts via the peg (1.95583), so 1955.83 BGN → ~1000 EUR. + const detail = await getContract( + fakeDb({ ...baseContractRow, tender_currency: null }, [ + { + lot_id: 'lot:UNP-1:1', + title: 'Lot 1', + estimated_value: 1955.83, + estimated_currency: null, + cpv_code: null, + contract_id: 'c:1', + signing_value_eur: null, + estimated_fx_rate: null, + bidder_name: 'Bidder', + bidder_kind: 'company', + bidder_id: 'eik:111111111', + }, + ]), + 'c:1', + ); + expect(detail?.lots?.rows[0]?.estimatedEur).toBeCloseTo(1000, 0); + expect(detail?.lots?.rows[0]?.lotLabel).toBe('1'); + }); + it('uses FX rates for foreign-currency estimated values when available', async () => { const usdContractRow = { ...baseContractRow, @@ -448,3 +525,543 @@ describe('getContract', () => { expect(detail?.amendments).toEqual([]); }); }); + +// ── getCompany ────────────────────────────────────────────────────────────────────────────────── +const companyRow = { + bidder_id: 'eik:111111111', + name: 'ТЕСТ ООД', + kind: 'company' as 'company' | 'consortium', + ownership_kind: null as string | null, + eik: '111111111', + eik_valid: 1, + settlement: 'София', + won_eur: 100000, + contracts: 12, + authorities: 4, + primary_sector: '45', + eu_eur: 25000, + first_date: '2021-01-01', + last_date: '2024-06-01', +}; + +function companyDb( + row: typeof companyRow | null, + extra: { primary_eur: number | null; avg_bids: number | null } | null = { + primary_eur: 60000, + avg_bids: 2.34, + }, +): D1Database { + return fakeD1([ + { when: 'FROM company_totals', first: row }, + { when: 'nuts_regions', first: { legal_form: 'ООД', region: 'София' } }, + { when: 'AS primary_eur', first: extra }, + { when: 'four_plus', first: { one: 1, two: 2, three: 0, four_plus: 1, unknown: 0 } }, + { when: ['COUNT(*) AS n', 'amount_eur IS NULL'], first: { n: 3 } }, + { + when: 'AS paid', + all: [ + { authority_id: 'auth:1', name: 'Общ 1', paid: 60000, n: 6 }, + { authority_id: 'auth:2', name: 'Общ 2', paid: 40000, n: 6 }, + ], + }, + { + when: ['GROUP BY t.procedure_type', 'c.bidder_id'], + all: [{ procedure_type: 'Открита процедура', n: 10, eur: 90000 }], + }, + // Both detail pages render a contracts panel through listContracts: its page read and the + // COUNT/SUM aggregate that accompanies it. Empty here; nothing in this file asserts on them. + { when: 'COALESCE(NULLIF(c.contract_subject', all: [] }, + { when: 'COUNT(*) AS total', first: { total: 0, eur: 0, suspect: 0 } }, + ]).db; +} + +describe('getCompany', () => { + it('returns null when the company is not found', async () => { + expect(await getCompany(companyDb(null), 'eik:x')).toBeNull(); + }); + + it('assembles the company DTO with shares, bids, and rounded average', async () => { + const d = (await getCompany(companyDb(companyRow), 'eik:111111111'))!; + expect(d.slug).toBe('111111111'); + expect(d.hasEik).toBe(true); + expect(d.wonEur).toBe(100000); + expect(d.euSharePct).toBeCloseTo(0.25); // 25000 / 100000 + expect(d.sectorSharePct).toBeCloseTo(0.6); // primary_eur 60000 / 100000 + expect(d.avgBids).toBe(2.3); // 2.34 rounded to 1dp + expect(d.suspect).toBe(3); + expect(d.topAuthorities[0]).toMatchObject({ slug: '1', paidEur: 60000, contracts: 6 }); + expect(d.topAuthorities[0]!.sharePct).toBeCloseTo(0.6); + expect(d.moreAuthorities).toBe(2); // authorities 4 − 2 listed + expect(d.bids).toEqual({ one: 1, two: 2, three: 0, fourPlus: 1, unknown: 0 }); + // Single procedure group (n:10, eur:90000, total 90000) → contracts/valueEur folded through and + // sharePct = 90000/90000. Asserting the shape, not just non-empty, guards toProcedureMix's fields. + expect(d.procedureMix[0]).toMatchObject({ contracts: 10, valueEur: 90000, sharePct: 1 }); + expect(d.participants).toEqual([]); // plain company → no participants + }); + + it('nulls sector share and average when the aggregate row is empty', async () => { + const d = (await getCompany(companyDb(companyRow, null), 'eik:111111111'))!; + expect(d.sectorSharePct).toBeNull(); + expect(d.avgBids).toBeNull(); + }); + + it('falls back to zero shares when the company has won nothing', async () => { + const d = (await getCompany(companyDb({ ...companyRow, won_eur: 0 }), 'eik:111111111'))!; + expect(d.euSharePct).toBe(0); + expect(d.sectorSharePct).toBeNull(); + expect(d.topAuthorities[0]!.sharePct).toBe(0); + }); + + it('parses a consortium member list into participants', async () => { + const d = (await getCompany( + companyDb({ ...companyRow, kind: 'consortium', name: 'А ООД; Б ЕООД; В АД' }), + 'eik:111111111', + ))!; + expect(d.isConsortium).toBe(true); + expect(d.participants.map((p) => p.name)).toEqual(['А ООД', 'Б ЕООД', 'В АД']); + expect(d.participants.every((p) => p.eik === null && p.resolvedSlug === null)).toBe(true); + expect(d.membershipNote).toBeNull(); + }); + + it('keeps a prose consortium string as a membership note, not participants', async () => { + const d = (await getCompany( + companyDb({ ...companyRow, kind: 'consortium', name: 'съдружници са следните лица: ...' }), + 'eik:111111111', + ))!; + expect(d.participants).toEqual([]); + expect(d.membershipNote).toContain('съдружници'); + }); + + it('marks hasEik false when the ЕИК is not validated', async () => { + const d = (await getCompany(companyDb({ ...companyRow, eik_valid: 0 }), 'eik:111111111'))!; + expect(d.hasEik).toBe(false); + expect(d.eikValid).toBe(false); + }); + + it('fills every fallback when metadata/bids/suspect rows and the procedure value are absent', async () => { + // Exercises the `?? null`/`?? 0` fallbacks: null bidderMeta, null bidsRow, null suspectRow, + // a null primary_sector (bound as ''), and a procedure row whose value is NULL. + const row = { ...companyRow, primary_sector: null }; + const db = fakeD1([ + { when: 'FROM company_totals', first: row }, + { when: 'nuts_regions', first: null }, // bidderMeta null + { when: 'AS primary_eur', first: null }, // extra null + { when: 'four_plus', first: null }, // bidsRow null + { when: ['COUNT(*) AS n', 'amount_eur IS NULL'], first: null }, // suspectRow null + { when: 'AS paid', all: [] }, + { + when: ['GROUP BY t.procedure_type', 'c.bidder_id'], + all: [{ procedure_type: 'Открита процедура', n: 1, eur: null }], + }, + // listContracts panel — see companyDb/authorityDb above. + { when: 'COALESCE(NULLIF(c.contract_subject', all: [] }, + { when: 'COUNT(*) AS total', first: { total: 0, eur: 0, suspect: 0 } }, + ]).db; + const d = (await getCompany(db, 'eik:111111111'))!; + expect(d.region).toBeNull(); + expect(d.legalForm).toBeNull(); + expect(d.bids).toEqual({ one: 0, two: 0, three: 0, fourPlus: 0, unknown: 0 }); + expect(d.suspect).toBe(0); + expect(d.sectorSharePct).toBeNull(); + expect(d.avgBids).toBeNull(); + expect(d.procedureMix).toEqual([]); // null proc value → group valueEur 0 → dropped + }); +}); + +// ── getAuthority ──────────────────────────────────────────────────────────────────────────────── +const authorityRow = { + authority_id: 'auth:123456789', + name: 'Министерство', + type_group: 'министерство' as string | null, + settlement: 'София', + region: 'Столична', + spent_eur: 200000, + contracts: 40, + suppliers: 10, + avg_eur: 5000, + primary_sector: '45', + eu_eur: 50000, + first_date: '2020-01-01', + last_date: '2024-12-31', +}; + +function authorityDb( + row: typeof authorityRow | null, + sectorRows: { division: string; eur: number }[] = [{ division: '45', eur: 120000 }], +): D1Database { + return fakeD1([ + { when: 'FROM authority_totals', first: row }, + { when: 'AVG(c.bids_received)', first: { avg_bids: 3.16 } }, + { when: ['COUNT(*) AS n', 'amount_eur IS NULL'], first: { n: 2 } }, + { + when: 'ORDER BY won DESC', + all: [ + { bidder_id: 'eik:1', name: 'A ООД', kind: 'company', won: 120000, n: 8 }, + { bidder_id: 'eik:2', name: 'Б АД', kind: 'company', won: 80000, n: 5 }, + ], + }, + { when: 'GROUP BY division', all: sectorRows }, + { + when: 'GROUP BY t.procedure_type', + all: [{ procedure_type: 'Пряко договаряне', n: 4, eur: 60000 }], + }, + // Both detail pages render a contracts panel through listContracts: its page read and the + // COUNT/SUM aggregate that accompanies it. Empty here; nothing in this file asserts on them. + { when: 'COALESCE(NULLIF(c.contract_subject', all: [] }, + { when: 'COUNT(*) AS total', first: { total: 0, eur: 0, suspect: 0 } }, + ]).db; +} + +describe('getAuthority', () => { + it('returns null when the authority is not found', async () => { + expect(await getAuthority(authorityDb(null), 'auth:x')).toBeNull(); + }); + + it('assembles the authority DTO with contractor + sector shares', async () => { + const d = (await getAuthority(authorityDb(authorityRow), 'auth:123456789'))!; + expect(d.eik).toBe('123456789'); + expect(d.spentEur).toBe(200000); + expect(d.euSharePct).toBeCloseTo(0.25); + expect(d.avgBids).toBe(3.2); // 3.16 → 3.2 + expect(d.suspect).toBe(2); + expect(d.topContractors[0]).toMatchObject({ slug: '1', wonEur: 120000, contracts: 8 }); + expect(d.topContractors[0]!.sharePct).toBeCloseTo(0.6); + expect(d.moreContractors).toBe(8); // suppliers 10 − 2 listed + expect(d.sectors[0]).toMatchObject({ code: '45', valueEur: 120000 }); + expect(d.sectorsOther).toBeNull(); // only one sector, no tail + // Single procedure group (n:4, eur:60000, total 60000) → sharePct = 60000/60000. Shape assertion + // guards toProcedureMix's contracts/valueEur/sharePct against a coverage-only mutation. + expect(d.procedureMix[0]).toMatchObject({ contracts: 4, valueEur: 60000, sharePct: 1 }); + }); + + it('rolls sectors beyond the top 6 into a „… още" tail bucket', async () => { + const rows = [ + { division: '45', eur: 70000 }, + { division: '33', eur: 40000 }, + { division: '15', eur: 30000 }, + { division: '09', eur: 20000 }, + { division: '48', eur: 15000 }, + { division: '72', eur: 10000 }, + { division: '34', eur: 5000 }, // 7th → tail + { division: '90', eur: 3000 }, // 8th → tail + ]; + const d = (await getAuthority(authorityDb(authorityRow, rows), 'auth:123456789'))!; + expect(d.sectors).toHaveLength(6); + expect(d.sectorsOther).not.toBeNull(); + expect(d.sectorsOther!.valueEur).toBe(8000); // 5000 + 3000 + expect(d.sectorsOther!.label).toContain('още'); + }); + + it('falls back to zero shares when the authority has spent nothing', async () => { + const d = (await getAuthority( + authorityDb({ ...authorityRow, spent_eur: 0 }), + 'auth:123456789', + ))!; + expect(d.euSharePct).toBe(0); + expect(d.topContractors[0]!.sharePct).toBe(0); + expect(d.sectors[0]!.sharePct).toBe(0); + }); + + it('spent-nothing authority: drops an unknown sector, zeroes the tail share, nulls absent bids/suspect', async () => { + const sectorRows = [ + { division: '45', eur: 70000 }, + { division: '33', eur: 40000 }, + { division: '15', eur: 30000 }, + { division: '09', eur: 20000 }, + { division: '48', eur: 15000 }, + { division: '72', eur: 10000 }, + { division: '34', eur: 5000 }, // 7th valid → tail + { division: 'XX', eur: 3000 }, // unknown CPV division → sectorRef null → filtered out + ]; + const db = fakeD1([ + { when: 'FROM authority_totals', first: { ...authorityRow, spent_eur: 0 } }, + { when: 'AVG(c.bids_received)', first: { avg_bids: null } }, // avgBids null + { when: ['COUNT(*) AS n', 'amount_eur IS NULL'], first: null }, // suspectRow null → 0 + { + when: 'ORDER BY won DESC', + all: [{ bidder_id: 'eik:1', name: 'A ООД', kind: 'company', won: 1, n: 1 }], + }, + { when: 'GROUP BY division', all: sectorRows }, + { when: 'GROUP BY t.procedure_type', all: [] }, + // listContracts panel — see companyDb/authorityDb above. + { when: 'COALESCE(NULLIF(c.contract_subject', all: [] }, + { when: 'COUNT(*) AS total', first: { total: 0, eur: 0, suspect: 0 } }, + ]).db; + const d = (await getAuthority(db, 'auth:123456789'))!; + expect(d.avgBids).toBeNull(); + expect(d.suspect).toBe(0); + expect(d.sectors).toHaveLength(6); // 7 valid sectors → 6 shown + tail + expect(d.sectors.some((s) => s.code === 'XX')).toBe(false); // unknown division dropped + expect(d.sectorsOther).not.toBeNull(); + expect(d.sectorsOther!.sharePct).toBe(0); // spent_eur 0 → 0 + }); +}); + +describe('getContract — subcontractor, framework, currency, and field branches', () => { + it('keeps an EUR subcontractor value as-is and normalises a BGN one via the peg', async () => { + const eur = (await getContract( + fakeDb( + { + ...baseContractRow, + subcontractor_name: 'Под ООД', + subcontractor_eik: '9', + subcontract_value: 1000, + contract_currency: 'EUR', + }, + [], + ), + 'c:1', + ))!; + expect(eur.subcontractor).toMatchObject({ name: 'Под ООД', eik: '9', valueEur: 1000 }); + const bgn = (await getContract( + fakeDb( + { + ...baseContractRow, + subcontractor_name: 'Под', + subcontract_value: 1955.83, + contract_currency: 'BGN', + }, + [], + ), + 'c:1', + ))!; + expect(bgn.subcontractor!.valueEur).toBeCloseTo(1000); + }); + + it('nulls a subcontractor value when unknown and drops a blank-named subcontractor', async () => { + const noVal = (await getContract( + fakeDb({ ...baseContractRow, subcontractor_name: 'Под', subcontract_value: null }, []), + 'c:1', + ))!; + expect(noVal.subcontractor!.valueEur).toBeNull(); + const blank = (await getContract( + fakeDb({ ...baseContractRow, subcontractor_name: ' ' }, []), + 'c:1', + ))!; + expect(blank.subcontractor).toBeNull(); + }); + + it('flags framework call-offs only when awards exceed the lot count', async () => { + const fw = (await getContract( + fakeDb({ ...baseContractRow, tender_awards: 5, num_lots: 2 }, []), + 'c:1', + ))!; + expect(fw.frameworkAwards).toBe(5); + const notFw = (await getContract( + fakeDb({ ...baseContractRow, tender_awards: 2, num_lots: 2 }, []), + 'c:1', + ))!; + expect(notFw.frameworkAwards).toBeNull(); + }); + + it('relabels the unknown procedure and maps eu funding tri-state', async () => { + const unknown = (await getContract( + fakeDb({ ...baseContractRow, procedure_type: 'неизвестна', eu_funded: null }, []), + 'c:1', + ))!; + expect(unknown.procedureLabel).toBe('Неизвестна'); + expect(unknown.euFunded).toBeNull(); + const funded = (await getContract(fakeDb({ ...baseContractRow, eu_funded: 1 }, []), 'c:1'))!; + expect(funded.euFunded).toBe(true); + }); + + it('falls back to the tender title for a blank subject and nulls lotLabel when no lot', async () => { + const d = (await getContract( + fakeDb({ ...baseContractRow, contract_subject: null, lot_id: null }, []), + 'c:1', + ))!; + expect(d.subject).toBe('Tender subject'); + expect(d.lotLabel).toBeNull(); + }); + + it('converts native signing values by currency when no *_eur column is present', async () => { + const bgn = (await getContract( + fakeDb( + { + ...baseContractRow, + signing_value_eur: null, + signing_value: 1955.83, + contract_currency: 'BGN', + }, + [], + ), + 'c:1', + ))!; + expect(bgn.value.signingEur).toBeCloseTo(1000); + const usd = (await getContract( + fakeDb( + { + ...baseContractRow, + signing_value_eur: null, + signing_value: 100, + contract_currency: 'USD', + fx_rate: 0.9, + }, + [], + ), + 'c:1', + ))!; + expect(usd.value.signingEur).toBeCloseTo(90); + const noFx = (await getContract( + fakeDb( + { + ...baseContractRow, + signing_value_eur: null, + signing_value: 100, + contract_currency: 'USD', + fx_rate: null, + }, + [], + ), + 'c:1', + ))!; + expect(noFx.value.signingEur).toBeNull(); + }); + + it('computes deltaPct for a clean contract but suppresses it for a suspect flag or zero base', async () => { + const clean = (await getContract( + fakeDb( + { ...baseContractRow, signing_value_eur: 5000, current_value_eur: 6000, value_flag: 'ok' }, + [], + ), + 'c:1', + ))!; + expect(clean.value.deltaPct).toBeCloseTo(0.2); + const suspect = (await getContract( + fakeDb( + { + ...baseContractRow, + signing_value_eur: 5000, + current_value_eur: 6000, + value_flag: 'value_suspect', + }, + [], + ), + 'c:1', + ))!; + expect(suspect.value.deltaPct).toBeNull(); + expect(suspect.value.suspect).toBe(true); + }); + + it('surfaces authority and company totals when the rollup rows exist', async () => { + const db = fakeD1([ + { when: 'WHERE c.id = ?', first: baseContractRow }, + { when: 'authority_totals', first: { spent_eur: 900000, contracts: 300 } }, + { when: 'company_totals', first: { won_eur: 400000, contracts: 40, primary_sector: '72' } }, + { when: 'cpv_division_stats', first: null }, + { when: 'FROM lots l', all: [] }, + { when: 'FROM amendments', all: [] }, + ]).db; + const d = (await getContract(db, 'c:1'))!; + expect(d.authority.totalEur).toBe(900000); + expect(d.authority.totalContracts).toBe(300); + expect(d.bidder.totalEur).toBe(400000); + expect(d.bidder.sector?.code).toBe('72'); + }); +}); + +describe('getContract — lot totals, framework floor, and sector fallbacks', () => { + it('nulls lot totals and contractor fields when a lot carries no values', async () => { + const lot = { + lot_id: 'lot:UNP-1:1', + title: 'Позиция', + estimated_value: null, + estimated_currency: null, + cpv_code: null, + contract_id: null, + signing_value_eur: null, + estimated_fx_rate: null, + bidder_name: null, + bidder_kind: null, + bidder_id: null, + }; + const d = (await getContract(fakeDb(baseContractRow, [lot]), 'c:1'))!; + expect(d.lots!.estimatedTotalEur).toBeNull(); + expect(d.lots!.signedTotalEur).toBeNull(); + expect(d.lots!.rows[0]!.contractorName).toBeNull(); + expect(d.lots!.rows[0]!.contractId).toBeNull(); + expect(d.lots!.rows[0]!.isCurrent).toBe(true); + }); + + it('deduplicates a lot that matches more than one contract row', async () => { + const mk = (contract_id: string) => ({ + lot_id: 'lot:UNP-1:1', + title: 'Позиция', + estimated_value: 1000, + estimated_currency: 'EUR', + cpv_code: '45000000', + contract_id, + signing_value_eur: 900, + estimated_fx_rate: null, + bidder_name: 'Изп ООД', + bidder_kind: 'company' as const, + bidder_id: 'eik:1', + }); + const d = (await getContract(fakeDb(baseContractRow, [mk('c:1'), mk('c:2')]), 'c:1'))!; + expect(d.lots!.rows).toHaveLength(1); // second duplicate skipped + expect(d.lots!.estimatedTotalEur).toBe(1000); + }); + + it('flags framework when num_lots is null (lot floor defaults to 1)', async () => { + const d = (await getContract( + fakeDb({ ...baseContractRow, tender_awards: 3, num_lots: null }, []), + 'c:1', + ))!; + expect(d.frameworkAwards).toBe(3); + }); + + it('nulls the contract sector when there is no CPV code', async () => { + const d = (await getContract(fakeDb({ ...baseContractRow, cpv_code: null }, []), 'c:1'))!; + expect(d.sector).toBeNull(); + }); +}); + +describe('getContract — not-found and lot kind default', () => { + it('returns null when the contract id matches no row', async () => { + const db = fakeD1([{ when: 'WHERE c.id = ?', first: null }]).db; + expect(await getContract(db, 'c:missing')).toBeNull(); + }); + + it('defaults a lot contractor kind to company when the join leaves it null', async () => { + const lot = { + lot_id: 'lot:UNP-1:2', + title: 'Позиция', + estimated_value: null, + estimated_currency: null, + cpv_code: null, + contract_id: 'c:9', + signing_value_eur: null, + estimated_fx_rate: null, + bidder_name: 'Изп ООД', + bidder_kind: null, + bidder_id: 'eik:9', + }; + const d = (await getContract(fakeDb(baseContractRow, [lot]), 'c:1'))!; + expect(d.lots!.rows[0]!.contractorName).toBe('Изп ООД'); // entityName(..., 'company') + }); +}); + +describe('getContract — the ordering unit on the document vs the authority it belongs to', () => { + it('surfaces the document’s ordering unit only when it names something other than the authority', async () => { + // Contracts are signed by a directorate/„второстепенен разпоредител" that rolls up to a parent + // authority. Showing „Възложител по документа" is only informative when the two actually differ — + // echoing the authority's own name back at the reader adds a line that says nothing. + const c = await getContract( + fakeDb({ ...baseContractRow, source_authority_name: 'ОП „Гробищни паркове“ — Русе' }, []), + 'c:1', + ); + // cleanName also normalises the Bulgarian quote glyphs on the way out, same as for the authority. + expect(c!.authority.orderingUnit).toBe('ОП "Гробищни паркове" — Русе'); + }); + + it('drops an ordering unit that is the same name folded differently', async () => { + // foldName, not a raw ===: the two fields come from different feeds, so the same unit routinely + // arrives with different case, spacing, or quote glyphs. A raw comparison would print the + // duplicate line on nearly every contract. + const c = await getContract( + fakeDb({ ...baseContractRow, source_authority_name: ' authority ' }, []), + 'c:1', + ); + expect(c!.authority.orderingUnit).toBeNull(); + }); +}); diff --git a/packages/db/src/queries/flows.test.ts b/packages/db/src/queries/flows.test.ts index 68aa7c906..a6b725159 100644 --- a/packages/db/src/queries/flows.test.ts +++ b/packages/db/src/queries/flows.test.ts @@ -123,3 +123,75 @@ describe('getFlows', () => { expect(Array.isArray(data.sectors)).toBe(true); }); }); + +describe('getFlows — funding scope and label truncation', () => { + it('scopes the base aggregation by EU funding', async () => { + const { db, sql } = spyFake(); + await getFlows(db, { funding: 'eu' }); + expect(sql.some((s) => s.includes('c.eu_funded = 1'))).toBe(true); + }); + + it('scopes the base aggregation by national funding', async () => { + const { db, sql } = spyFake(); + await getFlows(db, { funding: 'national' }); + expect(sql.some((s) => s.includes('c.eu_funded IS NULL OR c.eu_funded = 0'))).toBe(true); + }); + + it('reads the rollup (not a scoped aggregation) when funding is explicitly „all"', async () => { + const { db, sql } = spyFake(); + await getFlows(db, { funding: 'all' }); + expect(sql.some(usesFlowPairsRollup)).toBe(true); + expect(sql.every((s) => !usesBaseAggregation(s))).toBe(true); + }); + + it('truncates a sankey node label longer than 30 chars with an ellipsis', async () => { + const longName = 'Министерство на регионалното развитие и благоустройството'; + const data = await getFlows(fake([{ ...pairRow, authority_name: longName }]).db, {}); + const node = data.sankey.nodes.find((n) => n.side === 'authority')!; + expect(node.label.length).toBeLessThanOrEqual(30); + expect(node.label.endsWith('…')).toBe(true); + }); +}); + +describe('getFlows — sankey ordering', () => { + it('orders the authority column by descending node total across two authorities', async () => { + // Two DISTINCT authorities are needed for the authority-column `.sort()` comparator to run at all + // (a single authority key never invokes it). The higher-total authority ranks to the top (index 0). + const pairs = [ + { + ...pairRow, + authority_id: 'auth:small', + authority_name: 'Малко ведомство', + won_eur: 100000, + bidder_id: 'eik:a', + }, + { + ...pairRow, + authority_id: 'auth:big', + authority_name: 'Голямо ведомство', + won_eur: 900000, + bidder_id: 'eik:b', + }, + ]; + const data = await getFlows(fake(pairs).db, {}); + const auth = data.sankey.nodes.filter((n) => n.side === 'authority'); + expect(auth).toHaveLength(2); + const big = auth.find((n) => n.label.startsWith('Голямо'))!; + const small = auth.find((n) => n.label.startsWith('Малко'))!; + expect(big.y).toBeLessThan(small.y); // bigger total sits higher in the column + }); + + it('orders ribbons by company rank when two pairs share an authority (sort tiebreak)', async () => { + // Input is deliberately NOT in rank order (Бета before the bigger Алфа) so the comparator has to + // reorder: ribbons must come out in company-node order (Алфа's node ranks above Бета's by value). + // Without the `.sort()` the ribbons would keep input order — this assertion discriminates it. + const pairs = [ + { ...pairRow, bidder_id: 'eik:222', bidder_name: 'Бета ООД', won_eur: 200000, contracts: 3 }, + { ...pairRow, bidder_id: 'eik:111', bidder_name: 'Алфа ООД', won_eur: 300000, contracts: 5 }, + ]; + const data = await getFlows(fake(pairs).db, {}); + expect(data.sankey.ribbons.map((r) => r.toName)).toEqual(['Алфа ООД', 'Бета ООД']); + expect(data.sankey.nodes.filter((n) => n.side === 'authority')).toHaveLength(1); + expect(data.sankey.nodes.filter((n) => n.side === 'company')).toHaveLength(2); + }); +}); diff --git a/packages/db/src/queries/home.test.ts b/packages/db/src/queries/home.test.ts index 1762d5f45..c71f4db2a 100644 --- a/packages/db/src/queries/home.test.ts +++ b/packages/db/src/queries/home.test.ts @@ -62,7 +62,10 @@ const totalsRow = { refreshed_at: '2024-06-02T10:00:00Z', }; -function fake(totals: typeof totalsRow | null): FakeD1 { +function fake( + totals: typeof totalsRow | null, + singleOffer: { value_eur: number; contracts: number } | null = { value_eur: 50000, contracts: 1 }, +): FakeD1 { return fakeD1([ { when: 'home_totals', first: totals }, { when: 'company_totals', all: [companyRow] }, @@ -71,7 +74,7 @@ function fake(totals: typeof totalsRow | null): FakeD1 { // listSingleOfferContracts (two calls: 'recent' by date, 'value' by amount) { when: ['bids_received = 1', 'JOIN'], all: [contractRow] }, // the single-offer aggregate, which reads the same table without a join - { when: 'COALESCE(SUM(amount_eur)', first: { value_eur: 50000, contracts: 1 } }, + { when: 'COALESCE(SUM(amount_eur)', first: singleOffer }, ]); } @@ -107,10 +110,10 @@ describe('getHomeData', () => { }); it('excludes the unknown identity bucket from top companies', async () => { - const home = fake(totalsRow); - await getHomeData(home.db); + const calls = fake(totalsRow); + await getHomeData(calls.db); - expect(home.sql.find((query) => query.includes('FROM company_totals'))).toContain( + expect(calls.sql.find((query) => query.includes('FROM company_totals'))).toContain( "WHERE kind <> 'unknown'", ); }); @@ -128,4 +131,10 @@ describe('getHomeData', () => { expect(data.singleOffer.contracts).toBe(1); expect(data.singleOffer.valueEur).toBe(50000); }); + + it('falls back to zero single-offer aggregate when the scan returns no row', async () => { + const data = await getHomeData(fake(totalsRow, null).db); + + expect(data.singleOffer).toEqual({ valueEur: 0, contracts: 0 }); + }); }); diff --git a/packages/db/src/queries/identity.test.ts b/packages/db/src/queries/identity.test.ts index 333eb8fd7..b249983b8 100644 --- a/packages/db/src/queries/identity.test.ts +++ b/packages/db/src/queries/identity.test.ts @@ -129,6 +129,14 @@ describe('person slug (свързани лица)', () => { it('returns null for an undecodable slug rather than throwing', () => { expect(personIdFromSlug('!!!not base64!!!')).toBeNull(); }); + it('encodes a bare name key the same as its prefixed form (the prefix is stripped, not required)', () => { + // Callers hand it either shape — a row's person_id carries the prefix, a freshly computed name key + // does not. Encoding the literal 'person:' into one of them would mint two different URLs for the + // same human, and only one of them would round-trip. + expect(personSlug('ИВАН ПЕТРОВ')).toBe(personSlug('person:ИВАН ПЕТРОВ')); + // Decoding always canonicalises to the prefixed id, so both inputs land on the same person. + expect(personIdFromSlug(personSlug('ИВАН ПЕТРОВ'))).toBe('person:ИВАН ПЕТРОВ'); + }); }); describe('hrefForEntity', () => { @@ -143,3 +151,19 @@ describe('hrefForEntity', () => { expect(href).toBe('/contracts/e:UNP:ОП20-42%2F22%2F'); }); }); + +describe('slug decode edge cases', () => { + it('authoritySlug returns an unprefixed id verbatim (no auth: prefix)', () => { + expect(authoritySlug('000695089')).toBe('000695089'); + }); + it('companySlug returns an unprefixed id verbatim (neither eik: nor name:)', () => { + expect(companySlug('rawid-123')).toBe('rawid-123'); + }); + it('bidderIdFromSlug returns null for an undecodable name slug', () => { + // starts with 'n' but the remainder is not valid base64url → atob throws → caught → null + expect(bidderIdFromSlug('n@@@')).toBeNull(); + }); + it('bidderIdFromSlug returns null for a slug that is neither a ЕИК nor name-encoded', () => { + expect(bidderIdFromSlug('xyz')).toBeNull(); + }); +}); diff --git a/packages/db/src/queries/keyset.test.ts b/packages/db/src/queries/keyset.test.ts index d82982eb5..57c492086 100644 --- a/packages/db/src/queries/keyset.test.ts +++ b/packages/db/src/queries/keyset.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import { AUTHORITY_FILTER_KEYS } from './authorities'; import { COMPANY_FILTER_KEYS } from './companies'; import { CONTRACT_FILTER_KEYS } from './contracts'; -import { decodeCursor, encodeCursor, filterSignature, keyset, pageCursors } from './keyset'; +import { + MAX_CURSOR_CHARS, + decodeCursor, + encodeCursor, + filterSignature, + keyset, + pageCursors, +} from './keyset'; const FILTER_VALUE: Record = { authority: '000695089', @@ -165,6 +172,21 @@ describe('keyset clause', () => { expect(k.orderSql).toBe('ORDER BY won_eur ASC, bidder_id ASC'); expect(k.reverse).toBe(true); }); + it('rejects an unsafe sort direction (guards a non-TS/hostile caller)', () => { + expect(() => + keyset({ sortCol: 'won_eur', idCol: 'bidder_id', dir: 'sideways' as 'asc' }), + ).toThrow(/Unsafe keyset dir/); + }); + it('inverts an ascending sort for a backward (before) cursor', () => { + // dir='asc' + before ⇒ effectiveDir flips to desc: the `opts.dir === 'desc' ? 'asc' : 'desc'` + // else-branch. Walks backward through an ascending list. + const firstPage = keyset({ sortCol: 'won_eur', idCol: 'bidder_id', dir: 'asc' }); + const cursor = encodeCursor('before', 1000, 'x', firstPage.cursorToken); + const k = keyset({ sortCol: 'won_eur', idCol: 'bidder_id', dir: 'asc', cursor }); + expect(k.whereSql).toContain('won_eur < ?'); + expect(k.orderSql).toBe('ORDER BY won_eur DESC, bidder_id DESC'); + expect(k.reverse).toBe(true); + }); it('rejects unsafe sort fragments unless explicitly allowlisted', () => { expect(() => keyset({ sortCol: 'won_eur; DROP TABLE contracts', idCol: 'bidder_id', dir: 'desc' }), @@ -200,6 +222,18 @@ describe('pageCursors', () => { expect(decodeCursor(prevCursor)).toMatchObject({ dir: 'before', value: 900, id: 'a' }); expect(nextCursor).toBeNull(); }); + it('before page with no rows: both cursors null (empty prev-page)', () => { + // Walking backward off the top: the query returns zero rows, so there is neither a first nor a + // last row → the `last ? … : null` and `hasMore && first ? … : null` else-branches both yield null. + const incoming = encodeCursor('before', 700, 'c'); + const { prevCursor, nextCursor } = pageCursors({ + rows: [], + hasMore: false, + incomingCursor: incoming, + }); + expect(prevCursor).toBeNull(); + expect(nextCursor).toBeNull(); + }); it('before page: next always returns toward the page we came from, prev only when more', () => { const incoming = encodeCursor('before', 700, 'c'); const noMore = pageCursors({ @@ -219,3 +253,40 @@ describe('pageCursors', () => { expect(decodeCursor(more.nextCursor)).toMatchObject({ dir: 'after', value: 800, id: 'b' }); }); }); + +describe('decodeCursor — malformed and hostile input', () => { + const enc = (tuple: unknown) => + 'after:' + + btoa(unescape(encodeURIComponent(JSON.stringify(tuple)))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + it('rejects an oversized cursor before running the decode pipeline', () => { + // A raw run of 'A's is not decodable, so it would be rejected by the try/catch even without the + // length guard. Use a payload that WOULD decode to a valid tuple: only the length guard can reject + // it, so this actually exercises line 39 rather than the JSON.parse catch. + const oversizedButValid = enc([1, 'x'.repeat(600)]); // > MAX_CURSOR_CHARS once base64-expanded + expect(oversizedButValid.length).toBeGreaterThan(MAX_CURSOR_CHARS); + expect(decodeCursor(oversizedButValid)).toBeNull(); + // A valid cursor just under the limit must still decode — the guard rejects only the oversized. + const underLimit = enc([1, 'y'.repeat(300)]); + expect(underLimit.length).toBeLessThanOrEqual(MAX_CURSOR_CHARS); + expect(decodeCursor(underLimit)).toMatchObject({ dir: 'after', value: 1 }); + }); + it('rejects a payload that is not valid JSON (decode pipeline throws)', () => { + expect(decodeCursor('after:' + btoa('not json').replace(/=+$/, ''))).toBeNull(); + }); + it('rejects a non-string/number sort value or a non-string id', () => { + expect(decodeCursor(enc([{}, 'id']))).toBeNull(); + expect(decodeCursor(enc(['v', 123]))).toBeNull(); + }); + it('rejects a non-string sortToken', () => { + expect(decodeCursor(enc(['v', 'id', 123]))).toBeNull(); + }); + it('rejects a cursor whose sortToken does not match the expected one', () => { + const c = encodeCursor('after', 'v', 'id', 'tokA'); + expect(decodeCursor(c, 'tokB')).toBeNull(); + expect(decodeCursor(c, 'tokA')).toMatchObject({ sortToken: 'tokA' }); + }); +}); diff --git a/packages/db/src/queries/methodology.test.ts b/packages/db/src/queries/methodology.test.ts index 85efc6148..f18024acc 100644 --- a/packages/db/src/queries/methodology.test.ts +++ b/packages/db/src/queries/methodology.test.ts @@ -89,3 +89,18 @@ describe('getMethodologyStats', () => { expect(stats.coverage.eu).toBe(0); }); }); + +describe('getMethodologyStats — absent coverage counts with a positive total', () => { + it('coalesces a missing per-field count to zero (n ?? 0) when total > 0', async () => { + const db = fakeDb({ + totalsRow: null, + coverageRow: { total: 100 }, // bids/eu/dur/lot fields absent + sectorsRow: null, + }); + const stats = await getMethodologyStats(db); + expect(stats.coverage.bids).toBe(0); + expect(stats.coverage.eu).toBe(0); + expect(stats.coverage.duration).toBe(0); + expect(stats.coverage.lot).toBe(0); + }); +}); diff --git a/packages/db/src/queries/network.test.ts b/packages/db/src/queries/network.test.ts index ee8a7e84b..563f2c77a 100644 --- a/packages/db/src/queries/network.test.ts +++ b/packages/db/src/queries/network.test.ts @@ -54,7 +54,7 @@ const HOP2 = [ }, ]; -function fake(): FakeD1 { +function fakeDb(): FakeD1 { return fakeD1([ { when: 'ORDER BY spent_eur', all: PICKER_AUTH }, { when: 'FROM company_totals', all: PICKER_COMP }, @@ -68,7 +68,7 @@ function fake(): FakeD1 { describe('getEntityNetwork', () => { it('builds the centre, hop-1 neighbours and a deduped hop-2 ring', async () => { - const { center, nodes, edges } = await getEntityNetwork(fake().db, { + const { center, nodes, edges } = await getEntityNetwork(fakeDb().db, { kind: 'authority', id: 'auth:C', }); @@ -79,25 +79,237 @@ describe('getEntityNetwork', () => { }); it('alternates kinds by hop (authority centre -> company hop1 -> authority hop2)', async () => { - const { nodes } = await getEntityNetwork(fake().db, { kind: 'authority', id: 'auth:C' }); + const { nodes } = await getEntityNetwork(fakeDb().db, { kind: 'authority', id: 'auth:C' }); const byId = new Map(nodes.map((n) => [n.id, n])); expect(byId.get('eik:A')).toMatchObject({ kind: 'company', hop: 1 }); expect(byId.get('auth:X')).toMatchObject({ kind: 'authority', hop: 2 }); }); it('weights each node by the sum of its incident edges', async () => { - const { nodes } = await getEntityNetwork(fake().db, { kind: 'authority', id: 'auth:C' }); + const { nodes } = await getEntityNetwork(fakeDb().db, { kind: 'authority', id: 'auth:C' }); const byId = new Map(nodes.map((n) => [n.id, n])); expect(byId.get('auth:C')!.valueEur).toBe(8000); // 5000 + 3000 expect(byId.get('auth:X')!.valueEur).toBe(3500); // 2000 + 1500 }); it('offers centre options for the picker', async () => { - const { centerOptions } = await getEntityNetwork(fake().db, { + const { centerOptions } = await getEntityNetwork(fakeDb().db, { kind: 'authority', id: 'auth:C', }); - expect(centerOptions.authorities.length).toBeGreaterThan(0); - expect(centerOptions.authorities[0]).toMatchObject({ kind: 'authority', value: 'a:C' }); + // Assert the full mapped shape for BOTH sides — label (cleanName/entityName) and value (slug), + // not just the authority value, so the companies-branch mapping is actually exercised. + expect(centerOptions.authorities[0]).toEqual({ + kind: 'authority', + label: 'Център Институция', + value: 'a:C', + }); + expect(centerOptions.companies[0]).toEqual({ kind: 'company', label: 'Фирма А', value: 'c:A' }); + }); +}); + +// Flexible fake D1 for the paths the shared fakeDb() above does not exercise. Each option is its +// own route, so a query that stops matching fails loudly instead of falling through to no rows. +function netDb(opts: { + topAuthority?: { authority_id: string } | null; + centerAuth?: { name: string; spent_eur: number } | null; + centerComp?: { name: string; kind: 'company' | 'consortium'; won_eur: number } | null; + hop1?: unknown[]; + hop2?: unknown[]; + pickerAuth?: unknown[]; + pickerComp?: unknown[]; +}): D1Database { + return fakeD1([ + { when: ['EXISTS', 'FROM authority_totals'], all: opts.pickerAuth ?? [] }, + { when: ['EXISTS', 'FROM company_totals'], all: opts.pickerComp ?? [] }, + { when: 'flow_pairs WHERE authority_id = ?', all: opts.hop1 ?? [] }, + { when: 'flow_pairs WHERE bidder_id = ?', all: opts.hop1 ?? [] }, + { when: ' IN (', all: opts.hop2 ?? [] }, + { when: 'LIMIT 1', first: opts.topAuthority ?? null }, + { when: 'SELECT name, spent_eur', first: opts.centerAuth ?? null }, + { when: 'SELECT name, kind, won_eur', first: opts.centerComp ?? null }, + ]).db; +} + +const COMP_HOP1 = [ + { + authority_id: 'auth:A', + bidder_id: 'eik:C', + authority_name: 'Инст А', + bidder_name: 'Център ООД', + bidder_kind: 'company', + won_eur: 5000, + contracts: 5, + }, +]; +const COMP_HOP2 = [ + { + authority_id: 'auth:A', + bidder_id: 'eik:D', + authority_name: 'Инст А', + bidder_name: 'Друга ООД', + bidder_kind: 'company', + won_eur: 2000, + contracts: 2, + }, +]; + +describe('getEntityNetwork — company centre, defaults, and fallbacks', () => { + it('builds a company-centred network (company -> authority hop1 -> company hop2)', async () => { + const db = netDb({ + centerComp: { name: 'Център ООД', kind: 'company', won_eur: 9000 }, + hop1: COMP_HOP1, + hop2: COMP_HOP2, + }); + const { center, nodes } = await getEntityNetwork(db, { kind: 'company', id: 'eik:C' }); + expect(center).toMatchObject({ id: 'eik:C', kind: 'company', label: 'Център ООД' }); + const byId = new Map(nodes.map((n) => [n.id, n])); + expect(byId.get('auth:A')).toMatchObject({ kind: 'authority', hop: 1 }); + expect(byId.get('eik:D')).toMatchObject({ kind: 'company', hop: 2 }); + }); + + it('defaults to the top authority by spend when no centre is given', async () => { + const db = netDb({ + topAuthority: { authority_id: 'auth:C' }, + centerAuth: { name: 'Център Институция', spent_eur: 100000 }, + hop1: HOP1, + hop2: HOP2, + pickerAuth: PICKER_AUTH, + }); + const { center } = await getEntityNetwork(db, null); + expect(center).toMatchObject({ id: 'auth:C', kind: 'authority' }); + }); + + it('returns an empty network when no authority has any pairs', async () => { + const { center, nodes, edges } = await getEntityNetwork(netDb({ topAuthority: null }), null); + expect(center).toBeNull(); + expect(nodes).toEqual([]); + expect(edges).toEqual([]); + }); + + it('skips the centre picker when includeCenterOptions is false', async () => { + const db = netDb({ centerAuth: { name: 'Ц', spent_eur: 1 }, hop1: HOP1, hop2: HOP2 }); + const { centerOptions } = await getEntityNetwork( + db, + { kind: 'authority', id: 'auth:C' }, + { includeCenterOptions: false }, + ); + expect(centerOptions).toEqual({ authorities: [], companies: [] }); + }); + + it('falls back to a hop-1 sample name when the centre rollup row is missing', async () => { + const db = netDb({ centerAuth: null, hop1: HOP1, hop2: [] }); + const { center } = await getEntityNetwork(db, { kind: 'authority', id: 'auth:C' }); + expect(center).toMatchObject({ label: 'Център Институция', valueEur: 0 }); // name from HOP1[0] + }); + + it('returns an empty network when the centre cannot be resolved at all', async () => { + const { center, nodes } = await getEntityNetwork(netDb({ centerAuth: null, hop1: [] }), { + kind: 'authority', + id: 'auth:missing', + }); + expect(center).toBeNull(); + expect(nodes).toEqual([]); + }); +}); + +describe('getEntityNetwork — hop-2 reduction and node weighting', () => { + it('keeps one hop-2 counterparty per neighbour and drops one that is the centre', async () => { + const hop2 = [ + { + authority_id: 'auth:X', + bidder_id: 'eik:A', + authority_name: 'X', + bidder_name: 'Фирма А', + bidder_kind: 'company', + won_eur: 2000, + contracts: 2, + }, + { + authority_id: 'auth:Y', + bidder_id: 'eik:A', + authority_name: 'Y', + bidder_name: 'Фирма А', + bidder_kind: 'company', + won_eur: 1000, + contracts: 1, + }, // same neighbour → skipped + { + authority_id: 'auth:C', + bidder_id: 'eik:B', + authority_name: 'C', + bidder_name: 'Фирма Б', + bidder_kind: 'company', + won_eur: 500, + contracts: 1, + }, // counterparty is the centre → skipped + ]; + const db = netDb({ centerAuth: { name: 'Център', spent_eur: 42 }, hop1: HOP1, hop2 }); + const { nodes } = await getEntityNetwork(db, { kind: 'authority', id: 'auth:C' }); + expect(nodes.map((n) => n.id).sort()).toEqual(['auth:C', 'auth:X', 'eik:A', 'eik:B']); + }); + + it('weights an edgeless centre from its own rollup value', async () => { + const db = netDb({ centerAuth: { name: 'Център', spent_eur: 777 }, hop1: [], hop2: [] }); + const { nodes } = await getEntityNetwork(db, { kind: 'authority', id: 'auth:C' }); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ id: 'auth:C', valueEur: 777 }); // weight.get undefined → nd.valueEur + }); +}); + +describe('getEntityNetwork — company centre sample fallback', () => { + it('labels a company centre from a hop-1 sample when the rollup row is missing', async () => { + const db = netDb({ centerComp: null, hop1: COMP_HOP1, hop2: [] }); + const { center } = await getEntityNetwork(db, { kind: 'company', id: 'eik:C' }); + expect(center).toMatchObject({ id: 'eik:C', label: 'Център ООД', valueEur: 0 }); + }); + + it('returns an empty network when a company centre resolves to no name at all', async () => { + // company_totals row missing AND no hop-1 sample → name is null → loadCenter returns null. + const db = netDb({ centerComp: null, hop1: [] }); + const { center, nodes } = await getEntityNetwork(db, { kind: 'company', id: 'eik:gone' }); + expect(center).toBeNull(); + expect(nodes).toEqual([]); + }); + + it('defaults a company centre kind to „company" when neither the rollup nor the sample carries one', async () => { + // Sample supplies the name but no bidder_kind → row?.kind ?? sample?.bidder_kind ?? 'company'. + const sample = [ + { + authority_id: 'auth:A', + bidder_id: 'eik:C', + authority_name: 'Инст', + bidder_name: 'Безвидна ООД', + bidder_kind: null, + won_eur: 100, + contracts: 1, + }, + ]; + const db = netDb({ centerComp: null, hop1: sample, hop2: [] }); + const { center } = await getEntityNetwork(db, { kind: 'company', id: 'eik:C' }); + expect(center).toMatchObject({ id: 'eik:C', kind: 'company' }); + }); +}); + +describe('getEntityNetwork — includeCenterOptions and neighbour dedup', () => { + it('skips the picker on the empty-default path when includeCenterOptions is false', async () => { + const { center, centerOptions } = await getEntityNetwork(netDb({ topAuthority: null }), null, { + includeCenterOptions: false, + }); + expect(center).toBeNull(); + expect(centerOptions).toEqual({ authorities: [], companies: [] }); // emptyCenterOptions, no query + }); + + it('does not duplicate a hop-1 neighbour that appears twice in flow_pairs', async () => { + // Two flow rows to the same bidder → one node, but an edge each (the `!nodes.has` guard skips the + // second set() while still recording the edge). + const dupHop1 = [ + HOP1[0]!, + { ...HOP1[0]!, won_eur: 1000, contracts: 1 }, // same bidder_id 'eik:A' + ]; + const db = netDb({ centerAuth: { name: 'Ц', spent_eur: 1 }, hop1: dupHop1, hop2: [] }); + const { nodes, edges } = await getEntityNetwork(db, { kind: 'authority', id: 'auth:C' }); + expect(nodes.filter((n) => n.id === 'eik:A')).toHaveLength(1); // deduped node + expect(edges.filter((e) => e.to === 'eik:A')).toHaveLength(2); // one edge per row }); }); diff --git a/packages/db/src/queries/regions.test.ts b/packages/db/src/queries/regions.test.ts index 5e77e0b06..323caa56a 100644 --- a/packages/db/src/queries/regions.test.ts +++ b/packages/db/src/queries/regions.test.ts @@ -70,3 +70,36 @@ describe('getRegionalSpending', () => { expect(filtered.sql.some((s) => s.includes('JOIN tenders t'))).toBe(true); }); }); + +describe('getRegionalSpending — empty dataset', () => { + it('reports zero coverage without dividing by zero when no authorities exist', async () => { + // No region rows at all → withRegion 0 and unattributed 0 → total 0 → the `total > 0 ? … : 0` + // else-branch owns pct (guards against 0/0 = NaN). + const empty = fakeD1([ + { when: 'FROM authority_totals GROUP BY region', all: [] }, + { when: 'FROM sector_totals', all: [] }, + ]); + const { coverage } = await getRegionalSpending(empty.db, {}); + expect(coverage.total).toBe(0); + expect(coverage.pct).toBe(0); + }); +}); + +describe('getRegionalSpending — filter predicates', () => { + it('applies the year filter via base aggregation, not the rollup', async () => { + const cap = fake(); + await getRegionalSpending(cap.db, { year: '2025' }); + expect(cap.sql.some((s) => s.includes('substr(c.signed_at, 1, 4) = ?'))).toBe(true); + expect(cap.sql.some((s) => s.includes('FROM authority_totals'))).toBe(false); + }); + it('applies the EU funding predicate', async () => { + const cap = fake(); + await getRegionalSpending(cap.db, { funding: 'eu' }); + expect(cap.sql.some((s) => s.includes('c.eu_funded = 1'))).toBe(true); + }); + it('applies the national (non-EU) funding predicate', async () => { + const cap = fake(); + await getRegionalSpending(cap.db, { funding: 'national' }); + expect(cap.sql.some((s) => s.includes('c.eu_funded IS NULL OR c.eu_funded = 0'))).toBe(true); + }); +}); diff --git a/packages/db/src/queries/related-persons.test.ts b/packages/db/src/queries/related-persons.test.ts index 40751ff21..b9e8e1355 100644 --- a/packages/db/src/queries/related-persons.test.ts +++ b/packages/db/src/queries/related-persons.test.ts @@ -4,6 +4,7 @@ import { EIK_CONTRACTS_SQL, LINK_CONTRACTS_SQL, getCompanyConflicts, + isMissingConflictTableError, getConflictLeaderboard, getLinkContracts, getOfficialConflicts, @@ -201,6 +202,27 @@ describe('related-persons queries', () => { // an unknown/non-surfaced link_key yields no contracts (the SQL WHERE gate returns nothing) expect(await getLinkContracts(fakeDb({}), 'person:nobody|000')).toEqual([]); }); + + it('maps a null authority name to an empty string, never the literal "null"', async () => { + // authority is LEFT JOINed, so an unresolved body comes back NULL. The DTO must carry '' (the UI + // renders its own placeholder) rather than leaking a null into the rendered card. + const db = fakeDb({ + // Contract reads are namespaced under `contracts:` in the fake (see its comment). + 'contracts:person:ivan|111': [ + { + id: 'c:e:noauth', + signed_at: null, + authority: null, + contract_kind: null, + contract_number: null, + amount_eur: null, + temporal: 'after', + }, + ], + }); + const [c] = await getLinkContracts(db, 'person:ivan|111'); + expect(c!.authority).toBe(''); + }); }); // A D1 whose statements throw D1's „no such table" — the свързани-лица migration (0003) not yet applied to @@ -209,6 +231,26 @@ function throwingDb(err: Error): D1Database { return throwingD1(err).db; } +describe('isMissingConflictTableError', () => { + it('is false for a thrown non-Error value (never matched, always propagates)', () => { + // D1/workerd can surface a rejection that is not an Error instance; the predicate must not try to + // read .message off it, and a non-Error is never treated as the benign missing-table case. + expect(isMissingConflictTableError('no such table: declared_interests')).toBe(false); + expect(isMissingConflictTableError(null)).toBe(false); + expect(isMissingConflictTableError({ message: 'no such table: declared_interests' })).toBe( + false, + ); + }); + + it('matches only the свързани-лица tables, not an unrelated missing table', () => { + expect(isMissingConflictTableError(new Error('no such table: declared_interests'))).toBe(true); + // qualified + quoted forms the D1 message can take + expect(isMissingConflictTableError(new Error('no such table: main."declarations"'))).toBe(true); + expect(isMissingConflictTableError(new Error('no such table: bidders'))).toBe(false); + expect(isMissingConflictTableError(new Error('syntax error near "SELECT"'))).toBe(false); + }); +}); + describe('conflict reads soft-fail on an un-migrated env (no 500)', () => { const missing = () => throwingDb(new Error('D1_ERROR: no such table: interest_links: SQLITE_ERROR')); @@ -299,3 +341,21 @@ describe('an unrecognised evidence seal withholds the link instead of upgrading expect((await getConflictLeaderboard(db, 10)).map((l) => l.linkKey)).toEqual(['ok|111']); }); }); + +describe('registry_role is narrowed to the two rungs the card can render', () => { + it("maps 'manager' through as its own rung, not folded into 'owner'", async () => { + // The label for a manager („вписан като управител") makes a materially weaker claim than the one + // for an owner. Folding manager→owner would assert a stake the register never recorded. + const db = fakeDb({ '10': [row({ registry_role: 'manager' })] }); + expect((await getConflictLeaderboard(db, 10))[0]!.registryRole).toBe('manager'); + }); + + it('withholds any other role value rather than guessing a rung', async () => { + // A role the register carries but the surface has no vetted wording for ('procurator', a value from + // a later rules_version, or NULL on a row with no act) must render nothing, never the nearest label. + for (const role of ['procurator', 'board_member', '', null, undefined]) { + const db = fakeDb({ '10': [row({ registry_role: role })] }); + expect((await getConflictLeaderboard(db, 10))[0]!.registryRole).toBeNull(); + } + }); +}); diff --git a/packages/db/src/queries/search.test.ts b/packages/db/src/queries/search.test.ts index 523814d85..cdf4e4ae4 100644 --- a/packages/db/src/queries/search.test.ts +++ b/packages/db/src/queries/search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { fakeD1 } from '@sigma/test-support'; +import { fakeD1, type FakeD1 } from '@sigma/test-support'; import { MAX_QUERY_CHARS, MAX_QUERY_TOKENS, @@ -95,7 +95,10 @@ function searchDb(officialBestRank = -6, hasConflictTable = true): D1Database { return fakeD1([ // The свързани-лица table probe drives which hits SQL search() runs. Report present/absent per the // fixture flag so both the with-conflict path and the un-migrated fallback are exercisable. - { when: 'sqlite_master', first: hasConflictTable ? { n: 1 } : null }, + // BOTH свързани-лица tables must be present (n === 2) for the conflict-aware hits SQL to run; + // the fixture reports the real count, not a truthy stand-in, or it would silently exercise the + // un-migrated fallback while claiming to cover the badge path. + { when: 'sqlite_master', first: hasConflictTable ? { n: 2 } : null }, // Per-kind counts, before the hits route below: both read search_index, and only GROUP BY kind // tells them apart. { @@ -231,3 +234,110 @@ describe('search', () => { expect(results.groups.find((g) => g.kind === 'company')?.hits.length ?? 0).toBeGreaterThan(0); }); }); + +describe('search — empty query and href fallback', () => { + it('returns the empty shape for a blank or punctuation-only query', async () => { + expect(await search(searchDb(), '')).toEqual({ query: '', groups: [], empty: true }); + expect(await search(searchDb(), ' "" ')).toMatchObject({ empty: true, groups: [] }); + }); + + it('coalesces a null/undefined raw query to the empty shape', async () => { + // (rawQuery ?? '').trim() — a nullish query must not throw before normalisation. + expect(await search(searchDb(), null as unknown as string)).toMatchObject({ empty: true }); + expect(await search(searchDb(), undefined as unknown as string)).toMatchObject({ empty: true }); + }); + + it('searchMoreHref falls back to /search for an unrecognised kind', () => { + const href = searchMoreHref('nope' as unknown as Parameters[0], 'q'); + expect(href.startsWith('/search?q=')).toBe(true); + }); +}); + +// A search DB that reports an arbitrary свързани-лица table count and can starve one group of hits. +// The probe statement itself names the tables, so it is filtered out of the recorded SQL before +// asserting which hits SQL actually ran — see hitsSql below. +const hitsSql = (fake: FakeD1) => fake.sql.filter((s) => !s.includes('sqlite_master')); + +const OFFICIAL_HIT = { + ref: 'person:ИВАН МИНЕВ', + title: 'Иван Минев', + ident: null, + subtitle: 'Община Русе', + amount: 500000, + entity_kind: null, + ownership_kind: null, + eik_valid: null, + has_conflict: 0, + rank: -6, +}; +const COMPANY_HIT = { + ref: 'eik:111111113', + title: 'ТЕСТ ООД', + ident: '111111113', + subtitle: null, + amount: 1000, + entity_kind: 'company', + ownership_kind: null, + eik_valid: 1, + has_conflict: 1, + rank: -5, +}; + +function probeDb(tableCount: number | null, starvedKind: string | null = null): FakeD1 { + return fakeD1([ + { when: 'sqlite_master', first: tableCount === null ? null : { n: tableCount } }, + { + when: ['FROM search_index', 'GROUP BY kind'], + all: [ + { kind: 'official', n: 2 }, + { kind: 'company', n: 7 }, + ], + }, + { + when: 'FROM search_index', + all: (call) => { + const kind = String(call.binds[0]); + if (kind === starvedKind) return []; + return kind === 'official' ? [OFFICIAL_HIT] : [COMPANY_HIT]; + }, + }, + ]); +} + +describe('search — свързани-лица migration probe and count/hits divergence', () => { + it('runs the conflict-aware hits SQL only when BOTH tables are present', async () => { + const both = probeDb(2); + await search(both.db, 'тест'); + expect(hitsSql(both).some((s) => s.includes('interest_links'))).toBe(true); + + // 0003 applied but not 0006: the join would reference a table that isn't there and 500 every + // search, so a partial migration must read the same as no migration at all. + const partial = probeDb(1); + await search(partial.db, 'тест'); + expect(hitsSql(partial).some((s) => s.includes('interest_links'))).toBe(false); + }); + + it('treats a probe that returns no row at all as un-migrated', async () => { + const none = probeDb(null); + await search(none.db, 'тест'); + expect(hitsSql(none).some((s) => s.includes('interest_links'))).toBe(false); + }); + + it('keeps a group whose count is non-zero but whose hits come back empty', async () => { + // The count and the hits are two separate FTS reads; they can disagree (index churn between them). + // The group must survive with its real total and an intact „виж всички" link rather than vanish. + const results = await search(probeDb(2, 'company').db, 'тест'); + const company = results.groups.find((g) => g.kind === 'company'); + expect(company).toMatchObject({ total: 7, hits: [] }); + expect(company!.moreHref).not.toBeNull(); + expect(results.empty).toBe(false); // a real total, so the „нищо не е намерено" state stays off + }); + + it('does not let a hitless group out-rank a real match in the placement gate', async () => { + // A group with no rows has no bm25 rank at all. Reading one out of an empty result set (rather than + // standing it off at Infinity) would hand it the strongest possible score and sink „Свързани лица" + // below a group that matched nothing — the exact placement the minister's ask reverses. + const groups = (await search(probeDb(2, 'company').db, 'тест')).groups; + expect(groups[0]!.kind).toBe('official'); + }); +}); diff --git a/packages/db/src/queries/sitemaps.test.ts b/packages/db/src/queries/sitemaps.test.ts index 04264ee3b..5b03ca427 100644 --- a/packages/db/src/queries/sitemaps.test.ts +++ b/packages/db/src/queries/sitemaps.test.ts @@ -1,24 +1,262 @@ import { describe, expect, it } from 'vitest'; import { fakeD1 } from '@sigma/test-support'; -import { streamAuthoritySitemap } from './sitemaps'; +import { + contractSitemapPages, + streamAuthoritySitemap, + streamCompanySitemap, + streamContractSitemap, +} from './sitemaps'; -function fakeDb(): D1Database { +interface AuthRow { + authority_id: string; + last_date: string | null; +} +interface CompRow { + bidder_id: string; + name: string; + last_date: string | null; +} +interface ContractRow { + rid: number; + id: string; + signed_at: string | null; + published_at: string | null; +} + +// Paginating fake D1: keyset queries slice their list by the bound (after[, hi], limit) args, exactly +// like the real SQL. This drives the streamUrls pull loop through its multi-page and empty-chunk paths. +function fakeDb(opts: { + authorities?: AuthRow[]; + companies?: CompRow[]; + contracts?: ContractRow[]; + asOf?: string | null; + contractCount?: number | null; +}): D1Database { + const authorities = opts.authorities ?? []; + const companies = opts.companies ?? []; + const contracts = opts.contracts ?? []; return fakeD1([ - { when: 'home_totals', first: { as_of: '2026-06-01' } }, + { when: 'SELECT as_of FROM home_totals', first: { as_of: opts.asOf ?? null } }, + { when: 'SELECT contracts FROM home_totals', first: { contracts: opts.contractCount ?? null } }, { when: 'FROM authority_totals', - all: [{ authority_id: 'auth:12\u000134<&>', last_date: '2026-05-31' }], + all: ({ binds }) => { + const [after, limit] = binds as [string, number]; + return authorities.filter((r) => r.authority_id > after).slice(0, limit); + }, + }, + { + when: 'FROM company_totals', + all: ({ binds }) => { + const [after, limit] = binds as [string, number]; + return companies.filter((r) => r.bidder_id > after).slice(0, limit); + }, + }, + { + when: 'FROM contracts', + all: ({ binds }) => { + const [after, hi, limit] = binds as [number, number, number]; + return contracts.filter((r) => r.rid > after && r.rid <= hi).slice(0, limit); + }, }, ]).db; } -describe('sitemap XML escaping', () => { - it('strips XML-invalid C0 controls and keeps URLs escaped', async () => { - const xml = await streamAuthoritySitemap(fakeDb(), 'https://example.test').text(); +const CHUNK = 5000; +const authId = (i: number) => `auth:${String(i).padStart(6, '0')}`; +const bidderId = (i: number) => `eik:${String(1000000000 + i)}`; +describe('streamAuthoritySitemap', () => { + it('strips XML-invalid C0 controls and escapes the URL', async () => { + const db = fakeDb({ + authorities: [{ authority_id: 'auth:1234<&>', last_date: '2026-05-31' }], + asOf: '2026-06-01', + }); + const xml = await streamAuthoritySitemap(db, 'https://example.test').text(); expect(xml).not.toMatch(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/); expect(xml).toContain('https://example.test/authorities/1234<&>'); expect(xml).toContain('2026-05-31'); + expect(xml.startsWith('\n')).toBe(true); + }); + + it('falls back to the dataset as_of date when a row has no last_date', async () => { + const db = fakeDb({ + authorities: [{ authority_id: 'auth:000000111', last_date: null }], + asOf: '2026-06-01', + }); + const xml = await streamAuthoritySitemap(db, 'https://x.test').text(); + expect(xml).toContain('/authorities/000000111'); + expect(xml).toContain('2026-06-01'); + }); + + it('emits no when neither row date nor as_of is usable', async () => { + const db = fakeDb({ authorities: [{ authority_id: 'auth:1', last_date: null }], asOf: null }); + const xml = await streamAuthoritySitemap(db, 'https://x.test').text(); + expect(xml).toContain('/authorities/1'); // no between loc and /url + }); + + it('paginates across the CHUNK boundary (multi-page stream)', async () => { + const authorities = Array.from({ length: CHUNK + 1 }, (_, i) => ({ + authority_id: authId(i), + last_date: null, + })); + const xml = await streamAuthoritySitemap( + fakeDb({ authorities, asOf: null }), + 'https://x.test', + ).text(); + const urls = xml.match(//g) ?? []; + expect(urls).toHaveLength(CHUNK + 1); // every row across both pages is emitted exactly once + expect(xml.endsWith('\n')).toBe(true); + }); +}); + +describe('streamCompanySitemap', () => { + it('encodes the slug and skips natural-person (ЕТ) profiles', async () => { + const db = fakeDb({ + companies: [ + { bidder_id: 'eik:103267194', name: 'ТЕСТ ООД', last_date: '2026-01-01' }, + { bidder_id: 'name:ЕТ ИВАН', name: 'ЕТ ИВАН ПЕТРОВ', last_date: null }, // filtered out + ], + asOf: '2026-06-01', + }); + const xml = await streamCompanySitemap(db, 'https://x.test').text(); + expect(xml).toContain('/companies/103267194'); + expect(xml).not.toContain('ИВАН'); // natural person excluded + expect(xml.match(//g) ?? []).toHaveLength(1); + }); + + it('falls back to the dataset as_of when a company has no last_date', async () => { + const db = fakeDb({ + companies: [{ bidder_id: 'eik:103267194', name: 'ТЕСТ ООД', last_date: null }], + asOf: '2026-06-01', + }); + const xml = await streamCompanySitemap(db, 'https://x.test').text(); + expect(xml).toContain('/companies/1032671942026-06-01'); + }); + + it('skips an all-filtered page and continues to the next (empty-chunk loop)', async () => { + // A full first page of natural persons yields zero slugs but a non-null cursor, so the pull loop + // must fetch the next page rather than terminate early. + const companies: CompRow[] = [ + ...Array.from({ length: CHUNK }, (_, i) => ({ + bidder_id: bidderId(i), + name: 'ЕТ СОБСТВЕНИК', // every one is a natural person → filtered + last_date: null, + })), + { bidder_id: bidderId(CHUNK), name: 'РЕАЛНА ФИРМА ООД', last_date: '2026-02-02' }, + ]; + const xml = await streamCompanySitemap( + fakeDb({ companies, asOf: null }), + 'https://x.test', + ).text(); + expect(xml.match(//g) ?? []).toHaveLength(1); // only the real company survived + expect(xml).toContain('2026-02-02'); + }); +}); + +describe('streamContractSitemap', () => { + it('emits contract URLs for a page and closes with the tail', async () => { + const db = fakeDb({ + contracts: [ + { rid: 1, id: 'c:1', signed_at: '2026-03-01', published_at: null }, + { rid: 2, id: 'c:2', signed_at: null, published_at: '2026-03-02' }, // published_at fallback + { rid: 3, id: 'c:3', signed_at: null, published_at: null }, // as_of fallback + ], + asOf: '2026-06-01', + }); + const xml = await streamContractSitemap(db, 'https://x.test', 1).text(); + expect(xml).toContain('/contracts/12026-03-01'); + expect(xml).toContain('/contracts/22026-03-02'); // published_at + expect(xml).toContain('/contracts/32026-06-01'); // as_of + expect(xml.endsWith('\n')).toBe(true); + }); + + it('returns an empty urlset when the page range holds no contracts', async () => { + const xml = await streamContractSitemap( + fakeDb({ contracts: [], asOf: null }), + 'https://x.test', + 1, + ).text(); + expect(xml).toBe( + '\n' + + '\n\n', + ); + }); + + it('scopes a later page to its rowid window via page math', async () => { + // page 2 → rowid in (45000, 90000]; 40000 is below the lower bound, 90001 above the upper bound, + // and only 45001 falls in the window — guarding BOTH lo and hi of the page-math computation. + const contracts: ContractRow[] = [ + { rid: 40000, id: 'c:early', signed_at: '2026-01-01', published_at: null }, + { rid: 45001, id: 'c:inpage', signed_at: '2026-01-02', published_at: null }, + { rid: 90001, id: 'c:overpage', signed_at: '2026-01-03', published_at: null }, + ]; + const xml = await streamContractSitemap( + fakeDb({ contracts, asOf: null }), + 'https://x.test', + 2, + ).text(); + expect(xml).toContain('/contracts/inpage'); + expect(xml).not.toContain('/contracts/early'); // below lo (45000) + expect(xml).not.toContain('/contracts/overpage'); // above hi (90000) + }); + + it('prefers signed_at over published_at for when both are present', async () => { + // The ?? chain is signed_at ?? published_at ?? fallback — with both dates set, signed_at must win. + // Guards against a swapped precedence, which the single-date rows above cannot detect. + const db = fakeDb({ + contracts: [{ rid: 1, id: 'c:both', signed_at: '2026-03-05', published_at: '2026-03-09' }], + asOf: '2026-06-01', + }); + const xml = await streamContractSitemap(db, 'https://x.test', 1).text(); + expect(xml).toContain('/contracts/both2026-03-05'); + }); + + it('paginates across the CHUNK boundary within a page', async () => { + const contracts = Array.from({ length: CHUNK + 1 }, (_, i) => ({ + rid: i + 1, + id: `c:${i + 1}`, + signed_at: null, + published_at: null, + })); + const xml = await streamContractSitemap( + fakeDb({ contracts, asOf: null }), + 'https://x.test', + 1, + ).text(); + expect(xml.match(//g) ?? []).toHaveLength(CHUNK + 1); + expect(xml.endsWith('\n')).toBe(true); + }); +}); + +describe('contractSitemapPages', () => { + it('divides the corpus size into 45k-URL pages, minimum one', async () => { + expect(await contractSitemapPages(fakeDb({ contractCount: 0 }))).toBe(1); + expect(await contractSitemapPages(fakeDb({ contractCount: null }))).toBe(1); // missing row → 1 + expect(await contractSitemapPages(fakeDb({ contractCount: 45000 }))).toBe(1); + expect(await contractSitemapPages(fakeDb({ contractCount: 45001 }))).toBe(2); + expect(await contractSitemapPages(fakeDb({ contractCount: 190000 }))).toBe(5); + }); +}); + +describe('empty-input terminals', () => { + it('authority sitemap with no rows emits just head+tail', async () => { + const xml = await streamAuthoritySitemap( + fakeDb({ authorities: [], asOf: null }), + 'https://x.test', + ).text(); + expect(xml).toBe( + '\n' + + '\n\n', + ); + }); + it('company sitemap with no rows emits just head+tail', async () => { + const xml = await streamCompanySitemap( + fakeDb({ companies: [], asOf: null }), + 'https://x.test', + ).text(); + expect(xml.match(//g)).toBeNull(); expect(xml.endsWith('\n')).toBe(true); }); }); diff --git a/packages/db/src/queries/trend.test.ts b/packages/db/src/queries/trend.test.ts index 5b7749836..c3612f291 100644 --- a/packages/db/src/queries/trend.test.ts +++ b/packages/db/src/queries/trend.test.ts @@ -159,3 +159,65 @@ describe('getSpendingTrend', () => { expect(series.binds).toEqual(['2020-01-01', 'eik:222']); }); }); + +describe('getSpendingTrend — zero-spend prior year and empty coverage', () => { + it('nulls YoY against a zero prior year and reports zero coverage when nothing is dated', async () => { + // 2023 is fully zero-filled between the two endpoints → 2024 YoY is measured against 0 → null. + const custom = fakeD1([ + { + when: 'GROUP BY period', + all: [ + { period: '2022-01', value_eur: 1000, contracts: 10 }, + { period: '2024-01', value_eur: 2000, contracts: 20 }, + ], + }, + { when: 'COUNT(*) AS total', first: { dated: 0, total: 0 } }, + { when: 'SELECT as_of FROM home_totals', first: { as_of: null } }, + { when: 'FROM sector_totals', all: [] }, + ]); + const { years, coverage } = await getSpendingTrend(custom.db, {}); + const byYear = new Map(years.map((y) => [y.year, y])); + expect(byYear.get('2023')).toMatchObject({ valueEur: 0, yoyPct: -1 }); // (0 - 1000)/1000 + expect(byYear.get('2024')!.yoyPct).toBeNull(); // prev year is 0 → guarded + expect(coverage).toEqual({ dated: 0, total: 0, pct: 0 }); // total 0 → pct 0, no divide-by-zero + }); +}); + +describe('getSpendingTrend — funding scope, sectors toggle, empty inputs', () => { + it('scopes the series by EU funding', async () => { + const cap = fake(); + await getSpendingTrend(cap.db, { funding: 'eu' }); + expect(cap.sql.some((s) => s.includes('c.eu_funded = 1'))).toBe(true); + }); + + it('scopes the series by national funding', async () => { + const cap = fake(); + await getSpendingTrend(cap.db, { funding: 'national' }); + expect(cap.sql.some((s) => s.includes('c.eu_funded IS NULL OR c.eu_funded = 0'))).toBe(true); + }); + + it('skips the sector options when includeSectors is false', async () => { + const data = await getSpendingTrend(fake().db, {}, { includeSectors: false }); + expect(data.sectors).toEqual([]); + }); + + it('resolves sector options from sector_totals when includeSectors defaults on', async () => { + // scopedFake() answers sector_totals with { division: '45' }; the default (includeSectors) path + // must resolve it to a SectorRef. Asserting the resolved code guards the true branch against a + // mutation that always returns [] — which the includeSectors:false case cannot detect. + const data = await getSpendingTrend(scopedFake().db, {}); + expect(data.sectors.map((s) => s.code)).toEqual(['45']); + }); + + it('returns empty points and zero coverage when the series and coverage rows are absent', async () => { + const empty = fakeD1([ + { when: 'GROUP BY period', all: [] }, // no series rows → the points loop is skipped + { when: 'COUNT(*) AS total', first: null }, // coverageRow null → dated/total fall back to 0 + { when: 'SELECT as_of FROM home_totals', first: { as_of: null } }, + { when: 'FROM sector_totals', all: [] }, + ]); + const data = await getSpendingTrend(empty.db, {}); + expect(data.points).toEqual([]); + expect(data.coverage).toEqual({ dated: 0, total: 0, pct: 0 }); + }); +}); diff --git a/packages/db/src/readonly-d1.test.ts b/packages/db/src/readonly-d1.test.ts index 641ee94b5..8d26606a6 100644 --- a/packages/db/src/readonly-d1.test.ts +++ b/packages/db/src/readonly-d1.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { recordingD1, type FakeD1Call } from '@sigma/test-support'; -import { readonlyD1 } from './readonly-d1'; +import { getDb, readonlyD1 } from './readonly-d1'; // A recording D1 that answers anything and logs every statement: readonlyD1 is a *wrapper*, so what // is under test is which calls reach the handle underneath and with what SQL — not what comes back. @@ -85,3 +85,20 @@ describe('readonlyD1 — disabled write-capable methods', () => { expect(() => readonlyD1(db).withSession()).toThrow(/read-only/i); }); }); + +describe("getDb — the web worker's single D1 chokepoint", () => { + it('hands back a guarded handle, never the raw write-capable binding', () => { + // Web reads D1 only through getDb(env). Returning env.DB itself — or any handle that forwards a + // write — would put the raw binding back in the request path and undo #199 without failing a test. + const { db, calls } = fakeDb(); + const handle = getDb({ DB: db }); + + expect(handle).not.toBe(db); + expect(() => handle.prepare('DELETE FROM contracts')).toThrow(/read-only/i); + expect(() => handle.batch([])).toThrow(/read-only/i); + expect(calls).toEqual([]); // nothing reached the underlying binding + + handle.prepare('SELECT 1'); + expect(calls).toEqual([{ sql: 'SELECT 1', binds: [], via: 'prepare' }]); // reads pass through + }); +}); diff --git a/packages/db/src/readonly-sql.test.ts b/packages/db/src/readonly-sql.test.ts index e7b4e322c..012d2115c 100644 --- a/packages/db/src/readonly-sql.test.ts +++ b/packages/db/src/readonly-sql.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { assertReadOnly, isReadOnlySql } from './readonly-sql'; +import { assertReadOnly, assertReadOnlyExec, isReadOnlySql } from './readonly-sql'; // Statements that MUST be treated as writes (rejected). One row per construct. The CTE-prefixed and // RETURNING rows are the bypasses a naive `^(select|with)` check would miss; the stacked and @@ -96,3 +96,19 @@ describe('assertReadOnly', () => { expect(() => assertReadOnly('SELECT id FROM contracts')).not.toThrow(); }); }); + +describe('assertReadOnlyExec — multi-statement .exec() strings', () => { + it('rejects a string that carries no statement at all', () => { + // `.exec('')` and a comment-only body both reduce to zero statements. Falling through silently would + // let the guard report "nothing to object to" on input it never actually inspected; the empty case is + // refused explicitly so a caller cannot probe the handle with a body the splitter cannot see. + expect(() => assertReadOnlyExec('')).toThrow(/empty statement/i); + expect(() => assertReadOnlyExec(' ;; ')).toThrow(/empty statement/i); + expect(() => assertReadOnlyExec('-- DELETE FROM contracts')).toThrow(/empty statement/i); + }); + + it('checks every statement, not just the first', () => { + expect(() => assertReadOnlyExec('SELECT 1; DELETE FROM contracts')).toThrow(/read-only/i); + expect(() => assertReadOnlyExec('SELECT 1; SELECT 2')).not.toThrow(); + }); +}); diff --git a/packages/ingest/src/base.test.ts b/packages/ingest/src/base.test.ts index cb577d208..04c36aab5 100644 --- a/packages/ingest/src/base.test.ts +++ b/packages/ingest/src/base.test.ts @@ -7,6 +7,7 @@ import { baseSqlLiteral, escapeSqlText, mapBaseRecord, + toBool, toEventDate, toISODate, toInt, @@ -257,3 +258,115 @@ describe('offline SQL literal hardening', () => { expect(() => assertWellFormedSqlLiteral("'a'b'")).toThrow(); }); }); + +describe('toBool', () => { + it('maps affirmative tokens (case/locale-folded) to 1', () => { + for (const t of ['да', 'true', '1', 'yes', 'ДА', 'True', ' Yes ']) expect(toBool(t)).toBe(1); + }); + it('maps negative tokens to 0', () => { + for (const t of ['не', 'false', '0', 'no', 'НЕ', 'False']) expect(toBool(t)).toBe(0); + }); + it('returns null for unrecognised, empty, or absent input', () => { + expect(toBool('може би')).toBeNull(); + expect(toBool('2')).toBeNull(); + expect(toBool('')).toBeNull(); + expect(toBool(' ')).toBeNull(); + expect(toBool(null)).toBeNull(); + expect(toBool(undefined)).toBeNull(); + }); +}); + +describe('toISODate — Date.parse fallback branch', () => { + it('parses a non-ISO, non-DMY but Date.parseable string via the UTC fallback', () => { + // Neither the ISO nor the D.M.Y regex matches, so normalizedDateOnly falls to Date.parse. + // GMT-anchored input keeps the result timezone-independent. + expect(toISODate('01 Jan 2020 00:00:00 GMT', FIXED_NOW)).toBe('2020-01-01'); + expect(toEventDate('15 Mar 2021 00:00:00 GMT', FIXED_NOW)).toBe('2021-03-15'); + expect(toPeriodDate('01 Jan 2020 00:00:00 GMT', FIXED_NOW)).toBe('2020-01-01'); + }); + it('rejects a string Date.parse cannot read', () => { + expect(toISODate('изобщо не е дата', FIXED_NOW)).toBeNull(); + }); +}); + +describe('mapBaseRecord — annexes category', () => { + const meta = { day: '2026-05-01', fetchedAt: '2026-05-25T00:00:00Z' }; + + it('stamps the annexes fixed values and coerces annex fields', () => { + const row = mapBaseRecord( + 'annexes', + { contractNumber: 'Д-1', currentContractValue: '1 000,50', isEuFunded: 'да' }, + meta, + ); + expect(row).not.toBeNull(); + expect(row?.source).toBe('eop:annexes:2026-05-01'); + expect(row?.dataset_variant).toBe('eop'); + expect(row?.dataset_year).toBe(2026); + expect(row?.contract_number).toBe('Д-1'); + expect(row?.value_after).toBe(1000.5); // real, BG decimal comma + expect(row?.eu_funded).toBe(1); // bool + }); + + it('drops an annex whose contract number is blank (keep=false)', () => { + expect(mapBaseRecord('annexes', { contractNumber: ' ' }, meta)).toBeNull(); + expect(mapBaseRecord('annexes', {}, meta)).toBeNull(); + }); +}); + +describe('baseSqlLiteral — numeric vs text vs null branches', () => { + it('emits numeric-kind columns unquoted', () => { + expect(baseSqlLiteral('contracts', 'estimated_value', 12345.6)).toBe('12345.6'); // real + expect(baseSqlLiteral('contracts', 'bids_received', 3)).toBe('3'); // int + expect(baseSqlLiteral('contracts', 'eu_funded', 1)).toBe('1'); // bool + expect(baseSqlLiteral('tenders', 'secured_financing', 0)).toBe('0'); // secured_inverse + expect(baseSqlLiteral('tenders', 'variants', 1)).toBe('1'); // variants_enum + expect(baseSqlLiteral('contracts', 'dataset_year', 2026)).toBe('2026'); // special int column + }); + it('quotes and escapes text-kind columns', () => { + expect(baseSqlLiteral('contracts', 'authority_name', "О'Брайън")).toBe("'О''Брайън'"); + expect(baseSqlLiteral('contracts', 'unp', 'plain')).toBe("'plain'"); + }); + it('emits NULL for null or undefined', () => { + expect(baseSqlLiteral('contracts', 'authority_name', null)).toBe('NULL'); + expect(baseSqlLiteral('contracts', 'authority_name', undefined)).toBe('NULL'); + }); +}); + +describe('branch completion — coercion + column-kind fallbacks', () => { + const meta = { day: '2026-06-01', fetchedAt: '2026-06-07T00:00:00Z' }; + + it('toInt rejects an in-format value that exceeds the safe-integer range', () => { + expect(toInt('99999999999999999999')).toBeNull(); // passes \d+ but not safe-integer + expect(toInt('007')).toBe(7); // leading zeros still parse + }); + + it('secured_inverse inverts unsecured-funding and passes null through', () => { + const secured = (v: unknown) => + mapBaseRecord('tenders', { hasUnsecuredFunding: v }, meta)?.secured_financing; + expect(secured('да')).toBe(0); // unsecured=1 → secured 0 + expect(secured('не')).toBe(1); // unsecured=0 → secured 1 + expect(secured(undefined)).toBeNull(); // unknown → null + }); + + it('variants_enum maps the two allowed tokens and nulls anything else', () => { + const variants = (v: unknown) => mapBaseRecord('tenders', { hasVariants: v }, meta)?.variants; + expect(variants('Разрешено')).toBe(1); + expect(variants('Забранено')).toBe(0); + expect(variants('каквото и да е')).toBeNull(); + }); + + it('yearOf nulls the dataset_year when the source day is outside the valid range', () => { + expect( + mapBaseRecord('contracts', { contractNumber: 'C' }, { ...meta, day: '1985-01-01' }) + ?.dataset_year, + ).toBeNull(); + expect( + mapBaseRecord('contracts', { contractNumber: 'C' }, { ...meta, day: '3026-01-01' }) + ?.dataset_year, + ).toBeNull(); + }); + + it('baseSqlLiteral treats an unknown column as text (kind fallback)', () => { + expect(baseSqlLiteral('contracts', 'no_such_column', "a'b")).toBe("'a''b'"); + }); +}); diff --git a/packages/ingest/src/fx.test.ts b/packages/ingest/src/fx.test.ts index e01bb11c8..8d75abdd0 100644 --- a/packages/ingest/src/fx.test.ts +++ b/packages/ingest/src/fx.test.ts @@ -161,6 +161,20 @@ describe('loadFxRates', () => { expect(summary).toEqual({ fetched: [], skipped: [], inserted: 0, uncovered: [], warnings: [] }); }); + it('skips a coverage gap whose staged contract_date is not a valid ISO date', async () => { + // A malformed date in raw staging surfaces verbatim through findFxCoverageGaps' MIN/MAX (the SQL + // does not format-validate), so loadFxRates must reject the range before fetching. + const { db, d1 } = fxDb(); + stageContract(db, 'CHF', '2026-7-8'); // unpadded → isIsoDate false + const fetchFn = vi.fn(); + + const summary = await loadFxRates(d1, { fetchedAt: FETCHED_AT, fetchFn }); + + expect(fetchFn).not.toHaveBeenCalled(); // never fetches for an unusable range + expect(summary.skipped).toContain('CHF'); + expect(summary.warnings.some((w) => w.includes('invalid date range'))).toBe(true); + }); + it('fetches only the gap range and upserts rates idempotently', async () => { const { db, d1 } = fxDb(); stageContract(db, 'USD', '2026-07-08'); diff --git a/packages/ingest/src/ocds.test.ts b/packages/ingest/src/ocds.test.ts index fd8b15cd8..98e494155 100644 --- a/packages/ingest/src/ocds.test.ts +++ b/packages/ingest/src/ocds.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { classifyBucketKey, computeCatchupWindow, + daysInWindow, fullDeriveIsSafe, releaseToAmendments, releaseToContracts, @@ -270,6 +271,14 @@ describe('releaseToContracts', () => { 0, ); }); + + it('nulls bids_received when the release carries no bids statistics', () => { + // No `bids` block on the release → `rel.bids?.statistics` is nullish; the row still stages with a + // null bid count rather than throwing on the optional chain. + const rows = releaseToContracts({ ...release, bids: undefined }, meta); + expect(rows).toHaveLength(1); + expect(rows[0]?.bids_received).toBeNull(); + }); }); describe('releaseToAmendments', () => { @@ -485,3 +494,282 @@ describe('transient staging SQL helpers', () => { expect(dropTransientStagingStatements()).not.toContain('DROP TABLE IF EXISTS some_other_table'); }); }); + +describe('date normalization fallbacks (via releaseToContracts.contract_date)', () => { + const rel = (dateSigned: string): OcdsRelease => ({ + tag: ['contract'], + ocid: 'ocds-x', + contracts: [{ id: 'c1', dateSigned }], + }); + it('parses a Date.parseable non-ISO, non-DMY dateSigned via the UTC fallback', () => { + expect(releaseToContracts(rel('01 Jan 2020 00:00:00 GMT'), meta)[0]?.contract_date).toBe( + '2020-01-01', + ); + }); + it('nulls an unparseable dateSigned rather than throwing', () => { + expect(releaseToContracts(rel('изобщо не е дата'), meta)[0]?.contract_date).toBeNull(); + }); + it('nulls a date below the 1990 data floor', () => { + expect(releaseToContracts(rel('1985-06-01'), meta)[0]?.contract_date).toBeNull(); + }); +}); + +describe('releaseToAmendments — early exits', () => { + it('returns [] for a release without a contractAmendment/contractUpdate tag', () => { + expect(releaseToAmendments({ tag: ['contract'], contracts: [{ id: 'c1' }] }, meta)).toEqual([]); + }); + it('returns [] for an amendment-tagged release that carries no contracts', () => { + expect(releaseToAmendments({ tag: ['contractAmendment'], contracts: [] }, meta)).toEqual([]); + }); +}); + +describe('branch completion — absent/sparse optional OCDS fields', () => { + it('releaseToContracts returns [] when the tag or contracts key is entirely absent', () => { + expect(releaseToContracts({}, meta)).toEqual([]); // rel.tag ?? [] right side + expect(releaseToContracts({ tag: ['contract'] }, meta)).toEqual([]); // rel.contracts ?? [] right side + }); + + it('releaseToContracts nulls ocid/number/value and reads the id-less + identifier-less party branches', () => { + const rows = releaseToContracts( + { + tag: ['contract'], + // no ocid, no id, no date, no bids, no awards + parties: [ + { id: 'X' }, // id present but identifier & name absent → both `?? null` right sides + { name: 'без ид' }, // no id → the `if (p.id)` false branch + ], + tender: { items: [{ classification: { id: 'x' } }] }, // classification without a scheme → `c.scheme ?? ''` + contracts: [{ value: { amount: '', currency: 'EUR' }, dateSigned: ' ' }], // idless; '' amount; blank date + }, + meta, + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + unp: null, // rel.ocid ?? null + contract_number: null, // c.id ?? null + cpv_code: null, // no CPV-schemed classification matched + signing_value: null, // finiteNum('') → empty-string branch + contract_date: null, // clean(' ') → '' → null + authority_eik: null, + authority_name: null, + }); + }); + + it('releaseToAmendments returns [] when the tag or contracts key is entirely absent', () => { + expect(releaseToAmendments({ contracts: [{ id: 'c' }] }, meta)).toEqual([]); // rel.tag ?? [] right side + expect(releaseToAmendments({ tag: ['contractAmendment'] }, meta)).toEqual([]); // rel.contracts ?? [] right side + }); + + it('releaseToLots nulls ocid and tender id when both are absent', () => { + const [row] = releaseToLots({ tender: { lots: [{ id: 'L' }] } }, meta); // no ocid, no tender id + expect(row).toMatchObject({ ocid: null, tender_id: null, lot_id: 'L' }); + }); +}); + +describe('computeCatchupWindow / daysInWindow — day validation', () => { + it('rejects a today that is not strictly YYYY-MM-DD', () => { + expect(() => + computeCatchupWindow({ maxLoadedDate: null, today: '2026-6-1', lookbackDays: 7 }), + ).toThrow(/YYYY-MM-DD/); + }); + it('rejects a well-formatted but impossible calendar date', () => { + expect(() => daysInWindow('2026-02-30', '2026-03-01')).toThrow(/not a valid date/); + }); + it('counts an inclusive day window', () => { + expect(daysInWindow('2026-05-01', '2026-05-01')).toBe(1); + expect(daysInWindow('2026-05-01', '2026-05-10')).toBe(10); + }); + it('throws when from is after to', () => { + expect(() => daysInWindow('2026-05-10', '2026-05-01')).toThrow(/before or equal/); + }); +}); + +describe('branch completion — parties, lots, catch-up window', () => { + it('releaseToParties nulls every optional field and joins roles, blanking an empty role set', () => { + const rows = releaseToParties( + { + ocid: 'ocds-x', + parties: [ + { + id: 'P1', + name: 'Ф ООД', + identifier: { id: '111', scheme: 'BG-EIK' }, + roles: ['buyer', 'supplier'], + address: { region: 'BG411' }, + contactPoint: { email: 'a@b.bg' }, + }, + { id: 'P2' }, // no identifier, roles, address, or contactPoint → all nulls + ], + }, + meta, + ); + expect(rows[0]).toMatchObject({ + eik: '111', + roles: 'buyer,supplier', + region_nuts: 'BG411', + contact_email: 'a@b.bg', + }); + expect(rows[1]).toMatchObject({ + eik: null, + scheme: null, + roles: null, // empty roles → '' → null + region_nuts: null, + contact_email: null, + contact_name: null, + }); + }); + + it('releaseToLots coerces lot value/currency and nulls missing pieces', () => { + const rows = releaseToLots( + { + ocid: 'ocds-x', + tender: { + id: 'T1', + lots: [ + { id: 'L1', title: 'Позиция 1', value: { amount: 1000, currency: 'eur' } }, + { value: { amount: 'nope', currency: 'zz' } }, // bad amount/currency, no id/title + ], + }, + }, + meta, + ); + expect(rows[0]).toMatchObject({ + lot_id: 'L1', + title: 'Позиция 1', + value_amount: 1000, + value_currency: 'EUR', + }); + expect(rows[1]).toMatchObject({ + lot_id: null, + title: null, + value_amount: null, + value_currency: null, + }); + }); + + it('computeCatchupWindow subtracts from maxLoadedDate and clamps a future from to today', () => { + // maxLoadedDate present → window measured back from it. + expect( + computeCatchupWindow({ maxLoadedDate: '2026-05-20', today: '2026-05-25', lookbackDays: 5 }), + ).toEqual({ + from: '2026-05-15', + to: '2026-05-25', + }); + // maxLoadedDate ahead of today with zero lookback → from would exceed today → clamped to today. + expect( + computeCatchupWindow({ maxLoadedDate: '2026-05-20', today: '2026-05-01', lookbackDays: 0 }), + ).toEqual({ + from: '2026-05-01', + to: '2026-05-01', + }); + // no maxLoadedDate → window measured back from today. + expect( + computeCatchupWindow({ maxLoadedDate: null, today: '2026-05-10', lookbackDays: 3 }), + ).toEqual({ + from: '2026-05-07', + to: '2026-05-10', + }); + }); +}); + +describe('branch completion — amendments, empty/full party + lot shapes', () => { + it('releaseToAmendments reads the last amendment and nulls when none/idless', () => { + const rows = releaseToAmendments( + { + tag: ['contractUpdate'], + ocid: 'x', + contracts: [ + { + id: 'c1', + amendments: [{ description: 'първо' }, { description: 'финал', rationale: 'причина' }], + }, + { id: 'c2' }, // no amendments → amd null → description/reason null + { title: 'no id' }, // idless → skipped + ], + }, + meta, + ); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + contract_number: 'c1', + description: 'финал', + reason: 'причина', + }); + expect(rows[1]).toMatchObject({ contract_number: 'c2', description: null, reason: null }); + }); + + it('releaseToParties returns [] for a release with no parties', () => { + expect(releaseToParties({ ocid: 'x' }, meta)).toEqual([]); + }); + + it('releaseToParties passes a fully-populated address and contact point through', () => { + const [row] = releaseToParties( + { + parties: [ + { + id: 'P1', + name: 'Ф', + identifier: { id: '1', scheme: 'BG-EIK' }, + address: { + streetAddress: 'ул. Тест 1', + locality: 'София', + postalCode: '1000', + region: 'BG411', + countryName: 'България', + }, + contactPoint: { name: 'Иван', email: 'i@b.bg', telephone: '+359' }, + }, + ], + }, + meta, + ); + expect(row).toMatchObject({ + ocid: null, // release had no ocid → null branch + street_address: 'ул. Тест 1', + locality: 'София', + postal_code: '1000', + country: 'България', + contact_name: 'Иван', + contact_phone: '+359', + }); + }); + + it('releaseToParties nulls ocid and party_id when both are absent', () => { + const [row] = releaseToParties({ parties: [{ name: 'безименна' }] }, meta); + expect(row).toMatchObject({ ocid: null, party_id: null, name: 'безименна' }); + }); + + it('releaseToLots returns [] when the release carries no tender', () => { + expect(releaseToLots({ ocid: 'x' }, meta)).toEqual([]); + expect(releaseToLots({ ocid: 'x', tender: {} }, meta)).toEqual([]); + }); +}); + +describe('branch completion — amendment id/date/ocid and value-less lot', () => { + it('releaseToAmendments fills document_number, contract_date and unp from present fields', () => { + const [row] = releaseToAmendments( + { + id: 'NOTICE-9', + tag: ['contractAmendment'], + // no ocid → unp null branch + parties: [{ id: 'B', name: 'Общ', identifier: { id: '77' }, roles: ['buyer'] }], + buyer: { id: 'B', name: 'Общ' }, + contracts: [{ id: 'c1', dateSigned: '2026-04-02', amendments: [{ rationale: 'r' }] }], + }, + meta, + ); + expect(row).toMatchObject({ + document_number: 'NOTICE-9', + contract_date: '2026-04-02', + unp: null, + authority_eik: '77', + description: null, // amendment had no description + reason: 'r', + }); + }); + + it('releaseToLots nulls value fields for a lot with no value object', () => { + const [row] = releaseToLots({ ocid: 'x', tender: { id: 'T', lots: [{ id: 'L' }] } }, meta); + expect(row).toMatchObject({ lot_id: 'L', value_amount: null, value_currency: null }); + }); +}); diff --git a/packages/ingest/src/refresh.test.ts b/packages/ingest/src/refresh.test.ts new file mode 100644 index 000000000..3b2c9dd60 --- /dev/null +++ b/packages/ingest/src/refresh.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { recordingD1 } from '@sigma/test-support'; +import { + createTransientStaging, + dropTransientStaging, + dropTransientStagingStatements, + refreshDerivedContractCount, + refreshSliceStatementGroups, + runRefreshSliceStatementGroup, + splitSqlStatements, + transientStagingStatements, +} from './refresh'; + +// Capturing D1 over the shared recording double. refresh.ts hands D1 whole bundled SQL files, so +// marker routing has nothing to route on — recordingD1 is the shape for a wrapper like this: it +// accepts any statement and logs it. What the tests need on top is the batch GROUPING (which +// statements went out together), which the flat call log does not preserve, so batch() is wrapped +// to slice the log at each call. Wrapping, not re-implementing: the double still owns the surface. +function fakeDb(firstResult: { n: number } | null = { n: 0 }): { + db: D1Database; + batches: string[][]; +} { + const fake = recordingD1([{ when: [], first: firstResult }]); + const batches: string[][] = []; + // Batch groups come from the PREPARE log, not the batch log: batch() re-records each statement + // under via:'batch' without its binds (prepare() already logged those), and the binds are half of + // what these tests assert. Prepared statements are consumed by successive batches in order. + const inner = fake.db.batch.bind(fake.db); + let consumed = 0; + fake.db.batch = (async (statements: D1PreparedStatement[]) => { + const results = await inner(statements); + const prepared = fake.calls.filter((c) => c.via === 'prepare'); + batches.push(prepared.slice(consumed, consumed + statements.length).map((c) => c.sql)); + consumed += statements.length; + return results; + }) as typeof fake.db.batch; + return { db: fake.db, batches }; +} + +describe('splitSqlStatements', () => { + it('splits on semicolons outside string literals and trims', () => { + expect(splitSqlStatements('SELECT 1;\nSELECT 2;')).toEqual(['SELECT 1', 'SELECT 2']); + }); + + it('keeps the trailing statement that has no terminating semicolon', () => { + expect(splitSqlStatements('SELECT 1;\nSELECT 2')).toEqual(['SELECT 1', 'SELECT 2']); + }); + + it('drops empty statements from doubled or trailing semicolons', () => { + expect(splitSqlStatements('SELECT 1;;\n;')).toEqual(['SELECT 1']); + expect(splitSqlStatements(' ')).toEqual([]); + }); + + it('strips -- line comments outside literals but keeps them inside', () => { + expect(splitSqlStatements('SELECT 1; -- a note\nSELECT 2;')).toEqual(['SELECT 1', 'SELECT 2']); + // a -- inside a string literal is data, not a comment + expect(splitSqlStatements("SELECT '-- not a comment';")).toEqual(["SELECT '-- not a comment'"]); + }); + + it('does not split on a semicolon inside a string literal', () => { + expect(splitSqlStatements("INSERT INTO t VALUES ('a; b');")).toEqual([ + "INSERT INTO t VALUES ('a; b')", + ]); + }); + + it('treats a doubled single-quote as an escaped quote, staying in the literal', () => { + // The `;` lives inside the literal because the '' does not close it. + expect(splitSqlStatements("SELECT 'it''s; fine';")).toEqual(["SELECT 'it''s; fine'"]); + }); + + it('handles a comment that runs to end-of-input without a newline', () => { + expect(splitSqlStatements('SELECT 1; -- trailing comment no newline')).toEqual(['SELECT 1']); + }); +}); + +describe('refreshSliceStatementGroups', () => { + it('returns a single derive-slice group when there are no batch markers', () => { + const groups = refreshSliceStatementGroups('SELECT 1;\nSELECT 2;'); + expect(groups).toEqual([{ name: 'derive-slice', statements: ['SELECT 1', 'SELECT 2'] }]); + }); + + it('splits into named groups at each -- @refresh-batch marker', () => { + const sql = [ + 'SELECT 0;', + '-- @refresh-batch rollups', + 'SELECT 1;', + 'SELECT 2;', + '-- @refresh-batch health', + 'SELECT 3;', + ].join('\n'); + const groups = refreshSliceStatementGroups(sql); + expect(groups).toEqual([ + { name: 'derive-slice', statements: ['SELECT 0'] }, + { name: 'rollups', statements: ['SELECT 1', 'SELECT 2'] }, + { name: 'health', statements: ['SELECT 3'] }, + ]); + }); + + it('skips a marker group that contains no statements', () => { + const sql = '-- @refresh-batch empty\n-- @refresh-batch real\nSELECT 1;'; + expect(refreshSliceStatementGroups(sql)).toEqual([{ name: 'real', statements: ['SELECT 1'] }]); + }); + + it('falls back to one derive-slice group for statement-less input', () => { + expect(refreshSliceStatementGroups('')).toEqual([{ name: 'derive-slice', statements: [] }]); + }); + + it('is case-insensitive on the marker and accepts hyphenated names', () => { + const groups = refreshSliceStatementGroups('-- @REFRESH-BATCH my-batch\nSELECT 1;'); + expect(groups).toEqual([{ name: 'my-batch', statements: ['SELECT 1'] }]); + }); +}); + +describe('transient staging statements', () => { + it('keeps only statements that touch a transient staging table', () => { + const schema = [ + 'CREATE TABLE raw_contracts (id TEXT);', + 'CREATE TABLE authorities (id TEXT);', // permanent — must be filtered out + 'CREATE TABLE raw_ocds_lots (id TEXT);', + ].join('\n'); + expect(transientStagingStatements(schema)).toEqual([ + 'CREATE TABLE raw_contracts (id TEXT)', + 'CREATE TABLE raw_ocds_lots (id TEXT)', + ]); + }); + + it('drops every transient + legacy table in reverse of the declared order', () => { + // [...scratch, ...current, ...legacy].reverse() → legacy first, then current back-to-front, and + // the derive-step scratch table last. + expect(dropTransientStagingStatements()).toEqual([ + 'DROP TABLE IF EXISTS raw_egov_amendments', + 'DROP TABLE IF EXISTS raw_egov_tenders', + 'DROP TABLE IF EXISTS raw_egov_contracts', + 'DROP TABLE IF EXISTS raw_ocds_lots', + 'DROP TABLE IF EXISTS raw_ocds_parties', + 'DROP TABLE IF EXISTS raw_amendments', + 'DROP TABLE IF EXISTS raw_tenders', + 'DROP TABLE IF EXISTS raw_contracts', + 'DROP TABLE IF EXISTS amendment_contract_resolve', + ]); + }); +}); + +describe('D1 orchestration', () => { + it('createTransientStaging drops first, then creates only the transient tables', async () => { + const { db, batches } = fakeDb(); + const schema = 'CREATE TABLE raw_contracts (id TEXT);\nCREATE TABLE authorities (id TEXT);'; + await createTransientStaging(db, schema); + expect(batches).toHaveLength(2); + expect(batches[0]!.every((s) => s.startsWith('DROP TABLE IF EXISTS'))).toBe(true); + expect(batches[1]).toEqual(['CREATE TABLE raw_contracts (id TEXT)']); // authorities filtered + }); + + it('dropTransientStaging issues exactly one batch of DROPs', async () => { + const { db, batches } = fakeDb(); + await dropTransientStaging(db); + expect(batches).toHaveLength(1); + expect(batches[0]).toEqual(dropTransientStagingStatements()); + }); + + it('runRefreshSliceStatementGroup batches a group verbatim', async () => { + const { db, batches } = fakeDb(); + await runRefreshSliceStatementGroup(db, { name: 'g', statements: ['SELECT 1', 'SELECT 2'] }); + expect(batches).toEqual([['SELECT 1', 'SELECT 2']]); + }); + + it('refreshDerivedContractCount returns the counted rows', async () => { + const { db } = fakeDb({ n: 42 }); + expect(await refreshDerivedContractCount(db)).toBe(42); + }); + + it('refreshDerivedContractCount coalesces a null result to 0', async () => { + const { db } = fakeDb(null); + expect(await refreshDerivedContractCount(db)).toBe(0); + }); +}); diff --git a/packages/ingest/src/staging.test.ts b/packages/ingest/src/staging.test.ts new file mode 100644 index 000000000..a4f42e444 --- /dev/null +++ b/packages/ingest/src/staging.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { recordingD1, type FakeD1Call } from '@sigma/test-support'; +import { BASE_AMENDMENT_COLS, BASE_CONTRACT_COLS, BASE_TENDER_COLS } from './base'; +import { + AMENDMENT_STAGING_COLS, + CONTRACT_STAGING_COLS, + LOT_STAGING_COLS, + PARTY_STAGING_COLS, +} from './ocds'; +import { + upsertAmendmentStaging, + upsertBaseAmendmentStaging, + upsertBaseContractStaging, + upsertBaseTenderStaging, + upsertContractStaging, + upsertLotStaging, + upsertPartyStaging, +} from './staging'; + +// Capturing D1 over the shared recording double. The staging layer's whole job is statement +// construction (scoped DELETE, chunked INSERTs, null-fill) against arbitrary generated SQL, so there +// is nothing for marker routing to route on — recordingD1 is the shape for that: it accepts any +// statement and logs it with its binds. Only the batch GROUPING (which statements went out together, +// and how many batches there were) is missing from the flat log, so batch() is wrapped to slice the +// log at each call. Wrapping, not re-implementing: the double still owns the D1 surface. +type Captured = Pick; + +function captureDb(): { db: D1Database; batches: Captured[][] } { + const fake = recordingD1(); + const batches: Captured[][] = []; + // Batch groups come from the PREPARE log, not the batch log: batch() re-records each statement + // under via:'batch' without its binds (prepare() already logged those), and the binds are half of + // what these tests assert. Prepared statements are consumed by successive batches in order. + const inner = fake.db.batch.bind(fake.db); + let consumed = 0; + fake.db.batch = (async (statements: D1PreparedStatement[]) => { + const results = await inner(statements); + const prepared = fake.calls.filter((c) => c.via === 'prepare'); + batches.push( + prepared + .slice(consumed, consumed + statements.length) + .map(({ sql, binds }) => ({ sql, binds })), + ); + consumed += statements.length; + return results; + }) as typeof fake.db.batch; + return { db: fake.db, batches }; +} + +const row = (cols: readonly string[], overrides: Record = {}) => + Object.fromEntries(cols.map((c) => [c, overrides[c] ?? `${c}-val`])); + +describe('upsertContractStaging', () => { + it('deletes the source scope then inserts, all in one batch, returning the row count', async () => { + const { db, batches } = captureDb(); + const rows = [row(CONTRACT_STAGING_COLS), row(CONTRACT_STAGING_COLS)] as never; + const n = await upsertContractStaging(db, 'aop', rows); + + expect(n).toBe(2); + expect(batches).toHaveLength(1); + const batch = batches[0]!; + // DELETE is first and scoped to the source tag. + expect(batch[0]!.sql).toBe('DELETE FROM raw_contracts WHERE source = ?'); + expect(batch[0]!.binds).toEqual(['aop']); + // then one INSERT per row, with exactly one bound value per column. + expect(batch).toHaveLength(3); + expect(batch[1]!.sql).toContain('INSERT INTO raw_contracts'); + expect(batch[1]!.sql).toContain(CONTRACT_STAGING_COLS.join(', ')); + expect(batch[1]!.binds).toHaveLength(CONTRACT_STAGING_COLS.length); + }); + + it('coalesces a missing column to null rather than binding undefined', async () => { + const { db, batches } = captureDb(); + const partial = row(CONTRACT_STAGING_COLS); + delete partial[CONTRACT_STAGING_COLS[1]!]; // absent key + delete partial[CONTRACT_STAGING_COLS[2]!]; // absent key + await upsertContractStaging(db, 'aop', [partial] as never); + + const insert = batches[0]![1]!; + expect(insert.binds[0]).toBe(`${CONTRACT_STAGING_COLS[0]}-val`); // present column keeps its value + expect(insert.binds[1]).toBeNull(); // absent key → null, never undefined + expect(insert.binds[2]).toBeNull(); + expect(insert.binds.every((a) => a !== undefined)).toBe(true); + }); + + it('issues a lone scoped DELETE and inserts nothing for an empty set', async () => { + const { db, batches } = captureDb(); + const n = await upsertContractStaging(db, 'aop', []); + + expect(n).toBe(0); + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(1); + expect(batches[0]![0]!.sql).toBe('DELETE FROM raw_contracts WHERE source = ?'); + }); +}); + +describe('chunking at CHUNK=100', () => { + const make = (n: number) => Array.from({ length: n }, () => row(CONTRACT_STAGING_COLS)) as never; + + it('keeps a full 100-row set (plus the DELETE) in a single batch', async () => { + const { db, batches } = captureDb(); + const n = await upsertContractStaging(db, 's', make(100)); + expect(n).toBe(100); + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(101); // DELETE + 100 inserts + }); + + it('splits 101 rows into [DELETE+100] then [1], DELETE only in the first batch', async () => { + const { db, batches } = captureDb(); + const n = await upsertContractStaging(db, 's', make(101)); + expect(n).toBe(101); + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(101); + expect(batches[1]).toHaveLength(1); + // the DELETE must not repeat in later batches (would wipe the first chunk's inserts) + expect(batches[1]![0]!.sql).toContain('INSERT INTO'); + expect( + batches + .slice(1) + .flat() + .some((s) => s.sql.startsWith('DELETE')), + ).toBe(false); + }); + + it('splits 250 rows into three batches (101, 100, 50)', async () => { + const { db, batches } = captureDb(); + const n = await upsertContractStaging(db, 's', make(250)); + expect(n).toBe(250); + expect(batches.map((b) => b.length)).toEqual([101, 100, 50]); + }); +}); + +describe('table + column routing per staging target', () => { + const cases: Array<{ + name: string; + fn: (db: D1Database, source: string, rows: never) => Promise; + table: string; + cols: readonly string[]; + }> = [ + { + name: 'amendment', + fn: upsertAmendmentStaging, + table: 'raw_amendments', + cols: AMENDMENT_STAGING_COLS, + }, + { name: 'party', fn: upsertPartyStaging, table: 'raw_ocds_parties', cols: PARTY_STAGING_COLS }, + { name: 'lot', fn: upsertLotStaging, table: 'raw_ocds_lots', cols: LOT_STAGING_COLS }, + { + name: 'base-contract', + fn: upsertBaseContractStaging, + table: 'raw_contracts', + cols: BASE_CONTRACT_COLS, + }, + { + name: 'base-tender', + fn: upsertBaseTenderStaging, + table: 'raw_tenders', + cols: BASE_TENDER_COLS, + }, + { + name: 'base-amendment', + fn: upsertBaseAmendmentStaging, + table: 'raw_amendments', + cols: BASE_AMENDMENT_COLS, + }, + ]; + + for (const { name, fn, table, cols } of cases) { + it(`${name} → ${table} with its own column set`, async () => { + const { db, batches } = captureDb(); + const n = await fn(db, 'src', [row(cols)] as never); + expect(n).toBe(1); + expect(batches[0]![0]!.sql).toBe(`DELETE FROM ${table} WHERE source = ?`); + expect(batches[0]![1]!.sql).toContain(`INSERT INTO ${table} (${cols.join(', ')})`); + expect(batches[0]![1]!.binds).toHaveLength(cols.length); + }); + } +}); diff --git a/packages/shared/src/format.test.ts b/packages/shared/src/format.test.ts index cbbd63669..6bb1ecc20 100644 --- a/packages/shared/src/format.test.ts +++ b/packages/shared/src/format.test.ts @@ -3,6 +3,7 @@ import { cleanName, count, date, + eik, entityName, isNaturalPersonProfileName, longDate, @@ -15,6 +16,7 @@ import { parseConsortiumMembers, signedMoney, signedPct, + unp, } from './format'; const NBSP = ' '; // count()/money() use a non-breaking space so figures never wrap @@ -184,4 +186,119 @@ describe('isNaturalPersonProfileName', () => { it('does not flag ordinary company names', () => { expect(isNaturalPersonProfileName('СОФАРМА ТРЕЙДИНГ АД')).toBe(false); }); + + it('accepts the latin ET spelling and requires the trailing space', () => { + expect(isNaturalPersonProfileName('ET DRIFT')).toBe(true); + expect(isNaturalPersonProfileName(' ет дрифт ')).toBe(true); // trims + upcases first + expect(isNaturalPersonProfileName('ЕТАЖ ООД')).toBe(false); // "ЕТ" without the space is not a prefix + }); +}); + +describe('count (sign and absence branches)', () => { + it('signs negatives with U+2212 and groups thousands', () => { + expect(count(-1234)).toBe(`−1${NBSP}234`); + expect(count(-7)).toBe('−7'); + }); + it('rounds to the nearest integer before grouping', () => { + expect(count(0)).toBe('0'); + expect(count(1234.6)).toBe(`1${NBSP}235`); + expect(count(1234.4)).toBe(`1${NBSP}234`); + }); + it('returns a dash for absent or non-finite values', () => { + expect(count(null)).toBe('—'); + expect(count(undefined)).toBe('—'); + expect(count(NaN)).toBe('—'); + expect(count(Infinity)).toBe('—'); + }); +}); + +describe('pct / signedPct (dp, absence, and precision branches)', () => { + it('returns a dash for absent or non-finite ratios', () => { + expect(pct(null)).toBe('—'); + expect(pct(undefined)).toBe('—'); + expect(pct(NaN)).toBe('—'); + expect(signedPct(null)).toBe('—'); + expect(signedPct(Infinity)).toBe('—'); + }); + it('honours an explicit decimal-places argument', () => { + expect(pct(0.12345, 2)).toBe('12,35%'); // rounds at 2 dp + expect(pct(0.789, 0)).toBe('79%'); // 0 dp + expect(signedPct(0.12345, 2)).toBe('+12,35%'); + expect(signedPct(-0.12345, 2)).toBe('−12,35%'); + }); +}); + +describe('dates (fallback and tolerance branches)', () => { + it('tolerates a datetime suffix on date()/longDate()', () => { + expect(date('2024-10-14T09:30:00Z')).toBe('14.10.2024'); + expect(longDate('2024-10-01T00:00:00')).toBe('1 октомври 2024 г.'); + }); + it('passes an unparseable string through verbatim', () => { + expect(date('не е дата')).toBe('не е дата'); + expect(date('2024/10/14')).toBe('2024/10/14'); // needs dashes + expect(monthYear('няма')).toBe('няма'); + expect(longDate('няма')).toBe('няма'); + }); + it('falls back to the raw month number when it is out of range', () => { + // MONTHS_BG[12] is undefined → the `?? m[2]` guard keeps the digits, never "undefined". + expect(monthYear('2024-13')).toBe('13 2024'); + expect(longDate('2024-00-05')).toBe('5 00 2024 г.'); + }); + it('returns a dash for missing month/long dates', () => { + expect(monthYear(null)).toBe('—'); + expect(monthYear(undefined)).toBe('—'); + expect(longDate(null)).toBe('—'); + }); +}); + +describe('periodRange', () => { + it('joins two present endpoints', () => { + expect(periodRange('2020-07-03', '2026-05-20')).toBe('юли 2020 — май 2026'); + }); + it('collapses to the single present endpoint', () => { + expect(periodRange('2020-07-03', null)).toBe('юли 2020'); + expect(periodRange(null, '2026-05-20')).toBe('май 2026'); + expect(periodRange('2020-07-03', undefined)).toBe('юли 2020'); + }); + it('returns a dash when both endpoints are absent', () => { + expect(periodRange(null, null)).toBe('—'); + expect(periodRange(undefined, undefined)).toBe('—'); + expect(periodRange('', '')).toBe('—'); + }); +}); + +describe('eik / unp passthrough', () => { + it('trims a present value and returns empty string for absent', () => { + expect(eik(' 831634121 ')).toBe('831634121'); + expect(eik(null)).toBe(''); + expect(eik(undefined)).toBe(''); + expect(eik('')).toBe(''); + expect(unp(' 00073-2024-0012 ')).toBe('00073-2024-0012'); + expect(unp(null)).toBe(''); + expect(unp('')).toBe(''); + }); +}); + +describe('entityName (non-collapsing branches)', () => { + it('passes a consortium name without a member separator through unchanged', () => { + expect(entityName('ЕДНО ОБЕДИНЕНИЕ ДЗЗД', 'consortium')).toBe('ЕДНО ОБЕДИНЕНИЕ ДЗЗД'); + }); + it('does not collapse a company name even when it contains a semicolon', () => { + expect(entityName('A; B', 'company')).toBe('A; B'); + }); + it('falls through when the first member segment is empty', () => { + expect(entityName('; ВТОРО ООД', 'consortium')).toBe('; ВТОРО ООД'); + }); +}); + +describe('cleanName (unbalanced-quote branch)', () => { + it('drops a single leading unbalanced quote', () => { + expect(cleanName('"ФИРМА ООД')).toBe('ФИРМА ООД'); + }); + it('drops a single trailing unbalanced quote', () => { + expect(cleanName('ФИРМА ООД"')).toBe('ФИРМА ООД'); + }); + it('leaves a balanced pair of quotes intact', () => { + expect(cleanName('"ЛУКОЙЛ"')).toBe('"ЛУКОЙЛ"'); + }); }); diff --git a/vitest.shared.ts b/vitest.shared.ts index d5102bae5..0f2340c7b 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -18,6 +18,26 @@ export function sharedCoverage(include: string[]): NonNullable