From bf20865fe5a6e369821e374eb63241d1cd28f826 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 6 Nov 2026 14:48:56 -0800 Subject: [PATCH] test: reduce redundant consumer coverage Keep detailed rules at their owning boundaries and retain real native, SQLite, publication and service-lifetime integrations. Production code and financial format remain unchanged. --- apps/web/src/env.test.ts | 7 - apps/web/src/hooks.server.test.ts | 30 ++- apps/web/src/lib/amounts.test.ts | 2 - apps/web/src/lib/server/application.test.ts | 28 +-- apps/web/src/lib/server/holdings.test.ts | 69 ++---- .../server/preparation.integration.test.ts | 61 ++++-- .../lib/server/queries.integration.test.ts | 37 ++-- .../web/src/lib/server/registers/cash.test.ts | 51 +---- .../lib/server/registers/investment.test.ts | 48 ++-- .../(auth)/signin/page.server.test.ts | 12 +- .../(public)/schema.json/schema.test.ts | 14 -- apps/worker/src/index.integration.test.ts | 115 ++++------ apps/worker/src/index.test.ts | 206 ++---------------- apps/worker/src/inspect.test.ts | 140 +----------- .../src/workflows/cost-balance/run.test.ts | 17 +- .../worker/src/workflows/holdings/run.test.ts | 53 +---- apps/worker/src/workflows/index.test.ts | 46 +--- .../prepare-reports/edits.integration.test.ts | 67 +----- .../src/workflows/prepare-reports/run.test.ts | 40 ++-- .../src/workflows/query-retention/run.test.ts | 10 - .../config/scripts/generate-schema.test.ts | 13 +- packages/config/src/auth.test.ts | 9 +- packages/config/src/index.test.ts | 18 +- packages/config/src/input.test.ts | 31 +-- packages/config/src/paths.test.ts | 6 - packages/hledger/src/amounts.test.ts | 9 + packages/hledger/src/checks.test.ts | 14 -- packages/hledger/src/holdings/report.test.ts | 10 - .../src/overview/balance-sheet.test.ts | 8 - .../hledger/src/overview/net-worth.test.ts | 31 +-- packages/hledger/src/prepare-reports.test.ts | 2 - packages/hledger/src/report-stream.test.ts | 3 - .../src/transactions/account-rows.test.ts | 41 +--- .../src/transactions/investment.test.ts | 27 --- .../src/transactions/transaction.test.ts | 37 +--- packages/jobs/README.md | 6 +- packages/jobs/src/holdings.test.ts | 83 +------ packages/jobs/src/input.test.ts | 40 +--- .../jobs/src/queries/cost-balance.test.ts | 61 +----- packages/jobs/src/queue/client.test.ts | 36 +-- packages/jobs/src/queue/engine-logger.test.ts | 46 ++-- .../src/queue/migration.integration.test.ts | 41 ---- .../src/queue/schedules.integration.test.ts | 172 --------------- packages/jobs/src/specs.test.ts | 55 +---- packages/snapshots/src/reports.test.ts | 149 +++++-------- 45 files changed, 371 insertions(+), 1630 deletions(-) delete mode 100644 packages/jobs/src/queue/schedules.integration.test.ts diff --git a/apps/web/src/env.test.ts b/apps/web/src/env.test.ts index 324aadf..565821d 100644 --- a/apps/web/src/env.test.ts +++ b/apps/web/src/env.test.ts @@ -6,10 +6,3 @@ it('declares only the runtime-private configuration path', () => { expect(variables.CONFIG_PATH.public).toBe(false); expect(variables.CONFIG_PATH.static).toBe(false); }); - -it.each([undefined, '/deployment/config/hledger-reports.yaml', ''])( - 'keeps CONFIG_PATH optional and leaves startup validation to its owner: %j', - async (value) => { - expect(await variables.CONFIG_PATH.schema['~standard'].validate(value)).toEqual({ value }); - } -); diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index c46962a..820d442 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -88,23 +88,19 @@ it('does not open application resources at import time', async () => { expect(process.listeners('sveltekit:shutdown')).toEqual(existingShutdownListeners); }); -it.each([undefined, '', ' ', '/selected/config/hledger-reports.yaml'])( - 'skips resources during build with CONFIG_PATH %j', - async (path) => { - appEnv.building = true; - testEnv.CONFIG_PATH = path; - const { init } = await import('./hooks.server.js'); - await init(); - expect(openApplication).not.toHaveBeenCalled(); - const locals = await requestLocals(); - expect(locals.application).toBeNull(); - expect(await locals.auth()).toBeNull(); - expect(getSession).not.toHaveBeenCalled(); - expect(close).not.toHaveBeenCalled(); - expect(authGate).not.toHaveBeenCalled(); - expect(process.listeners('sveltekit:shutdown')).toEqual(existingShutdownListeners); - } -); +it('skips resources during build with a configured path', async () => { + appEnv.building = true; + const { init } = await import('./hooks.server.js'); + await init(); + expect(openApplication).not.toHaveBeenCalled(); + const locals = await requestLocals(); + expect(locals.application).toBeNull(); + expect(await locals.auth()).toBeNull(); + expect(getSession).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + expect(authGate).not.toHaveBeenCalled(); + expect(process.listeners('sveltekit:shutdown')).toEqual(existingShutdownListeners); +}); it('leaves unconfigured requests to the gate without installing shutdown work', async () => { testEnv.CONFIG_PATH = undefined; diff --git a/apps/web/src/lib/amounts.test.ts b/apps/web/src/lib/amounts.test.ts index 39faa5b..308c09f 100644 --- a/apps/web/src/lib/amounts.test.ts +++ b/apps/web/src/lib/amounts.test.ts @@ -27,8 +27,6 @@ it('preserves unsafe integers and every meaningful fractional digit', () => { it.each([ ['ABC', '2.500', '2.500 ABC'], - ['AAPL', '10.000', '10.000 AAPL'], - ['BTC', '0.0001000', '0.0001000 BTC'], ['usd', '123.090', '123.090 usd'], ['', '0.000', '0.000 no commodity'] ])('keeps the native quantity and literal commodity for %s', (commodity, quantity, expected) => { diff --git a/apps/web/src/lib/server/application.test.ts b/apps/web/src/lib/server/application.test.ts index a19caa0..24f760a 100644 --- a/apps/web/src/lib/server/application.test.ts +++ b/apps/web/src/lib/server/application.test.ts @@ -45,8 +45,8 @@ it('leaves an explicitly unconfigured process without paths or clients', async ( expect(openJobsClient).not.toHaveBeenCalled(); }); -it.each(['', ' ', '\t'])('rejects a blank configuration path before IO: %j', async (path) => { - await expect(openApplication(path)).rejects.toThrow('CONFIG_PATH must be a non-empty file path'); +it('rejects a whitespace-only configuration path before IO', async () => { + await expect(openApplication(' ')).rejects.toThrow('CONFIG_PATH must be a non-empty file path'); expect(loadConfig).not.toHaveBeenCalled(); expect(createAuth).not.toHaveBeenCalled(); expect(openJobsClient).not.toHaveBeenCalled(); @@ -92,30 +92,6 @@ it('does not return a partial context when the queue cannot open', async () => { expect(client.close).not.toHaveBeenCalled(); }); -it('does not share auth runtimes, clients or deployment paths between callers', async () => { - const otherClient = { close: vi.fn() } as unknown as JobsClient; - const otherAuth = { $context: Promise.resolve({}) } as unknown as AppAuth; - const firstConfig = { ...config, journal: '/first/journal' }; - const secondConfig = { ...config, journal: '/second/journal' }; - vi.mocked(loadConfig).mockResolvedValueOnce(firstConfig).mockResolvedValueOnce(secondConfig); - vi.mocked(createAuth).mockReturnValueOnce(auth).mockReturnValueOnce(otherAuth); - vi.mocked(openJobsClient).mockResolvedValueOnce(client).mockResolvedValueOnce(otherClient); - const first = await openApplication('/first/config/hledger-reports.yaml'); - const second = await openApplication('/second/config/hledger-reports.yaml'); - expect(first!.auth).toBe(auth); - expect(second!.auth).toBe(otherAuth); - expect(createAuth).toHaveBeenNthCalledWith(1, firstConfig); - expect(createAuth).toHaveBeenNthCalledWith(2, secondConfig); - expect(first!.config).toBe(firstConfig); - expect(second!.config).toBe(secondConfig); - expect(first!.jobsClient).toBe(client); - expect(second!.jobsClient).toBe(otherClient); - expect(first!.config.journal).toBe('/first/journal'); - expect(second!.config.journal).toBe('/second/journal'); - expect(first!.paths.snapshotDirectory).toBe('/first/data/snapshots'); - expect(second!.paths.snapshotDirectory).toBe('/second/data/snapshots'); -}); - it('waits for auth initialization before opening the queue or publishing a context', async () => { const initialized = Promise.withResolvers(); const created = Promise.withResolvers(); diff --git a/apps/web/src/lib/server/holdings.test.ts b/apps/web/src/lib/server/holdings.test.ts index d106093..6a2dd8d 100644 --- a/apps/web/src/lib/server/holdings.test.ts +++ b/apps/web/src/lib/server/holdings.test.ts @@ -46,27 +46,22 @@ beforeEach(() => { read.mockReset().mockResolvedValue({ status: 'completed', output: report }); }); afterEach(() => { - vi.useRealTimers(); vi.mocked(delay).mockRestore(); }); -it.each(['completed', 'succeeded'])( - 'returns exact native output for SDK status %s and always submits fresh', - async (status) => { - read.mockResolvedValue({ status, output: report }); - for (let index = 0; index < 2; index++) - expect(await runHoldingsReport(client, request)).toBe(report); - expect(enqueue.mock.calls).toEqual([ - [holdingsSpec, request], - [holdingsSpec, request] - ]); - expect(read.mock.calls).toEqual([ - [{ workflowRunId: 'fresh-run' }], - [{ workflowRunId: 'fresh-run' }] - ]); - expect(cancel).not.toHaveBeenCalled(); - } -); +it('returns exact native output for a completed run and always submits fresh', async () => { + for (let index = 0; index < 2; index++) + expect(await runHoldingsReport(client, request)).toBe(report); + expect(enqueue.mock.calls).toEqual([ + [holdingsSpec, request], + [holdingsSpec, request] + ]); + expect(read.mock.calls).toEqual([ + [{ workflowRunId: 'fresh-run' }], + [{ workflowRunId: 'fresh-run' }] + ]); + expect(cancel).not.toHaveBeenCalled(); +}); it('validates before queue access', async () => { await expect(runHoldingsReport(client, { ...request, journal: '/PRIVATE' })).rejects.toEqual( @@ -103,26 +98,6 @@ it.each(['enqueue', 'read', 'failed', 'canceled', 'missing'])( } ); -it('waits at the SDK one-second cadence beyond five minutes without a deadline', async () => { - vi.useFakeTimers(); - vi.mocked(delay).mockImplementation((ms) => new Promise((resolve) => setTimeout(resolve, ms))); - read.mockResolvedValue({ status: 'pending' }); - let settled = false; - const response = runHoldingsReport(client, request).then((value) => { - settled = true; - return value; - }); - await vi.advanceTimersByTimeAsync(301_000); - expect(settled).toBe(false); - expect(read).toHaveBeenCalledTimes(302); - expect(vi.mocked(delay).mock.calls.every(([ms]) => ms === 1_000)).toBe(true); - read.mockResolvedValue({ status: 'completed', output: report }); - await vi.advanceTimersByTimeAsync(1_000); - expect(await response).toBe(report); - expect(vi.getTimerCount()).toBe(0); - expect(cancel).not.toHaveBeenCalled(); -}); - it('aborts the timer without canceling durable work or reading again', async () => { const controller = new AbortController(); const waiting = Promise.withResolvers(); @@ -144,21 +119,3 @@ it('aborts the timer without canceling durable work or reading again', async () }); expect(cancel).not.toHaveBeenCalled(); }); - -it('discards an in-flight backend result after disconnect without canceling the run', async () => { - const controller = new AbortController(); - const started = Promise.withResolvers(); - const pending = Promise.withResolvers<{ status: string; output: HoldingsReport }>(); - read.mockImplementation(() => { - started.resolve(); - return pending.promise; - }); - const response = runHoldingsReport(client, request, controller.signal); - const rejected = expect(response).rejects.toEqual(new SafeHoldingsJobError('HOLDINGS_FAILED')); - await started.promise; - controller.abort(); - pending.resolve({ status: 'completed', output: report }); - await rejected; - expect(read).toHaveBeenCalledTimes(1); - expect(cancel).not.toHaveBeenCalled(); -}); diff --git a/apps/web/src/lib/server/preparation.integration.test.ts b/apps/web/src/lib/server/preparation.integration.test.ts index 6e38240..0dcabef 100644 --- a/apps/web/src/lib/server/preparation.integration.test.ts +++ b/apps/web/src/lib/server/preparation.integration.test.ts @@ -59,6 +59,37 @@ function runs(client: JobsClient) { return client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name, limit: 100 }); } +async function expectReadOnlyState( + client: JobsClient, + expected: Awaited> +) { + const prohibited = [ + vi.spyOn(client.workflow, 'runWorkflow'), + vi.spyOn(client.backend, 'createWorkflowRun'), + vi.spyOn(client.backend, 'countWorkflowRuns'), + vi.spyOn(client.backend, 'listStepAttempts') + ]; + const list = vi.spyOn(client.backend, 'listWorkflowRuns'); + try { + expect(await readReportsPreparation(client)).toBe(expected); + for (const spy of prohibited) expect(spy).not.toHaveBeenCalled(); + // A small metadata budget, not a promise about the exact query plan. + expect(list.mock.calls.length).toBeGreaterThan(0); + expect(list.mock.calls.length).toBeLessThanOrEqual(10); + for (const [params] of list.mock.calls) { + expect(params.workflowName).toBe(prepareReportsSpec.name); + expect(params.limit).toBeGreaterThan(0); + } + const requestedRows = list.mock.calls.reduce( + (total, [params]) => total + (params.limit ?? Infinity), + 0 + ); + expect(requestedRows).toBeLessThanOrEqual(10); + } finally { + for (const spy of [...prohibited, list]) spy.mockRestore(); + } +} + async function settle(client: JobsClient, id: string, status: 'completed' | 'failed' | 'canceled') { if (status === 'canceled') { await client.backend.cancelWorkflowRun({ workflowRunId: id }); @@ -149,7 +180,7 @@ it('joins a running worker, returns acceptance immediately, and observes complet const id = (await runs(first)).data[0].id; expect(await worker.tick()).toBe(1); await started.promise; - expect(await readReportsPreparation(second)).toBe('running'); + await expectReadOnlyState(second, 'running'); const enqueue = vi.spyOn(second.workflow, 'runWorkflow'); await Promise.all(Array.from({ length: 8 }, () => requestReportPreparation(second))); expect(enqueue).not.toHaveBeenCalled(); @@ -258,34 +289,16 @@ it.each(['completed', 'failed', 'canceled'] as const)( it.each([ ['none', 'idle'], - ['pending', 'pending'], - ['running', 'running'], - ['completed', 'idle'], - ['failed', 'failed'], - ['canceled', 'failed'] + ['failed', 'failed'] ] as const)( - 'reads only reports execution metadata for %s without creating work or counting globally', + 'reads bounded reports metadata for %s without creating work or counting globally', async (status, expected) => { const { first, second } = await setup(); - if (status !== 'none') { + if (status === 'failed') { const handle = await first.workflow.runWorkflow(prepareReportsSpec, undefined); - if (status === 'running') { - await first.backend.claimWorkflowRun({ workerId: 'read-test', leaseDurationMs: 60_000 }); - } else if (status !== 'pending') { - await settle(first, handle.workflowRun.id, status); - } - } - const enqueue = vi.spyOn(second.workflow, 'runWorkflow'); - const create = vi.spyOn(second.backend, 'createWorkflowRun'); - const counts = vi.spyOn(second.backend, 'countWorkflowRuns'); - const steps = vi.spyOn(second.backend, 'listStepAttempts'); - const list = vi.spyOn(second.backend, 'listWorkflowRuns'); - expect(await readReportsPreparation(second)).toBe(expected); - for (const spy of [enqueue, create, counts, steps]) expect(spy).not.toHaveBeenCalled(); - expect(list.mock.calls.length).toBeLessThanOrEqual(3); - for (const [params] of list.mock.calls) { - expect(params).toMatchObject({ workflowName: prepareReportsSpec.name, limit: 1 }); + await settle(first, handle.workflowRun.id, status); } + await expectReadOnlyState(second, expected); } ); diff --git a/apps/web/src/lib/server/queries.integration.test.ts b/apps/web/src/lib/server/queries.integration.test.ts index 7149a4f..718dc80 100644 --- a/apps/web/src/lib/server/queries.integration.test.ts +++ b/apps/web/src/lib/server/queries.integration.test.ts @@ -1,5 +1,5 @@ import { testConfig } from './application.fixture.js'; -import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -87,21 +87,6 @@ it('awaits a fresh SQLite workflow, stores exact financial output, and creates n expect( (await readdir(directory)).every((name) => /^queue\.sqlite(?:-(?:wal|shm))?$/.test(name)) ).toBe(true); - for (const name of await readdir(directory)) { - expect((await stat(join(directory, name))).mode & 0o777).toBe(0o600); - } - expect((await stat(directory)).mode & 0o777).toBe(0o700); -}); - -it('does not consult a legacy current-input pointer', async () => { - const { directory, client } = await setup(); - await writeFile(join(directory, 'current.json'), 'private invalid legacy descriptor'); - client.workflow.implementWorkflow(liveBalanceQuerySpec, () => result); - const worker = client.workflow.newWorker(); - workers.push(worker); - const response = runQuery(client, request); - await expect.poll(() => worker.tick()).toBe(1); - expect(await response).toEqual(result); }); it('rejects invalid requests before enqueuing and fails safely after close', async () => { @@ -154,24 +139,30 @@ it('sanitizes failed native/workflow results and never retries the operation', a expect(await worker.tick()).toBe(0); }); -it('keeps waiting past the former cutoff and returns the eventual result without canceling queued work', async () => { +it('observes a pending durable run and returns its eventual SQLite result', async () => { const { directory, client } = await setup(); client.workflow.implementWorkflow(liveBalanceQuerySpec, () => result); const worker = client.workflow.newWorker(); workers.push(worker); vi.useFakeTimers(); - // Vitest does not virtualize node:timers/promises; use its fake global clock here only. - vi.mocked(delay).mockImplementation((ms) => new Promise((resolve) => setTimeout(resolve, ms))); - const reads = vi.spyOn(client.backend, 'getWorkflowRun'); + const waiting = Promise.withResolvers(); + // Wait for a real pending read before allowing the worker to complete it. + vi.mocked(delay).mockImplementation((ms) => { + waiting.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); + }); let settled = false; const response = runQuery(client, request).then((value) => { settled = true; return value; }); - // Past both the former 35s application wait and the SDK's 5-minute default. - await vi.advanceTimersByTimeAsync(301_000); + await Promise.race([ + waiting.promise, + response.then(() => { + throw new Error('Query completed before its pending read.'); + }) + ]); expect(settled).toBe(false); - expect(reads.mock.calls.length).toBeGreaterThan(300); const db = new DatabaseSync(join(directory, 'queue.sqlite'), { readOnly: true }); try { expect(db.prepare('SELECT status FROM workflow_runs').get()?.status).toBe('pending'); diff --git a/apps/web/src/lib/server/registers/cash.test.ts b/apps/web/src/lib/server/registers/cash.test.ts index 055a103..5cd9a43 100644 --- a/apps/web/src/lib/server/registers/cash.test.ts +++ b/apps/web/src/lib/server/registers/cash.test.ts @@ -166,32 +166,6 @@ it('keeps empty bags unreported and blank commodity a literal supported unit', ( }); }); -it.each([ - 'mixed-change', - 'mixed-balance', - 'duplicate-change', - 'duplicate-balance', - 'across-rows', - 'change-vs-balance' -])('rejects unrepresentable account bags without summing or picking the first: %s', (mode) => { - const first = row(); - const second = row(); - if (mode === 'mixed-change') first.change.push(amount('1', 'EUR')); - if (mode === 'mixed-balance') first.balance.push(amount('1', 'EUR')); - if (mode === 'duplicate-change') first.change.push(amount('1')); - if (mode === 'duplicate-balance') first.balance.push(amount('1')); - if (mode === 'across-rows') { - second.change = [amount('1', 'EUR')]; - second.balance = []; - } - if (mode === 'change-vs-balance') first.balance = [amount('1', 'EUR')]; - expect(mapCashRegisterPage([first, second], 'asc', 1)).toEqual({ - unsupported: true, - endingBalance: null, - rows: [] - }); -}); - it('ignores counterpart units and preserves repeated-category and virtual postings in meaningful breakdowns', () => { const record = row(); record.transaction.postings[1].amounts = [amount('75', 'SHARES'), amount('20', 'EUR')]; @@ -623,17 +597,14 @@ it('does not infer a missing account change or line amount from reported counter }); }); -it.each([false, true])( - 'exposes no transaction or posting tags, including internal transfer = %s', - (internal) => { - const record = row(); - record.isSplit = true; - if (internal) record.accountPostingIndexes = [0, 1]; - record.transaction.tags = [['PRIVATE-transaction-tag', 'PRIVATE-value']]; - for (const posting of record.transaction.postings) - posting.tags = [['PRIVATE-posting-tag', 'PRIVATE-value']]; - const result = mapCashRegisterPage([record], 'asc', 1); - expect(result.rows[0].breakdown?.internalTransfer).toBe(internal); - expect(JSON.stringify(result)).not.toMatch(/tags|PRIVATE/); - } -); +it('exposes no transaction or posting tags for an internal transfer', () => { + const record = row(); + record.isSplit = true; + record.accountPostingIndexes = [0, 1]; + record.transaction.tags = [['PRIVATE-transaction-tag', 'PRIVATE-value']]; + for (const posting of record.transaction.postings) + posting.tags = [['PRIVATE-posting-tag', 'PRIVATE-value']]; + const result = mapCashRegisterPage([record], 'asc', 1); + expect(result.rows[0].breakdown?.internalTransfer).toBe(true); + expect(JSON.stringify(result)).not.toMatch(/tags|PRIVATE/); +}); diff --git a/apps/web/src/lib/server/registers/investment.test.ts b/apps/web/src/lib/server/registers/investment.test.ts index 70f0b0e..0485eda 100644 --- a/apps/web/src/lib/server/registers/investment.test.ts +++ b/apps/web/src/lib/server/registers/investment.test.ts @@ -211,26 +211,23 @@ it.each(['buy', 'sell'] as const)( } ); -it.each(['transfer-in', 'transfer-out', 'acquire', 'dispose', 'mixed', 'unknown'] as const)( - 'preserves native %s without fabricating a price, value or income label', - (action) => { - const record = row(); - const activity = record.investment.securities[0]; - activity.action = action; - activity.prices = []; - activity.transactedAmounts = null; - const result = mapInvestmentRegisterPage([record], 'asc', 1).transactions[0]; - expect(result.securityLines[0]).toEqual({ - security: 'AAA', - action, - shares: [{ commodity: 'AAA', quantity: '2.000000000000000001' }], - prices: [], - transactedAmounts: null - }); - expect(result.related[1].role).toBe('income'); - expect(result.issues).toEqual(['unattributed-income']); - } -); +it('preserves an unknown native action without fabricating a price, value or income label', () => { + const record = row(); + const activity = record.investment.securities[0]; + activity.action = 'unknown'; + activity.prices = []; + activity.transactedAmounts = null; + const result = mapInvestmentRegisterPage([record], 'asc', 1).transactions[0]; + expect(result.securityLines[0]).toEqual({ + security: 'AAA', + action: 'unknown', + shares: [{ commodity: 'AAA', quantity: '2.000000000000000001' }], + prices: [], + transactedAmounts: null + }); + expect(result.related[1].role).toBe('income'); + expect(result.issues).toEqual(['unattributed-income']); +}); it('keeps a cash-only transaction as a group with no fabricated security line', () => { const record = row(); @@ -297,17 +294,6 @@ it('preserves multiple, repeated-unit, mixed-currency and unclassified bags lite expect(record).toEqual(before); }); -it.each(['in', 'out', 'mixed', 'none', 'unknown'] as const)( - 'copies native cash direction %s without reclassifying its bags', - (direction) => { - const record = row(); - record.investment.cash.direction = direction; - expect(mapInvestmentRegisterPage([record], 'asc', 1).transactions[0].cash.direction).toBe( - direction - ); - } -); - it('deduplicates posting indexes only, with gain > income > expense > cash and original posting order', () => { const record = row(); const { firstSecurity, funding, income, expense, gain } = postingIndex; diff --git a/apps/web/src/routes/(public)/(auth)/signin/page.server.test.ts b/apps/web/src/routes/(public)/(auth)/signin/page.server.test.ts index 319885f..ffe2520 100644 --- a/apps/web/src/routes/(public)/(auth)/signin/page.server.test.ts +++ b/apps/web/src/routes/(public)/(auth)/signin/page.server.test.ts @@ -1,7 +1,5 @@ -import { fail } from '@sveltejs/kit'; import { expect, it, vi } from 'vitest'; -import { signIn } from '#lib/server/auth/actions.js'; -import { actions, load } from './+page.server.js'; +import { load } from './+page.server.js'; vi.mock('#lib/server/auth/actions.js', () => ({ signIn: vi.fn() })); @@ -46,11 +44,3 @@ it('discards unknown error text', async () => { error: null }); }); - -it('delegates the default POST action without changing safe failures', async () => { - const request = event() as unknown as Parameters[0]; - const failure = fail(400, { error: 'credentials' as const }); - vi.mocked(signIn).mockResolvedValueOnce(failure); - expect(await actions.default(request)).toBe(failure); - expect(signIn).toHaveBeenCalledWith(request); -}); diff --git a/apps/web/src/routes/(public)/schema.json/schema.test.ts b/apps/web/src/routes/(public)/schema.json/schema.test.ts index afe3822..0c6aa77 100644 --- a/apps/web/src/routes/(public)/schema.json/schema.test.ts +++ b/apps/web/src/routes/(public)/schema.json/schema.test.ts @@ -33,18 +33,4 @@ describe('GET /schema.json', () => { expect(response.headers.get('x-content-type-options')).toBe('nosniff'); await expect(response.json()).resolves.toEqual(editorSchema); }); - - it('does not mutate the artifact or share mutable response state across requests', async () => { - const original = structuredClone(editorSchema); - const first = await GET(poisonedEvent()); - const body = await first.json(); - body.properties = { private: 'not schema data' }; - first.headers.set('cache-control', 'no-store'); - - const second = await GET(poisonedEvent()); - expect(second).not.toBe(first); - await expect(second.json()).resolves.toEqual(original); - expect(second.headers.get('cache-control')).toBe('public, max-age=300'); - expect(editorSchema).toEqual(original); - }); }); diff --git a/apps/worker/src/index.integration.test.ts b/apps/worker/src/index.integration.test.ts index f9bf7d0..6c3828c 100644 --- a/apps/worker/src/index.integration.test.ts +++ b/apps/worker/src/index.integration.test.ts @@ -139,82 +139,53 @@ it('executes a journal larger than the former 8-MiB capture ceiling and leaves t }); }, 20_000); -it.each(['include', '!include', '@include'])( - 'reads current native %s contents rather than capturing or refusing an include tree', - async (directive) => { - const f = await fixture(); - const included = join(f.directory, 'included.journal'); - const root = `${directive} included.journal\n`; - await writeFile(f.source, root); - await writeFile(included, journal); - await withWorker(f, async (client) => { - expect((await query(client)).result).toEqual(expected); - const edited = journal - .replace( - 'expenses:living 100.00 USD', - 'expenses:living 125.00 USD' - ) - .replace( - 'assets:bank:checking -100.00 USD', - 'assets:bank:checking -125.00 USD' - ); - await writeFile(included, edited); - expect((await query(client)).result.total).toEqual([ - { commodity: 'USD', quantity: '125.00' } - ]); - expect(await readFile(f.source, 'utf8')).toBe(root); - expect(await readFile(included, 'utf8')).toBe(edited); - }); - }, - 20_000 -); - -it.each(['invalid', 'missing', 'undeclared-account'])( - 'can start healthy with a %s journal, fails safely, and uses the corrected journal on the next run', - async (kind) => { - const f = await fixture(); - if (kind === 'missing') { - await rm(f.source); - } else if (kind === 'undeclared-account') { - await writeFile( - f.source, - journal.replace(' expenses:living ', ' expenses:undeclared ') - ); - } else { - await writeFile(f.source, 'PRIVATE INVALID JOURNAL CONTENT'); - } - await withWorker(f, async (client, service) => { - expect((await fetch(`http://127.0.0.1:${service.port}/healthz`)).status).toBe(200); - const handle = await client.workflow.runWorkflow(liveBalanceQuerySpec, request); - await expect(handle.result({ timeoutMs: 10_000 })).rejects.toThrow( - 'Query could not be completed' - ); - const failed = await client.backend.getWorkflowRun({ workflowRunId: handle.workflowRun.id }); - const preparations = ( - await client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name }) - ).data; - expect(preparations).toEqual([]); - expect(failed?.status).toBe('failed'); - expect(failed?.attempts).toBe(1); - expect(failed?.output).toBeNull(); - expect(failed?.error?.message).toBe( - 'Query could not be completed. Check the expression and date.' - ); - expect(JSON.stringify(failed?.error)).not.toContain(f.source); - expect(JSON.stringify(failed?.error)).not.toContain('PRIVATE'); - await writeFile(f.source, journal); - expect((await query(client)).result).toEqual(expected); - }); - }, - 20_000 -); - -it('keeps native expression failures generic and can execute another valid query', async () => { +it('reads include edits between fresh requests without capturing the include tree', async () => { const f = await fixture(); + const included = join(f.directory, 'included.journal'); + const root = 'include included.journal\n'; + await writeFile(f.source, root); + await writeFile(included, journal); await withWorker(f, async (client) => { - await expect(query(client, { ...request, query: 'type:Z' })).rejects.toThrow( + expect((await query(client)).result).toEqual(expected); + const edited = journal + .replace( + 'expenses:living 100.00 USD', + 'expenses:living 125.00 USD' + ) + .replace( + 'assets:bank:checking -100.00 USD', + 'assets:bank:checking -125.00 USD' + ); + await writeFile(included, edited); + expect((await query(client)).result.total).toEqual([{ commodity: 'USD', quantity: '125.00' }]); + expect(await readFile(f.source, 'utf8')).toBe(root); + expect(await readFile(included, 'utf8')).toBe(edited); + }); +}, 20_000); + +it('starts healthy with an invalid journal, persists a safe failure, and recovers on the next request', async () => { + const f = await fixture(); + await writeFile(f.source, 'PRIVATE INVALID JOURNAL CONTENT'); + await withWorker(f, async (client, service) => { + expect((await fetch(`http://127.0.0.1:${service.port}/healthz`)).status).toBe(200); + const handle = await client.workflow.runWorkflow(liveBalanceQuerySpec, request); + await expect(handle.result({ timeoutMs: 10_000 })).rejects.toThrow( 'Query could not be completed' ); + const failed = await client.backend.getWorkflowRun({ workflowRunId: handle.workflowRun.id }); + const preparations = ( + await client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name }) + ).data; + expect(preparations).toEqual([]); + expect(failed?.status).toBe('failed'); + expect(failed?.attempts).toBe(1); + expect(failed?.output).toBeNull(); + expect(failed?.error?.message).toBe( + 'Query could not be completed. Check the expression and date.' + ); + expect(JSON.stringify(failed?.error)).not.toContain(f.source); + expect(JSON.stringify(failed?.error)).not.toContain('PRIVATE'); + await writeFile(f.source, journal); expect((await query(client)).result).toEqual(expected); }); }, 20_000); diff --git a/apps/worker/src/index.test.ts b/apps/worker/src/index.test.ts index 59610d2..4f60656 100644 --- a/apps/worker/src/index.test.ts +++ b/apps/worker/src/index.test.ts @@ -1,7 +1,4 @@ import { createServer } from 'node:http'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { loadWorkerConfig, resolveDataPaths } from '@hledger-reports/config'; import { @@ -11,16 +8,7 @@ import { prepareReports } from '@hledger-reports/hledger'; import { createReportStage } from '@hledger-reports/snapshots'; -import { - retiredBalanceQuerySpec, - liveBalanceQuerySpec, - holdingsSpec, - queryRetentionSpec, - prepareReportsSpec, - retiredIncomePreparationSpec, - openJobsClient, - type JobsClient -} from '@hledger-reports/jobs'; +import { openJobsClient, type JobsClient } from '@hledger-reports/jobs'; import { startWorker } from './index.js'; import { purgeExpiredQueryRuns } from './workflows/query-retention/run.js'; import { createPrepareReports } from './workflows/prepare-reports/run.js'; @@ -79,11 +67,9 @@ const client = { close: vi.fn() }; const services: Awaited>[] = []; -beforeEach(async () => { +beforeEach(() => { vi.resetAllMocks(); - vi.mocked(createServer).mockImplementation( - (await vi.importActual('node:http')).createServer - ); + mockHealthServer(); vi.mocked(createPrepareReports).mockReturnValue(prepare); retentionMinute.mockReturnValue(17); vi.mocked(loadWorkerConfig).mockResolvedValue({ @@ -124,7 +110,7 @@ function mockHealthServer() { vi.mocked(createServer).mockReturnValue(server as unknown as ReturnType); return server; } -it('loads config and version once, registers the configured preparation, and fixes concurrency at one', async () => { +it('loads config and version before registering work, fixes concurrency at one, and starts no financial work', async () => { await start(); expect(loadWorkerConfig).toHaveBeenCalledExactlyOnceWith( '/deployment/config/hledger-reports.yaml' @@ -142,18 +128,7 @@ it('loads config and version once, registers the configured preparation, and fix ); expect(client.workflow.newWorker).toHaveBeenCalledExactlyOnceWith({ concurrency: 1 }); expect(worker.start).toHaveBeenCalledOnce(); - expect(createPrepareReports).toHaveBeenCalledExactlyOnceWith( - '/configured/live.journal', - '/deployment/data/snapshots' - ); - expect(client.workflow.implementWorkflow.mock.calls.map(([spec]) => spec)).toEqual([ - retiredBalanceQuerySpec, - liveBalanceQuerySpec, - holdingsSpec, - retiredIncomePreparationSpec, - prepareReportsSpec, - { ...queryRetentionSpec, schedules: [{ cron: '17 0 * * *', timezone: 'UTC' }] } - ]); + expect(client.workflow.implementWorkflow).toHaveBeenCalled(); expect(vi.mocked(openJobsClient).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(createPrepareReports).mock.invocationCallOrder[0]! ); @@ -175,6 +150,9 @@ it('loads config and version once, registers the configured preparation, and fix }); it('drains the worker before closing health and client; concurrent shutdown is idempotent', async () => { + vi.mocked(createServer).mockImplementation( + (await vi.importActual('node:http')).createServer + ); const service = await start(); const server = vi.mocked(createServer).mock.results.at(-1)!.value; const closeHealth = vi.spyOn(server, 'close'); @@ -284,30 +262,6 @@ it('still closes the client when health close reports a callback error', async ( ); }); -it('still closes the client when health close throws synchronously', async () => { - const server = mockHealthServer(); - server.close.mockImplementation(() => { - throw new Error('private health close details'); - }); - const service = await startWorker('/deployment/config/hledger-reports.yaml'); - await expect(service.stop()).rejects.toEqual(new Error('Worker shutdown failed.')); - expect(worker.stop).toHaveBeenCalledOnce(); - expect(server.close).toHaveBeenCalledOnce(); - expect(client.close).toHaveBeenCalledOnce(); -}); - -it('reports only a safe error when client close throws synchronously', async () => { - const server = mockHealthServer(); - client.close.mockImplementation(() => { - throw new Error('SDK close failed at /private/native/queue.sqlite'); - }); - const service = await startWorker('/deployment/config/hledger-reports.yaml'); - await expect(service.stop()).rejects.toEqual(new Error('Worker shutdown failed.')); - expect(worker.stop).toHaveBeenCalledOnce(); - expect(server.close).toHaveBeenCalledOnce(); - expect(client.close).toHaveBeenCalledOnce(); -}); - it('attempts every cleanup stage when worker stop throws and both closes fail', async () => { const server = mockHealthServer(); worker.stop.mockImplementation(() => { @@ -362,16 +316,6 @@ it('does not open SQLite if the native version check fails', async () => { expect(createPrepareReports).not.toHaveBeenCalled(); }); -it('cleans up worker and client in order if worker startup fails', async () => { - worker.start.mockRejectedValue(new Error('start failed')); - await expect(start()).rejects.toThrow('start failed'); - expect(worker.stop).toHaveBeenCalledOnce(); - expect(client.close).toHaveBeenCalledOnce(); - expect(worker.stop.mock.invocationCallOrder[0]).toBeLessThan( - client.close.mock.invocationCallOrder[0]! - ); -}); - it('preserves the worker startup error when worker and client cleanup both reject', async () => { const server = mockHealthServer(); const original = new Error('worker startup failed'); @@ -387,20 +331,6 @@ it('preserves the worker startup error when worker and client cleanup both rejec ); }); -it('closes the opened client and preserves a createServer error even if cleanup rejects', async () => { - const original = new Error('server creation failed'); - vi.mocked(createServer).mockImplementationOnce(() => { - throw original; - }); - client.close.mockRejectedValue(new Error('client cleanup failed')); - await expect(start()).rejects.toBe(original); - expect(openJobsClient).toHaveBeenCalledOnce(); - expect(client.close).toHaveBeenCalledOnce(); - expect(client.workflow.implementWorkflow).not.toHaveBeenCalled(); - expect(client.workflow.newWorker).not.toHaveBeenCalled(); - expect(worker.stop).not.toHaveBeenCalled(); -}); - it('preserves a workflow registration error after attempting client cleanup', async () => { const server = mockHealthServer(); const original = new Error('registration failed'); @@ -416,54 +346,10 @@ it('preserves a workflow registration error after attempting client cleanup', as expect(client.close).toHaveBeenCalledOnce(); }); -it('preserves a worker creation error even if client cleanup throws synchronously', async () => { - const server = mockHealthServer(); - const original = new Error('worker creation failed'); - client.workflow.newWorker.mockImplementationOnce(() => { - throw original; - }); - client.close.mockImplementation(() => { - throw new Error('client cleanup failed'); - }); - await expect(start()).rejects.toBe(original); - expect(worker.start).not.toHaveBeenCalled(); - expect(worker.stop).not.toHaveBeenCalled(); - expect(server.close).not.toHaveBeenCalled(); - expect(client.close).toHaveBeenCalledOnce(); -}); - -it('preserves an emitted health listen error when worker and client cleanup reject', async () => { - const server = mockHealthServer(); - const original = new Error('health listen failed'); - server.listen.mockImplementation(() => { - const [event, listener] = server.once.mock.calls[0]!; - expect(event).toBe('error'); - listener(original); - }); - worker.stop.mockRejectedValue(new Error('worker cleanup failed')); - client.close.mockRejectedValue(new Error('client cleanup failed')); - await expect(start()).rejects.toBe(original); - expect(worker.start).toHaveBeenCalledOnce(); - expect(worker.stop).toHaveBeenCalledOnce(); - expect(server.close).not.toHaveBeenCalled(); - expect(client.close).toHaveBeenCalledOnce(); -}); - -it('preserves a synchronous health listen error after attempting cleanup', async () => { - const server = mockHealthServer(); - const original = new Error('health listen threw'); - server.listen.mockImplementation(() => { - throw original; - }); - worker.stop.mockRejectedValue(new Error('worker cleanup failed')); - client.close.mockRejectedValue(new Error('client cleanup failed')); - await expect(start()).rejects.toBe(original); - expect(worker.stop).toHaveBeenCalledOnce(); - expect(server.close).not.toHaveBeenCalled(); - expect(client.close).toHaveBeenCalledOnce(); -}); - it('cleans up worker and client if the health port is occupied', async () => { + vi.mocked(createServer).mockImplementation( + (await vi.importActual('node:http')).createServer + ); const reservation = createServer(); await new Promise((resolve) => reservation.listen(0, '127.0.0.1', resolve)); const port = (reservation.address() as { port: number }).port; @@ -481,77 +367,17 @@ it('cleans up worker and client if the health port is occupied', async () => { } }); -it('does not serve health until the native SDK loop has started', async () => { - const reservation = createServer(); - await new Promise((resolve) => reservation.listen(0, '127.0.0.1', resolve)); - const port = (reservation.address() as { port: number }).port; - await new Promise((resolve) => reservation.close(() => resolve())); +it('does not listen for health until the native SDK loop has started', async () => { + const server = mockHealthServer(); let release!: () => void; worker.start.mockReturnValue(new Promise((resolve) => (release = resolve))); - const starting = start(port); + const starting = start(); try { await vi.waitFor(() => expect(worker.start).toHaveBeenCalledOnce()); - expect(createPrepareReports).toHaveBeenCalledOnce(); - await expect(fetch(`http://127.0.0.1:${port}/healthz`)).rejects.toThrow(); + expect(server.listen).not.toHaveBeenCalled(); } finally { release(); await starting; } - expect((await fetch(`http://127.0.0.1:${port}/healthz`)).status).toBe(200); + expect(server.listen).toHaveBeenCalledOnce(); }); - -it.each([true, false])( - 'validates raw auth before starting but does not require runtime web secrets (valid: %s)', - async (valid) => { - const directory = await mkdtemp(join(tmpdir(), 'worker-config-')); - let service: Awaited> | undefined; - try { - const configPath = join(directory, 'hledger-reports.yaml'); - await writeFile( - configPath, - JSON.stringify({ - journal: './not-opened.journal', - url: { app: 'http://127.0.0.1:3000' }, - auth: valid - ? { credentials: { username: 'fixture', password: '${AUTH_PASSWORD}' } } - : { credentials: { username: 'fixture' } } - }) - ); - const actual = - await vi.importActual('@hledger-reports/config'); - vi.mocked(loadWorkerConfig).mockImplementation((path) => - actual.loadWorkerConfig(path, { env: {} }) - ); - const health = mockHealthServer(); - if (valid) { - service = await startWorker(configPath); - expect(worker.start).toHaveBeenCalledOnce(); - expect(health.listen).toHaveBeenCalledOnce(); - expect(createPrepareReports).toHaveBeenCalledExactlyOnceWith( - join(directory, 'not-opened.journal'), - '/deployment/data/snapshots' - ); - await expect(vi.mocked(loadWorkerConfig).mock.results[0]!.value).resolves.toEqual({ - version: 1, - journal: join(directory, 'not-opened.journal') - }); - } else { - await expect(startWorker(configPath)).rejects.toMatchObject({ code: 'CONFIG_INVALID' }); - expect(getHledgerVersion).not.toHaveBeenCalled(); - expect(openJobsClient).not.toHaveBeenCalled(); - expect(client.workflow.implementWorkflow).not.toHaveBeenCalled(); - expect(worker.start).not.toHaveBeenCalled(); - expect(health.listen).not.toHaveBeenCalled(); - } - expect(prepare).not.toHaveBeenCalled(); - expect(prepareReports).not.toHaveBeenCalled(); - expect(createReportStage).not.toHaveBeenCalled(); - expect(runCostBalance).not.toHaveBeenCalled(); - expect(runHoldings).not.toHaveBeenCalled(); - expect(purgeExpiredQueryRuns).not.toHaveBeenCalled(); - } finally { - await service?.stop(); - await rm(directory, { recursive: true, force: true }); - } - } -); diff --git a/apps/worker/src/inspect.test.ts b/apps/worker/src/inspect.test.ts index 9638c56..b4d3d27 100644 --- a/apps/worker/src/inspect.test.ts +++ b/apps/worker/src/inspect.test.ts @@ -1,13 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - ConfigError, - loadConfig, - loadWorkerConfig, - type Config, - type WorkerConfig -} from '@hledger-reports/config'; +import { ConfigError, type Config, type WorkerConfig } from '@hledger-reports/config'; import { describe, expect, it, vi } from 'vitest'; import { runCli, runInspection, usage, type InspectionDependencies } from './inspect.js'; @@ -91,21 +82,16 @@ describe('runCli', () => { expect(dependencies.loadWorkerConfig).not.toHaveBeenCalled(); }); - it.each([ - ['--config-only'], - ['--config-only', '--config', 'explicit.yaml'], - ['--config', 'explicit.yaml', '--config-only'] - ])('validates configuration only without native calls (%#)', async (...args) => { - const { config, webConfig, dependencies, output } = setup(); - Object.freeze(webConfig); + it('validates configuration only without native calls', async () => { + const { dependencies, output } = setup(); dependencies.loadWorkerConfig.mockRejectedValue(new Error('must use full web validation')); dependencies.getHledgerVersion.mockRejectedValue(new Error('must not run hledger')); dependencies.checkJournal.mockRejectedValue(new Error('must not open journal')); - expect(await runCli(args, { CONFIG_PATH: 'environment.yaml' }, dependencies, output)).toBe(0); - expect(dependencies.loadConfig).toHaveBeenCalledExactlyOnceWith( - args.includes('--config') ? 'explicit.yaml' : 'environment.yaml' - ); + expect( + await runCli(['--config-only'], { CONFIG_PATH: 'environment.yaml' }, dependencies, output) + ).toBe(0); + expect(dependencies.loadConfig).toHaveBeenCalledExactlyOnceWith('environment.yaml'); expect(dependencies.loadWorkerConfig).not.toHaveBeenCalled(); expect(dependencies.getHledgerVersion).not.toHaveBeenCalled(); expect(dependencies.checkJournal).not.toHaveBeenCalled(); @@ -113,31 +99,8 @@ describe('runCli', () => { '{"status":"ok","configuration":"valid"}' ); expect(output.stderr).not.toHaveBeenCalled(); - expect(webConfig).toEqual({ - ...config, - accounts: [], - url: { app: 'http://127.0.0.1:3000' }, - auth: { - secret: '0123456789abcdef0123456789abcdef', - credentials: { username: 'fixture', password: 'fictional-password' } - } - }); }); - it.each([undefined, '', ' '])( - 'requires an explicit nonempty path in config-only mode (%#)', - async (CONFIG_PATH) => { - const { dependencies, output } = setup(); - expect(await runCli(['--config-only'], { CONFIG_PATH }, dependencies, output)).toBe(2); - expect(output.stderr).toHaveBeenCalledExactlyOnceWith(usage); - expect(output.stdout).not.toHaveBeenCalled(); - expect(dependencies.loadConfig).not.toHaveBeenCalled(); - expect(dependencies.loadWorkerConfig).not.toHaveBeenCalled(); - expect(dependencies.getHledgerVersion).not.toHaveBeenCalled(); - expect(dependencies.checkJournal).not.toHaveBeenCalled(); - } - ); - it.each([ { error: new ConfigError('CONFIG_INVALID'), @@ -186,26 +149,9 @@ describe('runCli', () => { }); it.each([ - ['--config'], ['--config', ''], - ['--config', ' '], - ['--config', '--help'], ['--config', 'one', '--config', 'two'], - ['--other', 'private-value'], - ['positional.yaml'], - ['--help', 'extra'], - ['--config-only', '--config-only'], - ['--config-only', '--help'], - ['-h', '--config-only'], - ['--help', '--help'], - ['--config-only', '--other'], - ['--config-only', 'positional.yaml'], - ['--config-only', '--config'], - ['--config-only', '--config', ''], - ['--config-only', '--config', ' '], - ['--config-only', '--config', '--config-only'], - ['--config-only', '--config', 'one', '--config', 'two'], - ['--config', 'one', '--help'] + ['--other', 'private-value'] ])('rejects invalid arguments %# without falling back to CONFIG_PATH', async (...args) => { const { dependencies, output } = setup(); expect(await runCli(args, { CONFIG_PATH: 'environment.yaml' }, dependencies, output)).toBe(2); @@ -217,16 +163,9 @@ describe('runCli', () => { expect(dependencies.checkJournal).not.toHaveBeenCalled(); }); - it.each(['', ' '])('rejects an empty CONFIG_PATH %#', async (path) => { + it('shows help without loading configuration', async () => { const { dependencies, output } = setup(); - expect(await runCli([], { CONFIG_PATH: path }, dependencies, output)).toBe(2); - expect(dependencies.loadConfig).not.toHaveBeenCalled(); - expect(dependencies.loadWorkerConfig).not.toHaveBeenCalled(); - }); - - it.each(['--help', '-h'])('shows help without loading configuration (%s)', async (arg) => { - const { dependencies, output } = setup(); - expect(await runCli([arg], {}, dependencies, output)).toBe(0); + expect(await runCli(['--help'], {}, dependencies, output)).toBe(0); expect(output.stdout).toHaveBeenCalledExactlyOnceWith(usage); expect(dependencies.loadConfig).not.toHaveBeenCalled(); expect(dependencies.loadWorkerConfig).not.toHaveBeenCalled(); @@ -255,62 +194,3 @@ describe('runCli', () => { } ); }); - -it.each([false, true])( - 'uses real shared YAML without resolving web secrets only for native preflight (config-only: %s)', - async (configOnly) => { - const directory = await mkdtemp(join(tmpdir(), 'inspect-config-')); - try { - const configPath = join(directory, 'hledger-reports.yaml'); - await writeFile( - configPath, - [ - 'journal: ./not-opened.journal', - 'url:', - ' app: http://127.0.0.1:3000', - 'auth:', - ' credentials:', - ' username: fixture', - ' password: ${AUTH_PASSWORD}' - ].join('\n') - ); - const { dependencies, output } = setup(); - dependencies.loadConfig.mockImplementation((path) => loadConfig(path, { env: {} })); - dependencies.loadWorkerConfig.mockImplementation((path) => - loadWorkerConfig(path, { env: {} }) - ); - expect( - await runCli( - configOnly ? ['--config-only'] : [], - { CONFIG_PATH: configPath }, - dependencies, - output - ) - ).toBe(configOnly ? 1 : 0); - if (configOnly) { - expect(dependencies.loadConfig).toHaveBeenCalledExactlyOnceWith(configPath); - expect(dependencies.loadWorkerConfig).not.toHaveBeenCalled(); - expect(dependencies.getHledgerVersion).not.toHaveBeenCalled(); - expect(dependencies.checkJournal).not.toHaveBeenCalled(); - expect(output.stdout).not.toHaveBeenCalled(); - expect(output.stderr).toHaveBeenCalledExactlyOnceWith( - 'Configuration failed [CONFIG_MISSING_ENVIRONMENT].' - ); - } else { - expect(dependencies.loadConfig).not.toHaveBeenCalled(); - expect(dependencies.loadWorkerConfig).toHaveBeenCalledExactlyOnceWith(configPath); - await expect(dependencies.loadWorkerConfig.mock.results[0]!.value).resolves.toEqual({ - version: 1, - journal: join(directory, 'not-opened.journal') - }); - expect(dependencies.getHledgerVersion).toHaveBeenCalledExactlyOnceWith(); - expect(dependencies.checkJournal).toHaveBeenCalledExactlyOnceWith( - join(directory, 'not-opened.journal') - ); - expect(output.stderr).not.toHaveBeenCalled(); - } - } finally { - await rm(directory, { recursive: true, force: true }); - } - } -); diff --git a/apps/worker/src/workflows/cost-balance/run.test.ts b/apps/worker/src/workflows/cost-balance/run.test.ts index 0dafba5..9c8c148 100644 --- a/apps/worker/src/workflows/cost-balance/run.test.ts +++ b/apps/worker/src/workflows/cost-balance/run.test.ts @@ -8,14 +8,8 @@ const journal = '/owned/private.journal'; const execute = createCostBalanceQuery(journal); const context = (input: unknown) => ({ input }) as Parameters[0]; -it.each([ - null, - { query: 'PRIVATE\nINPUT', asOf: '2026-04-30' }, - { query: 'type:X', asOf: '2026-02-29' }, - { query: 'type:X', asOf: '0000-01-01' }, - { query: 'type:X', asOf: '9999-12-31' }, - { generation: 'private-generation', query: '', asOf: '2026-04-30' } -])('validates input before hledger and exposes no private cause: %j', async (input) => { +it('validates input before hledger and exposes no private cause', async () => { + const input = { query: 'PRIVATE\nINPUT', asOf: '2026-04-30' }; let failure: unknown; try { await execute(context(input)); @@ -39,10 +33,3 @@ it('uses the configured journal and exact input dates and returns native values expect(await execute(context(request))).toEqual({ ...request, ...report }); expect(runCostBalance).toHaveBeenCalledExactlyOnceWith(journal, request.query, request.asOf); }); - -it('preserves the already-safe public hledger failure without adding a cause or another error layer', async () => { - const failure = new Error('Query could not be completed. Check the expression and date.'); - vi.mocked(runCostBalance).mockRejectedValue(failure); - await expect(execute(context({ query: 'type:Z', asOf: '2026-04-30' }))).rejects.toBe(failure); - expect(failure.cause).toBeUndefined(); -}); diff --git a/apps/worker/src/workflows/holdings/run.test.ts b/apps/worker/src/workflows/holdings/run.test.ts index 894f93c..7326dfa 100644 --- a/apps/worker/src/workflows/holdings/run.test.ts +++ b/apps/worker/src/workflows/holdings/run.test.ts @@ -9,42 +9,13 @@ const execute = createHoldings(journal); const context = (input: unknown) => ({ input }) as Parameters[0]; const report: HoldingsReport = { asOf: '2026-04-30', - account: null, + account: 'assets:Broker (literal.*)', preparedAt: '2026-05-01T12:00:00Z', - accounts: [ - { - account: 'assets:broker', - positions: [ - { - security: 'EXAMPLE', - shares: '1.234567890123456789', - price: 'USD 100.001', - costBasis: 'USD 90.0000', - marketValue: 'USD 123.458023580246802468', - unrealizedGain: 'USD 33.458023580246802468' - }, - { - security: 'UNPRICED', - shares: '-2.000', - price: null, - costBasis: 'EUR -10.00', - marketValue: null, - unrealizedGain: null - } - ], - cash: [{ commodity: 'USD', quantity: '000.0049' }], - unclassified: [{ commodity: 'OTHER', quantity: '999999999999999999.00001' }] - } - ] + accounts: [] }; -it.each([ - null, - { asOf: '2026-02-29', account: null }, - { asOf: '9999-12-31', account: null }, - { asOf: '2026-04-30', account: 'PRIVATE\nINPUT' }, - { asOf: '2026-04-30', account: null, journal: '/PRIVATE' } -])('rejects invalid input before calling native code %#', async (input) => { +it('rejects invalid input before calling native code', async () => { + const input = { asOf: '2026-04-30', account: 'PRIVATE\nINPUT' }; await expect(execute(context(input))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'Invalid holdings request.' @@ -52,16 +23,12 @@ it.each([ expect(runHoldings).not.toHaveBeenCalled(); }); -it.each([null, 'assets:Broker (literal.*)'])( - 'passes scope %s through and returns native strings unchanged', - async (account) => { - const request = { asOf: report.asOf, account }; - const output = { ...report, account }; - vi.mocked(runHoldings).mockResolvedValue(output); - expect(await execute(context(request))).toBe(output); - expect(runHoldings).toHaveBeenCalledExactlyOnceWith(journal, request); - } -); +it('passes the selected literal account through and returns the native report unchanged', async () => { + const request = { asOf: report.asOf, account: report.account }; + vi.mocked(runHoldings).mockResolvedValue(report); + expect(await execute(context(request))).toBe(report); + expect(runHoldings).toHaveBeenCalledExactlyOnceWith(journal, request); +}); it('does not retain raw native failure text or causes', async () => { vi.mocked(runHoldings).mockRejectedValue(new Error('PRIVATE /owned/journal stderr')); diff --git a/apps/worker/src/workflows/index.test.ts b/apps/worker/src/workflows/index.test.ts index cad9d59..f47bf39 100644 --- a/apps/worker/src/workflows/index.test.ts +++ b/apps/worker/src/workflows/index.test.ts @@ -1,10 +1,5 @@ import { beforeEach, expect, it, vi } from 'vitest'; -import { - runCostBalance, - runHoldings, - prepareReports, - type OverviewData -} from '@hledger-reports/hledger'; +import { runCostBalance, runHoldings, prepareReports } from '@hledger-reports/hledger'; import { createReportStage } from '@hledger-reports/snapshots'; import { retiredBalanceQuerySpec, @@ -112,29 +107,17 @@ it('registers SDK retention and returns only a deleted count', async () => { expect(runCostBalance).not.toHaveBeenCalled(); }); -it('registers prepare directly and passes its result through unchanged', async () => { +it('registers the configured preparation handler directly', () => { register(); const registration = client.workflow.implementWorkflow.mock.calls.find( ([spec]) => spec.name === prepareReportsSpec.name ); expect(registration?.[0]).toBe(prepareReportsSpec); expect(registration?.[1]).toBe(prepare); - const context = { - input: undefined, - run: { id: 'example', createdAt: new Date('2026-10-21T12:00:00Z') } - }; - const data: OverviewData = { - asOf: null, - preparedAt: '2026-10-21T12:00:01Z', - journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - ranges: null, - incomeExpenses: { months: [] }, - netWorth: { currency: null, points: [] }, - balanceSheet: null - }; - prepare.mockResolvedValue(data); - expect(await registration![1](context)).toBe(data); - expect(prepare).toHaveBeenCalledExactlyOnceWith(context); + expect(createPrepareReports).toHaveBeenCalledExactlyOnceWith( + '/configured/live.journal', + '/deployment/data/snapshots' + ); expect(prepareReports).not.toHaveBeenCalled(); expect(createReportStage).not.toHaveBeenCalled(); }); @@ -170,19 +153,12 @@ it('draws the retention minute and creates a preparation handler for each worker ]); }); -it('validates each live request before native execution, returns only display fields, and uses startup config', async () => { +it('binds the live query spec to execution with the configured journal', async () => { register(); const live = handler('2'); expect( client.workflow.implementWorkflow.mock.calls.find(([spec]) => spec.version === '2')?.[0] ).toBe(liveBalanceQuerySpec); - await expect(live({ input: { query: 'PRIVATE\nINPUT', asOf: '2026-04-30' } })).rejects.toThrow( - 'Invalid query request.' - ); - await expect( - live({ input: { generation: 'a'.repeat(64), query: '', asOf: '2026-04-30' } }) - ).rejects.toThrow('Invalid query request.'); - expect(runCostBalance).not.toHaveBeenCalled(); const request = { query: 'type:X', asOf: '2026-04-30' }; const report = { rows: [], total: [] }; vi.mocked(runCostBalance).mockResolvedValue(report); @@ -194,12 +170,8 @@ it('validates each live request before native execution, returns only display fi ); }); -it.each([ - { input: { generation: 'a'.repeat(64), query: 'type:X', asOf: '2026-04-30' } }, - { input: { retiredPayload: ['opaque', null] } }, - { input: 'opaque legacy input' }, - { input: null } -])('rejects opaque retired input %# without calling native code', async ({ input }) => { +it('rejects opaque retired input without calling native code', async () => { + const input = { retiredPayload: ['opaque', null] }; register(); expect( client.workflow.implementWorkflow.mock.calls.find(([spec]) => spec.version === '1')?.[0] diff --git a/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts b/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts index c933491..1f475a3 100644 --- a/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts +++ b/apps/worker/src/workflows/prepare-reports/edits.integration.test.ts @@ -1,5 +1,5 @@ import { enqueuePreparation, financialValues } from './test-fixtures.js'; -import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; @@ -93,71 +93,6 @@ function gate() { }; } -it('explicitly prepares glob price additions/removals and atomic include/root saves at unchanged coverage', async () => { - const f = await fixture(); - const client = await openJobsClient(f.jobs); - const service = await startWorker(f.config, { port: 0 }); - try { - await enqueuePreparation(client); - await settled(client, 1); - const first = (await readReportPublication(f.snapshots))!; - const price = join(f.directory, 'parts', 'new-prices.journal'); - await writeFile(price, 'P 2026-04-16 VTI 200.00 USD\n'); - await enqueuePreparation(client); - await settled(client, 2); - const priced = (await readReportPublication(f.snapshots))!; - expect(priced.overview.journalCoverage).toEqual(first.overview.journalCoverage); - expect(priced.overview.asOf).toBe(first.overview.asOf); - expect(priced.overview.netWorth?.points[11].amounts).toEqual([ - { commodity: 'USD', quantity: '12725.00' } - ]); - await rm(price); - await enqueuePreparation(client); - await settled(client, 3); - const removed = (await readReportPublication(f.snapshots))!; - expect(removed.overview.netWorth).toEqual(first.overview.netWorth); - const replacement = join(f.directory, 'parts', '.editor-save'); - await writeFile(replacement, salary('1500.00')); - await rename(replacement, f.included); - await enqueuePreparation(client); - await settled(client, 4); - const edited = (await readReportPublication(f.snapshots))!; - expect(edited.overview.asOf).toBe(first.overview.asOf); - expect(edited.overview.journalCoverage).toEqual(first.overview.journalCoverage); - expect(edited.overview.incomeExpenses.months[11].income).toEqual([ - { commodity: 'USD', quantity: '1500.00' } - ]); - expect(edited.overview.netWorth?.points[11].amounts).toEqual([ - { commodity: 'USD', quantity: '12325.00' } - ]); - await writeFile( - join(f.directory, '.main-save'), - '; editor replacement\ninclude parts/*.journal\n' - ); - await rename(join(f.directory, '.main-save'), f.journal); - await enqueuePreparation(client); - await settled(client, 5); - const rootSaved = (await readReportPublication(f.snapshots))!; - expect(rootSaved.overview.incomeExpenses).toEqual(edited.overview.incomeExpenses); - expect(rootSaved.overview.netWorth).toEqual(edited.overview.netWorth); - // Every existing name retains its ID through additions/removals and edits. - for (const publication of [priced, removed, edited, rootSaved]) { - for (const account of first.accounts!) { - expect(publication.accounts?.find((entry) => entry.name === account.name)?.id).toBe( - account.id - ); - } - } - expect(await readFile(f.journal, 'utf8')).toBe( - '; editor replacement\ninclude parts/*.journal\n' - ); - expect(await readFile(f.included, 'utf8')).toBe(salary('1500.00')); - } finally { - await service.stop(); - await client.close(); - } -}, 35_000); - it('publishes its loaded native view despite later edits, with no automatic follow-up', async () => { const f = await fixture(); const client = await openJobsClient(f.jobs); diff --git a/apps/worker/src/workflows/prepare-reports/run.test.ts b/apps/worker/src/workflows/prepare-reports/run.test.ts index ce7302e..b528751 100644 --- a/apps/worker/src/workflows/prepare-reports/run.test.ts +++ b/apps/worker/src/workflows/prepare-reports/run.test.ts @@ -152,10 +152,10 @@ it.each([true, false])( } ); -it.each(['missing', 'unsupported', 'corrupt'] as const)( +it.each(['absent', 'unreadable'] as const)( 'allows an explicit job to replace %s saved data', async (kind) => { - if (kind === 'corrupt') + if (kind === 'unreadable') vi.mocked(readReportPublication).mockRejectedValue(new Error('corrupt publication')); else vi.mocked(readReportPublication).mockResolvedValue(null); expect(await prepare(context)).toBe(overview); @@ -164,23 +164,19 @@ it.each(['missing', 'unsupported', 'corrupt'] as const)( } ); -it.each(['producer', 'write', 'publish'] as const)( - 'discards its stage on %s failure', - async (kind) => { - const error = new Error('failed'); - if (kind === 'producer') vi.mocked(prepareReports).mockRejectedValue(error); - if (kind === 'write') stage.writeRows.mockRejectedValue(error); - if (kind === 'publish') stage.publish.mockRejectedValue(error); - await expect(prepare(context)).rejects.toBe(error); - // A later financial failure must not rewrite the completed preflight. - expect(writeValidation).toHaveBeenCalledExactlyOnceWith(directory, run, journal, { - outcome: 'passed', - completedAt: expect.any(String) - }); - expect(stage.discard).toHaveBeenCalledExactlyOnceWith(); - if (kind !== 'publish') expect(stage.publish).not.toHaveBeenCalled(); - } -); +it.each(['producer', 'publish'] as const)('discards its stage on %s failure', async (kind) => { + const error = new Error('failed'); + if (kind === 'producer') vi.mocked(prepareReports).mockRejectedValue(error); + if (kind === 'publish') stage.publish.mockRejectedValue(error); + await expect(prepare(context)).rejects.toBe(error); + // A later financial failure must not rewrite the completed preflight. + expect(writeValidation).toHaveBeenCalledExactlyOnceWith(directory, run, journal, { + outcome: 'passed', + completedAt: expect.any(String) + }); + expect(stage.discard).toHaveBeenCalledExactlyOnceWith(); + if (kind !== 'publish') expect(stage.publish).not.toHaveBeenCalled(); +}); it('awaits producer writes and publication before returning its own calculated result', async () => { let releaseWrite!: () => void; @@ -211,9 +207,9 @@ it('awaits producer writes and publication before returning its own calculated r } }); -it('does not retain a preparing guard if discard fails', async () => { +it('awaits discard and reports its failure rather than completing the job', async () => { stage.discard.mockRejectedValueOnce(new Error('discard failed')); await expect(prepare(context)).rejects.toThrow('discard failed'); - expect(await prepare(context)).toBe(overview); - expect(prepareReports).toHaveBeenCalledTimes(2); + expect(stage.publish).toHaveBeenCalledOnce(); + expect(stage.discard).toHaveBeenCalledOnce(); }); diff --git a/apps/worker/src/workflows/query-retention/run.test.ts b/apps/worker/src/workflows/query-retention/run.test.ts index 33d8015..41babaf 100644 --- a/apps/worker/src/workflows/query-retention/run.test.ts +++ b/apps/worker/src/workflows/query-retention/run.test.ts @@ -229,16 +229,6 @@ it('does not create missing queues, open non-files, or migrate unknown schemas', } }); -it('does not refuse cleanup solely because an additive migration was recorded', async () => { - const { path, db, create } = await setup(); - await create(); - db.exec('INSERT INTO openworkflow_migrations VALUES (8)'); - expect(purgeExpiredQueryRuns(path, now)).toBe(1); - expect( - db.prepare('SELECT version FROM openworkflow_migrations WHERE version = 8').get()?.version - ).toBe(8); -}); - it('leaves private journal/legacy files untouched and a live SDK connection usable for v2 work', async () => { const { path, backend, create } = await setup(); await create(); diff --git a/packages/config/scripts/generate-schema.test.ts b/packages/config/scripts/generate-schema.test.ts index 1ee21d3..7c9c025 100644 --- a/packages/config/scripts/generate-schema.test.ts +++ b/packages/config/scripts/generate-schema.test.ts @@ -49,14 +49,9 @@ it('check rejects a missing artifact without creating it', async () => { await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }); }); -it('invalid arguments never write, even with --check or a missing artifact', async () => { +it('invalid --check arguments leave the existing artifact untouched', async () => { const path = await fixture(); - for (const args of [['--other'], ['--check', '--check'], ['--check', 'extra'], ['--help']]) { - await expect(generateSchema(args, path)).rejects.toThrow(/Usage:/); - await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }); - await writeFile(path, 'unchanged'); - await expect(generateSchema(args, path)).rejects.toThrow(/Usage:/); - expect(await readFile(path, 'utf8')).toBe('unchanged'); - await rm(path); - } + await writeFile(path, 'unchanged'); + await expect(generateSchema(['--check', 'extra'], path)).rejects.toThrow(/Usage:/); + expect(await readFile(path, 'utf8')).toBe('unchanged'); }); diff --git a/packages/config/src/auth.test.ts b/packages/config/src/auth.test.ts index a2ce08c..778891c 100644 --- a/packages/config/src/auth.test.ts +++ b/packages/config/src/auth.test.ts @@ -80,10 +80,15 @@ it.each([ 'https://example.test/#fragment', // URL would repair this path into an origin; policy rejects the original input. 'https://example.test/a/..' -])('rejects non-origin or unsafe application URLs, including after expansion: %s', (app) => { +])('rejects non-origin or unsafe application URLs: %s', (app) => { expect(() => validateConfig(rawConfig({ url: { app } }))).toThrow(ConfigError); +}); + +it('revalidates an unsafe application origin after environment expansion', () => { expect(() => - validateConfig(rawConfig({ url: { app: '${APP}' } }), { env: { APP: app } }) + validateConfig(rawConfig({ url: { app: '${APP}' } }), { + env: { APP: 'http://public.example.test' } + }) ).toThrow(ConfigError); }); diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts index 02250c7..98a450c 100644 --- a/packages/config/src/index.test.ts +++ b/packages/config/src/index.test.ts @@ -2,15 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os'; import { join, relative, resolve } from 'node:path'; import { afterEach, expect, it } from 'vitest'; -import { Value } from '@sinclair/typebox/value'; -import { - ConfigError, - configEditorSchema, - configInputSchema, - configSchema, - loadConfig, - validateConfig -} from './index.js'; +import { ConfigError, configEditorSchema, loadConfig, validateConfig } from './index.js'; import { configYaml, rawConfig } from './test-fixtures.js'; const directories: string[] = []; @@ -50,13 +42,7 @@ it('requires version 1 and a nonempty journal path without coercion or interpola } }); -it('keeps the editor artifact exactly aligned with the source schema and its defaults', async () => { - expect(configInputSchema.required).toEqual(['journal', 'url', 'auth']); - expect(configInputSchema.properties.version.default).toBe(1); - expect(configInputSchema.properties.accounts.default).toEqual([]); - expect(Value.Check(configInputSchema, rawConfig({ journal: '${BOOK}' }))).toBe(true); - expect(Value.Check(configSchema, rawConfig())).toBe(false); - expect(Value.Check(configSchema, validateConfig(rawConfig()))).toBe(true); +it('keeps the editor artifact exactly aligned with the source schema', async () => { expect(JSON.parse(await readFile(new URL('../schema.json', import.meta.url), 'utf8'))).toEqual( JSON.parse(JSON.stringify(configEditorSchema)) ); diff --git a/packages/config/src/input.test.ts b/packages/config/src/input.test.ts index 3f76e17..e382b0c 100644 --- a/packages/config/src/input.test.ts +++ b/packages/config/src/input.test.ts @@ -1,38 +1,15 @@ -import { expect, it, vi } from 'vitest'; +import { expect, it } from 'vitest'; import { ConfigError, validateConfig } from './index.js'; import { rawConfig } from './test-fixtures.js'; -it('accepts plain and null-prototype input without mutation', () => { - const input = Object.freeze(Object.assign(Object.create(null), rawConfig())); - expect(validateConfig(input).journal).toBe(input.journal); - expect(Object.getPrototypeOf(input)).toBeNull(); - expect(input).not.toHaveProperty('version'); +it('rejects a malformed accounts container', () => { + expect(() => validateConfig(rawConfig({ accounts: {} }))).toThrow(ConfigError); }); -it('requires plain objects and dense plain arrays', () => { - for (const input of [ - Object.create(rawConfig()), - rawConfig({ accounts: {} }), - rawConfig({ accounts: new Array(1) }), - rawConfig({ accounts: new (class extends Array {})() }) - ]) { - expect(() => validateConfig(input)).toThrow(ConfigError); - } -}); - -it('does not coerce values, traverse cycles, or invoke accessors', () => { - const coerce = vi.fn(() => 'book'); - expect(() => validateConfig(rawConfig({ journal: { toString: coerce } }))).toThrow(ConfigError); +it('rejects cycles that YAML aliases can produce', () => { const cyclic: Record = rawConfig(); cyclic.journal = cyclic; expect(() => validateConfig(cyclic)).toThrow(ConfigError); - const getter = vi.fn(() => { - throw new Error('private-value'); - }); - const input = Object.defineProperty(rawConfig(), 'journal', { enumerable: true, get: getter }); - expect(() => validateConfig(input)).toThrow(ConfigError); - expect(coerce).not.toHaveBeenCalled(); - expect(getter).not.toHaveBeenCalled(); }); it('redacts unknown keys and values using only supported issue paths', () => { diff --git a/packages/config/src/paths.test.ts b/packages/config/src/paths.test.ts index 5f4e9eb..41ffd32 100644 --- a/packages/config/src/paths.test.ts +++ b/packages/config/src/paths.test.ts @@ -19,9 +19,3 @@ it('keeps the same storage when a selected file is renamed within its config dir resolveDataPaths(join(directory, 'previous.yaml')) ); }); -it('keeps separate deployments and fixture data isolated', () => { - const firstDeploymentPaths = resolveDataPaths('/first/config/hledger-reports.yaml'); - const secondDeploymentPaths = resolveDataPaths('/second/config/hledger-reports.yaml'); - expect(firstDeploymentPaths.jobsDirectory).not.toBe(secondDeploymentPaths.jobsDirectory); - expect(firstDeploymentPaths.snapshotDirectory).not.toBe(secondDeploymentPaths.snapshotDirectory); -}); diff --git a/packages/hledger/src/amounts.test.ts b/packages/hledger/src/amounts.test.ts index 50e878f..388b46b 100644 --- a/packages/hledger/src/amounts.test.ts +++ b/packages/hledger/src/amounts.test.ts @@ -40,6 +40,15 @@ it('rejects non-integer mantissa tokens', () => { ); }); +it('rejects a non-string commodity even when its quantity is valid', () => { + const amount = { + acommodity: 42, + aquantity: { decimalMantissa: 1n, decimalPlaces: 2 }, + acost: null + } as unknown as Amount; + expect(() => reportedAmounts([amount])).toThrow('Invalid native amount.'); +}); + it('projects exact amounts without combining repeated commodities or losing unnamed units', () => { const native = (commodity: string, mantissa: bigint, places: number): Amount => ({ acommodity: commodity, diff --git a/packages/hledger/src/checks.test.ts b/packages/hledger/src/checks.test.ts index 50120be..768cd64 100644 --- a/packages/hledger/src/checks.test.ts +++ b/packages/hledger/src/checks.test.ts @@ -46,20 +46,6 @@ describe('read-only hledger boundary', () => { await expect(getHledgerVersion(async () => 'hledger 2.0')).rejects.toThrow('Unsupported'); }); - it('passes a journal path as one argument to strict checking without write flags', async () => { - await checkJournal('/journals/a journal.journal', async (args) => { - expect(args).toEqual([ - '--no-conf', - '--lang=en', - '-f', - '/journals/a journal.journal', - 'check', - '--strict' - ]); - return ''; - }); - }); - it.each(['nonzero-exit', 'not-completed'] as const)( 'exposes only the safe check classification for %s', async (kind) => { diff --git a/packages/hledger/src/holdings/report.test.ts b/packages/hledger/src/holdings/report.test.ts index ea6329e..0e9f742 100644 --- a/packages/hledger/src/holdings/report.test.ts +++ b/packages/hledger/src/holdings/report.test.ts @@ -311,8 +311,6 @@ it('does not synthesize zero cash when a holding has no native asset bag', async it.each([ ['empty output', ''], ['renamed price header', header.replace('unitprice', 'price')], - ['missing commodity header', header.replace('commodity,', '')], - ['extra header column', `${header},extra`], ['extra row column', `${header}\n${position()},extra`], ['missing final column', `${header}\n${position().slice(0, -3)}`], ['trailing empty row', `${header}\n${position()}\n\n`], @@ -336,17 +334,9 @@ it.each([ ['null report', 'null'], ['null total', '[[],null]'], ['null amount bag', '[[["assets:a","a",0,null]],[]]'], - ['null amount', '[[["assets:a","a",0,[null]]],[]]'], - ['null quantity', '[[["assets:a","a",0,[{"acommodity":"USD","aquantity":null}]]],[]]'], ['negative decimal scale', balance([['assets:a', [amount('USD', '1', -1)]]])], ['scale outside native representation', balance([['assets:a', [amount('USD', '1', 256)]]])], ['non-integer mantissa', balance([['assets:a', [amount('USD', '1.5')]]])], - [ - 'string mantissa', - balance([ - ['assets:a', [amount('USD', '1').replace('"decimalMantissa":1', '"decimalMantissa":"1"')]] - ]) - ], ['control in account name', balance([['assets:a\0', []]])], [ 'duplicate account row', diff --git a/packages/hledger/src/overview/balance-sheet.test.ts b/packages/hledger/src/overview/balance-sheet.test.ts index c048469..b5eb2e6 100644 --- a/packages/hledger/src/overview/balance-sheet.test.ts +++ b/packages/hledger/src/overview/balance-sheet.test.ts @@ -22,11 +22,3 @@ it.each([ ])('rejects missing balance-sheet wire fields (case %#)', (text) => { expect(() => parseBalanceSheet(text)).toThrow(); }); - -it.each(['1.5', '"123"', 'null'])( - 'requires the same lossless integer mantissa codec for %s', - (token) => { - const json = `{"cbrDates":[],"cbrSubreports":[],"cbrTotals":{},"decimalMantissa":${token}}`; - expect(() => parseBalanceSheet(json)).toThrow(); - } -); diff --git a/packages/hledger/src/overview/net-worth.test.ts b/packages/hledger/src/overview/net-worth.test.ts index fd90577..7889865 100644 --- a/packages/hledger/src/overview/net-worth.test.ts +++ b/packages/hledger/src/overview/net-worth.test.ts @@ -92,7 +92,7 @@ it('uses the exact native net bag, retaining mixed amounts, order and explicit z expect(JSON.parse(JSON.stringify(data))).toEqual(data); }); -it.each(['', 'none', 'usd', 'US Dollar', 'US,Dollar', ' USD '])( +it.each(['', 'US,Dollar'])( 'preserves exact native base currency %j between both passes and in arguments', (currency) => { const data = decodeOverviewFixture(stats(currency), batch(frames(), currency)); @@ -147,25 +147,16 @@ it.each([ expect(() => decodeOverviewFixture(stats(), batch(reports))).toThrow(); }); -it.each(['null', '{}', '[]', '{"cbrDates":[],"cbrSubreports":null}', 'SECRET malformed JSON'])( - 'rejects a malformed middle balance report (case %#)', - (json) => { - const text = batch().replace( - /(__HLEDGER_NET_WORTH_3__\n)[\s\S]*?(\n__HLEDGER_NET_WORTH_4__)/, - `$1${json}$2` - ); - expect(() => decodeOverviewFixture(stats(), text)).toThrow(); - } -); +it('rejects a malformed middle balance report', () => { + const text = batch().replace( + /(__HLEDGER_NET_WORTH_3__\n)[\s\S]*?(\n__HLEDGER_NET_WORTH_4__)/, + '$1{"cbrDates":[],"cbrSubreports":null}$2' + ); + expect(() => decodeOverviewFixture(stats(), text)).toThrow(); +}); describe.each(['net', 'assets', 'liabilities'] as const)('malformed consumed %s bags', (side) => { - it.each([ - 'null', - '{}', - '[null]', - '[{"acommodity":42}]', - '[{"acommodity":"USD","aquantity":{"decimalMantissa":1,"decimalPlaces":-1}}]' - ])('rejects unsupported amounts without publishing partial reports (case %#)', (bag) => { + it('rejects a null bag without publishing partial reports', () => { const marker = '\n__HLEDGER_NET_WORTH_3__\n'; const next = '\n__HLEDGER_NET_WORTH_4__\n'; const text = batch(); @@ -179,7 +170,7 @@ describe.each(['net', 'assets', 'liabilities'] as const)('malformed consumed %s (matched) => { seen++; return seen === (side === 'liabilities' ? 2 : 1) - ? `"${key}":{"prrAmounts":[${bag}]}` + ? `"${key}":{"prrAmounts":[null]}` : matched; } ); @@ -324,7 +315,7 @@ describe('native market assets minus liabilities snapshots', () => { }); }); - it.each(['USD', 'usd', 'US Dollar', 'US,Dollar', ' USD ', 'none', 'BTC', ''])( + it.each(['usd', 'US Dollar', 'US,Dollar', ' USD ', 'none', ''])( 'uses native inferred currency %j without normalizing names or fabricating a missing currency', async (currency) => { const symbol = currency === '' ? '' : JSON.stringify(currency); diff --git a/packages/hledger/src/prepare-reports.test.ts b/packages/hledger/src/prepare-reports.test.ts index afc08ce..8c60c2e 100644 --- a/packages/hledger/src/prepare-reports.test.ts +++ b/packages/hledger/src/prepare-reports.test.ts @@ -275,8 +275,6 @@ ${Array.from( const expected = await oracle(path, selected); expect(rows).toHaveLength(112); expect(rows.map(financialRow)).toEqual(expected); - expect(rows.slice(50, 100).map(financialRow)).toEqual(expected.slice(50, 100)); - expect(quantities(rows.slice(100).at(-1)!.balance)).toEqual(expected.at(-1)!.balance); expect(quantities(rows.find((r) => r.transaction.description === 'Tiny')!.change)).toEqual([ { commodity: 'USD', quantity: '0.0049' } ]); diff --git a/packages/hledger/src/report-stream.test.ts b/packages/hledger/src/report-stream.test.ts index b7372b3..33aecb5 100644 --- a/packages/hledger/src/report-stream.test.ts +++ b/packages/hledger/src/report-stream.test.ts @@ -34,9 +34,6 @@ it.each([ ['array', '[{}]'], ['object', '[[]]'], ['object', '[null]'], - ['object', '[1]'], - ['object', '["x"]'], - ['object', '[true]'], ['object', '[{},]'], ['object', '[{} {}]'], ['object', '[{"nested":[] }'], diff --git a/packages/hledger/src/transactions/account-rows.test.ts b/packages/hledger/src/transactions/account-rows.test.ts index 919f011..05b43b0 100644 --- a/packages/hledger/src/transactions/account-rows.test.ts +++ b/packages/hledger/src/transactions/account-rows.test.ts @@ -57,17 +57,6 @@ it('retains original metadata alongside account-context date and exact reported change: [{ commodity: 'USD', quantity: '-9007199254740993.01', cost: null, costBasis: null }], balance: [] }); - // Keep the serialized field order, with only the account-relative date renamed. - expect(Object.keys(result)).toEqual([ - 'accountDate', - 'otherAccounts', - 'isSplit', - 'change', - 'balance', - 'transaction', - 'accountPostingIndexes', - 'investment' - ]); expect(input).toEqual(before); expect(JSON.parse(JSON.stringify(result))).toEqual(result); }); @@ -118,29 +107,15 @@ it('preserves ordered duplicate commodities, mixed units, unnamed and explicit z ]); }); -it('retains empty amount bags instead of inventing a currency zero', () => { - const input = tuple(); - input[4] = []; - expect(organize(input, 'assets:cash')).toMatchObject({ - change: [], - balance: [] - }); -}); - it.each(['{}', '[]', '[{}, {}, false, [], []]'])('rejects an incomplete tuple: %s', (json) => { expect(() => parseAccountRegisterRow(json)).toThrow('Prepared transactions could not be read.'); }); -it.each(['tdate2', 'tcomment', 'tstatus', 'tdescription'] as const)( - 'rejects unexpected contextual %s differences rather than losing information', - (key) => { - const input = tuple(); - input[1][key] = 'unexpected'; - expect(() => organize(input, 'assets:cash')).toThrow( - 'Prepared transactions could not be read.' - ); - } -); +it('rejects unexpected contextual header differences rather than losing information', () => { + const input = tuple(); + input[1].tdate2 = 'unexpected'; + expect(() => organize(input, 'assets:cash')).toThrow('Prepared transactions could not be read.'); +}); it('rejects contextual posting changes', () => { const input = tuple(); @@ -164,15 +139,13 @@ it('recomputes selected indexes from original print, not stripped register posti expect(result.investment).toBeDefined(); }); -it.each(['tindex', 'tdate', 'tdescription', 'tcomment', 'ttags', 'tsourcepos'] as const)( +it.each(['tindex', 'ttags'] as const)( 'rejects a joined print whose stable %s identity differs', (field) => { const input = tuple(); const print = structuredClone(input[0]); if (field === 'tindex') print[field]++; - else if (field === 'ttags') print[field].push(['different', '']); - else if (field === 'tsourcepos') print[field][0].sourceLine++; - else print[field] += 'different'; + else print[field].push(['different', '']); const transaction = prepareTransaction(print, structuredClone(print)); expect(() => organizeAccountRow( diff --git a/packages/hledger/src/transactions/investment.test.ts b/packages/hledger/src/transactions/investment.test.ts index fb7123d..49aa7ad 100644 --- a/packages/hledger/src/transactions/investment.test.ts +++ b/packages/hledger/src/transactions/investment.test.ts @@ -221,33 +221,6 @@ it('uses native sale proceeds, not basis, and keeps overlapping R/G gains separa expect(input.transaction.postings[0].amounts[0].costBasis?.amount?.quantity).toBe('100.00'); }); -it('preserves coalesced multi-lot unspecified basis instead of inventing average basis', () => { - const shares = security('VTI', '-8', '150.00', null); - shares.costBasis = { amount: null, date: null, label: null }; - const input = row( - [ - posting('holdings', [shares], 'dispose', [value('USD', '-1200')]), - posting('wallet', [amount('USD', '1200.00')]), - posting('gains', [amount('USD', '-300')], 'gain') - ], - [0], - [amount('VTI', '-8')], - [amount('VTI', '5')] - ); - const before = structuredClone(input); - expect(prepare(input).securities[0]).toMatchObject({ - action: 'sell', - prices: [value('USD', '150.00')], - transactedAmounts: [value('USD', '-1200')] - }); - expect(input).toEqual(before); - expect(input.transaction.postings[0].amounts[0].costBasis).toEqual({ - amount: null, - date: null, - label: null - }); -}); - it.each(['UnitCost', 'TotalCost'] as const)( 'retains native %s values without deriving a unit price', (kind) => { diff --git a/packages/hledger/src/transactions/transaction.test.ts b/packages/hledger/src/transactions/transaction.test.ts index fd3524f..526ce55 100644 --- a/packages/hledger/src/transactions/transaction.test.ts +++ b/packages/hledger/src/transactions/transaction.test.ts @@ -66,8 +66,6 @@ it('retains original transaction metadata, ordered duplicate tags, postings and ] }; expect(result).toEqual(expected); - // Field order and explicit nulls are part of the serialized private record. - expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); expect(JSON.parse(JSON.stringify(result))).toEqual(result); }); @@ -134,8 +132,6 @@ it('normalizes every nested amount from integer tokens, preserving cost/basis/st } }; expect(result).toEqual(expected); - // Field order and explicit nulls are part of the serialized private record. - expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); expect(JSON.parse(JSON.stringify(result))).toEqual(result); const emptyBasis = parseExactJson(json) as Parameters[0]; emptyBasis.acostbasis = { cbCost: null, cbDate: null, cbLabel: null }; @@ -222,11 +218,7 @@ it.each([ ['ascommodityside', 1], ['ascommodityspaced', 'true'], ['asdecimalmark', undefined], - ['asdecimalmark', 1], - ['asdigitgroups', undefined], - ['asdigitgroups', [1, [3]]], ['asdigitgroups', [',', [1.5]]], - ['asprecision', undefined], ['asprecision', Number.MAX_SAFE_INTEGER + 1], ['asrounding', null] ])('rejects malformed native amount style %s (%#)', (field, value) => { @@ -239,9 +231,7 @@ it.each([ it.each([ ['cbCost', undefined], ['cbDate', undefined], - ['cbDate', 1], - ['cbLabel', undefined], - ['cbLabel', false] + ['cbLabel', undefined] ])('rejects malformed native cost basis %s (%#)', (field, value) => { const native = parseExactJson(JSON.stringify(transactionFixture())) as NativeTransaction; const amount = native.tpostings[0].pamount[0]; @@ -252,9 +242,6 @@ it.each([ it.each([ ['tindex', 0], - ['tindex', -1], - ['tindex', 1.5], - ['tindex', Number.MAX_SAFE_INTEGER + 1], ['tdate', null], ['tdate2', undefined], ['tcomment', false], @@ -379,25 +366,3 @@ it('decodes unsafe-integer transacted tokens independently of original nested co { commodity: 'USD', quantity: '-9007199254740993.01' } ]); }); - -it.each(['cost', 'basis', 'assertion'])( - 'rejects fractional scientific mantissas nested in %s', - (location) => { - const native = transactionFixture(); - const amount = structuredClone(native.tpostings[0].pamount[0]); - amount.aquantity.decimalMantissa = 123456; - if (location === 'cost') - Reflect.set(native.tpostings[0].pamount[0], 'acost', { tag: 'UnitCost', contents: amount }); - if (location === 'basis') - Reflect.set(native.tpostings[0].pamount[0], 'acostbasis', { - cbCost: amount, - cbDate: null, - cbLabel: null - }); - if (location === 'assertion') - Reflect.set(native.tpostings[0], 'pbalanceassertion', { baamount: amount }); - expect(() => - decode(JSON.stringify(native).replace('"decimalMantissa":123456', '"decimalMantissa":1e-20')) - ).toThrow('integer mantissa'); - } -); diff --git a/packages/jobs/README.md b/packages/jobs/README.md index 5bec85a..8507d41 100644 --- a/packages/jobs/README.md +++ b/packages/jobs/README.md @@ -23,7 +23,6 @@ src/ client.ts client.test.ts migration.integration.test.ts - schedules.integration.test.ts queries/ cost-balance.ts cost-balance.test.ts @@ -91,8 +90,9 @@ migrates SQLite; mixed versions and downgrades are not supported rollback paths. Restore the pre-upgrade backup if rollback is required. Tests cover definitions, validation, private storage and lifecycle, genuine SDK -0.9 → 0.11 migration (including original rows and queued legacy/live work), -concurrent migration, and SDK schedule persistence, catch-up and pause behavior. +0.9 → 0.11 migration (including original rows and queued legacy/live work). +The worker tests its actual retention schedule, restart and pause behavior; this +package does not maintain a general SDK scheduling or migration-locking suite. The old SDK alias is test-only. Vitest uses compiled workspace dependencies; build shared packages first. Application submission and execution tests live with their web and worker owners. diff --git a/packages/jobs/src/holdings.test.ts b/packages/jobs/src/holdings.test.ts index 682c374..f5feac3 100644 --- a/packages/jobs/src/holdings.test.ts +++ b/packages/jobs/src/holdings.test.ts @@ -1,51 +1,16 @@ -import { Value } from '@sinclair/typebox/value'; -import { expect, expectTypeOf, it } from 'vitest'; -import { - HoldingsRequestSchema, - SafeHoldingsJobError, - validateHoldingsRequest, - type HoldingsRequest -} from './holdings.js'; +import { expect, it } from 'vitest'; +import { SafeHoldingsJobError, validateHoldingsRequest } from './holdings.js'; -it('declares the exact nullable literal-account request schema', () => { - expectTypeOf().toEqualTypeOf<{ asOf: string; account: string | null }>(); - expect(Object.keys(HoldingsRequestSchema.properties)).toEqual(['asOf', 'account']); - expect(HoldingsRequestSchema.additionalProperties).toBe(false); - for (const account of [null, ' Assets:Broker (A.*) and not tag:private ', 'x'.repeat(4096)]) { - const request = { asOf: '2026-04-30', account }; - expect(Value.Check(HoldingsRequestSchema, request)).toBe(true); - expect(validateHoldingsRequest(request)).toEqual(request); - } - for (const value of [ - { asOf: '2026-04-30' }, - { asOf: '2026-04-30', account: null, extra: true }, - { asOf: '2026-04-30', account: 123 }, - { asOf: '2026-04-30', account: ' \u00a0 ' }, - { asOf: '2026-04-30', account: 'assets\u0085' }, - { asOf: '2026-04-30\n', account: null } - ]) { - expect(Value.Check(HoldingsRequestSchema, value)).toBe(false); - } +it('accepts an explicit all-accounts scope and a semantic report date', () => { + const request = { asOf: '2024-02-29', account: null }; + expect(validateHoldingsRequest(request)).toEqual(request); }); -it.each(['0001-01-01', '2024-02-29', '9999-12-30'])( - 'accepts real dates with a representable next day: %s', - (asOf) => { - expect(validateHoldingsRequest({ asOf, account: null })).toEqual({ asOf, account: null }); - } -); - it.each([ null, - [], - {}, { asOf: '2026-04-30' }, { asOf: '2026-04-30', account: null, journal: '/PRIVATE' }, { asOf: '2026-04-30', account: null, [Symbol('PRIVATE')]: true }, - Object.assign(Object.create({ account: null }), { - asOf: '2026-04-30', - [Symbol('PRIVATE')]: true - }), Object.defineProperty({ asOf: '2026-04-30', account: null }, 'PRIVATE', { value: true }), { asOf: '2026-04-30', @@ -53,29 +18,11 @@ it.each([ throw new Error('/PRIVATE'); } }, - ...[ - '0000-01-01', - '9999-12-31', - '2026-02-29', - '2026-04-31', - '2026-4-01', - '2026-04-01\n', - 'PRIVATE' - ].map((asOf) => ({ asOf, account: null })), - ...[ - undefined, - 1, - '', - ' \t', - ' \u00a0 ', - { toString: () => 'assets' }, - 'assets\nPRIVATE', - 'assets\u0000PRIVATE', - 'assets\u007f', - 'assets\u0085', - 'assets\u2028', - 'assets\u2029' - ].map((account) => ({ asOf: '2026-04-30', account })) + { asOf: '2026-02-29', account: null }, + ...[1, ' \u00a0 ', 'assets\nPRIVATE', 'assets\u0085', 'assets\u2028'].map((account) => ({ + asOf: '2026-04-30', + account + })) ])('rejects malformed requests safely %#', (request) => { let failure: unknown; try { @@ -94,13 +41,3 @@ it('preserves literal account names, punctuation, case, and whitespace without q expect(validateHoldingsRequest(request)).toEqual(request); expect(validateHoldingsRequest(request)).not.toBe(request); }); - -it.each(['INVALID_REQUEST', 'NOT_READY', 'HOLDINGS_FAILED'] as const)( - 'exposes only a fixed public %s failure', - (code) => { - const error = new SafeHoldingsJobError(code); - expect(error.code).toBe(code); - expect(error.cause).toBeUndefined(); - expect(error.message).not.toMatch(/PRIVATE|stderr|\/owned/); - } -); diff --git a/packages/jobs/src/input.test.ts b/packages/jobs/src/input.test.ts index eb57d55..40335b7 100644 --- a/packages/jobs/src/input.test.ts +++ b/packages/jobs/src/input.test.ts @@ -1,12 +1,6 @@ import { expect, it } from 'vitest'; -import { HoldingsRequestSchema, validateHoldingsRequest } from './holdings.js'; -import { ReportDateSchema, toStandardSchema, validReportDate } from './input.js'; -import { QueryRequestSchema, validateQueryRequest } from './queries/cost-balance.js'; - -it('shares the report date shape between both request schemas', () => { - expect(HoldingsRequestSchema.properties.asOf).toEqual(ReportDateSchema); - expect(QueryRequestSchema.properties.asOf).toEqual(ReportDateSchema); -}); +import { validateHoldingsRequest } from './holdings.js'; +import { toStandardSchema, validReportDate } from './input.js'; it.each([ ['0001-01-01', true], @@ -18,17 +12,8 @@ it.each([ ['9999-12-31', false], ['10000-01-01', false], ['2024-01-01\n', false] -] as const)('uses the same semantic report date rule for %s', (asOf, valid) => { +] as const)('validates the semantic report date rule for %s', (asOf, valid) => { expect(validReportDate(asOf)).toBe(valid); - const holdings = { asOf, account: null }; - const query = { asOf, query: '' }; - if (valid) { - expect(validateHoldingsRequest(holdings)).toEqual(holdings); - expect(validateQueryRequest(query)).toEqual(query); - } else { - expect(() => validateHoldingsRequest(holdings)).toThrow('Invalid holdings request.'); - expect(() => validateQueryRequest(query)).toThrow('Invalid query request.'); - } }); it('converts the concrete validator result without retaining the original input', () => { @@ -41,14 +26,11 @@ it('converts the concrete validator result without retaining the original input' expect(result.value).not.toBe(request); }); -it.each([new Error('/PRIVATE stderr', { cause: '/PRIVATE journal' }), '/PRIVATE input'])( - 'isolates private validator failures behind the fixed Standard Schema message %#', - (failure) => { - const schema = toStandardSchema(() => { - throw failure; - }, 'Invalid request.'); - expect(schema['~standard'].validate({ secret: '/PRIVATE' })).toEqual({ - issues: [{ message: 'Invalid request.' }] - }); - } -); +it('isolates private validator failures behind the fixed Standard Schema message', () => { + const schema = toStandardSchema(() => { + throw new Error('/PRIVATE stderr', { cause: '/PRIVATE journal' }); + }, 'Invalid request.'); + expect(schema['~standard'].validate({ secret: '/PRIVATE' })).toEqual({ + issues: [{ message: 'Invalid request.' }] + }); +}); diff --git a/packages/jobs/src/queries/cost-balance.test.ts b/packages/jobs/src/queries/cost-balance.test.ts index a1ae541..b2619fa 100644 --- a/packages/jobs/src/queries/cost-balance.test.ts +++ b/packages/jobs/src/queries/cost-balance.test.ts @@ -1,49 +1,25 @@ -import { Value } from '@sinclair/typebox/value'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { - QueryJobError, - QueryRequestSchema, - validateQueryRequest, - type QueryRequest -} from './cost-balance.js'; +import { describe, expect, it } from 'vitest'; +import { QueryJobError, validateQueryRequest } from './cost-balance.js'; const request = { query: '(assets or expenses) and not tag:private', asOf: '2024-02-29' }; describe('query contracts', () => { - it('declares the exact expression request schema', () => { - expectTypeOf().toEqualTypeOf<{ query: string; asOf: string }>(); - expect(Object.keys(QueryRequestSchema.properties)).toEqual(['query', 'asOf']); - expect(QueryRequestSchema.additionalProperties).toBe(false); - expect(Value.Check(QueryRequestSchema, request)).toBe(true); - expect(Value.Check(QueryRequestSchema, { ...request, query: 'x'.repeat(513) })).toBe(false); - expect(Value.Check(QueryRequestSchema, { ...request, account: null })).toBe(false); - }); - it('accepts a single expression verbatim, blanks, and valid leap dates without coercion', () => { expect(validateQueryRequest(request)).toEqual(request); expect(validateQueryRequest(request)).not.toBe(request); const literal = { ...request, query: ' Assets:Broker\t\u007f\u0085 ' }; - expect(Value.Check(QueryRequestSchema, literal)).toBe(true); expect(validateQueryRequest(literal)).toEqual(literal); expect(validateQueryRequest({ query: '', asOf: '2025-12-31' }).query).toBe(''); - expect( - validateQueryRequest({ query: 'expr:assets or liabilities', asOf: '0001-01-01' }).query - ).toBe('expr:assets or liabilities'); - expect(validateQueryRequest({ query: 'x'.repeat(512), asOf: '9999-12-30' })).toEqual({ - query: 'x'.repeat(512), - asOf: '9999-12-30' - }); + expect(validateQueryRequest({ ...request, query: 'x'.repeat(512) }).query).toBe( + 'x'.repeat(512) + ); }); it.each([ null, - [], - {}, - { ...request, extra: true }, - { ...request, generation: 'a'.repeat(64) }, + { ...request, account: null }, { ...request, journal: '/private' }, { ...request, query: 123 }, - { ...request, query: { toString: () => 'assets' } }, { asOf: request.asOf, get query() { @@ -53,32 +29,9 @@ describe('query contracts', () => { { ...request, query: 'x'.repeat(513) }, { ...request, query: 'a\0b' }, { ...request, query: 'a\nb' }, - { ...request, query: 'a\rb' }, { ...request, query: 'a\u2028b' }, - { ...request, query: 'a\u2029b' }, - { ...request, asOf: '2023-02-29' }, - { ...request, asOf: '1900-02-29' }, - { ...request, asOf: '2024-04-31' }, - { ...request, asOf: '2024-2-01' }, - { ...request, asOf: '9999-12-31' }, - { ...request, asOf: '0000-01-01' }, - { ...request, asOf: '2024-01-01\n' } + { ...request, asOf: '2023-02-29' } ])('rejects malformed requests with the same safe error', (value) => { expect(() => validateQueryRequest(value)).toThrowError(new QueryJobError('INVALID_REQUEST')); }); - - it.each([ - ['INVALID_REQUEST', 'Invalid query request.'], - ['NOT_READY', 'Query service is not ready.'], - [ - 'QUERY_FAILED', - 'Query could not be completed. Check the worker, journal, expression, and date.' - ] - ] as const)('exposes only the fixed %s error contract', (code, message) => { - const error = new QueryJobError(code); - expect(error.name).toBe('QueryJobError'); - expect(error.code).toBe(code); - expect(error.message).toBe(message); - expect(error).not.toHaveProperty('cause'); - }); }); diff --git a/packages/jobs/src/queue/client.test.ts b/packages/jobs/src/queue/client.test.ts index 6791dd7..e7728f0 100644 --- a/packages/jobs/src/queue/client.test.ts +++ b/packages/jobs/src/queue/client.test.ts @@ -1,7 +1,7 @@ import { chmod, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { defineWorkflowSpec, OpenWorkflow } from 'openworkflow'; +import { OpenWorkflow } from 'openworkflow'; import { BackendSqlite } from 'openworkflow/sqlite'; import { afterEach, expect, it, vi } from 'vitest'; import { openJobsClient, type JobsClient } from './client.js'; @@ -87,40 +87,6 @@ it('supplies a fresh safe logger as the SDK worker default for each client', asy expect(warn.mock.calls).toEqual([['Workflow engine warn']]); }); -it('supports stopping and draining a worker before closing its backend', async () => { - const { client } = await setup(); - const spec = defineWorkflowSpec({ name: 'test.drain' }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let entered = false; - client.workflow.implementWorkflow(spec, async () => { - entered = true; - await gate; - return 'drained'; - }); - const worker = client.workflow.newWorker(); - try { - const handle = await client.workflow.runWorkflow(spec, undefined); - await worker.tick(); - await expect.poll(() => entered).toBe(true); - let stopped = false; - const stopping = worker.stop().then(() => { - stopped = true; - }); - await Promise.resolve(); - expect(stopped).toBe(false); - release(); - await stopping; - expect(await handle.result({ timeoutMs: 1_000 })).toBe('drained'); - await client.close(); - } finally { - release(); - await worker.stop(); - } -}); - it('joins concurrent and repeated closes into one backend stop', async () => { const { client } = await setup(); const originalStop = client.backend.stop.bind(client.backend); diff --git a/packages/jobs/src/queue/engine-logger.test.ts b/packages/jobs/src/queue/engine-logger.test.ts index 0a43a7e..eb646ef 100644 --- a/packages/jobs/src/queue/engine-logger.test.ts +++ b/packages/jobs/src/queue/engine-logger.test.ts @@ -42,16 +42,14 @@ it('keeps debug and info silent for both known and unknown messages', () => { for (const method of Object.values(sink)) expect(method).not.toHaveBeenCalled(); }); -it.each([undefined, null, 42, false, Symbol('PRIVATE')])( - 'handles a non-string first argument without coercion: %s', - (message) => { - const { sink, logger } = setup(); - logger.warn(message as unknown as string); - logger.error(message as unknown as string); - expect(sink.warn.mock.calls).toEqual([['Workflow engine warn']]); - expect(sink.error.mock.calls).toEqual([['Workflow engine error']]); - } -); +it('handles a non-string first argument without coercion', () => { + const { sink, logger } = setup(); + const message = Symbol('PRIVATE'); + logger.warn(message as unknown as string); + logger.error(message as unknown as string); + expect(sink.warn.mock.calls).toEqual([['Workflow engine warn']]); + expect(sink.error.mock.calls).toEqual([['Workflow engine error']]); +}); it('never inspects or serializes hostile messages or extra arguments', () => { const { sink, logger } = setup(); @@ -86,29 +84,19 @@ it('never inspects or serializes hostile messages or extra arguments', () => { ]); }); -it.each([runId, runId.toUpperCase()])( - 'retains only a complete execution message UUID: %s', - (id) => { - const { sink, logger } = setup(); - logger.error(executionMessage(id), new Error('PRIVATE native stderr')); - expect(sink.error.mock.calls).toEqual([ - ['Workflow engine execution failed', { workflowRunId: id }] - ]); - } -); +it('retains only a complete execution message UUID', () => { + const { sink, logger } = setup(); + logger.error(executionMessage(runId), new Error('PRIVATE native stderr')); + expect(sink.error.mock.calls).toEqual([ + ['Workflow engine execution failed', { workflowRunId: runId }] + ]); +}); it.each([ executionMessage('private-account'), - executionMessage('00000000-0000-0000-0000-000000000000'), - executionMessage('12345678-1234-9123-8123-123456789abc'), - executionMessage('12345678-1234-4123-7123-123456789abc'), - executionMessage(`${runId}PRIVATE`), - executionMessage(runId).slice(0, -1), `${executionMessage(runId)} PRIVATE`, - `${executionMessage(runId)}\n`, - `${executionMessage(runId)}\r\n`, - `${executionMessage(runId)}\u2028`, - `${executionMessage(runId)}\u2029` + // A trailing newline must not bypass the complete-message privacy whitelist. + `${executionMessage(runId)}\n` ])('omits run IDs from incomplete, invalid, or suffixed execution messages: %s', (message) => { const { sink, logger } = setup(); logger.error(message, { workflowRunId: runId }); diff --git a/packages/jobs/src/queue/migration.integration.test.ts b/packages/jobs/src/queue/migration.integration.test.ts index 175d5b0..738d5a8 100644 --- a/packages/jobs/src/queue/migration.integration.test.ts +++ b/packages/jobs/src/queue/migration.integration.test.ts @@ -1,9 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { once } from 'node:events'; import { DatabaseSync } from 'node:sqlite'; -import { Worker as MigrationThread } from 'node:worker_threads'; import { OpenWorkflow as PreviousOpenWorkflow, defineWorkflowSpec } from 'openworkflow-0-9'; import { BackendSqlite as PreviousBackend } from 'openworkflow-0-9/sqlite'; import { afterEach, expect, it } from 'vitest'; @@ -160,42 +158,3 @@ it('migrates a populated 0.9 database without changing records and resumes its q await reopened.close(); } }, 15_000); - -it('allows two concurrent startup connections to migrate the same 0.9 database without losing records', async () => { - const fixture = await previousDatabase(); - const before = readOriginalSchemaRecords(fixture.database); - const barrier = new SharedArrayBuffer(4); - const migrate = ` - const { parentPort, workerData } = require('node:worker_threads'); - (async () => { - const { BackendSqlite } = await import(workerData.sdk); - parentPort.postMessage('ready'); - Atomics.wait(new Int32Array(workerData.barrier), 0, 0); - const backend = BackendSqlite.connect(workerData.database); - await backend.stop(); - parentPort.close(); - })().catch(error => { throw error; }); - `; - const threads = [0, 1].map( - () => - new MigrationThread(migrate, { - eval: true, - workerData: { - sdk: import.meta.resolve('openworkflow/sqlite'), - database: fixture.database, - barrier - } - }) - ); - try { - const exits = threads.map((thread) => once(thread, 'exit')); - await Promise.all(threads.map((thread) => once(thread, 'message'))); - Atomics.store(new Int32Array(barrier), 0, 1); - Atomics.notify(new Int32Array(barrier), 0); - for (const [code] of await Promise.all(exits)) expect(code).toBe(0); - expect(migrationVersion(fixture.database)).toBe(7); - expect(readOriginalSchemaRecords(fixture.database)).toEqual(before); - } finally { - await Promise.all(threads.map((thread) => thread.terminate())); - } -}, 15_000); diff --git a/packages/jobs/src/queue/schedules.integration.test.ts b/packages/jobs/src/queue/schedules.integration.test.ts deleted file mode 100644 index d67c80d..0000000 --- a/packages/jobs/src/queue/schedules.integration.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { defineWorkflowSpec, type Worker } from 'openworkflow'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { - openJobsClient, - type LiveQueryResult, - type JobsClient, - type QueryRequest -} from '../index.js'; - -const request: QueryRequest = { query: 'type:X', asOf: '2026-04-30' }; -const output: LiveQueryResult = { ...request, rows: [], total: [] }; -const declaration = { id: 'test.recurring-report', cron: '* * * * *', input: request }; -// SDK recurrence requires an unversioned spec; this does not schedule the production query. -const spec = defineWorkflowSpec({ - name: 'test.recurring-report', - schedules: [declaration] -}); -const workers = new Set(); -const clients = new Set(); -const directories: string[] = []; - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-05-01T00:00:00.000Z')); -}); - -async function stopWorker(worker: Worker) { - const stopped = worker.stop(); - // stop() waits for both sleeping SDK polling loops. Drain them while time is - // still fake, rather than restoring timers with an unresolved loop promise. - await vi.advanceTimersByTimeAsync(1_000); - await stopped; -} - -afterEach(async () => { - try { - for (const worker of workers) await stopWorker(worker); - for (const client of clients) await client.close(); - for (const directory of directories) await rm(directory, { recursive: true, force: true }); - expect(vi.getTimerCount()).toBe(0); - } finally { - workers.clear(); - clients.clear(); - directories.length = 0; - vi.clearAllTimers(); - vi.useRealTimers(); - vi.restoreAllMocks(); - } -}); - -async function openClient(directory?: string) { - if (!directory) { - directory = await mkdtemp(join(tmpdir(), 'job-schedules-')); - directories.push(directory); - } - const client = await openJobsClient(directory); - clients.add(client); - const execute = vi.fn(({ input }: { input: QueryRequest }) => { - expect(input).toEqual(request); - return output; - }); - client.workflow.implementWorkflow(spec, execute); - const worker = client.workflow.newWorker({ concurrency: 1 }); - workers.add(worker); - return { client, worker, directory, execute }; -} - -it('persists declarative schedules on worker.start and executes each occurrence only once', async () => { - const { client, worker, execute } = await openClient(); - expect((await client.workflow.schedules.list()).data).toEqual([]); - await worker.start(); - const schedule = await client.workflow.schedules.get(declaration.id); - expect(schedule).toMatchObject({ - id: declaration.id, - workflowName: spec.name, - input: request, - timezone: 'UTC', - nextRunAt: new Date('2026-05-01T00:01:00.000Z') - }); - - // Wake the real SDK scheduler and execution loops at the next cron occurrence. - vi.setSystemTime(new Date('2026-05-01T00:01:00.000Z')); - await vi.advanceTimersByTimeAsync(1_000); - expect(execute).toHaveBeenCalledTimes(1); - expect((await client.backend.listWorkflowRuns({})).data).toEqual([ - expect.objectContaining({ - workflowScheduleId: declaration.id, - workflowName: spec.name, - status: 'completed', - input: request, - output - }) - ]); - - // Repeated scheduler passes against the same occurrence cannot enqueue again. - const due = { before: new Date(), limit: 100 }; - expect(await client.backend.enqueueScheduledRuns(due)).toBe(0); - expect(await client.backend.enqueueScheduledRuns(due)).toBe(0); - await vi.advanceTimersByTimeAsync(1_000); - expect(execute).toHaveBeenCalledTimes(1); - expect((await client.backend.listWorkflowRuns({})).data).toHaveLength(1); -}); - -it('preserves schedule identity and an administrative pause across reopen and repeated registration', async () => { - const first = await openClient(); - await first.worker.start(); - const original = await first.client.workflow.schedules.get(declaration.id); - await first.client.workflow.schedules.pause(declaration.id); - await stopWorker(first.worker); - await first.client.close(); - clients.delete(first.client); - - const reopened = await openClient(first.directory); - expect(await reopened.client.workflow.schedules.get(declaration.id)).toMatchObject({ - id: original?.id, - createdAt: original?.createdAt, - nextRunAt: null - }); - for (let registration = 0; registration < 2; registration++) { - const registered = await reopened.client.workflow.scheduleWorkflow(spec, request, declaration); - expect(registered).toMatchObject({ - id: original?.id, - createdAt: original?.createdAt, - nextRunAt: null - }); - } - await reopened.worker.start(); - await stopWorker(reopened.worker); - await reopened.worker.start(); - vi.setSystemTime(new Date('2026-05-01T00:05:00.000Z')); - await vi.advanceTimersByTimeAsync(1_000); - const schedules = (await reopened.client.workflow.schedules.list()).data; - expect(schedules).toHaveLength(1); - expect(schedules[0]).toMatchObject({ id: declaration.id, nextRunAt: null }); - expect(reopened.execute).not.toHaveBeenCalled(); - expect((await reopened.client.backend.listWorkflowRuns({})).data).toEqual([]); -}); - -it('retains the due cursor across downtime and dispatches one overdue run, not every missed minute', async () => { - const first = await openClient(); - await first.worker.start(); - const original = await first.client.workflow.schedules.get(declaration.id); - await stopWorker(first.worker); - await first.client.close(); - clients.delete(first.client); - - vi.setSystemTime(new Date('2026-05-01T00:05:30.000Z')); - const reopened = await openClient(first.directory); - expect(await reopened.client.workflow.schedules.get(declaration.id)).toEqual(original); - await reopened.worker.start(); - await vi.advanceTimersByTimeAsync(1_000); - expect(reopened.execute).toHaveBeenCalledTimes(1); - expect((await reopened.client.backend.listWorkflowRuns({})).data).toEqual([ - expect.objectContaining({ status: 'completed', input: request, output }) - ]); - const schedules = (await reopened.client.workflow.schedules.list()).data; - expect(schedules).toHaveLength(1); - expect(schedules[0]).toMatchObject({ - id: original?.id, - createdAt: original?.createdAt, - nextRunAt: new Date('2026-05-01T00:06:00.000Z') - }); - - await stopWorker(reopened.worker); - await reopened.worker.start(); - await vi.advanceTimersByTimeAsync(1_000); - expect(reopened.execute).toHaveBeenCalledTimes(1); - expect((await reopened.client.backend.listWorkflowRuns({})).data).toHaveLength(1); -}); diff --git a/packages/jobs/src/specs.test.ts b/packages/jobs/src/specs.test.ts index e5fe98d..addc14a 100644 --- a/packages/jobs/src/specs.test.ts +++ b/packages/jobs/src/specs.test.ts @@ -10,12 +10,10 @@ import { retiredIncomePreparationSpec } from './specs.js'; -const request = { query: '(assets or expenses) and not tag:private', asOf: '2024-02-29' }; - describe('workflow definitions', () => { it('returns the same uncoerced DTOs as the concrete validators', async () => { const holdings = { asOf: '2024-02-29', account: ' Assets:Broker (A.*) ' }; - const query = { ...request, query: ' assets or expenses ' }; + const query = { asOf: '2024-02-29', query: ' assets or expenses ' }; expect(await holdingsSpec.schema?.['~standard'].validate(holdings)).toEqual({ value: validateHoldingsRequest(holdings) }); @@ -24,32 +22,8 @@ describe('workflow definitions', () => { }); }); - it.each([ - null, - [], - {}, - { asOf: '2024-02-29', query: '', account: null }, - { - get asOf() { - throw new Error('/PRIVATE stderr', { cause: '/PRIVATE journal' }); - }, - account: null - }, - { - asOf: '2024-02-29', - get query() { - throw new Error('/PRIVATE input'); - } - }, - new Proxy( - {}, - { - ownKeys() { - throw new Error('/PRIVATE keys'); - } - } - ) - ])('isolates malformed workflow inputs behind fixed issues %#', async (value) => { + it('binds both workflows to safe request validation', async () => { + const value = { secret: '/PRIVATE' }; expect(await holdingsSpec.schema?.['~standard'].validate(value)).toEqual({ issues: [{ message: 'Invalid holdings request.' }] }); @@ -58,16 +32,6 @@ describe('workflow definitions', () => { }); }); - it('retains symbol-key rejection at the holdings workflow boundary', async () => { - expect( - await holdingsSpec.schema?.['~standard'].validate({ - asOf: '2024-02-29', - account: null, - [Symbol('PRIVATE')]: true - }) - ).toEqual({ issues: [{ message: 'Invalid holdings request.' }] }); - }); - it('declares six workflow identities without scheduling holdings', async () => { const specs = [ liveBalanceQuerySpec, @@ -82,22 +46,11 @@ describe('workflow definitions', () => { expect(holdingsSpec.version).toBe('1'); expect(holdingsSpec.retryPolicy?.maximumAttempts).toBe(1); expect(holdingsSpec.schedules).toBeUndefined(); - const request = { asOf: '2024-02-29', account: null }; - expect(await holdingsSpec.schema?.['~standard'].validate(request)).toEqual({ value: request }); - expect(await holdingsSpec.schema?.['~standard'].validate({ secret: '/PRIVATE' })).toEqual({ - issues: [{ message: 'Invalid holdings request.' }] - }); }); - it('validates the live v2 workflow input without retaining private validation details', async () => { + it('declares the live v2 query identity and one-attempt policy', () => { expect(liveBalanceQuerySpec.name).toBe('hledger.balance-query'); expect(liveBalanceQuerySpec.version).toBe('2'); expect(liveBalanceQuerySpec.retryPolicy?.maximumAttempts).toBe(1); - expect(await liveBalanceQuerySpec.schema?.['~standard'].validate(request)).toEqual({ - value: request - }); - expect( - await liveBalanceQuerySpec.schema?.['~standard'].validate({ secret: '/private' }) - ).toEqual({ issues: [{ message: 'Invalid query request.' }] }); }); it('leaves retention scheduling to worker startup on an unversioned SDK workflow', () => { diff --git a/packages/snapshots/src/reports.test.ts b/packages/snapshots/src/reports.test.ts index c97215d..12b1b2a 100644 --- a/packages/snapshots/src/reports.test.ts +++ b/packages/snapshots/src/reports.test.ts @@ -344,7 +344,6 @@ it('retains all rows in one file; callers slice 50 after reading it', async () = const publication = await selected(); const result = await readAccountTransactions(publication, publication.accounts[0].id); expect(result.transactions).toEqual(all); - expect(result.transactions!.slice(50, 100)).toEqual(all.slice(50, 100)); expect(await fs.readdir(join(publication.directory, 'transactions'))).toHaveLength(1); }); @@ -689,57 +688,49 @@ it('publishes headers, appended rows and JSON without a byte-length policy', asy expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(publication.directory)]); }); -it.each(['overview', 'account'])( - 'reads %s above the former byte bound using a size double', - async (kind) => { - await publish(); - const publication = await selected(); - const account = publication.accounts[0]; - const path = - kind === 'overview' - ? join(publication.directory, 'overview.json') - : join(publication.directory, 'transactions', `${account.id}.json`); - const raw = await fs.readFile(path, 'utf8'); - const reportedSize = 64 * 1024 * 1024 + 1; - // Model a large, consistent read without allocating or storing a large fixture. - const toString = vi.fn().mockReturnValue(raw); - const buffer = { length: reportedSize + 1, toString } as unknown as Buffer; - const alloc = Buffer.alloc; - const allocation = vi - .spyOn(Buffer, 'alloc') - .mockImplementation((size, ...args) => - size === reportedSize + 1 ? buffer : alloc(size, ...args) - ); - const read = vi - .fn() - .mockResolvedValueOnce({ bytesRead: reportedSize, buffer }) - .mockResolvedValueOnce({ bytesRead: 0, buffer }); - const close = vi.fn(); - const open = fs.open; - vi.spyOn(fs, 'open').mockImplementation(async (openedPath, ...args) => { - const file = await open(openedPath, ...args); - if (openedPath === path) { - const stat = await file.stat(); - vi.spyOn(file, 'stat').mockResolvedValue(Object.assign(stat, { size: reportedSize })); - vi.spyOn(file, 'read').mockImplementation(read); - const originalClose = file.close.bind(file); - vi.spyOn(file, 'close').mockImplementation(async () => { - close(); - await originalClose(); - }); - } - return file; - }); - if (kind === 'overview') expect(await readOverview(snapshots)).toEqual(fixture()); - else - expect((await readAccountTransactions(publication, account.id)).transactions).toEqual(rows()); - expect(allocation).toHaveBeenCalledWith(reportedSize + 1); - expect(read).toHaveBeenNthCalledWith(1, buffer, 0, reportedSize + 1, null); - expect(read).toHaveBeenNthCalledWith(2, buffer, reportedSize, 1, null); - expect(toString).toHaveBeenCalledWith('utf8', 0, reportedSize); - expect(close).toHaveBeenCalledOnce(); - } -); +it('reads a pinned account above the former byte bound using a size double', async () => { + await publish(); + const publication = await selected(); + const account = publication.accounts[0]; + const path = join(publication.directory, 'transactions', `${account.id}.json`); + const raw = await fs.readFile(path, 'utf8'); + const reportedSize = 64 * 1024 * 1024 + 1; + // Model a large, consistent read without allocating or storing a large fixture. + const toString = vi.fn().mockReturnValue(raw); + const buffer = { length: reportedSize + 1, toString } as unknown as Buffer; + const alloc = Buffer.alloc; + const allocation = vi + .spyOn(Buffer, 'alloc') + .mockImplementation((size, ...args) => + size === reportedSize + 1 ? buffer : alloc(size, ...args) + ); + const read = vi + .fn() + .mockResolvedValueOnce({ bytesRead: reportedSize, buffer }) + .mockResolvedValueOnce({ bytesRead: 0, buffer }); + const close = vi.fn(); + const open = fs.open; + vi.spyOn(fs, 'open').mockImplementation(async (openedPath, ...args) => { + const file = await open(openedPath, ...args); + if (openedPath === path) { + const stat = await file.stat(); + vi.spyOn(file, 'stat').mockResolvedValue(Object.assign(stat, { size: reportedSize })); + vi.spyOn(file, 'read').mockImplementation(read); + const originalClose = file.close.bind(file); + vi.spyOn(file, 'close').mockImplementation(async () => { + close(); + await originalClose(); + }); + } + return file; + }); + expect((await readAccountTransactions(publication, account.id)).transactions).toEqual(rows()); + expect(allocation).toHaveBeenCalledWith(reportedSize + 1); + expect(read).toHaveBeenNthCalledWith(1, buffer, 0, reportedSize + 1, null); + expect(read).toHaveBeenNthCalledWith(2, buffer, reportedSize, 1, null); + expect(toString).toHaveBeenCalledWith('utf8', 0, reportedSize); + expect(close).toHaveBeenCalledOnce(); +}); it.each(['growth', 'shrinkage'])('rejects account file %s after stat', async (change) => { await publish(); @@ -877,8 +868,8 @@ it.each([ expect(await fs.readFile(path, 'utf8')).toBe(raw); }); -it.each( - [ +it.each([ + ...[ ['investment'], ['investment', 'securities'], ['investment', 'cash'], @@ -893,12 +884,10 @@ it.each( ['investment', 'gainPostingIndexes'], ['investment', 'issues'], ['transaction', 'postings', '0', 'transactedAmounts'] - ].flatMap((path) => - [undefined, 'invalid', 0, {}, ...(path.at(-1) === 'transactedAmounts' ? [] : [null])].map( - (value) => ({ path, value }) - ) - ) -)( + ].map((path) => ({ path, value: undefined })), + { path: ['investment', 'cash', 'direction'], value: 'invalid' }, + { path: ['investment', 'cash', 'change'], value: {} } +])( 'rejects missing or malformed current investment/valuation containers ($path: $value)', async ({ path: fields, value }) => { await publish(); @@ -919,24 +908,6 @@ it.each( } ); -it('rejects a current row missing accountDate without falling back to date or rewriting it', async () => { - await publish(); - const publication = await selected(); - const account = publication.accounts[0]; - const path = join(publication.directory, 'transactions', `${account.id}.json`); - const { accountDate, ...row } = rows()[0]; - const raw = JSON.stringify({ - id: account.id, - name: account.name, - transactions: [{ ...row, date: accountDate }] - }); - await fs.writeFile(path, raw); - await expect(readAccountTransactions(publication, account.id)).rejects.toSatisfy( - safeError('Unable to read account transactions') - ); - expect(await fs.readFile(path, 'utf8')).toBe(raw); -}); - it('rejects symlinks at the final set, transactions directory and account file', async () => { await publish(); const publication = await selected(); @@ -1006,26 +977,18 @@ it('leaves opaque format 5 data untouched without fallback until explicit prepar expect(await fs.readdir(join(snapshots, '.sets'))).toHaveLength(3); }); -it('ignores unsafe/foreign/temporary names and leaves old namespaces untouched', async () => { - const foreign = [ - '.stage.tmp', - 'current.json', - '9999999999999999-foreign.json.bak', - '9999999999999999-foreign.json\n', - `9999999999999999-${'x'.repeat(129)}.json`, - '9999999999999999-bad id.json' - ]; - const directories = ['dashboard', 'income-expenses', 'transactions']; +it('leaves obsolete income-expenses and transactions namespaces untouched', async () => { + const directories = ['income-expenses', 'transactions']; for (const name of directories) { await fs.mkdir(join(snapshots, name), { recursive: true }); - for (const filename of foreign) - await fs.writeFile(join(snapshots, name, filename), 'untouched'); + await fs.writeFile(join(snapshots, name, 'opaque.json'), 'untouched invalid legacy data'); } expect(await readOverview(snapshots)).toBeNull(); await publish(); for (const name of directories) - for (const filename of foreign) - expect(await fs.readFile(join(snapshots, name, filename), 'utf8')).toBe('untouched'); + expect(await fs.readFile(join(snapshots, name, 'opaque.json'), 'utf8')).toBe( + 'untouched invalid legacy data' + ); }); it('rejects current overview file symlinks and non-files', async () => { @@ -1196,7 +1159,7 @@ it('publishes without reading selected financial files and keeps committed sets ); }); -it.each(['', ' ', null, undefined, 42])( +it.each([undefined, ' '])( 'rejects missing journal identity on new writes (%j)', async (journal) => { await publish(); -- 2.54.0