Reduce redundant test coverage at consumer boundaries #41
No files matched your search
@@ -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 });
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<unknown>();
|
||||
const created = Promise.withResolvers<void>();
|
||||
|
||||
@@ -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<void>();
|
||||
@@ -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<void>();
|
||||
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();
|
||||
});
|
||||
@@ -59,6 +59,37 @@ function runs(client: JobsClient) {
|
||||
return client.backend.listWorkflowRuns({ workflowName: prepareReportsSpec.name, limit: 100 });
|
||||
}
|
||||
|
||||
async function expectReadOnlyState(
|
||||
client: JobsClient,
|
||||
expected: Awaited<ReturnType<typeof readReportsPreparation>>
|
||||
) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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<void>();
|
||||
// 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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof actions.default>[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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+16
-190
@@ -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<ReturnType<typeof startWorker>>[] = [];
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(createServer).mockImplementation(
|
||||
(await vi.importActual<typeof import('node:http')>('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<typeof createServer>);
|
||||
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<typeof import('node:http')>('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<typeof import('node:http')>('node:http')).createServer
|
||||
);
|
||||
const reservation = createServer();
|
||||
await new Promise<void>((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<void>((resolve) => reservation.listen(0, '127.0.0.1', resolve));
|
||||
const port = (reservation.address() as { port: number }).port;
|
||||
await new Promise<void>((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<void>((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<ReturnType<typeof startWorker>> | 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<typeof import('@hledger-reports/config')>('@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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
+10
-130
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -8,14 +8,8 @@ const journal = '/owned/private.journal';
|
||||
const execute = createCostBalanceQuery(journal);
|
||||
const context = (input: unknown) => ({ input }) as Parameters<typeof execute>[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();
|
||||
});
|
||||
@@ -9,42 +9,13 @@ const execute = createHoldings(journal);
|
||||
const context = (input: unknown) => ({ input }) as Parameters<typeof execute>[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'));
|
||||
|
||||
@@ -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).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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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<string, unknown> = 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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }
|
||||
]);
|
||||
|
||||
@@ -34,9 +34,6 @@ it.each([
|
||||
['array', '[{}]'],
|
||||
['object', '[[]]'],
|
||||
['object', '[null]'],
|
||||
['object', '[1]'],
|
||||
['object', '["x"]'],
|
||||
['object', '[true]'],
|
||||
['object', '[{},]'],
|
||||
['object', '[{} {}]'],
|
||||
['object', '[{"nested":[] }'],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<typeof detailedAmount>[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');
|
||||
}
|
||||
);
|
||||
@@ -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.
|
||||
@@ -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<HoldingsRequest>().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/);
|
||||
}
|
||||
);
|
||||
@@ -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.' }]
|
||||
});
|
||||
});
|
||||
@@ -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<QueryRequest>().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');
|
||||
});
|
||||
});
|
||||
@@ -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<void, string>({ name: 'test.drain' });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
@@ -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<QueryRequest, LiveQueryResult>({
|
||||
name: 'test.recurring-report',
|
||||
schedules: [declaration]
|
||||
});
|
||||
const workers = new Set<Worker>();
|
||||
const clients = new Set<JobsClient>();
|
||||
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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<ArrayBuffer>;
|
||||
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<ArrayBuffer>;
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user