@@ -31,8 +31,12 @@ import { describe, expect, it } from 'vitest';
3131// #5619 sank the two predicates into a package both sides already depend on.
3232import { assertEngineDeleteDispatch , assertEngineUpdateDispatch } from '@objectstack/metadata-core' ;
3333import { FlowSchema } from '@objectstack/spec/automation' ;
34- import { METADATA_READ_DECORATIONS } from '@objectstack/spec/kernel' ;
35- import { ObjectStackProtocolImplementation , stripReadDecorations } from './index.js' ;
34+ import { METADATA_READ_DECORATIONS , getMetadataTypeSchema } from '@objectstack/spec/kernel' ;
35+ import {
36+ ObjectStackProtocolImplementation ,
37+ computeMetadataDiagnostics ,
38+ stripReadDecorations ,
39+ } from './index.js' ;
3640
3741interface Row {
3842 id : string ;
@@ -326,3 +330,182 @@ describe('a served document survives its own (closed) schema — cloud#971', ()
326330 ) . toEqual ( [ ] ) ;
327331 } ) ;
328332} ) ;
333+
334+ /**
335+ * #7656 — the read must not judge its own badge.
336+ *
337+ * The THIRD consumer of the same invariant, and the one where the served
338+ * document never leaves the response: `decorateMetadataItem` re-parses the item
339+ * to compute `_diagnostics`, which is a re-parse of a served document in exactly
340+ * the sense the module header of `spec/kernel/metadata-read-decorations.ts`
341+ * means. It stripped `_diagnostics` (its own key, by hand) and nothing else, so
342+ * a `?preview=draft` read — which stamps `_draft:true` BEFORE decorating, on
343+ * both exits — validated the badge it had just added against a closed schema
344+ * and answered `_diagnostics.valid:false / unrecognized_keys: ["_draft"]` for a
345+ * perfectly valid draft. The verdict was about the reader, not the document.
346+ *
347+ * Same class as #6810 (`indexed`, rejected by name on a served object) but not
348+ * the same fix: `indexed` did not belong on the served body at all and was
349+ * removed at the injection site, whereas `_draft` is the preview badge the UI
350+ * reads — it belongs on the RESPONSE and is already a declared member of
351+ * `METADATA_READ_DECORATIONS`. So this closes where the list is consumed, not
352+ * where the badge is stamped.
353+ *
354+ * The anti-vacuity cases are the point of this block: "no `_draft` complaint"
355+ * is also satisfied by a read that stopped computing diagnostics at all, which
356+ * would be a strictly worse regression wearing a green test. Each side pairs a
357+ * valid draft (must be `valid:true`) with a genuinely broken one (must still be
358+ * `valid:false`, naming its OWN defect).
359+ */
360+ describe ( 'draft preview diagnostics do not judge the injected `_draft` badge (#7656)' , ( ) => {
361+ /** The symptom, verbatim from the card: a complaint naming `_draft`. */
362+ const draftKeyComplaints = ( diagnostics : any ) : string [ ] =>
363+ ( diagnostics ?. errors ?? [ ] )
364+ . map ( ( e : { message ?: string } ) => String ( e ?. message ?? '' ) )
365+ . filter ( ( m : string ) => m . includes ( '_draft' ) ) ;
366+
367+ /**
368+ * Seed a stored draft row directly. The save path refuses an invalid body
369+ * with 422, so a genuinely-broken draft cannot be authored through
370+ * `saveMetaItem` — which is the whole reason read-time diagnostics exist:
371+ * they badge rows that are already in the table (authored before a schema
372+ * tightened, or written by the ADR-0033 AI apply loop).
373+ */
374+ const seedDraft = async ( engine : any , name : string , body : unknown ) => {
375+ await engine . insert ( 'sys_metadata' , {
376+ type : 'object' ,
377+ name,
378+ organization_id : null ,
379+ package_id : null ,
380+ state : 'draft' ,
381+ metadata : JSON . stringify ( body ) ,
382+ } ) ;
383+ } ;
384+
385+ /** Valid except for one deliberately-planted defect: `type` is not a field type. */
386+ const brokenBody = ( name : string ) => ( {
387+ name,
388+ label : 'Broken' ,
389+ fields : { amount : { type : 'not_a_real_field_type' , label : 'Amount' } } ,
390+ } ) ;
391+
392+ describe ( 'single-item read (`getMetaItem`, previewDrafts)' , ( ) => {
393+ it ( 'a valid draft reads back `_diagnostics.valid:true`' , async ( ) => {
394+ const { engine } = makeStubEngine ( ) ;
395+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
396+ await protocol . saveMetaItem ( {
397+ type : 'object' , name : 'crm_quote' , item : objectBody ( 'crm_quote' ) , mode : 'draft' ,
398+ } ) ;
399+
400+ const served : any = ( await protocol . getMetaItem ( {
401+ type : 'object' , name : 'crm_quote' , previewDrafts : true ,
402+ } ) ) . item ;
403+
404+ expect ( served . _draft , 'precondition — the preview read badges' ) . toBe ( true ) ;
405+ expect ( draftKeyComplaints ( served . _diagnostics ) ) . toEqual ( [ ] ) ;
406+ expect (
407+ served . _diagnostics . valid ,
408+ `draft preview reported invalid: ${ JSON . stringify ( served . _diagnostics ?. errors ) } ` ,
409+ ) . toBe ( true ) ;
410+ } ) ;
411+
412+ it ( 'a genuinely broken draft still reports its OWN error (anti-vacuity)' , async ( ) => {
413+ const { engine } = makeStubEngine ( ) ;
414+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
415+ await seedDraft ( engine , 'crm_broken' , brokenBody ( 'crm_broken' ) ) ;
416+
417+ const served : any = ( await protocol . getMetaItem ( {
418+ type : 'object' , name : 'crm_broken' , previewDrafts : true ,
419+ } ) ) . item ;
420+
421+ expect ( served . _draft , 'precondition — the preview read badges' ) . toBe ( true ) ;
422+ // Still computed, still false — the fix must not silence the path.
423+ expect ( served . _diagnostics . valid ) . toBe ( false ) ;
424+ expect ( served . _diagnostics . errors ?. length ) . toBeGreaterThan ( 0 ) ;
425+ // …and false for the DOCUMENT's reason, not for the reader's badge.
426+ expect ( draftKeyComplaints ( served . _diagnostics ) ) . toEqual ( [ ] ) ;
427+ expect (
428+ JSON . stringify ( served . _diagnostics . errors ) ,
429+ 'the real defect must still be named' ,
430+ ) . toContain ( 'amount' ) ;
431+ } ) ;
432+ } ) ;
433+
434+ describe ( 'list overlay (`getMetaItems`, previewDrafts)' , ( ) => {
435+ /** The overlaid draft entry for `name`, as the Studio list receives it. */
436+ const listed = async ( protocol : any , name : string ) => {
437+ const res : any = await protocol . getMetaItems ( { type : 'object' , previewDrafts : true } ) ;
438+ const items : any [ ] = Array . isArray ( res ) ? res : ( res ?. items ?? [ ] ) ;
439+ const served = items . find ( ( i ) => i ?. name === name ) ;
440+ expect ( served , `getMetaItems('object') overlaid the draft ${ name } ` ) . toBeDefined ( ) ;
441+ return served ;
442+ } ;
443+
444+ it ( 'a valid draft reads back `_diagnostics.valid:true`' , async ( ) => {
445+ const { engine } = makeStubEngine ( ) ;
446+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
447+ await protocol . saveMetaItem ( {
448+ type : 'object' , name : 'crm_quote' , item : objectBody ( 'crm_quote' ) , mode : 'draft' ,
449+ } ) ;
450+
451+ const served = await listed ( protocol , 'crm_quote' ) ;
452+ expect ( served . _draft , 'precondition — the overlay badges' ) . toBe ( true ) ;
453+ expect ( draftKeyComplaints ( served . _diagnostics ) ) . toEqual ( [ ] ) ;
454+ expect (
455+ served . _diagnostics . valid ,
456+ `draft overlay reported invalid: ${ JSON . stringify ( served . _diagnostics ?. errors ) } ` ,
457+ ) . toBe ( true ) ;
458+ } ) ;
459+
460+ it ( 'a genuinely broken draft still reports its OWN error (anti-vacuity)' , async ( ) => {
461+ const { engine } = makeStubEngine ( ) ;
462+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
463+ await seedDraft ( engine , 'crm_broken' , brokenBody ( 'crm_broken' ) ) ;
464+
465+ const served = await listed ( protocol , 'crm_broken' ) ;
466+ expect ( served . _draft , 'precondition — the overlay badges' ) . toBe ( true ) ;
467+ expect ( served . _diagnostics . valid ) . toBe ( false ) ;
468+ expect ( served . _diagnostics . errors ?. length ) . toBeGreaterThan ( 0 ) ;
469+ expect ( draftKeyComplaints ( served . _diagnostics ) ) . toEqual ( [ ] ) ;
470+ expect (
471+ JSON . stringify ( served . _diagnostics . errors ) ,
472+ 'the real defect must still be named' ,
473+ ) . toContain ( 'amount' ) ;
474+ } ) ;
475+ } ) ;
476+
477+ describe ( 'the verdict is computed from the list, and the schema stays closed' , ( ) => {
478+ it ( 'every declared read decoration is invisible to the verdict (drift guard)' , ( ) => {
479+ // The mirror of the cloud#971 drift guard above, one layer down: a
480+ // FOURTH decoration added to `METADATA_READ_DECORATIONS` must not
481+ // have to remember this consumer. It fails here, on a unit, instead
482+ // of as `valid:false` on somebody's badge.
483+ const body = objectBody ( 'crm_invoice' ) ;
484+ expect ( computeMetadataDiagnostics ( 'object' , body ) ?. valid ) . toBe ( true ) ;
485+
486+ for ( const key of METADATA_READ_DECORATIONS ) {
487+ const verdict = computeMetadataDiagnostics ( 'object' , { ...body , [ key ] : true } ) ;
488+ expect (
489+ verdict ?. valid ,
490+ `read decoration \`${ key } \` leaked into the verdict: `
491+ + `${ JSON . stringify ( verdict ?. errors ) } ` ,
492+ ) . toBe ( true ) ;
493+ }
494+ } ) ;
495+
496+ it ( 'the object schema itself still rejects `_draft` — only the strip moved' , ( ) => {
497+ // ⛔ The remedy is NOT a looser item schema. `_draft` is a response
498+ // badge; a STORED body carrying it is a polluted row and must keep
499+ // failing by name, which is what makes the #4326 write-path strip
500+ // load-bearing rather than cosmetic.
501+ const schema = getMetadataTypeSchema ( 'object' ) ;
502+ expect ( schema , 'precondition — `object` has a registered schema' ) . toBeDefined ( ) ;
503+
504+ const parsed = ( schema as any ) . safeParse ( { ...objectBody ( 'crm_invoice' ) , _draft : true } ) ;
505+ expect ( parsed . success , 'the closed schema must still reject the badge' ) . toBe ( false ) ;
506+ expect (
507+ parsed . error . issues . some ( ( i : { code : string } ) => i . code === 'unrecognized_keys' ) ,
508+ ) . toBe ( true ) ;
509+ } ) ;
510+ } ) ;
511+ } ) ;
0 commit comments