Skip to content

Commit 03cab4a

Browse files
committed
wip: write-path (import/REST) test; float-rounding pins (#27192)
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent e62d038 commit 03cab4a

4 files changed

Lines changed: 195 additions & 0 deletions

File tree

‎packages/objectql/src/engine-home-amount-projection.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ describe('insert: the home amount is the amount divided by the rate in force on
160160
const before = t.finds.sys_exchange_rate ?? 0;
161161
const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 12.345 });
162162
expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 12.35, amount_rate: 1 });
163+
// 1.005 is 1.00499999… as a double: a float rounding stores 1.00, the decimal one 1.01.
164+
const half = await t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'CNY', amount: 1.005 });
165+
expect(t.storeFor('fx_invoice').get(half.id)).toMatchObject({ amount_home: 1.01, amount_rate: 1 });
163166
expect(t.finds.sys_exchange_rate ?? 0).toBe(before);
164167
});
165168

‎packages/objectql/src/home-amount-projection.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ describe('the exact decimal arithmetic', () => {
8181
expect(div('0.125', 1, 2)).toBe('0.13');
8282
expect(div('-0.125', 1, 2)).toBe('-0.13');
8383
expect(div('0.124999', 1, 2)).toBe('0.12');
84+
// 1.005 has no exact binary double (1.00499999…), so a float `toFixed(2)` answers 1.00; the decimal is 1.01.
85+
expect(div(1.005, 1, 2)).toBe('1.01');
8486
// A float division would answer 0.30000000000000004 for 0.1 + 0.2 style inputs; exact here.
8587
expect(div('0.3', '0.1', 2)).toBe('3.00');
8688
// Three minor-unit classes: JPY 0, USD 2, KWD 3.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0144 D5] Import and REST writes reach the home projection through the
4+
// one engine path, on a booted kernel: `ObjectQLPlugin` with its settings
5+
// bridge (the home currency is the `settings` service's
6+
// `localization.currency`, asked for the row's organization), the real
7+
// protocol, and the shared import runner.
8+
//
9+
// The reading this pins: the REST data routes write through the protocol's
10+
// `createData` / `updateData` (`packages/rest/src/crud-routes.ts`), which call
11+
// `engine.insert` / `engine.update`; an import (`POST …/import` and the
12+
// import-job worker, both `runImport`) writes through the protocol's
13+
// `insertManyData` (batched, `engine.insertMany` → `engine.insert`) or
14+
// `createData` / `updateData` (per row). Every one of them lands in the same
15+
// insert or update seam, so every one of them converts, and refuses, alike.
16+
17+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18+
import { ObjectKernel, runImport, type ImportProtocolLike } from '@objectstack/core';
19+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
20+
import { ObjectQLPlugin } from './plugin.js';
21+
import { ObjectQL } from './engine.js';
22+
import { applyDriverQueryInMemory } from './driver-query-in-memory.js';
23+
24+
function makeStubDriver() {
25+
const stores = new Map<string, Map<string, Record<string, any>>>();
26+
const storeFor = (o: string) => {
27+
let s = stores.get(o);
28+
if (!s) { s = new Map(); stores.set(o, s); }
29+
return s;
30+
};
31+
const select = (o: string, ast: any) =>
32+
applyDriverQueryInMemory(Array.from(storeFor(o).values()), { where: ast?.where, orderBy: ast?.orderBy, limit: ast?.limit, offset: ast?.offset });
33+
let n = 0;
34+
const driver: any = {
35+
name: 'fx-store', version: '0.0.0', supports: {},
36+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
37+
async syncSchema() {},
38+
async find(o: string, ast: any) { return select(o, ast).map((r) => ({ ...r })); },
39+
async findOne(o: string, ast: any) { const [r] = select(o, { ...ast, limit: 1 }); return r ? { ...r } : null; },
40+
async create(o: string, data: Record<string, unknown>) {
41+
n += 1;
42+
const id = (data.id as string) ?? `rec_${n}`;
43+
const row = { ...data, id };
44+
storeFor(o).set(id, row);
45+
return { ...row };
46+
},
47+
async update(o: string, id: string, data: Record<string, unknown>) {
48+
const row = { ...storeFor(o).get(id), ...data, id };
49+
storeFor(o).set(id, row);
50+
return { ...row };
51+
},
52+
async delete(o: string, id: string) { return storeFor(o).delete(id); },
53+
async count(o: string, ast: any) { return select(o, ast).length; },
54+
};
55+
return { driver, storeFor };
56+
}
57+
58+
const OBJECT = 'fx_invoice';
59+
const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' };
60+
const FIELDS = {
61+
code: { name: 'code', type: 'text' },
62+
currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' },
63+
invoice_date: { name: 'invoice_date', type: 'date' },
64+
amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } },
65+
amount_home: { name: 'amount_home', type: 'currency', readonly: true },
66+
amount_rate: { name: 'amount_rate', type: 'number', readonly: true },
67+
};
68+
69+
describe('import and REST writes convert through the same engine path', () => {
70+
let kernel: ObjectKernel;
71+
let objectql: ObjectQL;
72+
let stub: ReturnType<typeof makeStubDriver>;
73+
/** What the settings service was asked, and what it answers. */
74+
let settingsAsked: Array<{ ns: string; key: string; tenantId?: unknown }>;
75+
let home: string | undefined;
76+
77+
async function bootKernel(withSettings: boolean) {
78+
settingsAsked = [];
79+
home = 'CNY';
80+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
81+
stub = makeStubDriver();
82+
await kernel.use({
83+
name: 'fx-store-plugin', type: 'driver', version: '1.0.0',
84+
init: async (ctx: any) => { ctx.registerService('driver.fx-store', stub.driver); },
85+
} as any);
86+
if (withSettings) {
87+
await kernel.use({
88+
name: 'fake-settings', type: 'standard', version: '1.0.0',
89+
init: async (ctx: any) => {
90+
ctx.registerService('settings', {
91+
async get(ns: string, key: string, c?: { tenantId?: unknown }) {
92+
settingsAsked.push({ ns, key, tenantId: c?.tenantId });
93+
return { value: home };
94+
},
95+
});
96+
},
97+
} as any);
98+
}
99+
await kernel.use(new ObjectQLPlugin());
100+
await kernel.bootstrap();
101+
objectql = kernel.getService<ObjectQL>('objectql');
102+
objectql.registry.registerObject({
103+
name: 'sys_exchange_rate', datasource: 'fx-store',
104+
fields: {
105+
currency: { name: 'currency', type: 'text', valueDomain: 'iso_4217_currency' },
106+
rate: { name: 'rate', type: 'number', scale: 12, precision: 24 },
107+
effective_date: { name: 'effective_date', type: 'date' },
108+
},
109+
} as any, 'test', 'test');
110+
objectql.registry.registerObject({ name: OBJECT, label: 'FX invoice', datasource: 'fx-store', fields: FIELDS } as any, 'test', 'test');
111+
stub.storeFor('sys_exchange_rate').set('usd', { id: 'usd', currency: 'USD', effective_date: '2026-01-01', rate: 0.1389 });
112+
}
113+
114+
afterEach(async () => {
115+
if (kernel?.getState() === 'running') await kernel.shutdown();
116+
});
117+
118+
const storedByCode = (code: string) => {
119+
const hits = Array.from(stub.storeFor(OBJECT).values()).filter((r) => r.code === code);
120+
expect(hits.length, `one stored row for ${code}`).toBe(1);
121+
return hits[0]!;
122+
};
123+
const protocol = () => new ObjectStackProtocolImplementation(objectql as never) as unknown as ImportProtocolLike & {
124+
createData(a: unknown): Promise<any>; updateData(a: unknown): Promise<any>;
125+
};
126+
const perRow = (): ImportProtocolLike => {
127+
const impl = protocol();
128+
return { findData: (a) => impl.findData(a), createData: (a) => impl.createData(a), updateData: (a) => impl.updateData(a) };
129+
};
130+
const metaMap = new Map<string, any>(Object.entries(FIELDS));
131+
const importRows = (p: ImportProtocolLike, rows: Array<Record<string, unknown>>, over: Record<string, unknown> = {}) => runImport({
132+
p, objectName: OBJECT, metaMap, writeMode: 'insert', matchFields: ['code'], dryRun: false, runAutomations: false,
133+
trimWhitespace: true, skipBlankMatchKey: false, context: { userId: 'usr_importer', tenantId: 'org_a' }, rows, ...over,
134+
} as any);
135+
136+
describe('with the settings service composed', () => {
137+
beforeEach(async () => { await bootKernel(true); });
138+
139+
it('the REST door (protocol createData / updateData) converts on create and on an amount change', async () => {
140+
const p = protocol();
141+
await p.createData({ object: OBJECT, data: { code: 'rest', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } });
142+
expect(storedByCode('rest')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 });
143+
await p.updateData({ object: OBJECT, id: storedByCode('rest').id, data: { amount: 1389 }, context: { userId: 'u1', tenantId: 'org_a' } });
144+
expect(storedByCode('rest')).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 });
145+
// The home currency was asked of the settings service, for the row's organization.
146+
expect(settingsAsked).toContainEqual({ ns: 'localization', key: 'currency', tenantId: 'org_a' });
147+
});
148+
149+
for (const [label, p] of [['batched (insertManyData)', protocol], ['per-row (createData)', perRow]] as const) {
150+
it(`an import, ${label}, converts each row and refuses a row with no rate in force`, async () => {
151+
const summary = await importRows(p(), [
152+
{ code: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' },
153+
{ code: 'b', currency_code: 'CNY', amount: '12.345' },
154+
{ code: 'c', currency_code: 'USD', invoice_date: '2025-06-30', amount: '1' },
155+
]);
156+
expect(summary.results.map((r: any) => r.ok)).toEqual([true, true, false]);
157+
expect(JSON.stringify(summary.results[2])).toContain('ERR_EXCHANGE_RATE_MISSING');
158+
expect(storedByCode('a')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 });
159+
expect(storedByCode('b')).toMatchObject({ amount_home: 12.35, amount_rate: 1 });
160+
expect(Array.from(stub.storeFor(OBJECT).values()).some((r) => r.code === 'c')).toBe(false);
161+
});
162+
}
163+
164+
it('a historical (preserveAudit) update-mode import of the home column is recomputed over, not kept', async () => {
165+
await importRows(protocol(), [{ code: 'h', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }]);
166+
const summary = await importRows(perRow(), [{ code: 'h', amount_home: '1', amount_rate: '9' }], {
167+
writeMode: 'update', treatAsHistorical: true,
168+
});
169+
expect(summary.results.map((r: any) => r.ok)).toEqual([true]);
170+
expect(storedByCode('h')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 });
171+
});
172+
173+
it('an unset localization.currency refuses the REST write ERR_HOME_CURRENCY_UNSET', async () => {
174+
home = undefined;
175+
const err = await protocol().createData({ object: OBJECT, data: { code: 'x', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, context: { userId: 'u1' } })
176+
.then(() => null, (e: { code?: string }) => e);
177+
expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET');
178+
expect(stub.storeFor(OBJECT).size).toBe(0);
179+
});
180+
});
181+
182+
it('with no settings service composed, the home currency is unset — refused, never defaulted', async () => {
183+
await bootKernel(false);
184+
const err = await protocol().createData({ object: OBJECT, data: { code: 'x', currency_code: 'CNY', amount: 1 }, context: { userId: 'u1' } })
185+
.then(() => null, (e: { code?: string }) => e);
186+
expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET');
187+
});
188+
});

‎packages/qa/dogfood/test/home-amount-projection.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ for (const cell of CELLS) {
178178
it('the same currency as home is rate 1, rounded to the home minor units', async () => {
179179
home = 'CNY';
180180
expect(await written({ currency_code: 'CNY', amount: 12.345 })).toMatchObject({ home: 12.35, rate: 1 });
181+
// 1.005 is 1.00499999… as a double: a float rounding stores 1.00, the decimal one 1.01.
182+
expect(await written({ currency_code: 'CNY', amount: 1.005 })).toMatchObject({ home: 1.01, rate: 1 });
181183
});
182184

183185
it('the rate row is the latest on or before the document date; none ⇒ refused, nothing stored', async () => {

0 commit comments

Comments
 (0)