@@ -402,6 +402,286 @@ describe('CRM Metadata Spec Compliance', () => {
402402 }
403403 } ) ;
404404 } ) ;
405+
406+ // ------------------------------------------------------------------
407+ // Enterprise Lookup Field Configuration
408+ // ------------------------------------------------------------------
409+
410+ describe ( 'Enterprise Lookup Metadata' , ( ) => {
411+ /** Extract all lookup fields from an object definition */
412+ function getLookupFields ( obj : Record < string , any > ) : Array < [ string , Record < string , any > ] > {
413+ return Object . entries ( obj . fields ) . filter (
414+ ( [ , f ] : [ string , any ] ) => f . type === 'lookup' || f . type === 'master_detail' ,
415+ ) as Array < [ string , Record < string , any > ] > ;
416+ }
417+
418+ it ( 'every CRM lookup field has lookup_columns configured' , ( ) => {
419+ for ( const obj of allObjects ) {
420+ const lookups = getLookupFields ( obj ) ;
421+ for ( const [ fieldName , field ] of lookups ) {
422+ expect ( field . lookup_columns , `${ obj . name } .${ fieldName } missing lookup_columns` ) . toBeDefined ( ) ;
423+ expect ( Array . isArray ( field . lookup_columns ) ) . toBe ( true ) ;
424+ expect ( field . lookup_columns . length ) . toBeGreaterThanOrEqual ( 2 ) ;
425+ }
426+ }
427+ } ) ;
428+
429+ it ( 'every CRM lookup field has lookup_filters configured' , ( ) => {
430+ for ( const obj of allObjects ) {
431+ const lookups = getLookupFields ( obj ) ;
432+ for ( const [ fieldName , field ] of lookups ) {
433+ expect ( field . lookup_filters , `${ obj . name } .${ fieldName } missing lookup_filters` ) . toBeDefined ( ) ;
434+ expect ( Array . isArray ( field . lookup_filters ) ) . toBe ( true ) ;
435+ expect ( field . lookup_filters . length ) . toBeGreaterThanOrEqual ( 1 ) ;
436+ }
437+ }
438+ } ) ;
439+
440+ it ( 'every CRM lookup field has description_field configured' , ( ) => {
441+ for ( const obj of allObjects ) {
442+ const lookups = getLookupFields ( obj ) ;
443+ for ( const [ fieldName , field ] of lookups ) {
444+ expect ( field . description_field , `${ obj . name } .${ fieldName } missing description_field` ) . toBeDefined ( ) ;
445+ expect ( typeof field . description_field ) . toBe ( 'string' ) ;
446+ }
447+ }
448+ } ) ;
449+
450+ it ( 'lookup_columns include at least one column with a type hint for cell rendering' , ( ) => {
451+ for ( const obj of allObjects ) {
452+ const lookups = getLookupFields ( obj ) ;
453+ for ( const [ fieldName , field ] of lookups ) {
454+ const cols = field . lookup_columns as Array < string | Record < string , any > > ;
455+ const typedCols = cols . filter (
456+ ( c ) => typeof c === 'object' && c . type ,
457+ ) ;
458+ expect (
459+ typedCols . length ,
460+ `${ obj . name } .${ fieldName } has no typed columns for cell rendering` ,
461+ ) . toBeGreaterThanOrEqual ( 1 ) ;
462+ }
463+ }
464+ } ) ;
465+
466+ it ( 'lookup_columns cover diverse cell types (select, currency, boolean, date)' , ( ) => {
467+ const allTypedColumns : string [ ] = [ ] ;
468+ for ( const obj of allObjects ) {
469+ const lookups = getLookupFields ( obj ) ;
470+ for ( const [ , field ] of lookups ) {
471+ const cols = field . lookup_columns as Array < string | Record < string , any > > ;
472+ for ( const c of cols ) {
473+ if ( typeof c === 'object' && c . type ) {
474+ allTypedColumns . push ( c . type ) ;
475+ }
476+ }
477+ }
478+ }
479+ const uniqueTypes = new Set ( allTypedColumns ) ;
480+ expect ( uniqueTypes . has ( 'select' ) ) . toBe ( true ) ;
481+ expect ( uniqueTypes . has ( 'currency' ) ) . toBe ( true ) ;
482+ expect ( uniqueTypes . has ( 'boolean' ) ) . toBe ( true ) ;
483+ expect ( uniqueTypes . has ( 'date' ) ) . toBe ( true ) ;
484+ } ) ;
485+
486+ it ( 'lookup_filters have valid operator values' , ( ) => {
487+ const validOperators = [ 'eq' , 'ne' , 'gt' , 'lt' , 'gte' , 'lte' , 'contains' , 'in' , 'notIn' ] ;
488+ for ( const obj of allObjects ) {
489+ const lookups = getLookupFields ( obj ) ;
490+ for ( const [ fieldName , field ] of lookups ) {
491+ for ( const filter of field . lookup_filters ) {
492+ expect ( filter ) . toHaveProperty ( 'field' ) ;
493+ expect ( filter ) . toHaveProperty ( 'operator' ) ;
494+ expect ( filter ) . toHaveProperty ( 'value' ) ;
495+ expect (
496+ validOperators ,
497+ `${ obj . name } .${ fieldName } filter operator "${ filter . operator } " invalid` ,
498+ ) . toContain ( filter . operator ) ;
499+ }
500+ }
501+ }
502+ } ) ;
503+
504+ it ( 'lookup_filters cover diverse operators (eq, ne, in, notIn)' , ( ) => {
505+ const allOperators : string [ ] = [ ] ;
506+ for ( const obj of allObjects ) {
507+ const lookups = getLookupFields ( obj ) ;
508+ for ( const [ , field ] of lookups ) {
509+ for ( const filter of field . lookup_filters ) {
510+ allOperators . push ( filter . operator ) ;
511+ }
512+ }
513+ }
514+ const uniqueOps = new Set ( allOperators ) ;
515+ expect ( uniqueOps . has ( 'eq' ) ) . toBe ( true ) ;
516+ expect ( uniqueOps . has ( 'in' ) ) . toBe ( true ) ;
517+ expect ( uniqueOps . has ( 'notIn' ) ) . toBe ( true ) ;
518+ expect ( uniqueOps . has ( 'ne' ) ) . toBe ( true ) ;
519+ } ) ;
520+
521+ it ( 'account.owner references user with active-only filter' , ( ) => {
522+ const owner = ( AccountObject . fields as any ) . owner ;
523+ expect ( owner . reference ) . toBe ( 'user' ) ;
524+ expect ( owner . description_field ) . toBe ( 'email' ) ;
525+ expect ( owner . lookup_filters ) . toEqual ( [ { field : 'active' , operator : 'eq' , value : true } ] ) ;
526+ } ) ;
527+
528+ it ( 'opportunity.account references account with type filter' , ( ) => {
529+ const account = ( OpportunityObject . fields as any ) . account ;
530+ expect ( account . reference ) . toBe ( 'account' ) ;
531+ expect ( account . description_field ) . toBe ( 'industry' ) ;
532+ const typeFilter = account . lookup_filters . find ( ( f : any ) => f . field === 'type' ) ;
533+ expect ( typeFilter ) . toBeDefined ( ) ;
534+ expect ( typeFilter . operator ) . toBe ( 'in' ) ;
535+ expect ( typeFilter . value ) . toContain ( 'Customer' ) ;
536+ } ) ;
537+
538+ it ( 'order_item.product filters active products only' , ( ) => {
539+ const product = ( OrderItemObject . fields as any ) . product ;
540+ expect ( product . reference ) . toBe ( 'product' ) ;
541+ expect ( product . description_field ) . toBe ( 'sku' ) ;
542+ expect ( product . lookup_filters ) . toEqual ( [ { field : 'is_active' , operator : 'eq' , value : true } ] ) ;
543+ const cols = product . lookup_columns as Array < Record < string , any > > ;
544+ expect ( cols . find ( ( c ) => c . field === 'price' ) ?. type ) . toBe ( 'currency' ) ;
545+ expect ( cols . find ( ( c ) => c . field === 'stock' ) ?. type ) . toBe ( 'number' ) ;
546+ expect ( cols . find ( ( c ) => c . field === 'is_active' ) ?. type ) . toBe ( 'boolean' ) ;
547+ } ) ;
548+
549+ it ( 'opportunity_contact.opportunity filters out closed stages' , ( ) => {
550+ const opp = ( OpportunityContactObject . fields as any ) . opportunity ;
551+ expect ( opp . reference ) . toBe ( 'opportunity' ) ;
552+ const stageFilter = opp . lookup_filters . find ( ( f : any ) => f . field === 'stage' ) ;
553+ expect ( stageFilter ) . toBeDefined ( ) ;
554+ expect ( stageFilter . operator ) . toBe ( 'notIn' ) ;
555+ expect ( stageFilter . value ) . toContain ( 'closed_won' ) ;
556+ expect ( stageFilter . value ) . toContain ( 'closed_lost' ) ;
557+ } ) ;
558+
559+ it ( 'opportunity.contacts has lookup_page_size for multi-select' , ( ) => {
560+ const contacts = ( OpportunityObject . fields as any ) . contacts ;
561+ expect ( contacts . lookup_page_size ) . toBe ( 15 ) ;
562+ } ) ;
563+ } ) ;
564+
565+ // ------------------------------------------------------------------
566+ // Enterprise Query Parameter Injection & Filter Bar Integration
567+ // ------------------------------------------------------------------
568+
569+ describe ( 'Enterprise Query Parameter & Filter Bar Compatibility' , ( ) => {
570+ /**
571+ * Simulate RecordPickerDialog's lookupFiltersToRecord conversion.
572+ * This mirrors the internal function in RecordPickerDialog.tsx to verify
573+ * that CRM metadata produces correct $filter query parameters.
574+ */
575+ function lookupFiltersToRecord (
576+ filters : Array < { field : string ; operator : string ; value : unknown } > ,
577+ ) : Record < string , any > {
578+ const result : Record < string , any > = { } ;
579+ for ( const f of filters ) {
580+ switch ( f . operator ) {
581+ case 'eq' : result [ f . field ] = f . value ; break ;
582+ case 'ne' : result [ f . field ] = { $ne : f . value } ; break ;
583+ case 'gt' : result [ f . field ] = { $gt : f . value } ; break ;
584+ case 'lt' : result [ f . field ] = { $lt : f . value } ; break ;
585+ case 'gte' : result [ f . field ] = { $gte : f . value } ; break ;
586+ case 'lte' : result [ f . field ] = { $lte : f . value } ; break ;
587+ case 'contains' : result [ f . field ] = { $contains : f . value } ; break ;
588+ case 'in' : result [ f . field ] = { $in : f . value } ; break ;
589+ case 'notIn' : result [ f . field ] = { $nin : f . value } ; break ;
590+ }
591+ }
592+ return result ;
593+ }
594+
595+ /**
596+ * Simulate LookupField's mapFieldTypeToFilterType conversion.
597+ * This mirrors the internal function in LookupField.tsx to verify
598+ * CRM lookup_columns produce valid filter bar configurations.
599+ */
600+ function mapFieldTypeToFilterType ( fieldType : string ) : string | undefined {
601+ const mapping : Record < string , string > = {
602+ text : 'text' , number : 'number' , currency : 'number' ,
603+ percent : 'number' , select : 'select' , status : 'select' ,
604+ date : 'date' , datetime : 'date' , boolean : 'boolean' ,
605+ } ;
606+ return mapping [ fieldType ] ;
607+ }
608+
609+ it ( 'account.owner lookup_filters produce correct $filter for active users' , ( ) => {
610+ const owner = ( AccountObject . fields as any ) . owner ;
611+ const $filter = lookupFiltersToRecord ( owner . lookup_filters ) ;
612+ expect ( $filter ) . toEqual ( { active : true } ) ;
613+ } ) ;
614+
615+ it ( 'contact.account lookup_filters produce $in for type restriction' , ( ) => {
616+ const account = ( ContactObject . fields as any ) . account ;
617+ const $filter = lookupFiltersToRecord ( account . lookup_filters ) ;
618+ expect ( $filter ) . toEqual ( { type : { $in : [ 'Customer' , 'Partner' ] } } ) ;
619+ } ) ;
620+
621+ it ( 'order_item.order lookup_filters produce $ne for cancelled exclusion' , ( ) => {
622+ const order = ( OrderItemObject . fields as any ) . order ;
623+ const $filter = lookupFiltersToRecord ( order . lookup_filters ) ;
624+ expect ( $filter ) . toEqual ( { status : { $ne : 'cancelled' } } ) ;
625+ } ) ;
626+
627+ it ( 'opportunity_contact.opportunity lookup_filters produce $nin for closed stages' , ( ) => {
628+ const opp = ( OpportunityContactObject . fields as any ) . opportunity ;
629+ const $filter = lookupFiltersToRecord ( opp . lookup_filters ) ;
630+ expect ( $filter ) . toEqual ( { stage : { $nin : [ 'closed_won' , 'closed_lost' ] } } ) ;
631+ } ) ;
632+
633+ it ( 'typed lookup_columns produce valid filter bar configurations' , ( ) => {
634+ const product = ( OrderItemObject . fields as any ) . product ;
635+ const cols = product . lookup_columns as Array < { field : string ; type ?: string ; label ?: string } > ;
636+
637+ const filterColumns = cols
638+ . filter ( ( c ) => c . type )
639+ . map ( ( c ) => ( {
640+ field : c . field ,
641+ label : c . label ,
642+ type : mapFieldTypeToFilterType ( c . type ! ) ,
643+ } ) )
644+ . filter ( ( c ) => c . type !== undefined ) ;
645+
646+ // Product lookup should produce filter bar entries for select, currency(→number), number, boolean
647+ expect ( filterColumns . length ) . toBeGreaterThanOrEqual ( 3 ) ;
648+ const types = filterColumns . map ( ( c ) => c . type ) ;
649+ expect ( types ) . toContain ( 'select' ) ; // category
650+ expect ( types ) . toContain ( 'number' ) ; // price, stock
651+ expect ( types ) . toContain ( 'boolean' ) ; // is_active
652+ } ) ;
653+
654+ it ( 'opportunity.account typed columns map to valid filter bar types' , ( ) => {
655+ const account = ( OpportunityObject . fields as any ) . account ;
656+ const cols = account . lookup_columns as Array < { field : string ; type ?: string } > ;
657+
658+ const filterTypes = cols
659+ . filter ( ( c ) => c . type )
660+ . map ( ( c ) => mapFieldTypeToFilterType ( c . type ! ) )
661+ . filter ( Boolean ) ;
662+
663+ // account columns have select + currency(→number) types
664+ expect ( filterTypes ) . toContain ( 'select' ) ;
665+ expect ( filterTypes ) . toContain ( 'number' ) ;
666+ } ) ;
667+
668+ it ( 'all CRM lookup_filters convert to valid $filter records without errors' , ( ) => {
669+ for ( const obj of allObjects ) {
670+ const lookups = Object . entries ( obj . fields ) . filter (
671+ ( [ , f ] : [ string , any ] ) => f . type === 'lookup' || f . type === 'master_detail' ,
672+ ) ;
673+ for ( const [ fieldName , field ] of lookups ) {
674+ const f = field as any ;
675+ if ( ! f . lookup_filters ) continue ;
676+ const $filter = lookupFiltersToRecord ( f . lookup_filters ) ;
677+ expect (
678+ Object . keys ( $filter ) . length ,
679+ `${ obj . name } .${ fieldName } lookup_filters produced empty $filter` ,
680+ ) . toBeGreaterThan ( 0 ) ;
681+ }
682+ }
683+ } ) ;
684+ } ) ;
405685} ) ;
406686
407687// ====================================================================
0 commit comments