Skip to content

Commit c496886

Browse files
fix(plugin-audit): render the activity summary in the reader's language (#26258)
Fixes #26167 Clause-②: no (no exported type, schema field or spec key changes; the code rides in the existing `sys_activity.metadata` JSON, and `summary` keeps its type and meaning) Metadata-protocol: none Maintainer ruling on the card: 「同意,立卡」. The activity summary is stored as its message key plus params and rendered in the **reader's** language, as the approvals precedents do (#25627, the `admin_rescue` note; #24977). The English sentence stays in `summary` as the fallback text. ## What changed The code is all in `packages/plugins/plugin-audit`. One docs row changes too: `content/docs/permissions/system-context.mdx` row 45 now cites the new read site, because `check-system-context-census` requires every elevation read to have an anchor. No `packages/spec/**` file and no `ui/**` file is touched, so the serial rule with PR #26055 holds. - **New `activity-summary-message.ts`.** It holds one composer, `composeActivitySummary`, which the writer and the reader both use. The code is one of three shapes: - `activityCreated` / `activityDeleted` / `activityUpdated` with `{object, label}` - `activityUpdatedFields` with `{object, label, fields, more}` - `fieldChanges` with `{object, changes: [{field, from, to}]}`, where each side is `{text, option?}` The object param is the object **name**. Object, field and option labels are resolved per reader from that reader's pack (`objects.NAME.label`, `…fields.FIELD.label`, `…options.VALUE`), with the authored label as the fallback. - **The writer (`audit-writers.ts`).** Each CRUD mirror row stores the English sentence in `summary` and the code in `metadata.summary_message`. The write no longer asks for a locale for the summary. Milestone summaries (the #7290 path) store no code and keep their authored text. - **The reader (`installActivitySummaryLocale`, registered in `audit-plugin.ts` after `installActivityFieldRedaction`).** It is an engine middleware on `sys_activity` `find` / `findOne`. - It negotiates `ctx.context.locale` against the held packs with `resolveBundleLocale`, so a bare `zh` reads the zh-CN pack. - It composes the summary in that locale and overlays `summary`. - When the projection names `summary` only, it adds `metadata` and removes it again afterwards. - It drops the code from `metadata` for every caller that is not the system, because its params are field values. - Middleware is an onion, so the field redaction, registered earlier, still judges the composed text. - A reader locale with no template, or a code this version cannot compose, keeps the stored sentence. **Hypothesis A held: rendering happens on read, on the server.** The Discussion feed fetches its rows with `dataSource.find('sys_activity', …)` (`ui/packages/plugin-detail/src/renderers/record-activity.tsx`) and shows `row.summary` (`recordActivityFeed.ts`). The authenticated fetch sends `Accept-Language` on every API call (`ui/packages/auth/src/createAuthenticatedFetch.ts`), and the REST door resolves it into the caller context's `locale`. So the server overlay changes what the feed shows, with no UI change, and Hypothesis B was not needed. ## Rows written before this change They **stay as written**, for every reader. There is no migration and no backfill, and no row is dropped. A row with no `summary_message` in its `metadata` is served its stored `summary` unchanged. This is pinned by the last case of the integration test, which uses a row in the earlier `{ old, new }` metadata shape. ## Tests The red test came first, in commit cb6b6af: `src/activity-summary-reader-locale.integration.test.ts`. It runs on a real kernel with ObjectQL, SQLite, a security double and AuditPlugin. It writes as the system with the workspace locale left at `en`, then reads as a zh-CN caller. - **On `main` (fbb7599)** it fails 4 and passes 3: `Expected: "创建了 合同 "NDA""`, `Received: "Created Contract "NDA""`, and the lock reported `VERDICT command-exit 1`. - **On this branch** all cases pass. A zh-CN reader gets `创建了 合同 "NDA"`, `阶段: 草稿 → 已签署` and `更新了 合同 "NDA":备注`. The system, an en-US reader and a reader with no locale get the English sentences. A bare `zh` is negotiated to zh-CN, and fr-FR keeps the stored text. The code is served only to the system. A `fields: ['summary']` projection still composes. A reader who is not served `note` gets no summary for that row. A row written before the change keeps its text. The unit tests for the composer and the overlay are in `src/activity-summary-message.test.ts`. Six existing suites pinned the summary being localized at write time. They now assert the stored English sentence plus the text a reader gets through the same overlay, using `src/__tests__/summary-as-read.ts`. The workspace-locale and host-locale suites now pin the remaining write-locale consumer, which is the @mention notification title (`Ada 提到了你`). One new case asserts that an audited write asks for no locale at all. Local results: - `pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2`: Test Files 62 passed (62), Tests 1013 passed (1013), exit 0. - `pnpm --filter @objectstack/plugin-audit typecheck` (which includes `check:test-typecheck`): exit 0. - eslint scoped to `packages/plugins/plugin-audit`: exit 0. - **Ablation.** I disabled the one registration line, `installActivitySummaryLocale(engine as any, getI18n);` in `audit-plugin.ts`, using `scripts/ablation-replace.mjs`. The integration test then failed 4 and passed 3, the same as on `main`. The restore matched the HEAD blob and `git diff HEAD` was empty. - **dispatch-gates.** `--commands` on this diff derives 102 families. All 102 ran and exit 0, and `--ran` reconciles as 102 derived, 102 run, 0 NOT-MEASURED. Five of them were re-run before they went green: - `check-system-context-census` found the new read site with no anchor on the page. The docs row above fixes it. - `check:query-options-erasure` found one new erased options bag in the tests. The new tests now pass typed options. - `check-plugin-teardown-shape --self-test` failed on a shallow clone and passed after unshallowing. - `check:i18n` and `check:dual-build-cjs-loads` failed for missing builds and passed after building. ## Acceptance notes - **Search reads English now.** `summary` is `searchable`, and its stored text is now the English sentence wherever the workspace locale is set. So in a Chinese workspace, full-text search over `summary` matches the English words for rows written after this change. This follows the ruling that the English sentence is the fallback text. The same goes for an export of the raw column. - **The write locale still chooses the language of @mention notification titles.** That is `AuditPluginOptions.getLocale`, otherwise the localization settings. Only its doc comment changed. - **Not measured: the hotclm `pnpm demo:zh` Console check.** That repo is outside this session. The read path was measured from the code above and is pinned end to end by the integration test. --- _Generated by [Claude Code](https://claude.ai/code/session_01UBPXQdapfjkjYdQck9dtTa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 617f27e commit c496886

14 files changed

Lines changed: 1294 additions & 269 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): an activity summary reads in the reader's language, not the language of the write
6+
7+
Clause-②: no (no exported type, schema field or spec key changes; the code rides in the existing `sys_activity.metadata` JSON, and `summary` keeps its type and meaning)
8+
Metadata-protocol: none
9+
10+
The CRUD mirror now stores two things for each summary: `summary` holds the English sentence, as the fallback text for any reader, search and export, and `metadata.summary_message` holds the message key plus its params (object, record label, field names, from/to values with option values). A new `sys_activity` read middleware composes the summary again in the caller's negotiated locale (from `Accept-Language`, else the workspace locale) with the object, field and option labels of that reader's language pack. A locale with no template, or a code this version cannot compose, keeps the stored sentence. The field redaction still judges the composed text, and the code is served only to system callers.
11+
12+
The write no longer asks for a locale for the summary, so a workspace set to Chinese stores the English sentence: full-text search over `summary` matches the English words. `getLocale` / the localization settings still pick the language of @mention notification titles.
13+
14+
Rows written before this change carry no code: they keep the sentence they were written with, for every reader. There is no migration or backfill, and no row is dropped.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ One of the largest consumers of the flag;
171171
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` |
172172
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` |
173173
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` |
174-
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
174+
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), the activity summary's message code (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, and the activity row's stored summary code (`metadata.summary_message`), on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), the removal of that summary code from the activity row's `metadata` (its params are field values), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/activity-summary-message.ts#installActivitySummaryLocale`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
175175
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
176176
| 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) | `packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware` |
177177
| 47b | The inbound-mail ingest seam: `ingest` admits the system context ALONE, and `rematch` admits it without the organization, owner or `manage_mailboxes` check | service-mailbox | Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of `ingest` is refused (`PERMISSION_DENIED`), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of `rematch` must be in the mailbox's organization and own the mailbox or hold `manage_mailboxes` | `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest`, `#authorizeRematch` |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26167] A stored `sys_activity` row's `summary` as a reader in `locale` is
5+
* served it, for suites that write through the audit writer and read the row
6+
* straight from their store (no engine read, so no middleware ran). It applies
7+
* the read side's own overlay (`localizeActivityRow`) to a copy of the row, so
8+
* what it answers is what `installActivitySummaryLocale` serves that reader.
9+
*/
10+
11+
import {
12+
localizeActivityRow,
13+
readerSummaryLocale,
14+
type ActivitySummaryI18n,
15+
} from '../activity-summary-message.js';
16+
17+
/** The engine slice a summary's labels are read from. */
18+
interface DefinitionSource {
19+
getSchema?(objectName: string): unknown;
20+
}
21+
22+
export function summaryAsRead(
23+
row: Record<string, unknown> | undefined,
24+
i18n: unknown,
25+
locale: string | undefined,
26+
engine: DefinitionSource,
27+
): unknown {
28+
if (!row) return undefined;
29+
const copy: Record<string, unknown> = { ...row };
30+
const reader = readerSummaryLocale(i18n as ActivitySummaryI18n | undefined, locale, (objectName) =>
31+
typeof engine.getSchema === 'function' ? engine.getSchema(objectName) : null,
32+
);
33+
localizeActivityRow(copy, reader, false);
34+
return copy.summary;
35+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26167] The summary code's composer, and the read-side overlay, against a
5+
* REAL locale: `createMemoryI18n` (what the kernel registers as the `i18n`
6+
* fallback) loaded with this plugin's shipped bundle plus an app's labels, so
7+
* the key shapes asked are the bundles' own. The real read path is pinned in
8+
* `activity-summary-reader-locale.integration.test.ts`.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { createMemoryI18n } from '@objectstack/core';
13+
14+
import { AuditTranslations } from './translations/index.js';
15+
import {
16+
ACTIVITY_SUMMARY_MESSAGE_KEY,
17+
composeActivitySummary,
18+
composeStoredSummary,
19+
localizeActivityRow,
20+
readActivitySummaryMessage,
21+
readerSummaryLocale,
22+
type ActivitySummaryMessage,
23+
} from './activity-summary-message.js';
24+
25+
const DEFS: Record<string, any> = {
26+
biz_deal: {
27+
name: 'biz_deal',
28+
label: 'Deal',
29+
fields: {
30+
stage: { label: 'Stage', options: [{ value: 'won', label: 'Won' }] },
31+
amount: { label: 'Amount' },
32+
owner: { label: 'Owner' },
33+
},
34+
},
35+
};
36+
const getObjectDef = (name: string) => DEFS[name] ?? null;
37+
38+
function makeI18n() {
39+
const i18n = createMemoryI18n();
40+
for (const [locale, data] of Object.entries(AuditTranslations)) {
41+
i18n.loadTranslations(locale, data as Record<string, any>);
42+
}
43+
i18n.loadTranslations('zh-CN', {
44+
objects: {
45+
biz_deal: {
46+
label: '商机',
47+
fields: { stage: { label: '阶段', options: { won: '赢单' } }, amount: { label: '金额' } },
48+
},
49+
},
50+
});
51+
return i18n;
52+
}
53+
54+
const zh = () => readerSummaryLocale(makeI18n(), 'zh-CN', getObjectDef)!;
55+
const ja = () => readerSummaryLocale(makeI18n(), 'ja-JP', getObjectDef)!;
56+
57+
const CREATED: ActivitySummaryMessage = { key: 'activityCreated', object: 'biz_deal', label: 'Acme' };
58+
const NAMED: ActivitySummaryMessage = {
59+
key: 'activityUpdatedFields',
60+
object: 'biz_deal',
61+
label: 'Acme',
62+
fields: ['amount', 'owner'],
63+
more: 2,
64+
};
65+
const CHANGES: ActivitySummaryMessage = {
66+
key: 'fieldChanges',
67+
object: 'biz_deal',
68+
changes: [
69+
{ field: 'stage', from: { text: '∅' }, to: { text: 'Won', option: 'won' } },
70+
{ field: 'owner', from: { text: '张伟' }, to: { text: 'Ana' } },
71+
],
72+
};
73+
74+
describe('composeActivitySummary — one code, each reader\'s language (#26167)', () => {
75+
it('composes every template in the reader locale, labels resolved per reader', () => {
76+
expect(composeActivitySummary(CREATED, zh())).toBe('创建了 商机 "Acme"');
77+
expect(composeActivitySummary({ ...CREATED, key: 'activityDeleted' }, zh())).toBe('删除了 商机 "Acme"');
78+
expect(composeActivitySummary({ ...CREATED, key: 'activityUpdated' }, zh())).toBe('更新了 商机 "Acme"');
79+
// `owner` has no zh-CN label: the authored one answers; the list is zh-CN's.
80+
expect(composeActivitySummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段');
81+
// An option value is the reader's option label; a title is the record's own datum.
82+
expect(composeActivitySummary(CHANGES, zh())).toBe('阶段: ∅ → 赢单; Owner: 张伟 → Ana');
83+
});
84+
85+
it('falls back to the authored labels where the reader\'s pack has none', () => {
86+
expect(composeActivitySummary(CREATED, ja())).toBe('Deal「Acme」を作成しました');
87+
expect(composeActivitySummary(CHANGES, ja())).toBe('Stage: ∅ → Won; Owner: 张伟 → Ana');
88+
});
89+
90+
it('answers undefined for a locale with no template, and the field-less frame where only that exists', () => {
91+
const fr = createMemoryI18n();
92+
const none = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!;
93+
expect(composeActivitySummary(CREATED, none)).toBeUndefined();
94+
fr.loadTranslations('fr-FR', { messages: { activityUpdated: 'A mis à jour {{object}} « {{label}} »' } });
95+
const older = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!;
96+
expect(composeActivitySummary(NAMED, older)).toBe('A mis à jour Deal « Acme »');
97+
});
98+
99+
it('the stored sentence is the write locale\'s, else the English catalog\'s — never a second spelling', () => {
100+
expect(composeStoredSummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段');
101+
const none = readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)!;
102+
expect(composeStoredSummary(CREATED, none)).toBe('Created Deal "Acme"');
103+
expect(composeStoredSummary(NAMED, none)).toBe('Updated Deal "Acme": Amount, Owner, and 2 more');
104+
});
105+
});
106+
107+
describe('readerSummaryLocale — the reader\'s negotiated locale', () => {
108+
it('negotiates against the held packs: a bare `zh` reads the zh-CN pack', () => {
109+
expect(composeActivitySummary(CREATED, readerSummaryLocale(makeI18n(), 'zh', getObjectDef)!)).toBe(
110+
'创建了 商机 "Acme"',
111+
);
112+
});
113+
114+
it('composes nothing without a locale or a `t()`', () => {
115+
expect(readerSummaryLocale(makeI18n(), undefined, getObjectDef)).toBeUndefined();
116+
expect(readerSummaryLocale(makeI18n(), '', getObjectDef)).toBeUndefined();
117+
expect(readerSummaryLocale(undefined, 'zh-CN', getObjectDef)).toBeUndefined();
118+
});
119+
120+
it('a throwing service is a miss, never a failed read', () => {
121+
const throwing = readerSummaryLocale(
122+
{
123+
t: () => {
124+
throw new Error('boom');
125+
},
126+
},
127+
'zh-CN',
128+
getObjectDef,
129+
)!;
130+
expect(composeActivitySummary(CREATED, throwing)).toBeUndefined();
131+
});
132+
});
133+
134+
describe('readActivitySummaryMessage — only a code this version can compose', () => {
135+
it('accepts each stored shape and refuses anything else', () => {
136+
for (const m of [CREATED, NAMED, CHANGES]) expect(readActivitySummaryMessage(m)).toEqual(m);
137+
expect(readActivitySummaryMessage({ key: 'activityArchived', object: 'biz_deal', label: 'x' })).toBeNull();
138+
expect(readActivitySummaryMessage({ key: 'activityCreated', object: 'biz_deal' })).toBeNull();
139+
expect(readActivitySummaryMessage({ ...NAMED, fields: [1] })).toBeNull();
140+
expect(readActivitySummaryMessage({ key: 'fieldChanges', object: 'biz_deal', changes: [{ field: 'x' }] })).toBeNull();
141+
expect(readActivitySummaryMessage('activityCreated')).toBeNull();
142+
});
143+
});
144+
145+
describe('localizeActivityRow — the read-side overlay', () => {
146+
const row = (metadata: unknown, summary = 'Created Deal "Acme"') => ({ summary, metadata });
147+
const stored = (message: unknown) => JSON.stringify({ old: null, new: { name: 'Acme' }, [ACTIVITY_SUMMARY_MESSAGE_KEY]: message });
148+
149+
it('overlays the summary and drops the code from what a caller is served', () => {
150+
const r: Record<string, unknown> = row(stored(CREATED));
151+
localizeActivityRow(r, zh(), true);
152+
expect(r.summary).toBe('创建了 商机 "Acme"');
153+
expect(JSON.parse(r.metadata as string)).toEqual({ old: null, new: { name: 'Acme' } });
154+
});
155+
156+
it('keeps the code for the system, and works on a parsed `metadata` object too', () => {
157+
const r: Record<string, unknown> = row({ old: null, new: {}, [ACTIVITY_SUMMARY_MESSAGE_KEY]: CREATED });
158+
localizeActivityRow(r, zh(), false);
159+
expect(r.summary).toBe('创建了 商机 "Acme"');
160+
expect((r.metadata as Record<string, unknown>)[ACTIVITY_SUMMARY_MESSAGE_KEY]).toEqual(CREATED);
161+
});
162+
163+
it('keeps the stored sentence with no locale, an unknown code, or a missing template — and still drops the code', () => {
164+
for (const [message, locale] of [
165+
[CREATED, undefined],
166+
[{ key: 'activityArchived', object: 'biz_deal', label: 'Acme' }, zh()],
167+
[CREATED, readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)],
168+
] as const) {
169+
const r: Record<string, unknown> = row(stored(message));
170+
localizeActivityRow(r, locale, true);
171+
expect(r.summary).toBe('Created Deal "Acme"');
172+
expect(JSON.parse(r.metadata as string)).not.toHaveProperty(ACTIVITY_SUMMARY_MESSAGE_KEY);
173+
}
174+
});
175+
176+
it('a row written before the change (no code) and a row whose summary was not projected are left alone', () => {
177+
const legacy: Record<string, unknown> = row(JSON.stringify({ old: null, new: { name: 'Acme' } }));
178+
localizeActivityRow(legacy, zh(), true);
179+
expect(legacy).toEqual(row(JSON.stringify({ old: null, new: { name: 'Acme' } })));
180+
181+
const unprojected: Record<string, unknown> = { metadata: stored(CREATED) };
182+
localizeActivityRow(unprojected, zh(), true);
183+
expect(unprojected).not.toHaveProperty('summary');
184+
});
185+
});

0 commit comments

Comments
 (0)