Skip to content

Commit 58d4ee6

Browse files
fix(plugin-approvals): the data door serves an approval request's flow and step labels in the reader's language (#27092)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X27DRismkrd3QfL8JeFsGMQ)_ Fixes #27041 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:通过通用数据接口读 `sys_approval_request`(Setup › 审批申请列表、申请的记录页、查找字段的标题、导出)时,流程名 `process_label` 和步骤名 `current_step_label` 按读者语言返回,用的就是审批接口(`/api/v1/approvals/requests`)已有的那一套查找;语言包没有的条目照旧返回存储文本。 - **为什么改**:zh-CN 用户在审批收件箱看到中文,在 Setup › 审批申请里看到同一批申请却是英文(HotCRM 的 "Large Deal Approval" / "Sales Manager Review")。收件箱那一半由控制台 PR #27040 修了,列表这一半只能在服务端修。 - **风险与回滚**:低。只改读出的行,存储不动;系统上下文读取、无语言、语言包无条目都返回原文。列表按列 `$select` 时会临时多取 `process_name` / `current_step` 用来拼键,返回前删掉,调用方拿到的列不变。回滚即 revert。 - **先看**:`packages/plugins/plugin-approvals/src/reader-locale-labels-middleware.ts`(借列与剥列、系统读跳过);`approvals-plugin.ts` 的注册位置(与读门同处,不在 `disableAutoHooks` 后面);`content/docs/permissions/system-context.mdx` 第 17 行。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-approvals exec vitest run src/reader-locale-labels-data-door.integration.test.ts`,预期 10/10 通过;或启动 showcase 后用 `Accept-Language: zh-CN` 请求 `GET /api/v1/data/sys_approval_request?$select=process_label,current_step_label`,看到「发票双重签批(并行审批)」等中文。 - **需要你做的**:无,席位落地。 ## What changes `@objectstack/plugin-approvals` registers one more engine middleware on `sys_approval_request`, `bindReaderLocaleLabelsMiddleware` (new file `reader-locale-labels-middleware.ts`), beside the snapshot redaction (`payload-redaction-middleware.ts`) and for its reason: one registration at the engine covers the whole generic-read family (REST data routes, ObjectQL callers, the CSV/XLSX export, the MCP data tool, the AI-context interceptor), and middleware carries the door's real `ExecutionContext`, whose `locale` is the one the approvals door reads (`Accept-Language`, else the workspace `localization` settings). - **One lookup.** The labels come from `readerLabelsFor` and are placed by `localizeRequestLabels` (`reader-locale-labels.ts`), the approvals door's own pair. No second translation table, no new key spelling: same keys (`flows.FLOW.label`, `flows.FLOW.approvals.NODE.label`), same locale negotiation (`resolveBundleLocale`), same fallback to the stored text on a miss. - **Only the columns the read carries.** `process_label` and `current_step_label` are overlaid; `step_label` and `object_label` are approvals-door fields, not columns, and this door does not add them. - **Projection kept.** Setup's `all_requests` view selects the labels and not the machine columns. When a read selects a label without the column its key is spelled from (`process_name`, `current_step`), the middleware borrows that column into `ast.fields` for the read and deletes it from every row before the rows go back, so the caller is served exactly the columns it asked for. - **Read-time only.** Storage is untouched. A system read (`isSystem`) is served as stored even when its context carries a locale, so nothing the platform reads back and writes again holds a reader's language. No locale, no `i18n` service, or a missing pack entry: the stored text. - **Registered always.** Bound beside the generic-door read gate (`bindRequestReadGate`), not behind `disableAutoHooks`, because the approvals door localizes whenever the service runs. - **Census row.** The new `isSystem` read is named on `content/docs/permissions/system-context.mdx` row 17 (the plugin-approvals generic-read row) and the generated register is regenerated with `node scripts/check-system-context-census.mjs --write` (claim amendment `6461039298`). - Docblocks: `reader-locale-labels.ts` no longer says the data door serves the stored text; `sys-approval-request.object.ts` notes the overlay beside the display-column comment. ## Tests New `reader-locale-labels-data-door.integration.test.ts`: the rig of `reader-locale-display-labels.integration.test.ts` (real `ObjectQL` over `SqlDriver` better-sqlite3, the real `AutomationEngine` opening the request through the real approval node, the real `ApprovalsServicePlugin` over the real in-memory `i18n` service with a zh-CN pack parsed by `TranslationDataSchema`), read through `ObjectStackProtocolImplementation.findData` / `getData`, the calls the REST data routes make. - zh-CN reader: Setup's list projected to `all_requests`' columns, the full-column list, the by-id read, a bare `zh`: the zh-CN labels. - The borrowed key columns are not served on the projected list. - Parity: the same row read through `svc.getRequest` and through the data door (list and by-id) carries equal label text. - A step the pack does not carry: stored step label beside the translated flow label. - Fences (green on both sides): ja-JP, en and no locale serve the stored text; a system read with a locale serves the stored text; no `step_label` / `object_label` column is added; the stored row keeps the authored text. Package: `pnpm --filter @objectstack/plugin-approvals test` 105 files / 1331 tests passed; `typecheck` exit 0; `build` exit 0 (on `f7d8e486`; the only later commit, `ee26b793`, touches `system-context.mdx` alone). ## Acceptance notes - `display_title` (a formula, `process_label` else `process_name`) is computed inside the engine before middleware runs, so it stays the stored text. It is not the title pointer (`nameField: process_label`) and no list view shows it. - Filter, sort, group and search over `process_label` / `current_step_label` compare the stored text, so a grid grouped by `process_label` heads its groups in the authoring language. Not filed: no reproduction on a shipped view (none groups by these columns). - `count` / `aggregate` are untouched; they serve no label text. ## Verification <details><summary>Red first, in-package (base f708cf2 + the test only, commit 36c5405)</summary> ``` × Setup's list, projected to its columns: the labels in zh-CN, and only the columns it selected × the full-column list: the labels in zh-CN, the machine columns as stored × the by-id read × a bare `zh` reaches the zh-CN pack × parity: the data door and the approvals door serve the same label text for the same row × a step the pack does not carry is served as stored, beside the flow label it does - "current_step_label": "经理审核", - "process_label": "客户审批", + "current_step_label": "Manager Review", + "process_label": "Account Approval", Tests 6 failed | 4 passed (10) ``` Green on the fix: the new file plus `reader-locale-display-labels.integration.test.ts` and `request-read-gate.integration.test.ts`: 3 files, 36/36 passed. </details> <details><summary>Live red/green through the real REST data door (showcase, `pnpm dev -- --fresh`)</summary> Signed in as the seeded dev admin; the showcase boot opens three pending requests whose flows its zh-CN pack translates. Red = the base blob of `approvals-plugin.ts` written over the file and the package rebuilt (bind count in `dist/index.js` 0); green = HEAD (count 2). Restored by `git checkout HEAD --`, blob `869363ef` equal to HEAD, `git diff HEAD` empty. `GET /api/v1/data/sys_approval_request?$select=process_label,record_id,status,current_step_label,pending_approver_labels,submitter_id,updated_at`, `Accept-Language: zh-CN`: | | process_label | current_step_label | |---|---|---| | base | Invoice Dual Sign-off (parallel approval) | Finance + Legal Sign-off | | base | High-Value Expense — Committee Quorum | Committee Sign-off (2 of 3) | | base | Expense Report Sign-off | Manager + Finance Sign-off | | head | 发票双重签批(并行审批) | 财务 + 法务签批 | | head | 大额费用 — 委员会多数签批 | 委员会签批(三分之二) | | head | 费用报告签批 | 经理 + 财务签批 | On head, `process_name` / `current_step` absent from those rows; `ja-JP` and `en` serve the English text on both sides. By id, `zh-CN`: base `GET /data/sys_approval_request/ID` served "Invoice Dual Sign-off (parallel approval)" while `GET /approvals/requests/ID` served "发票双重签批(并行审批)"; head serves "发票双重签批(并行审批)" / "财务 + 法务签批" on both doors. The data door row carries no `step_label` / `object_label`. </details> <details><summary>Ablations (src-resolved suite, `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty)</summary> - A: the `bindReaderLocaleLabelsMiddleware(...)` call in `approvals-plugin.ts` replaced: 6 failed / 4 passed, the six zh-CN pins. - B: the projection widening disabled in `borrowLabelKeyColumns`: 3 failed (the three projected-list pins). - C: the borrowed-column strip disabled: 1 failed (the projected list serves `process_name` / `current_step`). </details> <details><summary>Gates (head ee26b79)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 92 families, all run, all exit 0; `--ran` reconciliation: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN. First pass reds re-run after the full build settled: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:i18n` (each ran against a `dist/` mid-rebuild), `check-plugin-teardown-shape --self-test` (shallow clone; fixture commit fetched), all exit 0 on re-run. `check:system-context-census` was a real red (the new `isSystem` read had no row) fixed by the row-17 edit. `pnpm exec eslint --no-inline-config packages/plugins/plugin-approvals` exit 0; `check-nul-bytes` exit 0. Not run locally: `test:repo` (CI Test Core); full dogfood (no `!`, no `(narrowing)` arm). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (spec untouched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the package). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kcTaxH7yNuUvHxaPR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49d652e commit 58d4ee6

7 files changed

Lines changed: 474 additions & 3 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): a read of `sys_approval_request` through the generic data door serves the flow and step labels in the reader's language
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Setup's approval request list, the request's record page, a lookup's title and the export read `sys_approval_request` through the generic data door, which served the stored `process_label` / `current_step_label` to every reader. A zh-CN user saw "Large Deal Approval" / "Sales Manager Review" there while the approvals inbox showed the same rows translated.
11+
12+
- **The same lookup as the approvals door.** The data door now overlays `process_label` and `current_step_label` with the app's language pack entries (`flows.FLOW.label`, `flows.FLOW.approvals.NODE.label`) for the reader's locale (`Accept-Language`, else the workspace `localization` settings), by the same lookup `GET /api/v1/approvals/requests` applies, so both doors serve the same text for the same row.
13+
- **Stored text on a miss.** A label the pack does not carry, a locale no pack holds, a read with no locale and a system read are served the stored text, as before.
14+
- **Same columns.** A read that selects a label without `process_name` / `current_step` is still served only the columns it selected; no column is added (`step_label` and `object_label` stay approvals-door fields).
15+
- **Storage is untouched.** The overlay happens on the rows a read hands back; the stored labels stay the authored text as the request opened with it. Filtering, sorting and grouping on `process_label` / `current_step_label` still compare the stored text.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ bugs — it is the protection or the side effect that silently does not happen.
118118
| 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `isPerfDisclosurePrincipal` |
119119
| 15 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `enforceApiExposure` |
120120
| 16 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `installReadAuditWriter` |
121-
| 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read. Lose: field-visibility redaction over approval payloads, and the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach | `bindSnapshotRedactionMiddleware`, `bindSnapshotPredicateGuard` |
121+
| 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard and the reader-locale label overlay | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read; and a request's `process_label` / `current_step_label` as stored, never in a locale the context carries, so nothing the platform reads back and writes again holds a reader's language. Lose: field-visibility redaction over approval payloads, the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach, and the flow and step labels in the reader's language | `bindSnapshotRedactionMiddleware`, `bindSnapshotPredicateGuard`, `bindReaderLocaleLabelsMiddleware` |
122122
| 18 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `enforceAuth` |
123123
| 18b | Settings namespace read scope not applied | service-settings | Get: a read of `sys_setting`, `sys_setting_audit` or `sys_platform_setting` across every namespace, whatever each namespace's `readPermission` names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce | `settingsReadDoorMiddleware` |
124124

@@ -550,6 +550,7 @@ and CI refuses one that differs from what the tree produces.
550550
| `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` | 1 | 42 |
551551
| `packages/plugins/plugin-approvals/src/payload-predicate-guard.ts#bindSnapshotPredicateGuard` | 1 | 17 |
552552
| `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware` | 1 | 17 |
553+
| `packages/plugins/plugin-approvals/src/reader-locale-labels-middleware.ts#bindReaderLocaleLabelsMiddleware` | 1 | 17 |
553554
| `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction` | 1 | 45 |
554555
| `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility` | 1 | 45 |
555556
| `packages/plugins/plugin-audit/src/activity-summary-message.ts#installActivitySummaryLocale` | 1 | 45 |

‎packages/plugins/plugin-approvals/src/approvals-plugin.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { bindSnapshotRedactionMiddleware } from './payload-redaction-middleware.js';
3636
import { bindSnapshotPredicateGuard } from './payload-predicate-guard.js';
3737
import { bindRequestChildReadGates, bindRequestReadGate } from './request-read-gate.js';
38+
import { bindReaderLocaleLabelsMiddleware } from './reader-locale-labels-middleware.js';
3839
import type { FieldVisibilitySource } from './payload-redaction.js';
3940
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
4041
import { backfillActionSlots } from './action-slot-backfill.js';
@@ -380,6 +381,11 @@ export class ApprovalsServicePlugin implements Plugin {
380381
const visibility = requestVisibilitySourceOf(this.service);
381382
bindRequestReadGate(engine as any, visibility, ctx.logger);
382383
bindRequestChildReadGates(engine as any, visibility, ctx.logger);
384+
// [#27041] …and the flow and step labels in the reader's language, by
385+
// the approvals door's own lookup (`reader-locale-labels-middleware.ts`).
386+
// Bound beside the gate, not behind `disableAutoHooks`, for the gate's
387+
// reason: the approvals door localizes whenever the service runs.
388+
bindReaderLocaleLabelsMiddleware(engine as any, i18nService, ctx.logger);
383389
} else {
384390
ctx.logger.warn(
385391
'ApprovalsServicePlugin: the ObjectQL engine has no middleware seam — reads of sys_approval_request, '
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27041] The generic data door names a request's flow and step in the
5+
* READER's language, by the lookup the approvals door applies.
6+
*
7+
* ## The defect
8+
*
9+
* Setup's request list (`sys_approval_request`, view `all_requests`) reads the
10+
* generic data door, which served the stored `process_label` /
11+
* `current_step_label` to every reader: a zh-CN user saw "Account Approval" /
12+
* "Manager Review" there, while the approvals door served the same rows in
13+
* zh-CN from the app's language pack (#24863).
14+
*
15+
* ## The rig
16+
*
17+
* The rig of `reader-locale-display-labels.integration.test.ts`: a real
18+
* `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory), the real
19+
* `AutomationEngine` opening the request through the real approval node, and
20+
* the real `ApprovalsServicePlugin` over a kernel whose `i18n` service is the
21+
* real in-memory one loaded with a zh-CN pack. The data door is
22+
* `ObjectStackProtocolImplementation.findData` / `getData`, the calls
23+
* `GET /api/v1/data/sys_approval_request` and `GET …/:id` make, with the
24+
* reader's `ExecutionContext.locale` as the REST door's shared assembler
25+
* resolves it from `Accept-Language`.
26+
*
27+
* ## What it pins
28+
*
29+
* - a zh-CN reader is served the zh-CN flow and step labels on the list as
30+
* Setup projects it (the labels selected, the machine columns not), on the
31+
* full-column list, and on the by-id read;
32+
* - the projection is kept: a label selected without its key column is served
33+
* without that column;
34+
* - parity: the same row read through both doors carries the same label text;
35+
* - a step the pack does not carry is served as stored, beside a translated
36+
* flow label;
37+
* - a reader in a locale no pack holds, an en reader and a reader with no
38+
* locale are served the stored text, and a system read is served as stored;
39+
* - the door adds no column: no `step_label`, no `object_label`;
40+
* - the stored row keeps the authored text.
41+
*
42+
* ⚠️ Direction, predicted before running: every zh-CN pin is RED on the unfixed
43+
* door (it served the stored text whatever the locale); every "as stored" pin is
44+
* green on both sides on purpose, fencing the fallback.
45+
*/
46+
47+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
48+
import { ObjectQL } from '@objectstack/objectql';
49+
import { SqlDriver } from '@objectstack/driver-sql';
50+
import { AutomationEngine } from '@objectstack/service-automation';
51+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
52+
import { createMemoryI18n } from '@objectstack/core';
53+
import { TranslationDataSchema } from '@objectstack/spec/system';
54+
import type { EngineQueryOptions } from '@objectstack/spec/data';
55+
import type { ApprovalService } from './approval-service.js';
56+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
57+
import { installDefaultOrganization } from './default-organization.testkit.js';
58+
59+
const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any;
60+
const reader = (userId: string, locale?: string) =>
61+
({ userId, positions: [], permissions: [], ...(locale ? { locale } : {}) }) as any;
62+
const silent = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any;
63+
64+
const REQUEST = 'sys_approval_request';
65+
const SUBMITTER = 'u_sub';
66+
const MANAGER = 'u_mgr';
67+
const DIRECTOR = 'u_dir';
68+
const OBJECT = 'crm_account';
69+
70+
const sysUser = {
71+
name: 'sys_user',
72+
label: 'User',
73+
fields: {
74+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
75+
name: { name: 'name', label: 'Name', type: 'text' as const },
76+
email: { name: 'email', label: 'Email', type: 'text' as const },
77+
},
78+
};
79+
const account = {
80+
name: OBJECT,
81+
label: 'Account',
82+
fields: {
83+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
84+
name: { name: 'name', label: 'Name', type: 'text' as const },
85+
},
86+
};
87+
88+
/** Two approval steps; the pack translates the first step and not the second. */
89+
const FLOW = {
90+
name: 'account_approval', label: 'Account Approval', type: 'autolaunched',
91+
nodes: [
92+
{ id: 'start', type: 'start', label: 'Start' },
93+
{ id: 'manager_review', type: 'approval', label: 'Manager Review', config: { approvers: [{ type: 'user', value: MANAGER }] } },
94+
{ id: 'director_review', type: 'approval', label: 'Director Review', config: { approvers: [{ type: 'user', value: DIRECTOR }] } },
95+
{ id: 'ok', type: 'end', label: 'Approved' },
96+
{ id: 'no', type: 'end', label: 'Rejected' },
97+
],
98+
edges: [
99+
{ id: 'e1', source: 'start', target: 'manager_review' },
100+
{ id: 'e2', source: 'manager_review', target: 'director_review', label: 'approve' },
101+
{ id: 'e3', source: 'manager_review', target: 'no', label: 'reject' },
102+
{ id: 'e4', source: 'director_review', target: 'ok', label: 'approve' },
103+
{ id: 'e5', source: 'director_review', target: 'no', label: 'reject' },
104+
],
105+
};
106+
107+
/** The app's zh-CN language pack: the flow, its first step, and the subject object. */
108+
const ZH_PACK = {
109+
objects: { [OBJECT]: { label: '客户' } },
110+
flows: {
111+
account_approval: {
112+
label: '客户审批',
113+
approvals: { manager_review: { label: '经理审核' } },
114+
},
115+
},
116+
};
117+
118+
const AS_STORED = { process_label: 'Account Approval', current_step_label: 'Manager Review' };
119+
const IN_ZH = { process_label: '客户审批', current_step_label: '经理审核' };
120+
121+
const labelsOf = (row: any) => ({
122+
process_label: row?.process_label,
123+
current_step_label: row?.current_step_label,
124+
});
125+
126+
/** Setup's request list as the console grid reads it: `all_requests`' columns, nothing else. */
127+
const ALL_REQUESTS_SELECT = 'process_label,record_id,status,current_step_label,pending_approver_labels,submitter_id,updated_at';
128+
129+
/** The plugin's own object definitions, as its manifest registers them. */
130+
async function packageObjects(): Promise<any[]> {
131+
let manifest: any;
132+
const ctx: any = {
133+
getService: (name: string) => {
134+
if (name === 'manifest') return { register: (m: any) => { manifest = m; } };
135+
throw new Error(`no service '${name}'`);
136+
},
137+
logger: silent,
138+
};
139+
await new ApprovalsServicePlugin().init(ctx);
140+
return manifest.objects;
141+
}
142+
143+
describe('[#27041] the data door serves a request\'s labels in the reader\'s locale', () => {
144+
let engine: ObjectQL;
145+
let automation: AutomationEngine;
146+
let svc: ApprovalService;
147+
let protocol: ObjectStackProtocolImplementation;
148+
let seq = 0;
149+
150+
/** Start the flow on a fresh account as the submitter; answer the request it paused on. */
151+
const submit = async (): Promise<any> => {
152+
const id = `acc_${++seq}`;
153+
const record = { id, name: `Acme ${seq}` };
154+
await engine.insert(OBJECT, record, { context: SYSTEM } as any);
155+
const run = await automation.execute(FLOW.name, { object: OBJECT, record, userId: SUBMITTER } as any);
156+
expect(run.status, 'the run paused on its first approval step').toBe('paused');
157+
const rows: any[] = await engine.find(REQUEST, {
158+
where: { record_id: id, status: 'pending' }, context: SYSTEM,
159+
} satisfies EngineQueryOptions);
160+
expect(rows).toHaveLength(1);
161+
return rows[0];
162+
};
163+
164+
/** `GET /data/sys_approval_request?…`: the rows the door hands back for `id`. */
165+
const listRow = async (context: any, id: string, query: Record<string, unknown> = {}): Promise<any> => {
166+
const res: any = await protocol.findData({ object: REQUEST, query: { id, ...query }, context });
167+
expect(res.records, 'the reader is served the request').toHaveLength(1);
168+
return res.records[0];
169+
};
170+
/** `GET /data/sys_approval_request/:id`. */
171+
const byId = async (context: any, id: string): Promise<any> =>
172+
(await protocol.getData({ object: REQUEST, id, context } as any)).record;
173+
174+
beforeAll(async () => {
175+
const parsed = TranslationDataSchema.safeParse(ZH_PACK) as { success: boolean; error?: unknown };
176+
expect(parsed.success, JSON.stringify(parsed.error)).toBe(true);
177+
178+
engine = new ObjectQL();
179+
engine.registerDriver(new SqlDriver({
180+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
181+
}), true);
182+
await engine.init();
183+
for (const def of [sysUser, account, ...(await packageObjects())]) {
184+
engine.registry.registerObject(def as any, 'approvals-data-door-locale-test', 'approvals-data-door-locale-test');
185+
}
186+
await engine.syncSchemas();
187+
await installDefaultOrganization(engine);
188+
for (const [id, name] of [[SUBMITTER, 'Sam Submitter'], [MANAGER, 'Mona Manager'], [DIRECTOR, 'Dina Director']]) {
189+
await engine.insert('sys_user', { id, name, email: `${id}@example.com` }, { context: SYSTEM } as any);
190+
}
191+
192+
automation = new AutomationEngine(silent);
193+
const i18n = createMemoryI18n();
194+
i18n.loadTranslations('zh-CN', ZH_PACK);
195+
196+
const services: Record<string, unknown> = { objectql: engine, automation, i18n };
197+
const ctx: any = {
198+
getService: (name: string) => {
199+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
200+
return services[name];
201+
},
202+
registerService: (name: string, service: unknown) => { services[name] = service; },
203+
hook: () => {},
204+
logger: silent,
205+
};
206+
await new ApprovalsServicePlugin().start(ctx);
207+
svc = services.approvals as ApprovalService;
208+
automation.registerFlow(FLOW.name, FLOW as any);
209+
protocol = new ObjectStackProtocolImplementation(engine as any);
210+
});
211+
212+
afterAll(async () => {
213+
try { await engine?.destroy(); } catch { /* noop */ }
214+
});
215+
216+
describe('a zh-CN reader', () => {
217+
it('Setup\'s list, projected to its columns: the labels in zh-CN, and only the columns it selected', async () => {
218+
const req = await submit();
219+
const row = await listRow(reader(SUBMITTER, 'zh-CN'), req.id, { $select: ALL_REQUESTS_SELECT });
220+
expect(labelsOf(row)).toEqual(IN_ZH);
221+
// The key columns the lookup borrowed are not served.
222+
expect(Object.keys(row)).not.toContain('process_name');
223+
expect(Object.keys(row)).not.toContain('current_step');
224+
});
225+
226+
it('the full-column list: the labels in zh-CN, the machine columns as stored', async () => {
227+
const req = await submit();
228+
const row = await listRow(reader(SUBMITTER, 'zh-CN'), req.id);
229+
expect(labelsOf(row)).toEqual(IN_ZH);
230+
expect([row.process_name, row.current_step]).toEqual(['flow:account_approval', 'manager_review']);
231+
});
232+
233+
it('the by-id read', async () => {
234+
const req = await submit();
235+
expect(labelsOf(await byId(reader(SUBMITTER, 'zh-CN'), req.id))).toEqual(IN_ZH);
236+
});
237+
238+
it('a bare `zh` reaches the zh-CN pack', async () => {
239+
const req = await submit();
240+
expect(labelsOf(await listRow(reader(SUBMITTER, 'zh'), req.id))).toEqual(IN_ZH);
241+
});
242+
243+
it('parity: the data door and the approvals door serve the same label text for the same row', async () => {
244+
const req = await submit();
245+
const ctx = reader(SUBMITTER, 'zh-CN');
246+
const viaApprovals = await svc.getRequest(req.id, ctx);
247+
const viaData = await byId(ctx, req.id);
248+
const viaList = await listRow(ctx, req.id, { $select: ALL_REQUESTS_SELECT });
249+
expect(labelsOf(viaData)).toEqual(labelsOf(viaApprovals));
250+
expect(labelsOf(viaList)).toEqual(labelsOf(viaApprovals));
251+
});
252+
253+
it('a step the pack does not carry is served as stored, beside the flow label it does', async () => {
254+
const req = await submit();
255+
await svc.decide(req.id, { decision: 'approve', actorId: MANAGER }, reader(MANAGER));
256+
const next: any[] = await engine.find(REQUEST, {
257+
where: { record_id: req.record_id, status: 'pending' }, context: SYSTEM,
258+
} satisfies EngineQueryOptions);
259+
expect(next.map((r) => r.current_step)).toEqual(['director_review']);
260+
const row = await listRow(reader(SUBMITTER, 'zh-CN'), next[0].id, { $select: ALL_REQUESTS_SELECT });
261+
expect(labelsOf(row)).toEqual({ process_label: '客户审批', current_step_label: 'Director Review' });
262+
});
263+
264+
it('the door adds no column the object does not declare', async () => {
265+
const req = await submit();
266+
const row = await byId(reader(SUBMITTER, 'zh-CN'), req.id);
267+
expect(Object.keys(row)).not.toContain('step_label');
268+
expect(Object.keys(row)).not.toContain('object_label');
269+
});
270+
});
271+
272+
it('a reader in a locale no pack holds, an en reader and a reader with no locale are served the stored text', async () => {
273+
const req = await submit();
274+
for (const locale of ['ja-JP', 'en', undefined]) {
275+
expect(labelsOf(await listRow(reader(SUBMITTER, locale), req.id, { $select: ALL_REQUESTS_SELECT })), String(locale))
276+
.toEqual(AS_STORED);
277+
expect(labelsOf(await byId(reader(SUBMITTER, locale), req.id)), String(locale)).toEqual(AS_STORED);
278+
}
279+
});
280+
281+
it('a system read is served as stored, a locale on it notwithstanding', async () => {
282+
const req = await submit();
283+
const row = await engine.findOne(REQUEST, { where: { id: req.id } }, { context: { ...SYSTEM, locale: 'zh-CN' } });
284+
expect(labelsOf(row)).toEqual(AS_STORED);
285+
});
286+
287+
it('the stored row keeps the authored text it opened with, after every read', async () => {
288+
const req = await submit();
289+
await listRow(reader(SUBMITTER, 'zh-CN'), req.id, { $select: ALL_REQUESTS_SELECT });
290+
await byId(reader(SUBMITTER, 'zh-CN'), req.id);
291+
const row = await engine.findOne(REQUEST, { where: { id: req.id } }, { context: SYSTEM });
292+
expect(labelsOf(row)).toEqual(AS_STORED);
293+
});
294+
});

0 commit comments

Comments
 (0)