Skip to content

Commit 02f34c6

Browse files
committed
wip(cli): stored-expressions command tests
Claude-Session: https://claude.ai/code/session_01Utp6Cb2FL7H75wB1wA6PpZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a703ca commit 02f34c6

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate stored-expressions` command shape (#29961).
5+
*
6+
* The backfill itself is proven in `@objectstack/objectql`'s
7+
* `stored-expression-backfill.test.ts`. What is pinned here is what a unit
8+
* test of the walk cannot see: the command is a dry run by default, every
9+
* flag reaches `backfillStoredExpressions` as its option, `--field` needs
10+
* `--object`, a run that leaves a row skipped or unwritten exits 1, and the
11+
* human face closes a dry run with the report's own line. The seams that
12+
* would boot a database are replaced; the command's own parse and control
13+
* flow, and the report's formatter, run for real.
14+
*/
15+
16+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
17+
import { dirname, resolve } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
import { stripVTControlCharacters } from 'node:util';
20+
import MigrateStoredExpressions from './stored-expressions.js';
21+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
22+
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
23+
import { isExitSignal } from '../../utils/format.js';
24+
import { backfillStoredExpressions } from '@objectstack/objectql';
25+
26+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
27+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
28+
OCCUPANCY_HINT: 'occupancy hint',
29+
probeMigrationTarget: vi.fn(),
30+
}));
31+
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
32+
// The walk is replaced; the formatter is the real one.
33+
vi.mock('@objectstack/objectql', async (importOriginal) => {
34+
const real = await importOriginal<typeof import('@objectstack/objectql')>();
35+
return {
36+
backfillStoredExpressions: vi.fn(),
37+
formatStoredExpressionBackfillReport: real.formatStoredExpressionBackfillReport,
38+
};
39+
});
40+
41+
const HERE = dirname(fileURLToPath(import.meta.url));
42+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
43+
/** oclif builds its whole command table on the first `run()` in a process. */
44+
const RUN_TIMEOUT = 60_000;
45+
46+
/** The engine surface the command checks before it runs, and one loaded app object. */
47+
const engine = {
48+
computeStoredExpressionsOver: async () => [],
49+
getConfigs: () => ({ contract: {}, sys_user: {} }),
50+
};
51+
52+
const EMPTY_REPORT = {
53+
scannedObjects: [], scannedRecords: 0, fields: [], nullRows: 0, staleRows: 0, faultedRows: 0,
54+
skippedRows: 0, writtenRows: 0, changed: false, applied: false, truncated: false,
55+
unreadableObjects: [], faults: [], faultsTruncated: false, failures: [],
56+
};
57+
58+
/** A dry run that found the hotclm shape: one field NULL on every row. */
59+
const DRY_REPORT_WITH_HOLES = {
60+
...EMPTY_REPORT,
61+
scannedObjects: ['contract'],
62+
scannedRecords: 2,
63+
fields: [{ object: 'contract', field: 'review_sla_days_offset', nullRows: 2, staleRows: 0, faultedRows: 0, written: 0, sampleRecordIds: ['c1', 'c2'] }],
64+
nullRows: 2,
65+
};
66+
67+
let stdout: ReturnType<typeof vi.spyOn>;
68+
let log: ReturnType<typeof vi.spyOn>;
69+
beforeEach(() => {
70+
vi.mocked(probeMigrationTarget).mockResolvedValue({ status: 'free' } as any);
71+
vi.mocked(bootSchemaStack).mockResolvedValue({
72+
kernel: { getService: () => engine },
73+
dbLabel: 'postgres://db.example.com/app',
74+
driver: { config: { client: 'pg' } },
75+
shutdown: vi.fn(async () => {}),
76+
} as any);
77+
vi.mocked(backfillStoredExpressions).mockReset();
78+
vi.mocked(backfillStoredExpressions).mockResolvedValue(EMPTY_REPORT as any);
79+
// `emitJson` awaits the write's DRAIN callback, so the double has to invoke it.
80+
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
81+
const done = typeof enc === 'function' ? enc : cb;
82+
if (typeof done === 'function') done();
83+
return true;
84+
}) as typeof process.stdout.write);
85+
log = vi.spyOn(console, 'log').mockImplementation(() => {});
86+
});
87+
afterEach(() => {
88+
stdout.mockRestore();
89+
log.mockRestore();
90+
});
91+
92+
const optionsHandedToBackfill = () => {
93+
expect(vi.mocked(backfillStoredExpressions)).toHaveBeenCalledTimes(1);
94+
return vi.mocked(backfillStoredExpressions).mock.calls[0][2]!;
95+
};
96+
const exitCodeOf = (err: unknown) => (isExitSignal(err) ? (err as { oclif?: { exit?: number } }).oclif?.exit : undefined);
97+
98+
describe('os migrate stored-expressions', () => {
99+
it('is a dry run by default, and writing needs --yes', () => {
100+
expect(MigrateStoredExpressions.flags.apply.default).toBe(false);
101+
expect(MigrateStoredExpressions.flags.yes.default).toBe(false);
102+
expect(MigrateStoredExpressions.flags.changed.default).toBe(false);
103+
});
104+
105+
it('a bare run walks every object, NULL holes only, without writing', async () => {
106+
await MigrateStoredExpressions.run(['--json'], { root: CLI_ROOT });
107+
expect(optionsHandedToBackfill()).toEqual({
108+
apply: false, objects: undefined, field: undefined, changed: false, maxRecordsPerObject: undefined,
109+
});
110+
}, RUN_TIMEOUT);
111+
112+
it('hands --object, --field, --changed, --max-records and --apply --yes through', async () => {
113+
await MigrateStoredExpressions.run(
114+
['--json', '--object', 'contract', '--field', 'review_sla_days_offset', '--changed', '--max-records', '50', '--apply', '--yes'],
115+
{ root: CLI_ROOT },
116+
);
117+
expect(optionsHandedToBackfill()).toEqual({
118+
apply: true, objects: ['contract'], field: 'review_sla_days_offset', changed: true, maxRecordsPerObject: 50,
119+
});
120+
}, RUN_TIMEOUT);
121+
122+
it('refuses --field without --object at parse time', async () => {
123+
const err = await MigrateStoredExpressions.run(['--json', '--field', 'x'], { root: CLI_ROOT }).catch((e: unknown) => e);
124+
expect(String((err as Error)?.message)).toMatch(/--object/);
125+
expect(vi.mocked(backfillStoredExpressions)).not.toHaveBeenCalled();
126+
}, RUN_TIMEOUT);
127+
128+
it('--apply without --yes in a non-interactive run writes nothing and exits 1', async () => {
129+
const err = await MigrateStoredExpressions.run(['--json', '--apply'], { root: CLI_ROOT }).catch((e: unknown) => e);
130+
expect(exitCodeOf(err)).toBe(1);
131+
expect(vi.mocked(bootSchemaStack)).not.toHaveBeenCalled();
132+
expect(vi.mocked(backfillStoredExpressions)).not.toHaveBeenCalled();
133+
}, RUN_TIMEOUT);
134+
135+
it('an object the deployment does not declare is refused before the walk', async () => {
136+
const err = await MigrateStoredExpressions.run(['--json', '--object', 'nope'], { root: CLI_ROOT }).catch((e: unknown) => e);
137+
expect(exitCodeOf(err)).toBe(1);
138+
expect(vi.mocked(backfillStoredExpressions)).not.toHaveBeenCalled();
139+
const payload = JSON.parse(stdout.mock.calls.map((c: unknown[]) => String(c[0])).join(''));
140+
expect(payload).toMatchObject({ code: 'OBJECT_NOT_FOUND' });
141+
}, RUN_TIMEOUT);
142+
143+
it.each([
144+
['a row skipped because a faulting field holds a value', { skippedRows: 1 }],
145+
['a row the write refused', { failures: [{ object: 'contract', recordId: 'c1', error: 'refused' }] }],
146+
['a walk --max-records cut short', { truncated: true }],
147+
])('exits 1 on %s', async (_label, extra) => {
148+
vi.mocked(backfillStoredExpressions).mockResolvedValue({ ...EMPTY_REPORT, ...extra } as any);
149+
const err = await MigrateStoredExpressions.run(['--json'], { root: CLI_ROOT }).catch((e: unknown) => e);
150+
expect(exitCodeOf(err)).toBe(1);
151+
}, RUN_TIMEOUT);
152+
153+
it('a dry run that finds holes exits 0 and closes with the report\'s dry-run line', async () => {
154+
vi.mocked(backfillStoredExpressions).mockResolvedValue(DRY_REPORT_WITH_HOLES as any);
155+
await MigrateStoredExpressions.run([], { root: CLI_ROOT });
156+
const printed = log.mock.calls.map((c: unknown[]) => stripVTControlCharacters(String(c[0]))).join('\n');
157+
expect(printed).toContain('contract.review_sla_days_offset — 2 NULL row(s)');
158+
expect(printed).toContain('Dry run — no row or table was written. Re-run with --apply to write.');
159+
}, RUN_TIMEOUT);
160+
});

0 commit comments

Comments
 (0)