|
| 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 | +}); |
0 commit comments