@@ -304,6 +304,197 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
304304 expect ( reads ) . toHaveLength ( 0 ) ;
305305 } ) ;
306306
307+ // ── #5869: the list-shaped operators' comparands ──────────────────────
308+ //
309+ // `isFilterAST` vouches for the OPERATOR and nothing else, and
310+ // `parseFilterAST` lowers whatever comparand it is handed. So the shapes
311+ // below passed both, reached the driver, and — on driver-sql, via
312+ // `whereIn(field, scalar)` — came back as `500 DATABASE_ERROR`: a
313+ // server-fault code for a filter the caller can fix, naming neither the
314+ // operator nor the field. Same collection point, same envelope as the
315+ // refusals above.
316+
317+ it . each ( [
318+ [ 'not_in' , [ [ 'stage' , 'not_in' , 'won' ] ] ] ,
319+ [ 'nin' , [ [ 'stage' , 'nin' , 'won' ] ] ] ,
320+ [ 'notin' , [ [ 'stage' , 'notin' , 'won' ] ] ] ,
321+ [ 'in' , [ [ 'stage' , 'in' , 'won' ] ] ] ,
322+ ] ) ( 'refuses a scalar comparand on the collection operator %s' , async ( _op , where ) => {
323+ await expect ( engine . find ( 'deal' , { where } as any ) )
324+ . rejects . toMatchObject ( { status : 400 , code : 'INVALID_FILTER' } ) ;
325+ // Nothing ran: a refused filter must not reach the driver at all, or the
326+ // 400 would be describing a query that already returned rows.
327+ expect ( reads ) . toHaveLength ( 0 ) ;
328+ } ) ;
329+
330+ it ( 'the refusal NAMES the operator, the field and the expected shape (#5346/#5348 wording)' , async ( ) => {
331+ const err = await engine . find ( 'deal' , { where : [ [ 'stage' , 'not_in' , 'won' ] ] } as any )
332+ . then ( ( ) => null , ( e : any ) => e ) ;
333+
334+ expect ( err ) . not . toBeNull ( ) ;
335+ // The entry point that refused, matching the sibling refusals above.
336+ expect ( err . message ) . toMatch ( / ^ f i n d \( ' d e a l ' \) : / ) ;
337+ // The operator, in the lowered spelling…
338+ expect ( err . message ) . toMatch ( / O p e r a t o r " \$ n i n " / ) ;
339+ // …and in the spellings an author actually types on a ViewFilterRule —
340+ // nobody writes `$nin` into metadata, so a refusal naming only the lowered
341+ // form sends them looking for a key their file does not contain.
342+ expect ( err . message ) . toMatch ( / n o t _ i n / ) ;
343+ // The member.
344+ expect ( err . message ) . toMatch ( / f i e l d " s t a g e " / ) ;
345+ // What was received, and where.
346+ expect ( err . message ) . toMatch ( / R e c e i v e d s t r i n g \( " w o n " \) / ) ;
347+ expect ( err . message ) . toMatch ( / w h e r e \. s t a g e \. \$ n i n / ) ;
348+ // The expected shape, as a value the caller can paste.
349+ expect ( err . message ) . toMatch ( / \[ " w o n " \] / ) ;
350+ // The alternative, for the caller who meant a scalar comparison.
351+ expect ( err . message ) . toMatch ( / " ! = " \( \$ n e \) / ) ;
352+ // And the part a status code cannot carry.
353+ expect ( err . message ) . toMatch ( / N O T a p p l i e d / ) ;
354+ expect ( err . message ) . toMatch ( / U N F I L T E R E D r e s u l t s e t / ) ;
355+ } ) ;
356+
357+ it ( 'the whole refusal survives the REST boundary — it fits under CLIENT_MESSAGE_MAX' , async ( ) => {
358+ // `rest-server.ts` truncates a declared-4xx message at 500 chars before it
359+ // reaches the client (#5423 made it a truncation rather than a swap). The
360+ // "NOT applied" sentence is the part a caller cannot infer from a status
361+ // code, and it sits at the END — so a message that overflows loses exactly
362+ // the sentence the refusal exists to deliver. Pinned here rather than
363+ // trusted, because the bound lives in another package.
364+ const CLIENT_MESSAGE_MAX = 500 ;
365+ for ( const where of [
366+ [ [ 'stage' , 'not_in' , 'won' ] ] ,
367+ [ [ 'stage' , 'in' , 'won' ] ] ,
368+ [ [ 'amount' , 'between' , 5 ] ] ,
369+ ] ) {
370+ const err = await engine . find ( 'deal' , { where } as any )
371+ . then ( ( ) => null , ( e : any ) => e ) ;
372+ expect ( err . message . length , JSON . stringify ( where ) ) . toBeLessThan ( CLIENT_MESSAGE_MAX ) ;
373+ expect ( err . message , JSON . stringify ( where ) ) . toMatch ( / U N F I L T E R E D r e s u l t s e t / ) ;
374+ }
375+ } ) ;
376+
377+ it ( 'refuses through the OBJECT door too — the door #5869 was measured through' , async ( ) => {
378+ // The protocol/HTTP face runs its own `isFilterAST` → `parseFilterAST` and
379+ // hands the engine an already-lowered FilterCondition, so the array branch
380+ // above never sees a wire query. This is that shape, arriving as an object.
381+ await expect ( engine . find ( 'deal' , { where : { stage : { $nin : 'won' } } } as any ) )
382+ . rejects . toMatchObject ( { status : 400 , code : 'INVALID_FILTER' } ) ;
383+ await expect ( engine . find ( 'deal' , { where : { stage : { $in : 'won' } } } as any ) )
384+ . rejects . toMatchObject ( { status : 400 , code : 'INVALID_FILTER' } ) ;
385+ } ) ;
386+
387+ it . each ( [
388+ [ 'null' , { stage : { $in : null } } ] ,
389+ [ 'a number' , { amount : { $in : 10 } } ] ,
390+ [ 'an object' , { stage : { $in : { a : 1 } } } ] ,
391+ ] ) ( 'refuses a comparand that is %s — every non-list, not just strings' , async ( _l , where ) => {
392+ await expect ( engine . find ( 'deal' , { where } as any ) )
393+ . rejects . toMatchObject ( { status : 400 , code : 'INVALID_FILTER' } ) ;
394+ } ) ;
395+
396+ it ( 'walks into $and / $or / $not — a nested scalar is refused with its own path' , async ( ) => {
397+ const err = await engine . find ( 'deal' , {
398+ where : [ 'and' , [ 'amount' , '>' , 5 ] , [ 'stage' , 'not_in' , 'won' ] ] ,
399+ } as any ) . then ( ( ) => null , ( e : any ) => e ) ;
400+ expect ( err ?. status ) . toBe ( 400 ) ;
401+ expect ( err . message ) . toMatch ( / w h e r e \. \$ a n d \[ 1 \] \. s t a g e \. \$ n i n / ) ;
402+
403+ await expect ( engine . find ( 'deal' , { where : { $not : { stage : { $in : 'won' } } } } as any ) )
404+ . rejects . toMatchObject ( { status : 400 , code : 'INVALID_FILTER' } ) ;
405+ } ) ;
406+
407+ it ( 'every engine entry point refuses it, not just find()' , async ( ) => {
408+ const where = [ [ 'stage' , 'not_in' , 'won' ] ] ;
409+ await expect ( engine . findOne ( 'deal' , { where } as any ) ) . rejects . toMatchObject ( { status : 400 } ) ;
410+ await expect ( engine . count ( 'deal' , { where } as any ) ) . rejects . toMatchObject ( { status : 400 } ) ;
411+ await expect ( engine . aggregate ( 'deal' , {
412+ where, groupBy : [ 'stage' ] , aggregations : [ { function : 'count' , field : 'id' , alias : 'n' } ] ,
413+ } as any ) ) . rejects . toMatchObject ( { status : 400 } ) ;
414+ await expect ( engine . update ( 'deal' , { amount : 1 } , { where, multi : true } as any ) )
415+ . rejects . toMatchObject ( { status : 400 } ) ;
416+ await expect ( engine . delete ( 'deal' , { where, multi : true } as any ) )
417+ . rejects . toMatchObject ( { status : 400 } ) ;
418+ // Refused before any of them touched the store.
419+ expect ( reads ) . toHaveLength ( 0 ) ;
420+ expect ( writes ) . toHaveLength ( 0 ) ;
421+ expect ( await engine . count ( 'deal' ) ) . toBe ( 3 ) ;
422+ } ) ;
423+
424+ // `$between`'s arity, hoisted to the same seam. driver-sql and driver-memory
425+ // each already refuse this (#5328); driver-mongodb's arm falls through
426+ // without emitting a range predicate. Checking here is what makes the three
427+ // agree — the same reason the collection point exists.
428+ it . each ( [
429+ [ 'a scalar' , [ [ 'amount' , 'between' , 5 ] ] ] ,
430+ [ 'a 1-tuple' , [ [ 'amount' , 'between' , [ 1 ] ] ] ] ,
431+ [ 'a 3-tuple' , [ [ 'amount' , 'between' , [ 1 , 2 , 3 ] ] ] ] ,
432+ ] ) ( 'refuses a $between comparand that is %s' , async ( _l , where ) => {
433+ await expect ( engine . find ( 'deal' , { where } as any ) )
434+ . rejects . toMatchObject ( { status : 400 , code : 'INVALID_FILTER' } ) ;
435+ } ) ;
436+
437+ it ( 'the $between refusal keeps the platform-wide wording and names the field' , async ( ) => {
438+ const err = await engine . find ( 'deal' , { where : [ [ 'amount' , 'between' , 5 ] ] } as any )
439+ . then ( ( ) => null , ( e : any ) => e ) ;
440+ // Verbatim leading sentence from driver-sql / driver-memory: one condition,
441+ // one wording, wherever the caller meets it.
442+ expect ( err . message ) . toMatch (
443+ / O p e r a t o r " \$ b e t w e e n " o n f i e l d " a m o u n t " r e q u i r e s a \[ m i n , m a x \] v a l u e a r r a y \. / ,
444+ ) ;
445+ expect ( err . message ) . toMatch ( / w h e r e \. a m o u n t \. \$ b e t w e e n / ) ;
446+ } ) ;
447+
448+ // ── what must KEEP working: the declared list shapes ───────────────────
449+
450+ it ( 'a proper list comparand still reaches the driver untouched' , async ( ) => {
451+ await engine . find ( 'deal' , { where : [ [ 'stage' , 'in' , [ 'won' , 'lost' ] ] ] } as any ) ;
452+ expect ( lastWhere ( ) ) . toEqual ( { stage : { $in : [ 'won' , 'lost' ] } } ) ;
453+
454+ await engine . find ( 'deal' , { where : [ [ 'stage' , 'not_in' , [ 'lost' ] ] ] } as any ) ;
455+ expect ( lastWhere ( ) ) . toEqual ( { stage : { $nin : [ 'lost' ] } } ) ;
456+
457+ await engine . find ( 'deal' , { where : [ [ 'amount' , 'between' , [ 5 , 25 ] ] ] } as any ) ;
458+ expect ( lastWhere ( ) ) . toEqual ( { amount : { $between : [ 5 , 25 ] } } ) ;
459+ } ) ;
460+
461+ it ( 'an EMPTY list is a declared predicate, not a malformed one' , async ( ) => {
462+ // `$in: []` matches nothing and `$nin: []` matches everything — both
463+ // drivers say so in as many words. Arity is not this gate's business.
464+ await engine . find ( 'deal' , { where : { stage : { $in : [ ] } } } as any ) ;
465+ expect ( lastWhere ( ) ) . toEqual ( { stage : { $in : [ ] } } ) ;
466+ await engine . find ( 'deal' , { where : { stage : { $nin : [ ] } } } as any ) ;
467+ expect ( lastWhere ( ) ) . toEqual ( { stage : { $nin : [ ] } } ) ;
468+ } ) ;
469+
470+ it ( 'the gate does not re-judge list MEMBERS — that is #5234, on another face' , async ( ) => {
471+ // A `$field` reference and a plain object are both legitimate members here;
472+ // this gate asks only whether the comparand is a list at all.
473+ const where = { stage : { $in : [ { $field : 'other' } , 'won' ] } } ;
474+ await engine . find ( 'deal' , { where } as any ) ;
475+ expect ( lastWhere ( ) ) . toEqual ( where ) ;
476+ } ) ;
477+
478+ it ( 'does not descend into a deep-equality comparand that merely LOOKS like an operator map' , async ( ) => {
479+ // `{ $eq: {...} }` holds DATA. A gate that walked into it would refuse a
480+ // stored document whose own key happens to be `$in` — a stricter contract
481+ // than any backend applies.
482+ const where = { stage : { $eq : { $in : 'not-an-operator-here' } } } ;
483+ await engine . find ( 'deal' , { where } as any ) ;
484+ expect ( lastWhere ( ) ) . toEqual ( where ) ;
485+ } ) ;
486+
487+ it ( 'a scalar on a NON-collection operator is untouched' , async ( ) => {
488+ await engine . find ( 'deal' , { where : [ [ 'stage' , '!=' , 'won' ] ] } as any ) ;
489+ expect ( lastWhere ( ) ) . toEqual ( { stage : { $ne : 'won' } } ) ;
490+ // String bounds on a range comparison stay legal — `FieldOperatorsSchema`
491+ // declares `$gt` as number|Date|FieldReference, but ISO strings are what the
492+ // showcase apps send and every backend accepts. This gate enforces the
493+ // three list declarations, not the whole schema.
494+ await engine . find ( 'deal' , { where : [ [ 'stage' , '>' , '2026-01-01' ] ] } as any ) ;
495+ expect ( lastWhere ( ) ) . toEqual ( { stage : { $gt : '2026-01-01' } } ) ;
496+ } ) ;
497+
307498 // ── the object form is untouched ──────────────────────────────────────
308499
309500 it ( 'a FilterCondition object passes through byte-for-byte' , async ( ) => {
0 commit comments