|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#23497] `GET /forms/:slug` localizes the served form's SECTION headings |
| 4 | +// under `Accept-Language`, beside the field labels it already localized. |
| 5 | +// |
| 6 | +// An anonymous visitor cannot load the translation bundle client-side (the |
| 7 | +// `/i18n` domain answers an anonymous caller 401, and the console's language |
| 8 | +// loader answers `{}` signed out), so the client-side |
| 9 | +// `objects.OBJECT._sections.NAME` lookup never fires for it. Before this, the |
| 10 | +// route translated `objectSchema` and spread each section as authored: a |
| 11 | +// zh-CN visitor read English section titles over Chinese field labels. |
| 12 | +// |
| 13 | +// The RULES live in `@objectstack/spec/system` (`translateView`, which the |
| 14 | +// route reaches through `translateMetadataDocument('view', …)`): the key is |
| 15 | +// the section's `name`, a missing key keeps the authored text, a nameless |
| 16 | +// section is returned as authored, and an explicit override beats the catalog |
| 17 | +// against the packaged view. What is pinned here is the ROUTE's half: that it |
| 18 | +// makes the call, with the request's locale and the packaged view base, and |
| 19 | +// serves its result through the existing lookup / master-detail / user strip. |
| 20 | + |
| 21 | +import { describe, it, expect, vi } from 'vitest'; |
| 22 | +import { RestServer } from './rest-server'; |
| 23 | + |
| 24 | +// [#10126] Pay the first transform of the dist-resolved workspace dep the |
| 25 | +// handler reaches through a dynamic `import()` at MODULE LOAD, not inside a |
| 26 | +// clocked `it()` body (`scripts/check-test-source-alias.mjs`). |
| 27 | +import '@objectstack/spec/system'; |
| 28 | + |
| 29 | +const ABOUT_EN = 'Tell us about yourself'; |
| 30 | +const ABOUT_DESC_EN = 'We will get back to you within a day.'; |
| 31 | +const ABOUT_ZH = '介绍一下你自己'; |
| 32 | +const ABOUT_DESC_ZH = '我们会在一天内回复你。'; |
| 33 | + |
| 34 | +/** |
| 35 | + * The served form view, in the flattened registered shape the protocol |
| 36 | + * enumerates (`viewKind: 'form'`, the form under `config`). Three sections: |
| 37 | + * |
| 38 | + * - `about` — its label and description are in the zh-CN bundle; it also |
| 39 | + * declares a lookup field, which the anonymous route strips; |
| 40 | + * - `details` — named, but the bundle carries no key for it; |
| 41 | + * - a NAMELESS section labelled `More` — the bundle carries |
| 42 | + * `_sections.more.label`, so a route that slugged a label into a key would |
| 43 | + * translate it. |
| 44 | + */ |
| 45 | +function formView(aboutLabel = ABOUT_EN) { |
| 46 | + return { |
| 47 | + name: 'ticket_form', |
| 48 | + object: 'ticket', |
| 49 | + viewKind: 'form', |
| 50 | + label: 'Contact us', |
| 51 | + config: { |
| 52 | + data: { object: 'ticket' }, |
| 53 | + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/test' }, |
| 54 | + sections: [ |
| 55 | + { name: 'about', label: aboutLabel, description: ABOUT_DESC_EN, fields: ['subject', 'contact_id'] }, |
| 56 | + { name: 'details', label: 'Details', description: 'Anything else we should know.', fields: ['email'] }, |
| 57 | + { label: 'More', fields: ['status'] }, |
| 58 | + ], |
| 59 | + }, |
| 60 | + }; |
| 61 | +} |
| 62 | + |
| 63 | +const ticketObject = { |
| 64 | + name: 'ticket', |
| 65 | + label: 'Ticket', |
| 66 | + fields: { |
| 67 | + subject: { type: 'text', label: 'Subject' }, |
| 68 | + email: { type: 'text', label: 'Email' }, |
| 69 | + status: { type: 'select', label: 'Status' }, |
| 70 | + contact_id: { type: 'lookup', reference: 'contact', label: 'Contact' }, |
| 71 | + }, |
| 72 | +}; |
| 73 | + |
| 74 | +/** |
| 75 | + * The catalog. `en` carries the field labels only, so an `en` request has no |
| 76 | + * section key to read; `zh-CN` translates the fields and two `_sections` keys. |
| 77 | + */ |
| 78 | +const BUNDLE: Record<string, any> = { |
| 79 | + en: { objects: { ticket: { fields: { subject: { label: 'Subject' }, email: { label: 'Email' } } } } }, |
| 80 | + 'zh-CN': { |
| 81 | + objects: { |
| 82 | + ticket: { |
| 83 | + fields: { subject: { label: '主题' }, email: { label: '邮箱' }, status: { label: '状态' } }, |
| 84 | + _sections: { |
| 85 | + about: { label: ABOUT_ZH, description: ABOUT_DESC_ZH }, |
| 86 | + more: { label: '更多' }, |
| 87 | + }, |
| 88 | + }, |
| 89 | + }, |
| 90 | + }, |
| 91 | +}; |
| 92 | + |
| 93 | +const i18nService = { |
| 94 | + getLocales: () => ['en', 'zh-CN'], |
| 95 | + getTranslations: (locale: string) => BUNDLE[locale], |
| 96 | + getDefaultLocale: () => 'en', |
| 97 | +}; |
| 98 | + |
| 99 | +function mockServer() { |
| 100 | + return { |
| 101 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), |
| 102 | + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), |
| 103 | + }; |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * The route over a protocol double. `packagedView`, when given, is what the |
| 108 | + * protocol's `getPackagedViewBase` answers — the packaged view before any |
| 109 | + * overlay, which the override rule compares the served strings against. |
| 110 | + */ |
| 111 | +function buildServer(view: ReturnType<typeof formView>, packagedView?: ReturnType<typeof formView>) { |
| 112 | + const getPackagedViewBase = vi.fn((name: string) => (name === view.name ? packagedView : undefined)); |
| 113 | + const protocol: any = { |
| 114 | + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), |
| 115 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 116 | + getMetaItems: vi.fn(async ({ type }: { type: string }) => { |
| 117 | + if (type === 'view') return [view]; |
| 118 | + if (type === 'object') return [ticketObject]; |
| 119 | + return []; |
| 120 | + }), |
| 121 | + ...(packagedView ? { getPackagedViewBase } : {}), |
| 122 | + }; |
| 123 | + const rest = new RestServer( |
| 124 | + mockServer() as any, protocol, { api: { requireAuth: false } } as any, |
| 125 | + undefined, undefined, undefined, undefined, undefined, |
| 126 | + undefined, undefined, undefined, undefined, undefined, |
| 127 | + // i18nServiceProvider — the 14th constructor argument. |
| 128 | + async () => i18nService as any, |
| 129 | + ); |
| 130 | + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); |
| 131 | + rest.registerRoutes(); |
| 132 | + const resolve = rest.getRoutes().find((r) => r.method === 'GET' && r.path.endsWith('/forms/:slug'))!; |
| 133 | + const get = async (locale: string) => { |
| 134 | + const res: any = { statusCode: 200, body: undefined, headers: {} as Record<string, string> }; |
| 135 | + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); |
| 136 | + res.json = vi.fn((b: any) => { res.body = b; return res; }); |
| 137 | + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); |
| 138 | + await resolve.handler({ params: { slug: 'test' }, query: {}, headers: { 'accept-language': locale } } as any, res); |
| 139 | + expect(res.statusCode).toBe(200); |
| 140 | + return res; |
| 141 | + }; |
| 142 | + return { get, getPackagedViewBase }; |
| 143 | +} |
| 144 | + |
| 145 | +const section = (res: any, index: number) => res.body.form.sections[index]; |
| 146 | +const renderedFields = (res: any) => res.body.form.sections.map((s: any) => |
| 147 | + (s.fields ?? []).map((f: any) => (typeof f === 'string' ? f : f.field))); |
| 148 | + |
| 149 | +describe('[#23497] GET /forms/:slug — section headings are localized under Accept-Language', () => { |
| 150 | + it('zh-CN: a named section with a `_sections` key is served its translated label and description', async () => { |
| 151 | + const res = await buildServer(formView()).get('zh-CN'); |
| 152 | + expect(section(res, 0)).toMatchObject({ name: 'about', label: ABOUT_ZH, description: ABOUT_DESC_ZH }); |
| 153 | + }); |
| 154 | + |
| 155 | + it('zh-CN: a named section whose key the bundle lacks keeps its authored label and description', async () => { |
| 156 | + const res = await buildServer(formView()).get('zh-CN'); |
| 157 | + expect(section(res, 1)).toMatchObject({ |
| 158 | + name: 'details', label: 'Details', description: 'Anything else we should know.', |
| 159 | + }); |
| 160 | + }); |
| 161 | + |
| 162 | + it('zh-CN: a section with no `name` is served as authored, even though a key matching its label exists', async () => { |
| 163 | + const res = await buildServer(formView()).get('zh-CN'); |
| 164 | + expect(section(res, 2).label).toBe('More'); |
| 165 | + expect(section(res, 2).name).toBeUndefined(); |
| 166 | + }); |
| 167 | + |
| 168 | + it('zh-CN: an override beats the catalog — an edited heading keeps the edit, the unedited description translates', async () => { |
| 169 | + // The served view carries a heading edited on top of the packaged one; |
| 170 | + // the packaged view (the protocol's `getPackagedViewBase`) still ships |
| 171 | + // the original. The catalog is the translation of the ORIGINAL, so it |
| 172 | + // must not overwrite the edit. |
| 173 | + const edited = 'Tell us about yourself (edited)'; |
| 174 | + const { get, getPackagedViewBase } = buildServer(formView(edited), formView()); |
| 175 | + const res = await get('zh-CN'); |
| 176 | + expect(section(res, 0)).toMatchObject({ name: 'about', label: edited, description: ABOUT_DESC_ZH }); |
| 177 | + // Looked up by the served item's registry name, as the `/meta/view` reads do. |
| 178 | + expect(getPackagedViewBase).toHaveBeenCalledWith('ticket_form'); |
| 179 | + }); |
| 180 | + |
| 181 | + it('CONTROL — en: every section is served its authored text', async () => { |
| 182 | + const res = await buildServer(formView()).get('en'); |
| 183 | + expect(res.body.form.sections.map((s: any) => [s.label, s.description])).toEqual([ |
| 184 | + [ABOUT_EN, ABOUT_DESC_EN], |
| 185 | + ['Details', 'Anything else we should know.'], |
| 186 | + ['More', undefined], |
| 187 | + ]); |
| 188 | + }); |
| 189 | + |
| 190 | + it('CONTROL: the field labels in `objectSchema` are translated as before', async () => { |
| 191 | + const { get } = buildServer(formView()); |
| 192 | + const zh = await get('zh-CN'); |
| 193 | + expect(zh.body.objectSchema.fields.subject.label).toBe('主题'); |
| 194 | + expect(zh.body.objectSchema.fields.email.label).toBe('邮箱'); |
| 195 | + const en = await get('en'); |
| 196 | + expect(en.body.objectSchema.fields.subject.label).toBe('Subject'); |
| 197 | + }); |
| 198 | + |
| 199 | + it('CONTROL: the lookup strip still removes a lookup field from a translated section', async () => { |
| 200 | + const res = await buildServer(formView()).get('zh-CN'); |
| 201 | + expect(section(res, 0).label).toBe(ABOUT_ZH); |
| 202 | + expect(renderedFields(res)).toEqual([['subject'], ['email'], ['status']]); |
| 203 | + expect(res.body.objectSchema.fields.contact_id).toBeUndefined(); |
| 204 | + }); |
| 205 | + |
| 206 | + it('CONTROL: the response shape, its top-level label and `Vary` are unchanged, and the stored view is not mutated', async () => { |
| 207 | + const view = formView(); |
| 208 | + const res = await buildServer(view).get('zh-CN'); |
| 209 | + expect(Object.keys(res.body).sort()).toEqual(['form', 'label', 'object', 'objectSchema', 'slug']); |
| 210 | + expect(res.body).toMatchObject({ slug: 'test', object: 'ticket', label: 'Contact us' }); |
| 211 | + expect(res.headers.Vary).toBe('Accept-Language'); |
| 212 | + expect(view.config.sections[0]).toMatchObject({ label: ABOUT_EN, description: ABOUT_DESC_EN }); |
| 213 | + expect(view.config.sections[0].fields).toEqual(['subject', 'contact_id']); |
| 214 | + }); |
| 215 | +}); |
0 commit comments