|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +// [#24235] The held write locale against a REAL engine, in its own file: it |
| 4 | +// boots a kernel, an engine, a SQLite driver and the audit plugin, and that |
| 5 | +// load is kept out of `audit-writers.test.ts`. Static imports, so the load is |
| 6 | +// paid during collection, not by one test's timeout. |
| 7 | +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; |
| 8 | +import { ObjectKernel } from '@objectstack/core'; |
| 9 | +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; |
| 10 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 11 | +import { AuditPlugin } from './audit-plugin.js'; |
| 12 | + |
| 13 | +/** |
| 14 | + * [#24235] A summary line is stored ONCE, at write time, so the writer's held |
| 15 | + * locale must not outlive a language change. Pinned on `ObjectKernel` + |
| 16 | + * `ObjectQLPlugin` + `AuditPlugin` over a real SQLite driver: the plugin's own |
| 17 | + * locale composition, the core resolver beneath it (which caches here, since |
| 18 | + * this engine carries the write epoch) and the engine's real hook dispatch. |
| 19 | + * |
| 20 | + * No settings service is registered, so the core resolver answers from the |
| 21 | + * tenant-scoped `sys_setting` rows directly, and the change is a write to |
| 22 | + * that store through the engine — the write `SettingsService` makes when it |
| 23 | + * persists, and the one a seeder makes without emitting any settings event. |
| 24 | + * |
| 25 | + * The clock is frozen from the first audited write on, so no case can pass by |
| 26 | + * the TTL running out: what retires the held locale here is the write, or |
| 27 | + * nothing does. |
| 28 | + */ |
| 29 | +describe('audit writers — a localization change reaches the very next audited write (#24235)', () => { |
| 30 | + const HARNESS_PACKAGE = 'com.objectstack.audit.test.write-locale-seam'; |
| 31 | + /** A stand-in under the real name: service-settings owns the definition, and the name is what the seam reads. */ |
| 32 | + const sysSetting = { |
| 33 | + name: 'sys_setting', |
| 34 | + label: 'Setting', |
| 35 | + isSystem: true, |
| 36 | + fields: { |
| 37 | + namespace: { name: 'namespace', label: 'Namespace', type: 'text' as const }, |
| 38 | + key: { name: 'key', label: 'Key', type: 'text' as const }, |
| 39 | + scope: { name: 'scope', label: 'Scope', type: 'text' as const }, |
| 40 | + value: { name: 'value', label: 'Value', type: 'text' as const }, |
| 41 | + }, |
| 42 | + }; |
| 43 | + const task = { |
| 44 | + name: 'biz_task', |
| 45 | + label: 'Task', |
| 46 | + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, |
| 47 | + }; |
| 48 | + |
| 49 | + let kernel: ObjectKernel; |
| 50 | + let engine: ObjectQL; |
| 51 | + let sql: (statement: string, bindings: unknown[]) => Promise<Record<string, unknown>[]>; |
| 52 | + let settingsReads: () => number; |
| 53 | + |
| 54 | + const SYSTEM = { context: { isSystem: true } }; |
| 55 | + const asAdmin = { context: { userId: 'u_admin' } }; |
| 56 | + |
| 57 | + /** Write one task as a user and answer the summary its activity row stored. */ |
| 58 | + async function auditedWrite(name: string): Promise<string> { |
| 59 | + const row = await engine.insert('biz_task', { name }, asAdmin as any); |
| 60 | + const rows = await sql('SELECT summary FROM "sys_activity" WHERE object_name = ? AND record_id = ?', [ |
| 61 | + 'biz_task', |
| 62 | + String((row as { id: unknown }).id), |
| 63 | + ]); |
| 64 | + expect(rows).toHaveLength(1); |
| 65 | + return String(rows[0].summary); |
| 66 | + } |
| 67 | + |
| 68 | + beforeAll(async () => { |
| 69 | + kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); |
| 70 | + await kernel.use(new ObjectQLPlugin()); |
| 71 | + await kernel.use(new AuditPlugin()); |
| 72 | + await kernel.bootstrap(); |
| 73 | + |
| 74 | + engine = kernel.getService<ObjectQL>('objectql'); |
| 75 | + const driver = new SqliteWasmDriver({ filename: ':memory:' }); |
| 76 | + await driver.connect(); |
| 77 | + engine.registerDriver(driver, true); |
| 78 | + engine.registry.registerObject(sysSetting as any, HARNESS_PACKAGE); |
| 79 | + engine.registry.registerObject(task as any, HARNESS_PACKAGE); |
| 80 | + await engine.syncSchemas(); |
| 81 | + |
| 82 | + sql = async (statement, bindings) => { |
| 83 | + const result = await (driver as unknown as { execute(s: string, b: unknown[]): Promise<unknown> }).execute( |
| 84 | + statement, |
| 85 | + bindings, |
| 86 | + ); |
| 87 | + // knex wraps some results as `[rows]`; normalize both shapes. |
| 88 | + const list = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; |
| 89 | + expect(Array.isArray(list)).toBe(true); |
| 90 | + return list as Record<string, unknown>[]; |
| 91 | + }; |
| 92 | + const find = vi.spyOn(engine, 'find'); |
| 93 | + settingsReads = () => find.mock.calls.filter(([object]) => object === 'sys_setting').length; |
| 94 | + }, 120_000); |
| 95 | + |
| 96 | + afterAll(async () => { |
| 97 | + vi.useRealTimers(); |
| 98 | + vi.restoreAllMocks(); |
| 99 | + if (kernel) { |
| 100 | + await Promise.race([kernel.shutdown(), new Promise<void>((resolve) => setTimeout(resolve, 10_000))]); |
| 101 | + } |
| 102 | + }, 30_000); |
| 103 | + |
| 104 | + it('pin: a settings-store write between two audited writes; the second stores the new language', async () => { |
| 105 | + // The precondition the whole case leans on: this engine carries the write |
| 106 | + // epoch, so the core resolver beneath the writer caches as it does in a |
| 107 | + // stock boot rather than declining to. |
| 108 | + expect(typeof (engine as unknown as { writeEpoch?: { current?: unknown } }).writeEpoch?.current).toBe('number'); |
| 109 | + vi.useFakeTimers({ toFake: ['Date'] }); |
| 110 | + vi.setSystemTime(new Date('2026-10-18T00:00:00Z')); |
| 111 | + |
| 112 | + // No row yet: the built-in default (`en-US`), read once and held. |
| 113 | + expect(await auditedWrite('T1')).toBe('Created Task "T1"'); |
| 114 | + |
| 115 | + // CONTROL: a write with no settings change between is answered from the |
| 116 | + // held locale — not one settings read. |
| 117 | + const beforeControl = settingsReads(); |
| 118 | + expect(await auditedWrite('T2')).toBe('Created Task "T2"'); |
| 119 | + expect(settingsReads() - beforeControl).toBe(0); |
| 120 | + |
| 121 | + // The change: an insert into the settings store… |
| 122 | + await engine.insert( |
| 123 | + 'sys_setting', |
| 124 | + { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'zh-CN' }, |
| 125 | + SYSTEM as any, |
| 126 | + ); |
| 127 | + expect(await auditedWrite('T3')).toBe('创建了 Task "T3"'); |
| 128 | + |
| 129 | + // …and an update of it, the shape a settings write takes once the row exists. |
| 130 | + await engine.update('sys_setting', { value: 'ja-JP' }, { where: { key: 'locale' }, multi: true, ...SYSTEM } as any); |
| 131 | + expect(await auditedWrite('T4')).toBe('Task「T4」を作成しました'); |
| 132 | + |
| 133 | + // The new answer is held in turn: the next unchanged write reads nothing. |
| 134 | + const beforeHeld = settingsReads(); |
| 135 | + expect(await auditedWrite('T5')).toBe('Task「T5」を作成しました'); |
| 136 | + expect(settingsReads() - beforeHeld).toBe(0); |
| 137 | + }); |
| 138 | +}); |
0 commit comments