@@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest';
44import {
55 validateSearchableFields ,
66 SEARCHABLE_FIELD_UNKNOWN ,
7+ SEARCHABLE_FIELD_UNSEARCHABLE ,
78} from './validate-searchable-fields.js' ;
89
910/**
@@ -287,6 +288,173 @@ describe('validateSearchableFields — list views that narrow the set', () => {
287288 expect ( findings ) . toEqual ( [ ] ) ;
288289 } ) ;
289290
291+ it ( 'flags a lookup entry the runtime would refuse — the #4830 defect' , ( ) => {
292+ // The issue's repro verbatim: `searchableFields: ['name', '<lookup>']` on a
293+ // view, validate all green, first keystroke in the toolbar search → the
294+ // whole query 400s (INVALID_FIELD) for every role. The runtime judgment
295+ // is `resolveSearchFieldResolution` (@objectstack/spec/data); this rule
296+ // consults the same function, so declared = enforced.
297+ const findings = validateSearchableFields ( {
298+ objects : [
299+ {
300+ name : 'ehr_task' ,
301+ fields : {
302+ name : { type : 'text' } ,
303+ project_id : { type : 'lookup' , reference : 'ehr_project' } ,
304+ } ,
305+ listViews : { all : { type : 'grid' , searchableFields : [ 'name' , 'project_id' ] } } ,
306+ } ,
307+ ] ,
308+ } ) ;
309+
310+ expect ( findings ) . toHaveLength ( 1 ) ;
311+ expect ( findings [ 0 ] . rule ) . toBe ( SEARCHABLE_FIELD_UNSEARCHABLE ) ;
312+ expect ( findings [ 0 ] . severity ) . toBe ( 'error' ) ;
313+ expect ( findings [ 0 ] . path ) . toBe ( 'objects[0].listViews.all.searchableFields[1]' ) ;
314+ expect ( findings [ 0 ] . message ) . toContain ( "type 'lookup'" ) ;
315+ expect ( findings [ 0 ] . message ) . toContain ( '400 INVALID_FIELD' ) ;
316+ // The lookup-specific prescription: search cannot cross objects, so the
317+ // related record's title must be mirrored onto a local text/formula field.
318+ expect ( findings [ 0 ] . hint ) . toContain ( 'mirror' ) ;
319+ } ) ;
320+
321+ it ( 'flags a real field outside the object\'s declared searchableFields' , ( ) => {
322+ // Runtime parity, declared branch: the object declares the canonical set,
323+ // and the #4254 gate refuses a `$searchFields` entry outside it even when
324+ // the field exists and is text-like.
325+ const findings = validateSearchableFields ( {
326+ objects : [
327+ {
328+ name : 'crm_account' ,
329+ fields : {
330+ name : { type : 'text' } ,
331+ billing_email : { type : 'email' } ,
332+ notes : { type : 'textarea' } ,
333+ } ,
334+ searchableFields : [ 'name' , 'billing_email' ] ,
335+ listViews : { all : { type : 'grid' , searchableFields : [ 'notes' ] } } ,
336+ } ,
337+ ] ,
338+ } ) ;
339+
340+ expect ( findings ) . toHaveLength ( 1 ) ;
341+ expect ( findings [ 0 ] . rule ) . toBe ( SEARCHABLE_FIELD_UNSEARCHABLE ) ;
342+ expect ( findings [ 0 ] . message ) . toContain ( 'name, billing_email' ) ;
343+ expect ( findings [ 0 ] . hint ) . toContain ( 'crm_account.searchableFields' ) ;
344+ } ) ;
345+
346+ it ( 'passes a view entry of odd type once the object declares it searchable' , ( ) => {
347+ // The runtime's declared branch filters by EXISTENCE, never by type: a
348+ // json/lookup column declared on the OBJECT is honored by the engine and
349+ // admitted by the gate, so the view echoing it must stay green — flagging
350+ // it would reject metadata the runtime accepts.
351+ const findings = validateSearchableFields ( {
352+ objects : [
353+ {
354+ name : 'crm_account' ,
355+ fields : { name : { type : 'text' } , payload : { type : 'json' } } ,
356+ searchableFields : [ 'name' , 'payload' ] ,
357+ listViews : { all : { type : 'grid' , searchableFields : [ 'payload' ] } } ,
358+ } ,
359+ ] ,
360+ } ) ;
361+
362+ expect ( findings ) . toEqual ( [ ] ) ;
363+ } ) ;
364+
365+ it ( 'flags a hidden field in a view narrowing (auto-default excludes it)' , ( ) => {
366+ const findings = validateSearchableFields ( {
367+ objects : [
368+ {
369+ name : 'crm_account' ,
370+ fields : { name : { type : 'text' } , secret_note : { type : 'text' , hidden : true } } ,
371+ listViews : { all : { type : 'grid' , searchableFields : [ 'secret_note' ] } } ,
372+ } ,
373+ ] ,
374+ } ) ;
375+
376+ expect ( findings ) . toHaveLength ( 1 ) ;
377+ expect ( findings [ 0 ] . rule ) . toBe ( SEARCHABLE_FIELD_UNSEARCHABLE ) ;
378+ expect ( findings [ 0 ] . message ) . toContain ( 'hidden' ) ;
379+ } ) ;
380+
381+ it ( 'checks defineView list and named listViews the same way' , ( ) => {
382+ const findings = validateSearchableFields ( {
383+ objects : [
384+ {
385+ name : 'crm_account' ,
386+ fields : { name : { type : 'text' } , owner_ref : { type : 'lookup' , reference : 'sys_user' } } ,
387+ } ,
388+ ] ,
389+ views : [
390+ {
391+ objectName : 'crm_account' ,
392+ list : { type : 'grid' , searchableFields : [ 'owner_ref' ] } ,
393+ listViews : { active : { type : 'grid' , searchableFields : [ 'owner_ref' ] } } ,
394+ } ,
395+ ] ,
396+ } ) ;
397+
398+ expect ( findings . map ( ( f ) => [ f . rule , f . path ] ) ) . toEqual ( [
399+ [ SEARCHABLE_FIELD_UNSEARCHABLE , 'views[0].list.searchableFields[0]' ] ,
400+ [ SEARCHABLE_FIELD_UNSEARCHABLE , 'views[0].listViews.active.searchableFields[0]' ] ,
401+ ] ) ;
402+ } ) ;
403+
404+ it ( 'keeps runtime parity when the object declares system columns searchable' , ( ) => {
405+ // The runtime resolves the declared branch against the REGISTRY map, so
406+ // `searchableFields: ['created_at']` is a non-empty declared set there —
407+ // NOT a fall-through to the auto-default. A view entry outside that set
408+ // must be flagged the way the gate refuses it, even though `created_at`
409+ // is invisible to the authored field map.
410+ const findings = validateSearchableFields ( {
411+ objects : [
412+ {
413+ name : 'audit_log' ,
414+ fields : { name : { type : 'text' } , detail : { type : 'textarea' } } ,
415+ searchableFields : [ 'created_at' ] ,
416+ listViews : { all : { type : 'grid' , searchableFields : [ 'detail' ] } } ,
417+ } ,
418+ ] ,
419+ } ) ;
420+
421+ expect ( findings ) . toHaveLength ( 1 ) ;
422+ expect ( findings [ 0 ] . rule ) . toBe ( SEARCHABLE_FIELD_UNSEARCHABLE ) ;
423+ expect ( findings [ 0 ] . message ) . toContain ( 'created_at' ) ;
424+ } ) ;
425+
426+ it ( 'leaves a system column in a view narrowing alone (registry meta invisible)' , ( ) => {
427+ // `created_at` in a narrowing would be refused by the runtime, but its
428+ // registry-side metadata is not visible to the linter — a judgment here
429+ // risks the false positive ADR-0072 D1 forbids, so it is a documented
430+ // missed finding instead.
431+ const findings = validateSearchableFields ( {
432+ objects : [
433+ {
434+ name : 'crm_account' ,
435+ fields : { name : { type : 'text' } } ,
436+ listViews : { all : { type : 'grid' , searchableFields : [ 'name' , 'created_at' ] } } ,
437+ } ,
438+ ] ,
439+ } ) ;
440+
441+ expect ( findings ) . toEqual ( [ ] ) ;
442+ } ) ;
443+
444+ it ( 'does not type-check the object\'s own canonical set (runtime honors it)' , ( ) => {
445+ const findings = validateSearchableFields ( {
446+ objects : [
447+ {
448+ name : 'crm_account' ,
449+ fields : { name : { type : 'text' } , owner_ref : { type : 'lookup' , reference : 'sys_user' } } ,
450+ searchableFields : [ 'name' , 'owner_ref' ] ,
451+ } ,
452+ ] ,
453+ } ) ;
454+
455+ expect ( findings ) . toEqual ( [ ] ) ;
456+ } ) ;
457+
290458 it ( 'skips a view bound to an object this stack does not define' , ( ) => {
291459 // The object may come from another package; a field map we cannot see
292460 // cannot be judged — the same skip the page/flow/widget rules take.
0 commit comments