|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0029 D9.2a — a tenant's rewrite of a packaged rule message beats the |
| 5 | + * packaged translation, on BOTH readers of the rule, and the two still agree. |
| 6 | + * |
| 7 | + * A packaged object declares two rules and ships their `zh-CN` translations at |
| 8 | + * `objects.<o>._validations.<rule>.message`. A tenant overlays the object |
| 9 | + * (ADR-0029 D9) and rewrites one of the two messages, keeping the rule's name. |
| 10 | + * The catalog translates the PACKAGED rule, so under `zh-CN`: |
| 11 | + * |
| 12 | + * - the rewritten rule must answer the tenant's own sentence, as written; |
| 13 | + * - the rule the tenant left alone must still answer its translation; |
| 14 | + * - a rule a second package folds on through `objectExtensions` — absent |
| 15 | + * from the owner's declaration by construction — must still answer its own |
| 16 | + * package's translation: it has no packaged message to diverge from. |
| 17 | + * |
| 18 | + * Both readers are driven off ONE real registry: the refusal of a real write |
| 19 | + * (the engine's validator), and the served object as `/meta` translates it — |
| 20 | + * `translateObject` over the resolved object, with the packaged base the |
| 21 | + * serving layer hands it (`getPackagedObjectBase`, the accessor `/meta` reads |
| 22 | + * through `packagedObjectBaseOf`). For every header they must be one string. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 26 | +import { preferredLocaleFromHeader, translateObject } from '@objectstack/spec/system'; |
| 27 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 28 | +import { ObjectQL } from './index.js'; |
| 29 | + |
| 30 | +const OBJ = 'tr_duty'; |
| 31 | +const PKG = 'com.objectstack.test.tenant-rewrite'; |
| 32 | +const EXT_PKG = 'com.objectstack.test.tenant-rewrite-extension'; |
| 33 | + |
| 34 | +const PACKAGED = { |
| 35 | + returned_needs_note: 'Say why the duty is being returned.', |
| 36 | + done_needs_owner: 'A finished duty needs an owner.', |
| 37 | + ext_blocked_needs_reason: 'Say what is blocking the duty.', |
| 38 | +}; |
| 39 | +const ZH = { |
| 40 | + returned_needs_note: '请写明打回的原因。', |
| 41 | + done_needs_owner: '已完成的职责需要负责人。', |
| 42 | + ext_blocked_needs_reason: '请写明阻塞原因。', |
| 43 | +}; |
| 44 | + |
| 45 | +/** The extension package's own rule, folded onto the object. */ |
| 46 | +const EXTENSION = { |
| 47 | + name: OBJ, |
| 48 | + validations: [{ |
| 49 | + type: 'script', |
| 50 | + name: 'ext_blocked_needs_reason', |
| 51 | + message: PACKAGED.ext_blocked_needs_reason, |
| 52 | + condition: "record.status == 'blocked' && record.return_note == null", |
| 53 | + fields: ['return_note'], |
| 54 | + }], |
| 55 | +}; |
| 56 | +const REWRITE = 'Tell the owner what to change before you send it back.'; |
| 57 | + |
| 58 | +const rules = (returnedMessage: string) => [ |
| 59 | + { |
| 60 | + type: 'script', |
| 61 | + name: 'returned_needs_note', |
| 62 | + message: returnedMessage, |
| 63 | + condition: "record.status == 'returned' && record.return_note == null", |
| 64 | + fields: ['return_note'], |
| 65 | + }, |
| 66 | + { |
| 67 | + type: 'script', |
| 68 | + name: 'done_needs_owner', |
| 69 | + message: PACKAGED.done_needs_owner, |
| 70 | + condition: "record.status == 'done' && record.owner == null", |
| 71 | + fields: ['owner'], |
| 72 | + }, |
| 73 | +]; |
| 74 | + |
| 75 | +const body = (returnedMessage: string) => ({ |
| 76 | + name: OBJ, |
| 77 | + label: 'Duty', |
| 78 | + fields: { |
| 79 | + status: { type: 'text', label: 'Status' }, |
| 80 | + return_note: { type: 'text', label: 'Return note' }, |
| 81 | + owner: { type: 'text', label: 'Owner' }, |
| 82 | + }, |
| 83 | + validations: rules(returnedMessage), |
| 84 | +}); |
| 85 | + |
| 86 | +const BUNDLE: Record<string, any> = { |
| 87 | + en: { |
| 88 | + objects: { |
| 89 | + [OBJ]: { |
| 90 | + _validations: { |
| 91 | + returned_needs_note: { message: PACKAGED.returned_needs_note }, |
| 92 | + done_needs_owner: { message: PACKAGED.done_needs_owner }, |
| 93 | + ext_blocked_needs_reason: { message: PACKAGED.ext_blocked_needs_reason }, |
| 94 | + }, |
| 95 | + }, |
| 96 | + }, |
| 97 | + }, |
| 98 | + 'zh-CN': { |
| 99 | + objects: { |
| 100 | + [OBJ]: { |
| 101 | + _validations: { |
| 102 | + returned_needs_note: { message: ZH.returned_needs_note }, |
| 103 | + done_needs_owner: { message: ZH.done_needs_owner }, |
| 104 | + ext_blocked_needs_reason: { message: ZH.ext_blocked_needs_reason }, |
| 105 | + }, |
| 106 | + }, |
| 107 | + }, |
| 108 | + }, |
| 109 | +}; |
| 110 | + |
| 111 | +/** `FileI18nAdapter.t()`-shaped: the locale exactly, then the fallback, then the key echoed. */ |
| 112 | +function servedI18n(bundle: Record<string, any>, fallbackLocale = 'en') { |
| 113 | + const dig = (data: unknown, key: string): unknown => |
| 114 | + key.split('.').reduce<unknown>( |
| 115 | + (cur, part) => (cur && typeof cur === 'object' ? (cur as any)[part] : undefined), |
| 116 | + data, |
| 117 | + ); |
| 118 | + return { |
| 119 | + t(key: string, locale: string): string { |
| 120 | + const exact = dig(bundle[locale], key); |
| 121 | + if (typeof exact === 'string') return exact; |
| 122 | + const fallback = dig(bundle[fallbackLocale], key); |
| 123 | + return typeof fallback === 'string' ? fallback : key; |
| 124 | + }, |
| 125 | + getLocales: () => Object.keys(bundle), |
| 126 | + }; |
| 127 | +} |
| 128 | + |
| 129 | +function makeDriver() { |
| 130 | + const rows = new Map<string, any>(); |
| 131 | + return { |
| 132 | + name: 'com.objectstack.driver.memory.tr', |
| 133 | + version: '1.0.0', |
| 134 | + async connect() { /* noop */ }, |
| 135 | + async disconnect() { /* noop */ }, |
| 136 | + async initObjects() { /* noop */ }, |
| 137 | + async find() { return [...rows.values()]; }, |
| 138 | + async findOne(_o: string, options?: any) { return rows.get(String(options?.where?.id)) ?? null; }, |
| 139 | + async count() { return rows.size; }, |
| 140 | + async create(_o: string, data: any) { rows.set(String(data.id), data); return data; }, |
| 141 | + async update(_o: string, data: any) { return data; }, |
| 142 | + async delete() { return false; }, |
| 143 | + } as any; |
| 144 | +} |
| 145 | + |
| 146 | +describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on /meta', () => { |
| 147 | + let engine: ObjectQL; |
| 148 | + let protocol: any; |
| 149 | + |
| 150 | + /** Boot the packaged object, then (optionally) the tenant's overlay layer. */ |
| 151 | + async function boot(overlay: boolean) { |
| 152 | + engine = new ObjectQL(); |
| 153 | + engine.registerDriver(makeDriver(), true); |
| 154 | + await engine.init(); |
| 155 | + engine.setI18nService(servedI18n(BUNDLE) as any); |
| 156 | + engine.registry.registerObject(body(PACKAGED.returned_needs_note) as any, PKG); |
| 157 | + engine.registry.registerObject(EXTENSION as any, EXT_PKG, undefined, 'extend'); |
| 158 | + if (overlay) { |
| 159 | + engine.registry.registerObject({ ...body(REWRITE), _provenance: 'org' } as any, PKG, undefined, 'overlay'); |
| 160 | + } |
| 161 | + protocol = new ObjectStackProtocolImplementation(engine as any); |
| 162 | + } |
| 163 | + |
| 164 | + afterEach(async () => { |
| 165 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 166 | + }); |
| 167 | + |
| 168 | + async function refusal(header: string, data: Record<string, unknown>): Promise<string> { |
| 169 | + try { |
| 170 | + await engine.insert(OBJ, data, { context: { locale: preferredLocaleFromHeader(header) } } as any); |
| 171 | + } catch (e: any) { |
| 172 | + return String(e?.fields?.[0]?.message ?? e?.message); |
| 173 | + } |
| 174 | + throw new Error('expected the write to be rejected'); |
| 175 | + } |
| 176 | + |
| 177 | + function served(header: string): Record<string, string> { |
| 178 | + const doc = translateObject(engine.registry.getObject(OBJ) as any, BUNDLE as any, { |
| 179 | + locale: preferredLocaleFromHeader(header), |
| 180 | + fallbackChain: ['en'], |
| 181 | + packagedBase: protocol.getPackagedObjectBase(OBJ), |
| 182 | + }) as any; |
| 183 | + return Object.fromEntries(doc.validations.map((r: any) => [r.name, r.message])); |
| 184 | + } |
| 185 | + |
| 186 | + async function table() { |
| 187 | + const out: Record<string, unknown> = {}; |
| 188 | + for (const header of ['zh-CN', 'zh', 'en']) { |
| 189 | + out[header] = { |
| 190 | + served: served(header), |
| 191 | + refusal: { |
| 192 | + returned_needs_note: await refusal(header, { id: 'r1', status: 'returned' }), |
| 193 | + done_needs_owner: await refusal(header, { id: 'r2', status: 'done' }), |
| 194 | + ext_blocked_needs_reason: await refusal(header, { id: 'r3', status: 'blocked' }), |
| 195 | + }, |
| 196 | + }; |
| 197 | + } |
| 198 | + return out; |
| 199 | + } |
| 200 | + |
| 201 | + describe('with the tenant overlay', () => { |
| 202 | + beforeEach(async () => { await boot(true); }); |
| 203 | + |
| 204 | + it('the overlay is what both readers see, over a packaged owner that is still there', () => { |
| 205 | + const served = (engine.registry.getObject(OBJ) as any).validations.map((r: any) => r.name); |
| 206 | + expect(served).toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); |
| 207 | + expect((engine.registry.getObject(OBJ) as any).validations[0].message).toBe(REWRITE); |
| 208 | + const packaged = protocol.getPackagedObjectBase(OBJ) as any; |
| 209 | + expect(packaged.validations.map((r: any) => r.name)).toEqual(['returned_needs_note', 'done_needs_owner']); |
| 210 | + expect(packaged.validations[0].message).toBe(PACKAGED.returned_needs_note); |
| 211 | + }); |
| 212 | + |
| 213 | + it('serves and refuses with the rewrite as written, and still translates the untouched and extension rules', async () => { |
| 214 | + const rows = (t: typeof ZH) => ({ |
| 215 | + returned_needs_note: REWRITE, |
| 216 | + done_needs_owner: t.done_needs_owner, |
| 217 | + ext_blocked_needs_reason: t.ext_blocked_needs_reason, |
| 218 | + }); |
| 219 | + expect(await table()).toEqual({ |
| 220 | + 'zh-CN': { served: rows(ZH), refusal: rows(ZH) }, |
| 221 | + zh: { served: rows(ZH), refusal: rows(ZH) }, |
| 222 | + en: { served: rows(PACKAGED), refusal: rows(PACKAGED) }, |
| 223 | + }); |
| 224 | + }); |
| 225 | + }); |
| 226 | + |
| 227 | + describe('control: no overlay', () => { |
| 228 | + beforeEach(async () => { await boot(false); }); |
| 229 | + |
| 230 | + it('translates every rule on both readers', async () => { |
| 231 | + expect(await table()).toEqual({ |
| 232 | + 'zh-CN': { served: ZH, refusal: ZH }, |
| 233 | + zh: { served: ZH, refusal: ZH }, |
| 234 | + en: { served: PACKAGED, refusal: PACKAGED }, |
| 235 | + }); |
| 236 | + }); |
| 237 | + }); |
| 238 | +}); |
0 commit comments