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
2128import { 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' ;
2233import { 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
145161const 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+ } ) ;
0 commit comments