Skip to content

Commit cb6b6af

Browse files
committed
test(plugin-audit): pin the activity summary in the reader's language (red on main)
Claude-Session: https://claude.ai/code/session_01UBPXQdapfjkjYdQck9dtTa Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbb7599 commit cb6b6af

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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

Comments
 (0)