|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #14972 — every platform-injected system column reaches the `/meta/object` |
| 5 | + * reads with a localised display name. |
| 6 | + * |
| 7 | + * The RULE lives in `@objectstack/spec/system` (`translateObject`'s built-in |
| 8 | + * system-field label table, unit-tested per column and per shipped locale in |
| 9 | + * `i18n-resolver.test.ts`). What can only be tested here is the SEAM: the |
| 10 | + * protocol's read exits inject the columns (`applyInjectedSystemColumns`) |
| 11 | + * BEFORE this boundary translates the document, and the boundary translates |
| 12 | + * even when the tenant's bundle carries nothing for the object — a custom |
| 13 | + * object ships no per-object entries for columns it never declared, so the |
| 14 | + * built-in table is the only thing that can answer. The served document |
| 15 | + * below therefore spreads the columns from the provenance module's own |
| 16 | + * definitions, exactly as the protocol's injection does, and the bundle names |
| 17 | + * a different object on purpose. |
| 18 | + * |
| 19 | + * Every injected column is named in the assertion: the defect was two rows |
| 20 | + * missing from a table of seven, and a loop over whatever the table happens to |
| 21 | + * carry would have been green with them missing. |
| 22 | + */ |
| 23 | + |
| 24 | +import { describe, it, expect, vi } from 'vitest'; |
| 25 | +import { injectedSystemColumnDefs } from '@objectstack/spec/data'; |
| 26 | +import { RestServer } from './rest-server.js'; |
| 27 | + |
| 28 | +// --------------------------------------------------------------------------- |
| 29 | +// Fixtures — one custom object, every injected column, a bundle that knows |
| 30 | +// another object |
| 31 | +// --------------------------------------------------------------------------- |
| 32 | + |
| 33 | +const INJECTED = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } }); |
| 34 | + |
| 35 | +/** What the protocol serves: the author's field plus the injected columns. */ |
| 36 | +const SERVED = { |
| 37 | + name: 'contracts', |
| 38 | + label: 'Contract', |
| 39 | + fields: { |
| 40 | + title: { name: 'title', type: 'text', label: 'Title' }, |
| 41 | + ...INJECTED, |
| 42 | + }, |
| 43 | +}; |
| 44 | + |
| 45 | +const BUNDLE: Record<string, any> = { |
| 46 | + 'zh-CN': { objects: { showcase_contact: { label: '联系人' } } }, |
| 47 | +}; |
| 48 | + |
| 49 | +const i18nService = { |
| 50 | + getLocales: () => ['en', 'zh-CN'], |
| 51 | + getTranslations: (locale: string) => BUNDLE[locale], |
| 52 | + getDefaultLocale: () => 'en', |
| 53 | +}; |
| 54 | + |
| 55 | +// --------------------------------------------------------------------------- |
| 56 | +// Doubles |
| 57 | +// --------------------------------------------------------------------------- |
| 58 | + |
| 59 | +function mockServer() { |
| 60 | + return { |
| 61 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), |
| 62 | + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), |
| 63 | + }; |
| 64 | +} |
| 65 | + |
| 66 | +function mockRes() { |
| 67 | + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; |
| 68 | +} |
| 69 | + |
| 70 | +function protocol() { |
| 71 | + return { |
| 72 | + getDiscovery: vi.fn().mockResolvedValue({ |
| 73 | + version: 'v0', |
| 74 | + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, |
| 75 | + }), |
| 76 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 77 | + getMetaItems: vi.fn(async ({ type }: any) => (type === 'object' || type === 'objects' ? [SERVED] : [])), |
| 78 | + getMetaItem: vi.fn(async ({ type, name }: any) => ({ |
| 79 | + type: type === 'objects' ? 'object' : type, |
| 80 | + name, |
| 81 | + item: SERVED, |
| 82 | + lock: 'none', |
| 83 | + editable: true, |
| 84 | + })), |
| 85 | + getMetaItemCached: undefined as any, |
| 86 | + findData: vi.fn().mockResolvedValue([]), |
| 87 | + }; |
| 88 | +} |
| 89 | + |
| 90 | +function makeRest() { |
| 91 | + const rest = new RestServer( |
| 92 | + mockServer() as any, protocol() as any, { api: { requireAuth: false } } as any, |
| 93 | + undefined, undefined, undefined, undefined, undefined, |
| 94 | + undefined, undefined, undefined, undefined, undefined, |
| 95 | + // i18nServiceProvider — the 14th constructor argument. |
| 96 | + async () => i18nService as any, |
| 97 | + ); |
| 98 | + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); |
| 99 | + rest.registerRoutes(); |
| 100 | + return rest; |
| 101 | +} |
| 102 | + |
| 103 | +function routeFor(rest: RestServer, path: string) { |
| 104 | + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); |
| 105 | + if (!route) throw new Error(`route not registered: GET ${path}`); |
| 106 | + return route; |
| 107 | +} |
| 108 | + |
| 109 | +/** The body of the last `res.json(...)` (indexed: this package's `lib` target predates `.at`). */ |
| 110 | +function lastBody(res: ReturnType<typeof mockRes>): any { |
| 111 | + const calls = res.json.mock.calls; |
| 112 | + return calls.length ? calls[calls.length - 1][0] : undefined; |
| 113 | +} |
| 114 | + |
| 115 | +async function itemFields(locale: string): Promise<Record<string, any>> { |
| 116 | + const res = mockRes(); |
| 117 | + await routeFor(makeRest(), '/api/v1/meta/:type/:name').handler( |
| 118 | + { |
| 119 | + method: 'GET', |
| 120 | + params: { type: 'object', name: 'contracts' }, |
| 121 | + query: {}, |
| 122 | + body: {}, |
| 123 | + headers: { 'accept-language': locale }, |
| 124 | + }, |
| 125 | + res, |
| 126 | + ); |
| 127 | + return lastBody(res)?.item?.fields; |
| 128 | +} |
| 129 | + |
| 130 | +async function listFields(locale: string): Promise<Record<string, any>> { |
| 131 | + const res = mockRes(); |
| 132 | + await routeFor(makeRest(), '/api/v1/meta/:type').handler( |
| 133 | + { method: 'GET', params: { type: 'object' }, query: {}, body: {}, headers: { 'accept-language': locale } }, |
| 134 | + res, |
| 135 | + ); |
| 136 | + const body = lastBody(res); |
| 137 | + const items = Array.isArray(body) ? body : body?.items ?? []; |
| 138 | + return items[0]?.fields; |
| 139 | +} |
| 140 | + |
| 141 | +const ZH_CN = { |
| 142 | + organization_id: '组织', |
| 143 | + created_at: '创建时间', |
| 144 | + created_by: '创建人', |
| 145 | + updated_at: '更新时间', |
| 146 | + updated_by: '更新人', |
| 147 | + owner_id: '所有者', |
| 148 | + owning_business_unit_id: '所属业务单元', |
| 149 | +}; |
| 150 | + |
| 151 | +function labelsOf(fields: Record<string, any>): Record<string, unknown> { |
| 152 | + return { |
| 153 | + organization_id: fields.organization_id?.label, |
| 154 | + created_at: fields.created_at?.label, |
| 155 | + created_by: fields.created_by?.label, |
| 156 | + updated_at: fields.updated_at?.label, |
| 157 | + updated_by: fields.updated_by?.label, |
| 158 | + owner_id: fields.owner_id?.label, |
| 159 | + owning_business_unit_id: fields.owning_business_unit_id?.label, |
| 160 | + }; |
| 161 | +} |
| 162 | + |
| 163 | +// --------------------------------------------------------------------------- |
| 164 | +// The seam |
| 165 | +// --------------------------------------------------------------------------- |
| 166 | + |
| 167 | +describe('#14972 — injected system columns reach the /meta/object reads localised', () => { |
| 168 | + it('the fixture spreads all seven injected columns with their shipped English labels', () => { |
| 169 | + expect(Object.keys(INJECTED).sort()).toEqual([ |
| 170 | + 'created_at', 'created_by', 'organization_id', 'owner_id', |
| 171 | + 'owning_business_unit_id', 'updated_at', 'updated_by', |
| 172 | + ]); |
| 173 | + expect((SERVED.fields as any).organization_id.label).toBe('Organization'); |
| 174 | + }); |
| 175 | + |
| 176 | + it('by-name read: every injected column answers Chinese on a zh-CN request', async () => { |
| 177 | + const fields = await itemFields('zh-CN'); |
| 178 | + expect(labelsOf(fields)).toEqual(ZH_CN); |
| 179 | + // The author's own field is untouched: the bundle carries nothing for it. |
| 180 | + expect(fields.title.label).toBe('Title'); |
| 181 | + }); |
| 182 | + |
| 183 | + it('list read: every injected column answers Chinese on a zh-CN request', async () => { |
| 184 | + const fields = await listFields('zh-CN'); |
| 185 | + expect(labelsOf(fields)).toEqual(ZH_CN); |
| 186 | + expect(fields.title.label).toBe('Title'); |
| 187 | + }); |
| 188 | + |
| 189 | + it('an en request keeps the shipped English defaults on both reads', async () => { |
| 190 | + for (const fields of [await itemFields('en'), await listFields('en')]) { |
| 191 | + expect(labelsOf(fields)).toEqual({ |
| 192 | + organization_id: 'Organization', |
| 193 | + created_at: 'Created At', |
| 194 | + created_by: 'Created By', |
| 195 | + updated_at: 'Last Modified At', |
| 196 | + updated_by: 'Last Modified By', |
| 197 | + owner_id: 'Owner', |
| 198 | + owning_business_unit_id: 'Owning Business Unit', |
| 199 | + }); |
| 200 | + } |
| 201 | + }); |
| 202 | +}); |
0 commit comments