|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#26167] An activity summary reads in the READER's language, on the plugin's |
| 5 | + * read path. |
| 6 | + * |
| 7 | + * The workspace locale is left at its default (`en`), so the CRUD mirror stores |
| 8 | + * English sentences — the hotclm measurement: a zh-CN reader's 讨论 tab showed |
| 9 | + * `Created Contract "…"` although the plugin ships `创建了 {{object}} "{{label}}"`. |
| 10 | + * The rows are written by the real audit writer, stored by a real SQLite |
| 11 | + * driver, and read back through the real engine middleware chain under a |
| 12 | + * caller context whose `locale` is what the REST door resolves from |
| 13 | + * `Accept-Language`. |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 17 | +import { ObjectKernel } from '@objectstack/core'; |
| 18 | +import type { II18nService } from '@objectstack/spec/contracts'; |
| 19 | +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; |
| 20 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 21 | + |
| 22 | +import { AuditPlugin } from './audit-plugin.js'; |
| 23 | +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; |
| 24 | +import { installDefaultOrganization } from './default-organization.testkit.js'; |
| 25 | + |
| 26 | +const HARNESS_PACKAGE = 'com.objectstack.audit.test.summary-reader-locale'; |
| 27 | +const CONTRACT = 'biz_contract'; |
| 28 | + |
| 29 | +const contract = { |
| 30 | + name: CONTRACT, |
| 31 | + label: 'Contract', |
| 32 | + fields: { |
| 33 | + name: { name: 'name', label: 'Name', type: 'text' as const }, |
| 34 | + stage: { |
| 35 | + name: 'stage', |
| 36 | + label: 'Stage', |
| 37 | + type: 'select' as const, |
| 38 | + trackHistory: true, |
| 39 | + options: [ |
| 40 | + { value: 'draft', label: 'Draft' }, |
| 41 | + { value: 'signed', label: 'Signed' }, |
| 42 | + ], |
| 43 | + }, |
| 44 | + note: { name: 'note', label: 'Note', type: 'text' as const }, |
| 45 | + }, |
| 46 | +}; |
| 47 | + |
| 48 | +const SYS = { context: { isSystem: true } }; |
| 49 | +const READER = { userId: 'u_reader', tenantId: 'org_1', positions: ['org_member'] }; |
| 50 | +/** A reader the security service does not serve the contract's `note` field. */ |
| 51 | +const NOTE_HIDDEN = { userId: 'u_note_hidden', tenantId: 'org_1', positions: ['org_member'] }; |
| 52 | +const reader = (locale?: string, who: Record<string, unknown> = READER) => ({ |
| 53 | + context: { ...who, ...(locale ? { locale } : {}) }, |
| 54 | +}); |
| 55 | + |
| 56 | +type Row = Record<string, any>; |
| 57 | + |
| 58 | +describe('sys_activity summary — rendered in the reader\'s language (#26167)', () => { |
| 59 | + let kernel: ObjectKernel; |
| 60 | + let engine: ObjectQL; |
| 61 | + let recordId: string; |
| 62 | + |
| 63 | + /** The record's activity rows as `opts`'s caller reads them, by activity row id. */ |
| 64 | + async function read(opts: Record<string, unknown>, extra: Record<string, unknown> = {}): Promise<Row[]> { |
| 65 | + return (await engine.find('sys_activity', { |
| 66 | + where: { object_name: CONTRACT, record_id: recordId }, |
| 67 | + ...extra, |
| 68 | + ...opts, |
| 69 | + } as any)) as Row[]; |
| 70 | + } |
| 71 | + /** The three mirror rows' summaries — created, the tracked change, the named-field update — as `opts` reads them. */ |
| 72 | + async function summaries(opts: Record<string, unknown>): Promise<Array<string | undefined>> { |
| 73 | + const rows = await read(opts); |
| 74 | + return [rowIds.created, rowIds.stage, rowIds.note].map((id) => rows.find((r) => r.id === id)?.summary); |
| 75 | + } |
| 76 | + const rowIds: Record<'created' | 'stage' | 'note', string> = {} as never; |
| 77 | + |
| 78 | + /** The double: a field the `NOTE_HIDDEN` reader is not served; everything else is. */ |
| 79 | + const security = { |
| 80 | + async getReadableFields(object: string, context?: any): Promise<string[] | undefined> { |
| 81 | + const all = Object.keys(((engine as any).getSchema(object)?.fields ?? {}) as Record<string, unknown>); |
| 82 | + return context?.userId === NOTE_HIDDEN.userId && object === CONTRACT ? all.filter((f) => f !== 'note') : all; |
| 83 | + }, |
| 84 | + async getQueryableFields(object: string, context?: any): Promise<string[] | undefined> { |
| 85 | + return security.getReadableFields(object, context); |
| 86 | + }, |
| 87 | + }; |
| 88 | + |
| 89 | + beforeAll(async () => { |
| 90 | + kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); |
| 91 | + await kernel.use(new ObjectQLPlugin()); |
| 92 | + await kernel.use({ |
| 93 | + name: 'test.security-double', |
| 94 | + version: '0.0.0', |
| 95 | + init: async (ctx: any) => ctx.registerService('security', security), |
| 96 | + start: async () => {}, |
| 97 | + } as any); |
| 98 | + await kernel.use(singlePostureTenancy()); |
| 99 | + await kernel.use(new AuditPlugin()); |
| 100 | + await kernel.bootstrap(); |
| 101 | + |
| 102 | + engine = kernel.getService<ObjectQL>('objectql'); |
| 103 | + const driver = new SqliteWasmDriver({ filename: ':memory:' }); |
| 104 | + await driver.connect(); |
| 105 | + engine.registerDriver(driver, true); |
| 106 | + engine.registry.registerObject(contract as any, HARNESS_PACKAGE); |
| 107 | + await engine.syncSchemas(); |
| 108 | + await installDefaultOrganization(engine); |
| 109 | + |
| 110 | + // The app's language pack: its object, field and option labels in zh-CN. |
| 111 | + // The plugin's own `messages.*` templates are loaded by AuditPlugin. |
| 112 | + kernel.getService<II18nService>('i18n').loadTranslations('zh-CN', { |
| 113 | + objects: { |
| 114 | + [CONTRACT]: { |
| 115 | + label: '合同', |
| 116 | + fields: { |
| 117 | + stage: { label: '阶段', options: { draft: '草稿', signed: '已签署' } }, |
| 118 | + note: { label: '备注' }, |
| 119 | + }, |
| 120 | + }, |
| 121 | + }, |
| 122 | + }); |
| 123 | + |
| 124 | + // Written as the system: the workspace locale is the default, `en`. |
| 125 | + const created = (await engine.insert(CONTRACT, { name: 'NDA', stage: 'draft' }, SYS as any)) as Row; |
| 126 | + recordId = String(created.id); |
| 127 | + await engine.update(CONTRACT, { stage: 'signed' }, { where: { id: recordId }, ...SYS } as any); |
| 128 | + await engine.update(CONTRACT, { note: 'countersigned' }, { where: { id: recordId }, ...SYS } as any); |
| 129 | + const atRest = await read(SYS); |
| 130 | + expect(atRest).toHaveLength(3); |
| 131 | + for (const row of atRest) { |
| 132 | + const meta = JSON.parse(row.metadata); |
| 133 | + if (row.type === 'created') rowIds.created = row.id; |
| 134 | + else if ('stage' in (meta.new ?? {})) rowIds.stage = row.id; |
| 135 | + else rowIds.note = row.id; |
| 136 | + } |
| 137 | + }, 120_000); |
| 138 | + |
| 139 | + afterAll(async () => { |
| 140 | + if (kernel) { |
| 141 | + await Promise.race([kernel.shutdown(), new Promise<void>((resolve) => setTimeout(resolve, 10_000))]); |
| 142 | + } |
| 143 | + }, 30_000); |
| 144 | + |
| 145 | + it('red first: a zh-CN reader reads every row the en workspace wrote in zh-CN', async () => { |
| 146 | + expect(await summaries(reader('zh-CN'))).toEqual([ |
| 147 | + '创建了 合同 "NDA"', |
| 148 | + '阶段: 草稿 → 已签署', |
| 149 | + '更新了 合同 "NDA":备注', |
| 150 | + ]); |
| 151 | + }); |
| 152 | + |
| 153 | + it('stores the English sentence, and serves it to an en reader and to a read with no locale', async () => { |
| 154 | + const english = ['Created Contract "NDA"', 'Stage: Draft → Signed', 'Updated Contract "NDA": Note']; |
| 155 | + expect(await summaries(SYS)).toEqual(english); |
| 156 | + expect(await summaries(reader('en-US'))).toEqual(english); |
| 157 | + expect(await summaries(reader())).toEqual(english); |
| 158 | + }); |
| 159 | + |
| 160 | + it('negotiates the reader locale against the held packs (a bare `zh` reads the zh-CN pack)', async () => { |
| 161 | + expect((await summaries(reader('zh')))[0]).toBe('创建了 合同 "NDA"'); |
| 162 | + }); |
| 163 | + |
| 164 | + it('a reader locale with no template keeps the stored sentence', async () => { |
| 165 | + expect((await summaries(reader('fr-FR')))[0]).toBe('Created Contract "NDA"'); |
| 166 | + }); |
| 167 | + |
| 168 | + it('serves the code to no caller but the system, and composes when only `summary` is projected', async () => { |
| 169 | + const served = (await read(reader('zh-CN'))).find((r) => r.id === rowIds.created)!; |
| 170 | + expect(JSON.parse(served.metadata)).not.toHaveProperty('summary_message'); |
| 171 | + const atRest = (await read(SYS)).find((r) => r.id === rowIds.created)!; |
| 172 | + expect(JSON.parse(atRest.metadata).summary_message).toEqual({ |
| 173 | + key: 'activityCreated', |
| 174 | + object: CONTRACT, |
| 175 | + label: 'NDA', |
| 176 | + }); |
| 177 | + |
| 178 | + const projected = await read(reader('zh-CN'), { where: { id: rowIds.created }, fields: ['summary'] }); |
| 179 | + expect(projected).toEqual([{ summary: '创建了 合同 "NDA"' }]); |
| 180 | + }); |
| 181 | + |
| 182 | + it('the field redaction still judges the composed text: a reader not served a named field gets no summary', async () => { |
| 183 | + expect(await summaries(reader('zh-CN', NOTE_HIDDEN))).toEqual([ |
| 184 | + '创建了 合同 "NDA"', |
| 185 | + '阶段: 草稿 → 已签署', |
| 186 | + undefined, |
| 187 | + ]); |
| 188 | + }); |
| 189 | + |
| 190 | + it('a row written before the change keeps its stored text for every reader', async () => { |
| 191 | + // The mirror's earlier shape: `metadata` exactly `{ old, new }`, no code. |
| 192 | + await engine.insert( |
| 193 | + 'sys_activity', |
| 194 | + { |
| 195 | + type: 'created', |
| 196 | + timestamp: '2020-01-01T00:00:00.000Z', |
| 197 | + summary: 'Created Contract "NDA"', |
| 198 | + object_name: CONTRACT, |
| 199 | + record_id: recordId, |
| 200 | + record_label: 'NDA', |
| 201 | + metadata: JSON.stringify({ old: null, new: { name: 'NDA' } }), |
| 202 | + }, |
| 203 | + SYS as any, |
| 204 | + ); |
| 205 | + const legacy = (await read(reader('zh-CN'))).filter((r) => r.timestamp === '2020-01-01T00:00:00.000Z'); |
| 206 | + expect(legacy.map((r) => r.summary)).toEqual(['Created Contract "NDA"']); |
| 207 | + }); |
| 208 | +}); |
0 commit comments