Skip to content

Commit b4e56cf

Browse files
committed
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 <noreply@anthropic.com>
1 parent dd9822e commit b4e56cf

2 files changed

Lines changed: 179 additions & 25 deletions

File tree

‎packages/rest/src/public-form-section-i18n.test.ts‎

Lines changed: 173 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,19 @@
1717
// against the packaged view. What is pinned here is the ROUTE's half: that it
1818
// makes the call, with the request's locale and the packaged view base, and
1919
// serves its result through the existing lookup / master-detail / user strip.
20+
//
21+
// The translator localizes sections at a view item's `config`, one of the
22+
// three slots an open form is found in. The other two (a container's `form`
23+
// and `formViews.KEY`) never reach the route: the protocol's view list drops
24+
// every container and serves each of its forms as the `viewKind: 'form'` item
25+
// it expands to. Pinned below over the REAL protocol, beside what the route
26+
// answers when it is handed a form in those slots anyway.
2027

2128
import { describe, it, expect, vi } from 'vitest';
29+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
30+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
31+
import { SchemaRegistry } from '@objectstack/objectql';
32+
import { expandViewContainer } from '@objectstack/spec/ui';
2233
import { RestServer } from './rest-server';
2334

2435
// [#10126] Pay the first transform of the dist-resolved workspace dep the
@@ -66,7 +77,7 @@ const ticketObject = {
6677
fields: {
6778
subject: { type: 'text', label: 'Subject' },
6879
email: { type: 'text', label: 'Email' },
69-
status: { type: 'select', label: 'Status' },
80+
status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] },
7081
contact_id: { type: 'lookup', reference: 'contact', label: 'Contact' },
7182
},
7283
};
@@ -103,25 +114,10 @@ function mockServer() {
103114
};
104115
}
105116

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-
};
117+
/** The real `GET /forms/:slug` route, mounted over `protocol`. `get` answers one anonymous request. */
118+
function routeOver(protocol: unknown) {
123119
const rest = new RestServer(
124-
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
120+
mockServer() as any, protocol as any, { api: { requireAuth: false } } as any,
125121
undefined, undefined, undefined, undefined, undefined,
126122
undefined, undefined, undefined, undefined, undefined,
127123
// i18nServiceProvider — the 14th constructor argument.
@@ -130,16 +126,36 @@ function buildServer(view: ReturnType<typeof formView>, packagedView?: ReturnTyp
130126
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
131127
rest.registerRoutes();
132128
const resolve = rest.getRoutes().find((r) => r.method === 'GET' && r.path.endsWith('/forms/:slug'))!;
133-
const get = async (locale: string) => {
129+
return async (locale: string, slug = 'test') => {
134130
const res: any = { statusCode: 200, body: undefined, headers: {} as Record<string, string> };
135131
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
136132
res.json = vi.fn((b: any) => { res.body = b; return res; });
137133
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);
134+
await resolve.handler({ params: { slug }, query: {}, headers: { 'accept-language': locale } } as any, res);
135+
expect(res.statusCode, JSON.stringify(res.body)).toBe(200);
140136
return res;
141137
};
142-
return { get, getPackagedViewBase };
138+
}
139+
140+
/**
141+
* The route over a protocol double that serves `view` as the one view body.
142+
* `packagedView`, when given, is what the protocol's `getPackagedViewBase`
143+
* answers — the packaged view before any overlay, which the override rule
144+
* compares the served strings against.
145+
*/
146+
function buildServer(view: Record<string, any>, packagedView?: Record<string, any>) {
147+
const getPackagedViewBase = vi.fn((name: string) => (name === view.name ? packagedView : undefined));
148+
const protocol: any = {
149+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
150+
getMetaTypes: vi.fn().mockResolvedValue([]),
151+
getMetaItems: vi.fn(async ({ type }: { type: string }) => {
152+
if (type === 'view') return [view];
153+
if (type === 'object') return [ticketObject];
154+
return [];
155+
}),
156+
...(packagedView ? { getPackagedViewBase } : {}),
157+
};
158+
return { get: routeOver(protocol), getPackagedViewBase };
143159
}
144160

145161
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
213229
expect(view.config.sections[0].fields).toEqual(['subject', 'contact_id']);
214230
});
215231
});
232+
233+
// ─── the three form slots ───────────────────────────────────────────────────
234+
//
235+
// `anonymousFormIntakeCandidates` finds an open form in three slots of a view
236+
// body: a container's `form`, a container's `formViews.KEY`, and a
237+
// `viewKind: 'form'` item's `config`. The spec's view translator localizes
238+
// sections at the THIRD address only, and the route serves its translation for
239+
// that slot alone. The two pins below are why that is the whole population, and
240+
// what the route does with a body that breaks the premise.
241+
242+
/** A container's form as the showcase authors its public form: a `formViews` entry and the default `form`. */
243+
const containerForm = (slug: string) => ({
244+
type: 'simple',
245+
data: { provider: 'object', object: 'ticket' },
246+
sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${slug}` },
247+
sections: [
248+
{ name: 'about', label: ABOUT_EN, description: ABOUT_DESC_EN, fields: ['subject', 'contact_id'] },
249+
{ name: 'details', label: 'Details', description: 'Anything else we should know.', fields: ['email'] },
250+
],
251+
});
252+
const ticketContainer = () => ({ object: 'ticket', form: containerForm('ticket-default'), formViews: { contact: containerForm('ticket-contact') } });
253+
254+
const PKG = 'com.example.helpdesk';
255+
256+
const matchesWhere = (row: Record<string, unknown>, where: Record<string, unknown>) =>
257+
Object.entries(where ?? {}).every(([k, v]) => {
258+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
259+
if (v === null) return row[k] === null || row[k] === undefined;
260+
return row[k] === v;
261+
});
262+
263+
/**
264+
* The REAL protocol (`ObjectStackProtocolImplementation`) over a real
265+
* `SchemaRegistry`, with the container registered the way the boot registers
266+
* a `defineView` container (the bare object key, then every item
267+
* `expandViewContainer` expands it to), over a READ-ONLY in-memory
268+
* `sys_metadata` the overlay rows are seeded into.
269+
*/
270+
function realProtocolSession() {
271+
const registry = new SchemaRegistry({ multiTenant: false });
272+
registry.logLevel = 'silent';
273+
const rows: Record<string, unknown>[] = [];
274+
const engine: any = {
275+
registry,
276+
async findOne(table: string, o: { where: Record<string, unknown> }) {
277+
// The real engine's predicate: a `findOne` naming no record is refused.
278+
assertEngineFindOnePredicate(table, o);
279+
if (table !== 'sys_metadata') return null;
280+
return rows.find((r) => matchesWhere(r, o.where)) ?? null;
281+
},
282+
async find(table: string, o: { where: Record<string, unknown>; limit?: number }) {
283+
if (table !== 'sys_metadata') return [];
284+
const matched = rows.filter((r) => matchesWhere(r, o.where));
285+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
286+
},
287+
};
288+
const protocol: any = new ObjectStackProtocolImplementation(engine, undefined, 'env_test');
289+
registry.registerObject(ticketObject as any, PKG);
290+
const container = ticketContainer();
291+
registry.registerItem('view', { name: 'ticket', ...container }, 'name', PKG);
292+
for (const item of expandViewContainer('ticket', container)) registry.registerItem('view', item, 'name', PKG);
293+
return { protocol, rows, get: routeOver(protocol) };
294+
}
295+
296+
describe('[#23497] a container-authored form reaches the route as the item the protocol expands it to', () => {
297+
it('the view list the route reads serves each container form only as a `viewKind: \'form\'` item, its form under `config`', async () => {
298+
const { protocol } = realProtocolSession();
299+
const views: any[] = (await protocol.getMetaItems({ type: 'view' })).items;
300+
// No served body carries a container slot, so no candidate the route
301+
// matches can come from `form` or `formViews`.
302+
expect(views.filter((v) => v && ('form' in v || 'formViews' in v))).toEqual([]);
303+
const forms = views.filter((v) => v?.viewKind === 'form');
304+
expect(forms.map((v) => [v.name, v.config?.sharing?.publicLink]).sort()).toEqual([
305+
['ticket.contact', '/forms/ticket-contact'],
306+
['ticket.form', '/forms/ticket-default'],
307+
]);
308+
});
309+
310+
for (const slug of ['ticket-contact', 'ticket-default']) {
311+
it(`zh-CN: the form authored at ${slug === 'ticket-contact' ? '`formViews.contact`' : 'the default `form`'} is served its translated headings`, async () => {
312+
const { get } = realProtocolSession();
313+
const res = await get('zh-CN', slug);
314+
expect(res.body.form.sections.map((s: any) => [s.name, s.label, s.description])).toEqual([
315+
['about', ABOUT_ZH, ABOUT_DESC_ZH],
316+
['details', 'Details', 'Anything else we should know.'],
317+
]);
318+
expect(renderedFields(res)).toEqual([['subject'], ['email']]);
319+
// CONTROL: the same form under `en` is served as authored.
320+
const en = await get('en', slug);
321+
expect(en.body.form.sections.map((s: any) => s.label)).toEqual([ABOUT_EN, 'Details']);
322+
});
323+
}
324+
325+
it('zh-CN: a heading an environment overlay edited keeps the edit against the packaged view the real protocol answers', async () => {
326+
const { rows, get, protocol } = realProtocolSession();
327+
const shipped: any = expandViewContainer('ticket', ticketContainer()).find((v) => v.name === 'ticket.contact');
328+
const edited = 'Tell us about yourself (edited)';
329+
const overlay = {
330+
...shipped,
331+
config: {
332+
...shipped.config,
333+
sections: shipped.config.sections.map((s: any) => (s.name === 'about' ? { ...s, label: edited } : s)),
334+
},
335+
};
336+
rows.push({
337+
id: 'r_1', type: 'view', name: 'ticket.contact', package_id: null, organization_id: null,
338+
state: 'active', metadata: JSON.stringify(overlay),
339+
});
340+
// The packaged base is the item the package shipped, under the served item's own name.
341+
expect((protocol.getPackagedViewBase('ticket.contact') as any)?.config?.sections?.[0]?.label).toBe(ABOUT_EN);
342+
const res = await get('zh-CN', 'ticket-contact');
343+
expect(section(res, 0)).toMatchObject({ name: 'about', label: edited, description: ABOUT_DESC_ZH });
344+
});
345+
});
346+
347+
describe('[#23497] a form matched outside `config` is served as matched', () => {
348+
// No body the route reads carries a container slot (the pin above), but
349+
// the route still answers one it is handed, from a protocol double or a
350+
// row stored before the write door stored parsed bodies: its own sections,
351+
// never the item's `config`, and as authored, because the spec's view
352+
// translator localizes sections at `config` only.
353+
for (const [where, slug] of [['form', 'ticket-default'], ['formViews.contact', 'ticket-contact']] as const) {
354+
it(`a container's \`${where}\` is answered with its own sections, as authored`, async () => {
355+
const res = await buildServer({ name: 'ticket', ...ticketContainer() }).get('zh-CN', slug);
356+
expect(res.body.form.sharing.publicLink).toBe(`/forms/${slug}`);
357+
expect(res.body.form.sections.map((s: any) => [s.name, s.label])).toEqual([
358+
['about', ABOUT_EN],
359+
['details', 'Details'],
360+
]);
361+
// The strip still ran over it.
362+
expect(renderedFields(res)).toEqual([['subject'], ['email']]);
363+
});
364+
}
365+
});

‎packages/rest/src/rest-server.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9507,9 +9507,13 @@ export class RestServer {
95079507
// call above hands its own base. ⛔ No walk of
95089508
// `_sections` and no locale chain here.
95099509
//
9510-
// The protocol enumerates view ITEMS only, so the
9510+
// The protocol's view list serves view ITEMS only
9511+
// (a container is dropped and served as the
9512+
// `viewKind: 'form'` items it expands to), so the
95119513
// matched form is the item's `config`, the address
9512-
// `translateView` translates sections at. It runs
9514+
// `translateView` translates sections at. A form
9515+
// matched in any other slot is served as matched,
9516+
// never swapped for the item's `config`. It runs
95139517
// BEFORE the strip below and returns each section's
95149518
// `fields` untouched, so it cannot re-introduce a
95159519
// stripped field. The top-level `label` keeps its

0 commit comments

Comments
 (0)