From ef0bea4e852d6dfcc4c60db8262f03959d6e208b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:47:35 +0000 Subject: [PATCH 1/5] fix(rest): GET /forms/:slug localizes the form's section headings under Accept-Language The public form read translated objectSchema's field labels and spread each section as authored, so an anonymous visitor in a non-English locale read source-language section titles beside translated field labels. The handler now makes one call into the spec's view translator (translateMetadataDocument('view', ...), which reads objects.OBJECT._sections.NAME) beside the existing object call, with the same bundle, locale options and the packaged view base, and serves the translated config through the unchanged field strip. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23497-public-form-section-i18n.md | 16 ++ .../rest/src/public-form-section-i18n.test.ts | 215 ++++++++++++++++++ packages/rest/src/rest-server.ts | 45 +++- 3 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 .changeset/23497-public-form-section-i18n.md create mode 100644 packages/rest/src/public-form-section-i18n.test.ts diff --git a/.changeset/23497-public-form-section-i18n.md b/.changeset/23497-public-form-section-i18n.md new file mode 100644 index 00000000000..3a28b1e2ae9 --- /dev/null +++ b/.changeset/23497-public-form-section-i18n.md @@ -0,0 +1,16 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): `GET /forms/:slug` serves a public form's section headings in the visitor's language, beside the field labels it already translated + +Clause-②: no + +An anonymous visitor of a published form cannot load the translation bundle in the browser, so the server is the only place its section titles can be translated. The route translated `objectSchema` (the field labels) under `Accept-Language` and returned each form section as authored, so a zh-CN visitor read English section titles over Chinese field labels. It now localizes each section's `label` and `description` through the same `objects.OBJECT._sections.NAME` keys the signed-in console reads, with the request's locale, the deployment's fallback chain and default locale, and the packaged view as the override base. + +With `Accept-Language: zh-CN` over a form whose section `about` is labelled `Tell us about yourself`, and a zh-CN bundle carrying `objects.crm_lead._sections.about.label: 介绍一下你自己`: + +- FROM: `form.sections[0].label` is `Tell us about yourself`, and `objectSchema.fields.first_name.label` is `名`. +- TO: `form.sections[0].label` is `介绍一下你自己`; the field label is unchanged (`名`). A section `description` follows the same key's `description`. + +What does not change: a section whose key the bundle lacks keeps its authored text; a section with no `name` is served as authored; a section that authors no heading is not given one; a heading edited on top of the packaged view keeps the edit. The response shape (`slug`, `object`, `label`, `form`, `objectSchema`), its top-level `label`, `Vary: Accept-Language`, the declared-field narrowing, the lookup / master-detail / user strip and `POST /forms/:slug/submit` are unchanged. diff --git a/packages/rest/src/public-form-section-i18n.test.ts b/packages/rest/src/public-form-section-i18n.test.ts new file mode 100644 index 00000000000..9fce86491d5 --- /dev/null +++ b/packages/rest/src/public-form-section-i18n.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#23497] `GET /forms/:slug` localizes the served form's SECTION headings +// under `Accept-Language`, beside the field labels it already localized. +// +// An anonymous visitor cannot load the translation bundle client-side (the +// `/i18n` domain answers an anonymous caller 401, and the console's language +// loader answers `{}` signed out), so the client-side +// `objects.OBJECT._sections.NAME` lookup never fires for it. Before this, the +// route translated `objectSchema` and spread each section as authored: a +// zh-CN visitor read English section titles over Chinese field labels. +// +// The RULES live in `@objectstack/spec/system` (`translateView`, which the +// route reaches through `translateMetadataDocument('view', …)`): the key is +// the section's `name`, a missing key keeps the authored text, a nameless +// section is returned as authored, and an explicit override beats the catalog +// against the packaged view. What is pinned here is the ROUTE's half: that it +// makes the call, with the request's locale and the packaged view base, and +// serves its result through the existing lookup / master-detail / user strip. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +// [#10126] Pay the first transform of the dist-resolved workspace dep the +// handler reaches through a dynamic `import()` at MODULE LOAD, not inside a +// clocked `it()` body (`scripts/check-test-source-alias.mjs`). +import '@objectstack/spec/system'; + +const ABOUT_EN = 'Tell us about yourself'; +const ABOUT_DESC_EN = 'We will get back to you within a day.'; +const ABOUT_ZH = '介绍一下你自己'; +const ABOUT_DESC_ZH = '我们会在一天内回复你。'; + +/** + * The served form view, in the flattened registered shape the protocol + * enumerates (`viewKind: 'form'`, the form under `config`). Three sections: + * + * - `about` — its label and description are in the zh-CN bundle; it also + * declares a lookup field, which the anonymous route strips; + * - `details` — named, but the bundle carries no key for it; + * - a NAMELESS section labelled `More` — the bundle carries + * `_sections.more.label`, so a route that slugged a label into a key would + * translate it. + */ +function formView(aboutLabel = ABOUT_EN) { + return { + name: 'ticket_form', + object: 'ticket', + viewKind: 'form', + label: 'Contact us', + config: { + data: { object: 'ticket' }, + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/test' }, + sections: [ + { name: 'about', label: aboutLabel, description: ABOUT_DESC_EN, fields: ['subject', 'contact_id'] }, + { name: 'details', label: 'Details', description: 'Anything else we should know.', fields: ['email'] }, + { label: 'More', fields: ['status'] }, + ], + }, + }; +} + +const ticketObject = { + name: 'ticket', + label: 'Ticket', + fields: { + subject: { type: 'text', label: 'Subject' }, + email: { type: 'text', label: 'Email' }, + status: { type: 'select', label: 'Status' }, + contact_id: { type: 'lookup', reference: 'contact', label: 'Contact' }, + }, +}; + +/** + * The catalog. `en` carries the field labels only, so an `en` request has no + * section key to read; `zh-CN` translates the fields and two `_sections` keys. + */ +const BUNDLE: Record = { + en: { objects: { ticket: { fields: { subject: { label: 'Subject' }, email: { label: 'Email' } } } } }, + 'zh-CN': { + objects: { + ticket: { + fields: { subject: { label: '主题' }, email: { label: '邮箱' }, status: { label: '状态' } }, + _sections: { + about: { label: ABOUT_ZH, description: ABOUT_DESC_ZH }, + more: { label: '更多' }, + }, + }, + }, + }, +}; + +const i18nService = { + getLocales: () => ['en', 'zh-CN'], + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * The route over a protocol double. `packagedView`, when given, is what the + * protocol's `getPackagedViewBase` answers — the packaged view before any + * overlay, which the override rule compares the served strings against. + */ +function buildServer(view: ReturnType, packagedView?: ReturnType) { + const getPackagedViewBase = vi.fn((name: string) => (name === view.name ? packagedView : undefined)); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'view') return [view]; + if (type === 'object') return [ticketObject]; + return []; + }), + ...(packagedView ? { getPackagedViewBase } : {}), + }; + const rest = new RestServer( + mockServer() as any, protocol, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + // i18nServiceProvider — the 14th constructor argument. + async () => i18nService as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const resolve = rest.getRoutes().find((r) => r.method === 'GET' && r.path.endsWith('/forms/:slug'))!; + const get = async (locale: string) => { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + await resolve.handler({ params: { slug: 'test' }, query: {}, headers: { 'accept-language': locale } } as any, res); + expect(res.statusCode).toBe(200); + return res; + }; + return { get, getPackagedViewBase }; +} + +const section = (res: any, index: number) => res.body.form.sections[index]; +const renderedFields = (res: any) => res.body.form.sections.map((s: any) => + (s.fields ?? []).map((f: any) => (typeof f === 'string' ? f : f.field))); + +describe('[#23497] GET /forms/:slug — section headings are localized under Accept-Language', () => { + it('zh-CN: a named section with a `_sections` key is served its translated label and description', async () => { + const res = await buildServer(formView()).get('zh-CN'); + expect(section(res, 0)).toMatchObject({ name: 'about', label: ABOUT_ZH, description: ABOUT_DESC_ZH }); + }); + + it('zh-CN: a named section whose key the bundle lacks keeps its authored label and description', async () => { + const res = await buildServer(formView()).get('zh-CN'); + expect(section(res, 1)).toMatchObject({ + name: 'details', label: 'Details', description: 'Anything else we should know.', + }); + }); + + it('zh-CN: a section with no `name` is served as authored, even though a key matching its label exists', async () => { + const res = await buildServer(formView()).get('zh-CN'); + expect(section(res, 2).label).toBe('More'); + expect(section(res, 2).name).toBeUndefined(); + }); + + it('zh-CN: an override beats the catalog — an edited heading keeps the edit, the unedited description translates', async () => { + // The served view carries a heading edited on top of the packaged one; + // the packaged view (the protocol's `getPackagedViewBase`) still ships + // the original. The catalog is the translation of the ORIGINAL, so it + // must not overwrite the edit. + const edited = 'Tell us about yourself (edited)'; + const { get, getPackagedViewBase } = buildServer(formView(edited), formView()); + const res = await get('zh-CN'); + expect(section(res, 0)).toMatchObject({ name: 'about', label: edited, description: ABOUT_DESC_ZH }); + // Looked up by the served item's registry name, as the `/meta/view` reads do. + expect(getPackagedViewBase).toHaveBeenCalledWith('ticket_form'); + }); + + it('CONTROL — en: every section is served its authored text', async () => { + const res = await buildServer(formView()).get('en'); + expect(res.body.form.sections.map((s: any) => [s.label, s.description])).toEqual([ + [ABOUT_EN, ABOUT_DESC_EN], + ['Details', 'Anything else we should know.'], + ['More', undefined], + ]); + }); + + it('CONTROL: the field labels in `objectSchema` are translated as before', async () => { + const { get } = buildServer(formView()); + const zh = await get('zh-CN'); + expect(zh.body.objectSchema.fields.subject.label).toBe('主题'); + expect(zh.body.objectSchema.fields.email.label).toBe('邮箱'); + const en = await get('en'); + expect(en.body.objectSchema.fields.subject.label).toBe('Subject'); + }); + + it('CONTROL: the lookup strip still removes a lookup field from a translated section', async () => { + const res = await buildServer(formView()).get('zh-CN'); + expect(section(res, 0).label).toBe(ABOUT_ZH); + expect(renderedFields(res)).toEqual([['subject'], ['email'], ['status']]); + expect(res.body.objectSchema.fields.contact_id).toBeUndefined(); + }); + + it('CONTROL: the response shape, its top-level label and `Vary` are unchanged, and the stored view is not mutated', async () => { + const view = formView(); + const res = await buildServer(view).get('zh-CN'); + expect(Object.keys(res.body).sort()).toEqual(['form', 'label', 'object', 'objectSchema', 'slug']); + expect(res.body).toMatchObject({ slug: 'test', object: 'ticket', label: 'Contact us' }); + expect(res.headers.Vary).toBe('Accept-Language'); + expect(view.config.sections[0]).toMatchObject({ label: ABOUT_EN, description: ABOUT_DESC_EN }); + expect(view.config.sections[0].fields).toEqual(['subject', 'contact_id']); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8f098de155e..f22708d4641 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9434,6 +9434,10 @@ export class RestServer { // now agree — but they agree by each enforcing the // declaration itself, NOT by one deferring to the other. let objectSchema: any = null; + // [#23497] The form served below: the matched form, with its + // sections' `label` / `description` localized beside the + // schema's field labels (see the translation block). + let servedForm: any = match.form; try { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems === 'function') { @@ -9469,9 +9473,10 @@ export class RestServer { } objectSchema = { name: obj.name, label: obj.label, fields }; // Localize labels / help text / option labels so anonymous - // clients render in the visitor's preferred language. The - // form payload is otherwise un-translated (resolveFormBySlug - // returns the raw view spec), so we hydrate the schema here. + // clients render in the visitor's preferred language. + // `resolveFormBySlug` returns the raw view spec, so both + // halves of the payload are hydrated here: the schema, and + // the form's section headings ([#23497], below). try { const i18n = await this.resolveI18nService(environmentId, req); const bundle = this.buildTranslationBundle(i18n); @@ -9488,6 +9493,34 @@ export class RestServer { ...RestServer.translateOptionsFor(i18n, locale), packagedBase: this.packagedObjectBase(p, 'object', objectSchema?.name), }); + // [#23497] The section headings. An anonymous + // visitor cannot load the bundle client-side (the + // `/i18n` domain answers it 401), so a section's + // `objects.OBJECT._sections.NAME` copy reaches it + // here or not at all. ONE call into the spec's view + // translator, which owns every rule: the key is the + // section's `name` (a nameless section is returned + // as authored), a missing key keeps the authored + // text, only authored copy is translated, and an + // explicit override beats the catalog against the + // packaged view, handed over exactly as the object + // call above hands its own base. ⛔ No walk of + // `_sections` and no locale chain here. + // + // The protocol enumerates view ITEMS only, so the + // matched form is the item's `config`, the address + // `translateView` translates sections at. It runs + // BEFORE the strip below and returns each section's + // `fields` untouched, so it cannot re-introduce a + // stripped field. The top-level `label` keeps its + // own reading: the translator falls back to the + // view's machine name where this route falls back + // to the form's label. + const view = translateMetadataDocument('view', match.view, bundle, { + ...RestServer.translateOptionsFor(i18n, locale), + packagedBase: this.packagedObjectBase(p, 'view', match.view?.name), + }); + if (match.form === match.view?.config) servedForm = view?.config; } } catch (e: any) { logError('[REST] Public form schema translation failed:', e); @@ -9506,7 +9539,7 @@ export class RestServer { // unconditional: no declaration can put record search on the // internet through a public form. const safeForm = (() => { - if (!match.form || !Array.isArray(match.form.sections)) return match.form; + if (!servedForm || !Array.isArray(servedForm.sections)) return servedForm; const allow = (name: string): boolean => { // [#3022] A declared server-managed anchor (e.g. a // FormView listing `owner_id`) is a spec mistake — @@ -9520,7 +9553,7 @@ export class RestServer { // expose unrestricted user search to the internet. return t !== 'lookup' && t !== 'master_detail' && t !== 'user'; }; - const sections = match.form.sections.map((sec: any) => { + const sections = servedForm.sections.map((sec: any) => { const fields = (sec?.fields ?? []).filter((f: any) => { const name = typeof f === 'string' ? f : f?.field; if (!name) return false; @@ -9528,7 +9561,7 @@ export class RestServer { }); return { ...sec, fields }; }); - return { ...match.form, sections }; + return { ...servedForm, sections }; })(); res.header('Vary', 'Accept-Language'); res.json({ From 980fb8d6e5b28f9c6090b3772398bcb7b09352bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:49:10 +0000 Subject: [PATCH 2/5] test(rest): the section-i18n strip control asserts the rendered sections only The anonymous objectSchema keeps a declared lookup field's definition (the strip narrows the sections); the control pins what the strip owns. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/rest/src/public-form-section-i18n.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rest/src/public-form-section-i18n.test.ts b/packages/rest/src/public-form-section-i18n.test.ts index 9fce86491d5..3323ba7f75a 100644 --- a/packages/rest/src/public-form-section-i18n.test.ts +++ b/packages/rest/src/public-form-section-i18n.test.ts @@ -200,7 +200,6 @@ describe('[#23497] GET /forms/:slug — section headings are localized under Acc const res = await buildServer(formView()).get('zh-CN'); expect(section(res, 0).label).toBe(ABOUT_ZH); expect(renderedFields(res)).toEqual([['subject'], ['email'], ['status']]); - expect(res.body.objectSchema.fields.contact_id).toBeUndefined(); }); it('CONTROL: the response shape, its top-level label and `Vary` are unchanged, and the stored view is not mutated', async () => { From 357c751a7c9e05fa91da35f39a47b629172de0aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:49:53 +0000 Subject: [PATCH 3/5] test(rest): the translated-section row asserts the strip ran over it; the strip control stands alone Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/rest/src/public-form-section-i18n.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/public-form-section-i18n.test.ts b/packages/rest/src/public-form-section-i18n.test.ts index 3323ba7f75a..d5cb0e2b9f8 100644 --- a/packages/rest/src/public-form-section-i18n.test.ts +++ b/packages/rest/src/public-form-section-i18n.test.ts @@ -150,6 +150,8 @@ describe('[#23497] GET /forms/:slug — section headings are localized under Acc it('zh-CN: a named section with a `_sections` key is served its translated label and description', async () => { const res = await buildServer(formView()).get('zh-CN'); expect(section(res, 0)).toMatchObject({ name: 'about', label: ABOUT_ZH, description: ABOUT_DESC_ZH }); + // The translated section is the one the strip ran over: its lookup is gone. + expect(section(res, 0).fields).toEqual(['subject']); }); it('zh-CN: a named section whose key the bundle lacks keeps its authored label and description', async () => { @@ -196,9 +198,8 @@ describe('[#23497] GET /forms/:slug — section headings are localized under Acc expect(en.body.objectSchema.fields.subject.label).toBe('Subject'); }); - it('CONTROL: the lookup strip still removes a lookup field from a translated section', async () => { + it('CONTROL: under zh-CN the lookup strip still removes a lookup field from its section', async () => { const res = await buildServer(formView()).get('zh-CN'); - expect(section(res, 0).label).toBe(ABOUT_ZH); expect(renderedFields(res)).toEqual([['subject'], ['email'], ['status']]); }); From b4e56cfa574125777a045d33fe68eed990a43467 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:58:10 +0000 Subject: [PATCH 4/5] test(rest): the public form's section translation is pinned over the real protocol and per form slot A container-authored public form (a `formViews` entry, the default `form`) reaches `GET /forms/:slug` as the `viewKind: 'form'` item the protocol expands it to, so the route's one view-translator call localizes it: pinned over the real ObjectStackProtocolImplementation and SchemaRegistry, with an environment overlay's edited heading kept against the packaged view the real protocol answers. A form handed to the route in a container slot is served as matched, its own sections as authored, never the item's config. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../rest/src/public-form-section-i18n.test.ts | 196 ++++++++++++++++-- packages/rest/src/rest-server.ts | 8 +- 2 files changed, 179 insertions(+), 25 deletions(-) diff --git a/packages/rest/src/public-form-section-i18n.test.ts b/packages/rest/src/public-form-section-i18n.test.ts index d5cb0e2b9f8..fbcaec14bc9 100644 --- a/packages/rest/src/public-form-section-i18n.test.ts +++ b/packages/rest/src/public-form-section-i18n.test.ts @@ -17,8 +17,19 @@ // against the packaged view. What is pinned here is the ROUTE's half: that it // makes the call, with the request's locale and the packaged view base, and // serves its result through the existing lookup / master-detail / user strip. +// +// The translator localizes sections at a view item's `config`, one of the +// three slots an open form is found in. The other two (a container's `form` +// and `formViews.KEY`) never reach the route: the protocol's view list drops +// every container and serves each of its forms as the `viewKind: 'form'` item +// it expands to. Pinned below over the REAL protocol, beside what the route +// answers when it is handed a form in those slots anyway. import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { expandViewContainer } from '@objectstack/spec/ui'; import { RestServer } from './rest-server'; // [#10126] Pay the first transform of the dist-resolved workspace dep the @@ -66,7 +77,7 @@ const ticketObject = { fields: { subject: { type: 'text', label: 'Subject' }, email: { type: 'text', label: 'Email' }, - status: { type: 'select', label: 'Status' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] }, contact_id: { type: 'lookup', reference: 'contact', label: 'Contact' }, }, }; @@ -103,25 +114,10 @@ function mockServer() { }; } -/** - * The route over a protocol double. `packagedView`, when given, is what the - * protocol's `getPackagedViewBase` answers — the packaged view before any - * overlay, which the override rule compares the served strings against. - */ -function buildServer(view: ReturnType, packagedView?: ReturnType) { - const getPackagedViewBase = vi.fn((name: string) => (name === view.name ? packagedView : undefined)); - const protocol: any = { - getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), - getMetaTypes: vi.fn().mockResolvedValue([]), - getMetaItems: vi.fn(async ({ type }: { type: string }) => { - if (type === 'view') return [view]; - if (type === 'object') return [ticketObject]; - return []; - }), - ...(packagedView ? { getPackagedViewBase } : {}), - }; +/** The real `GET /forms/:slug` route, mounted over `protocol`. `get` answers one anonymous request. */ +function routeOver(protocol: unknown) { const rest = new RestServer( - mockServer() as any, protocol, { api: { requireAuth: false } } as any, + mockServer() as any, protocol as any, { api: { requireAuth: false } } as any, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, // i18nServiceProvider — the 14th constructor argument. @@ -130,16 +126,36 @@ function buildServer(view: ReturnType, packagedView?: ReturnTyp (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const resolve = rest.getRoutes().find((r) => r.method === 'GET' && r.path.endsWith('/forms/:slug'))!; - const get = async (locale: string) => { + return async (locale: string, slug = 'test') => { const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); res.json = vi.fn((b: any) => { res.body = b; return res; }); res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); - await resolve.handler({ params: { slug: 'test' }, query: {}, headers: { 'accept-language': locale } } as any, res); - expect(res.statusCode).toBe(200); + await resolve.handler({ params: { slug }, query: {}, headers: { 'accept-language': locale } } as any, res); + expect(res.statusCode, JSON.stringify(res.body)).toBe(200); return res; }; - return { get, getPackagedViewBase }; +} + +/** + * The route over a protocol double that serves `view` as the one view body. + * `packagedView`, when given, is what the protocol's `getPackagedViewBase` + * answers — the packaged view before any overlay, which the override rule + * compares the served strings against. + */ +function buildServer(view: Record, packagedView?: Record) { + const getPackagedViewBase = vi.fn((name: string) => (name === view.name ? packagedView : undefined)); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'view') return [view]; + if (type === 'object') return [ticketObject]; + return []; + }), + ...(packagedView ? { getPackagedViewBase } : {}), + }; + return { get: routeOver(protocol), getPackagedViewBase }; } const section = (res: any, index: number) => res.body.form.sections[index]; @@ -213,3 +229,137 @@ describe('[#23497] GET /forms/:slug — section headings are localized under Acc expect(view.config.sections[0].fields).toEqual(['subject', 'contact_id']); }); }); + +// ─── the three form slots ─────────────────────────────────────────────────── +// +// `anonymousFormIntakeCandidates` finds an open form in three slots of a view +// body: a container's `form`, a container's `formViews.KEY`, and a +// `viewKind: 'form'` item's `config`. The spec's view translator localizes +// sections at the THIRD address only, and the route serves its translation for +// that slot alone. The two pins below are why that is the whole population, and +// what the route does with a body that breaks the premise. + +/** A container's form as the showcase authors its public form: a `formViews` entry and the default `form`. */ +const containerForm = (slug: string) => ({ + type: 'simple', + data: { provider: 'object', object: 'ticket' }, + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${slug}` }, + sections: [ + { name: 'about', label: ABOUT_EN, description: ABOUT_DESC_EN, fields: ['subject', 'contact_id'] }, + { name: 'details', label: 'Details', description: 'Anything else we should know.', fields: ['email'] }, + ], +}); +const ticketContainer = () => ({ object: 'ticket', form: containerForm('ticket-default'), formViews: { contact: containerForm('ticket-contact') } }); + +const PKG = 'com.example.helpdesk'; + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v === null) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +/** + * The REAL protocol (`ObjectStackProtocolImplementation`) over a real + * `SchemaRegistry`, with the container registered the way the boot registers + * a `defineView` container (the bare object key, then every item + * `expandViewContainer` expands it to), over a READ-ONLY in-memory + * `sys_metadata` the overlay rows are seeded into. + */ +function realProtocolSession() { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const rows: Record[] = []; + const engine: any = { + registry, + async findOne(table: string, o: { where: Record }) { + // The real engine's predicate: a `findOne` naming no record is refused. + assertEngineFindOnePredicate(table, o); + if (table !== 'sys_metadata') return null; + return rows.find((r) => matchesWhere(r, o.where)) ?? null; + }, + async find(table: string, o: { where: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = rows.filter((r) => matchesWhere(r, o.where)); + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + registry.registerObject(ticketObject as any, PKG); + const container = ticketContainer(); + registry.registerItem('view', { name: 'ticket', ...container }, 'name', PKG); + for (const item of expandViewContainer('ticket', container)) registry.registerItem('view', item, 'name', PKG); + return { protocol, rows, get: routeOver(protocol) }; +} + +describe('[#23497] a container-authored form reaches the route as the item the protocol expands it to', () => { + it('the view list the route reads serves each container form only as a `viewKind: \'form\'` item, its form under `config`', async () => { + const { protocol } = realProtocolSession(); + const views: any[] = (await protocol.getMetaItems({ type: 'view' })).items; + // No served body carries a container slot, so no candidate the route + // matches can come from `form` or `formViews`. + expect(views.filter((v) => v && ('form' in v || 'formViews' in v))).toEqual([]); + const forms = views.filter((v) => v?.viewKind === 'form'); + expect(forms.map((v) => [v.name, v.config?.sharing?.publicLink]).sort()).toEqual([ + ['ticket.contact', '/forms/ticket-contact'], + ['ticket.form', '/forms/ticket-default'], + ]); + }); + + for (const slug of ['ticket-contact', 'ticket-default']) { + it(`zh-CN: the form authored at ${slug === 'ticket-contact' ? '`formViews.contact`' : 'the default `form`'} is served its translated headings`, async () => { + const { get } = realProtocolSession(); + const res = await get('zh-CN', slug); + expect(res.body.form.sections.map((s: any) => [s.name, s.label, s.description])).toEqual([ + ['about', ABOUT_ZH, ABOUT_DESC_ZH], + ['details', 'Details', 'Anything else we should know.'], + ]); + expect(renderedFields(res)).toEqual([['subject'], ['email']]); + // CONTROL: the same form under `en` is served as authored. + const en = await get('en', slug); + expect(en.body.form.sections.map((s: any) => s.label)).toEqual([ABOUT_EN, 'Details']); + }); + } + + it('zh-CN: a heading an environment overlay edited keeps the edit against the packaged view the real protocol answers', async () => { + const { rows, get, protocol } = realProtocolSession(); + const shipped: any = expandViewContainer('ticket', ticketContainer()).find((v) => v.name === 'ticket.contact'); + const edited = 'Tell us about yourself (edited)'; + const overlay = { + ...shipped, + config: { + ...shipped.config, + sections: shipped.config.sections.map((s: any) => (s.name === 'about' ? { ...s, label: edited } : s)), + }, + }; + rows.push({ + id: 'r_1', type: 'view', name: 'ticket.contact', package_id: null, organization_id: null, + state: 'active', metadata: JSON.stringify(overlay), + }); + // The packaged base is the item the package shipped, under the served item's own name. + expect((protocol.getPackagedViewBase('ticket.contact') as any)?.config?.sections?.[0]?.label).toBe(ABOUT_EN); + const res = await get('zh-CN', 'ticket-contact'); + expect(section(res, 0)).toMatchObject({ name: 'about', label: edited, description: ABOUT_DESC_ZH }); + }); +}); + +describe('[#23497] a form matched outside `config` is served as matched', () => { + // No body the route reads carries a container slot (the pin above), but + // the route still answers one it is handed, from a protocol double or a + // row stored before the write door stored parsed bodies: its own sections, + // never the item's `config`, and as authored, because the spec's view + // translator localizes sections at `config` only. + for (const [where, slug] of [['form', 'ticket-default'], ['formViews.contact', 'ticket-contact']] as const) { + it(`a container's \`${where}\` is answered with its own sections, as authored`, async () => { + const res = await buildServer({ name: 'ticket', ...ticketContainer() }).get('zh-CN', slug); + expect(res.body.form.sharing.publicLink).toBe(`/forms/${slug}`); + expect(res.body.form.sections.map((s: any) => [s.name, s.label])).toEqual([ + ['about', ABOUT_EN], + ['details', 'Details'], + ]); + // The strip still ran over it. + expect(renderedFields(res)).toEqual([['subject'], ['email']]); + }); + } +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f22708d4641..730c2aaa03f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9507,9 +9507,13 @@ export class RestServer { // call above hands its own base. ⛔ No walk of // `_sections` and no locale chain here. // - // The protocol enumerates view ITEMS only, so the + // The protocol's view list serves view ITEMS only + // (a container is dropped and served as the + // `viewKind: 'form'` items it expands to), so the // matched form is the item's `config`, the address - // `translateView` translates sections at. It runs + // `translateView` translates sections at. A form + // matched in any other slot is served as matched, + // never swapped for the item's `config`. It runs // BEFORE the strip below and returns each section's // `fields` untouched, so it cannot re-introduce a // stripped field. The top-level `label` keeps its From 8d4b00444c753c80212aa799f6cd9b9f03e07dd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:24:35 +0000 Subject: [PATCH 5/5] test(rest): the real-protocol form harness doubles only the engine reads the route reaches The route's protocol reads touch the registry and the sys_metadata list read; the double no longer carries a findOne it never answers. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/rest/src/public-form-section-i18n.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/rest/src/public-form-section-i18n.test.ts b/packages/rest/src/public-form-section-i18n.test.ts index fbcaec14bc9..964e5672d13 100644 --- a/packages/rest/src/public-form-section-i18n.test.ts +++ b/packages/rest/src/public-form-section-i18n.test.ts @@ -26,7 +26,6 @@ // answers when it is handed a form in those slots anyway. import { describe, it, expect, vi } from 'vitest'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SchemaRegistry } from '@objectstack/objectql'; import { expandViewContainer } from '@objectstack/spec/ui'; @@ -271,14 +270,10 @@ function realProtocolSession() { const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; const rows: Record[] = []; + // Only what the route's reads reach: the registry and the `sys_metadata` + // list read the protocol's view list overlays rows from. const engine: any = { registry, - async findOne(table: string, o: { where: Record }) { - // The real engine's predicate: a `findOne` naming no record is refused. - assertEngineFindOnePredicate(table, o); - if (table !== 'sys_metadata') return null; - return rows.find((r) => matchesWhere(r, o.where)) ?? null; - }, async find(table: string, o: { where: Record; limit?: number }) { if (table !== 'sys_metadata') return []; const matched = rows.filter((r) => matchesWhere(r, o.where));