@@ -97,6 +97,31 @@ const TASK = {
9797 } ,
9898} ;
9999
100+ // Mirrors an AI-built object with required fields and NO default (framework
101+ // import dry-run fidelity): `member_name` (required text) and `status` (required
102+ // select, no default) must be present on create; `tier` is required but carries
103+ // a default, so the engine fills it and the importer must NOT demand it.
104+ const MEMBER = {
105+ name : 'member' , label : 'Member' , systemFields : false ,
106+ fields : {
107+ id : { name : 'id' , type : 'text' as const , primaryKey : true } ,
108+ member_name : { name : 'member_name' , type : 'text' as const , label : 'Name' , required : true } ,
109+ status : {
110+ name : 'status' , type : 'select' as const , label : 'Status' , required : true ,
111+ options : [
112+ { label : 'Active' , value : 'active' } ,
113+ { label : 'Frozen' , value : 'frozen' } ,
114+ { label : 'Lost Contact' , value : 'lost_contact' } ,
115+ { label : 'Archived' , value : 'archived' } ,
116+ ] ,
117+ } ,
118+ tier : {
119+ name : 'tier' , type : 'select' as const , label : 'Tier' , required : true , defaultValue : 'standard' ,
120+ options : [ { label : 'Standard' , value : 'standard' } , { label : 'Gold' , value : 'gold' } ] ,
121+ } ,
122+ } ,
123+ } ;
124+
100125function createMockServer ( ) {
101126 const noop = ( ) => { } ;
102127 return { get : noop , post : noop , put : noop , delete : noop , patch : noop , use : noop , listen : async ( ) => { } , close : async ( ) => { } } ;
@@ -119,6 +144,7 @@ async function boot() {
119144 await engine . init ( ) ;
120145 engine . registry . registerObject ( USER as any ) ;
121146 engine . registry . registerObject ( TASK as any ) ;
147+ engine . registry . registerObject ( MEMBER as any ) ;
122148 await engine . insert ( 'user' , { id : 'u1' , name : '张三' , email : 'zhang@x.com' } ) ;
123149 await engine . insert ( 'user' , { id : 'u2' , name : '李四' , email : 'li@x.com' } ) ;
124150
@@ -310,6 +336,71 @@ describe('import route — real engine + protocol integration', () => {
310336 } ) ;
311337} ) ;
312338
339+ // ---------------------------------------------------------------------------
340+ // Required-field dry-run fidelity — the dry run must predict the real insert's
341+ // NOT NULL / required failures instead of green-lighting a row the insert
342+ // rejects. Mirrors the live mx1n_member case (required `status` select, no
343+ // default): dryRun said ok, the real insert died on a NOT NULL constraint.
344+ // ---------------------------------------------------------------------------
345+ describe ( 'import route — required-field dry-run fidelity' , ( ) => {
346+ let route : any ;
347+ let engine : any ;
348+ beforeEach ( async ( ) => { ( { route, engine } = await boot ( ) ) ; } ) ;
349+
350+ const imp = ( body : any ) => {
351+ const res = makeRes ( ) ;
352+ return route . handler ( { params : { object : 'member' } , body } as any , res ) . then ( ( ) => res ) ;
353+ } ;
354+
355+ it ( 'dry run fails a create row missing a required no-default field — and the real insert agrees' , async ( ) => {
356+ const rows = [
357+ { id : 'm1' , member_name : 'Alice' } , // status missing → must fail
358+ { id : 'm2' , member_name : 'Bob' , status : 'active' } , // complete → ok
359+ ] ;
360+ // Dry run: no longer reports success for the row the insert will reject.
361+ const dry = await imp ( { format : 'json' , dryRun : true , rows } ) ;
362+ expect ( dry . _json ) . toMatchObject ( { dryRun : true , total : 2 , ok : 1 , errors : 1 } ) ;
363+ expect ( dry . _json . results . find ( ( r : any ) => ! r . ok ) ) . toMatchObject ( { row : 1 , field : 'status' , code : 'required' } ) ;
364+ expect ( await engine . findOne ( 'member' , { where : { id : 'm2' } } ) ) . toBeNull ( ) ; // dry run never writes
365+
366+ // Real insert: SAME verdict (parity), and a readable `status is required`
367+ // instead of a raw `NOT NULL constraint failed: member.status`.
368+ const real = await imp ( { format : 'json' , rows } ) ;
369+ expect ( real . _json ) . toMatchObject ( { total : 2 , ok : 1 , errors : 1 , created : 1 } ) ;
370+ expect ( real . _json . results . find ( ( r : any ) => ! r . ok ) ) . toMatchObject ( { field : 'status' , code : 'required' , error : 'status is required' } ) ;
371+ expect ( ( await engine . findOne ( 'member' , { where : { id : 'm2' } } ) ) ?. status ) . toBe ( 'active' ) ;
372+ expect ( await engine . findOne ( 'member' , { where : { id : 'm1' } } ) ) . toBeNull ( ) ;
373+ } ) ;
374+
375+ it ( 'a required field with a schema default is satisfied without being mapped' , async ( ) => {
376+ // `tier` is required but defaulted — the importer must not demand it; the
377+ // engine fills 'standard'. Only member_name + status are supplied.
378+ const res = await imp ( { format : 'json' , rows : [ { id : 'm3' , member_name : 'Cara' , status : 'frozen' } ] } ) ;
379+ expect ( res . _json ) . toMatchObject ( { ok : 1 , errors : 0 , created : 1 } ) ;
380+ expect ( await engine . findOne ( 'member' , { where : { id : 'm3' } } ) )
381+ . toMatchObject ( { member_name : 'Cara' , status : 'frozen' , tier : 'standard' } ) ;
382+ } ) ;
383+
384+ it ( 'flags a required text field too (not just selects); a blank cell counts as missing' , async ( ) => {
385+ const res = await imp ( { format : 'json' , dryRun : true , rows : [
386+ { id : 'm4' , status : 'active' } , // member_name missing
387+ { id : 'm5' , member_name : ' ' , status : 'active' } , // member_name blank
388+ ] } ) ;
389+ expect ( res . _json ) . toMatchObject ( { ok : 0 , errors : 2 } ) ;
390+ for ( const r of res . _json . results ) expect ( r ) . toMatchObject ( { field : 'member_name' , code : 'required' } ) ;
391+ } ) ;
392+
393+ it ( 'required check does not apply to update-mode rows (only the touched fields matter)' , async ( ) => {
394+ await engine . insert ( 'member' , { id : 'm6' , member_name : 'Dan' , status : 'active' , tier : 'gold' } ) ;
395+ // writeMode:update on an existing match, touching only member_name — status
396+ // is not supplied but the record already has it, so this must NOT fail.
397+ const res = await imp ( { format : 'json' , writeMode : 'update' , matchFields : [ 'id' ] ,
398+ rows : [ { id : 'm6' , member_name : 'Daniel' } ] } ) ;
399+ expect ( res . _json ) . toMatchObject ( { ok : 1 , errors : 0 , updated : 1 } ) ;
400+ expect ( ( await engine . findOne ( 'member' , { where : { id : 'm6' } } ) ) ?. member_name ) . toBe ( 'Daniel' ) ;
401+ } ) ;
402+ } ) ;
403+
313404// ---------------------------------------------------------------------------
314405// Named mapping artifacts (#2611) — `mappingName` resolves a registered
315406// `mapping` item and applies its fieldMapping pipeline before coercion.
0 commit comments