|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { validatePresetComparands, FILTER_PRESET_COMPARAND } from './validate-preset-comparands.js'; |
| 5 | + |
| 6 | +describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => { |
| 7 | + it('returns nothing for an empty / absent stack', () => { |
| 8 | + expect(validatePresetComparands(undefined)).toEqual([]); |
| 9 | + expect(validatePresetComparands(null)).toEqual([]); |
| 10 | + expect(validatePresetComparands({})).toEqual([]); |
| 11 | + }); |
| 12 | + |
| 13 | + // The exact measured defect shape from #8690: $gte "last_30_days" → 200 / 0. |
| 14 | + it('catches a bare preset under $gte in a dashboard widget filter, naming widget, path and fix', () => { |
| 15 | + const findings = validatePresetComparands({ |
| 16 | + dashboards: [{ |
| 17 | + name: 'sales', |
| 18 | + widgets: [{ |
| 19 | + id: 'won_deals', type: 'metric', dataset: 'deals', values: ['total'], |
| 20 | + filter: { closed_at: { $gte: 'last_30_days' } }, |
| 21 | + }], |
| 22 | + }], |
| 23 | + }); |
| 24 | + expect(findings).toHaveLength(1); |
| 25 | + const f = findings[0]; |
| 26 | + expect(f.severity).toBe('error'); |
| 27 | + expect(f.rule).toBe(FILTER_PRESET_COMPARAND); |
| 28 | + expect(f.where).toBe('dashboard "sales" · widget "won_deals"'); |
| 29 | + expect(f.path).toBe('dashboards[0].widgets[0].filter.closed_at.$gte'); |
| 30 | + expect(f.message).toContain('last_30_days'); |
| 31 | + expect(f.message).toContain('{30_days_ago}'); // the spelling that works |
| 32 | + expect(f.hint).toContain('{date-macro}'); |
| 33 | + }); |
| 34 | + |
| 35 | + it('judges all three authored filter shapes', () => { |
| 36 | + const findings = validatePresetComparands({ |
| 37 | + views: [{ |
| 38 | + name: 'recent', |
| 39 | + // Shape 2: view filter rules — ordering spelling, alias fold included. |
| 40 | + filter: [ |
| 41 | + { field: 'created_at', operator: 'after', value: 'last_7_days' }, |
| 42 | + { field: 'created_at', operator: 'gte', value: 'last_30_days' }, |
| 43 | + { field: 'created_at', operator: 'between', value: ['last_90_days', '2026-01-01'] }, |
| 44 | + ], |
| 45 | + }], |
| 46 | + pages: [{ |
| 47 | + name: 'board', |
| 48 | + // Shape 3: triples — infix and alias spellings. |
| 49 | + components: [ |
| 50 | + { type: 'list', filter: ['created_at', '>=', 'last_90_days'] }, |
| 51 | + { type: 'list', filter: ['and', ['created_at', 'after', 'this_week'], ['status', '=', 'open']] }, |
| 52 | + ], |
| 53 | + }], |
| 54 | + flows: [{ |
| 55 | + name: 'sweep', |
| 56 | + // Shape 1: Mongo-style, on a flow CRUD node. |
| 57 | + nodes: [{ id: 'find', config: { filter: { updated_at: { $lt: 'last_month' } } } }], |
| 58 | + }], |
| 59 | + }); |
| 60 | + const paths = findings.map((f) => f.path).sort(); |
| 61 | + expect(paths).toEqual([ |
| 62 | + 'flows[0].nodes[0].config.filter.updated_at.$lt', |
| 63 | + 'pages[0].components[0].filter[2]', |
| 64 | + 'pages[0].components[1].filter[1][2]', |
| 65 | + 'views[0].filter[0].value', |
| 66 | + 'views[0].filter[1].value', |
| 67 | + 'views[0].filter[2].value[0]', |
| 68 | + ].sort()); |
| 69 | + for (const f of findings) expect(f.severity).toBe('error'); |
| 70 | + }); |
| 71 | + |
| 72 | + it('stays quiet on every legitimate spelling — the discriminating controls', () => { |
| 73 | + expect(validatePresetComparands({ |
| 74 | + dashboards: [{ |
| 75 | + name: 'ok', |
| 76 | + widgets: [{ |
| 77 | + id: 'w', type: 'metric', dataset: 'd', values: ['v'], |
| 78 | + // The platform's own correct spellings in the SAME positions. |
| 79 | + filter: { |
| 80 | + closed_at: { $gte: '{30_days_ago}' }, |
| 81 | + opened_at: { $between: ['{week_start}', '{week_end}'] }, |
| 82 | + signed_at: { $lt: '2026-01-15' }, |
| 83 | + }, |
| 84 | + }], |
| 85 | + }], |
| 86 | + views: [{ |
| 87 | + name: 'v', |
| 88 | + filter: [ |
| 89 | + { field: 'closed_at', operator: 'after', value: '{30_days_ago}' }, |
| 90 | + { field: 'closed_at', operator: 'after', value: '2026-01-15' }, |
| 91 | + // Equality against a picklist value that collides with a preset |
| 92 | + // name is an author's own vocabulary — not this rule's business. |
| 93 | + { field: 'period', operator: 'equals', value: 'this_quarter' }, |
| 94 | + { field: 'period', operator: 'in', value: ['last_30_days'] }, |
| 95 | + ], |
| 96 | + }], |
| 97 | + pages: [{ |
| 98 | + name: 'p', |
| 99 | + components: [ |
| 100 | + { type: 'list', filter: ['closed_at', '>=', '{30_days_ago}'] }, |
| 101 | + // Equality triple — not judged. |
| 102 | + { type: 'list', filter: ['period', '=', 'last_30_days'] }, |
| 103 | + ], |
| 104 | + }], |
| 105 | + flows: [{ |
| 106 | + name: 'f', |
| 107 | + nodes: [{ |
| 108 | + id: 'n', |
| 109 | + config: { |
| 110 | + filter: { |
| 111 | + // Equality / membership — not judged (engine door owns the |
| 112 | + // temporal-field case, field type in hand). |
| 113 | + period: 'this_quarter', |
| 114 | + window: { $in: ['last_7_days'] }, |
| 115 | + // Undeclared string — the field-typed engine door owns it. |
| 116 | + updated_at: { $gte: 'not-a-date-at-all' }, |
| 117 | + // The empty-string cell stays its own card, by ruling. |
| 118 | + created_at: { $gte: '' }, |
| 119 | + }, |
| 120 | + }, |
| 121 | + }], |
| 122 | + }], |
| 123 | + })).toEqual([]); |
| 124 | + }); |
| 125 | + |
| 126 | + it('walks $and / $or / $not and nested relations in the Mongo shape', () => { |
| 127 | + const findings = validatePresetComparands({ |
| 128 | + objects: [{ |
| 129 | + name: 'deal', |
| 130 | + listViews: [{ |
| 131 | + name: 'recent', |
| 132 | + filter: { |
| 133 | + $and: [{ $or: [{ closed_at: { $gte: 'last_7_days' } }] }], |
| 134 | + $not: { account: { created_at: { $lt: 'this_year' } } }, |
| 135 | + }, |
| 136 | + }], |
| 137 | + }], |
| 138 | + }); |
| 139 | + const paths = findings.map((f) => f.path).sort(); |
| 140 | + expect(paths).toEqual([ |
| 141 | + 'objects[0].listViews[0].filter.$and[0].$or[0].closed_at.$gte', |
| 142 | + 'objects[0].listViews[0].filter.$not.account.created_at.$lt', |
| 143 | + ].sort()); |
| 144 | + }); |
| 145 | + |
| 146 | + it('does not double-report a value the token rule already owns', () => { |
| 147 | + // `{last_30_days}` is a WRAPPED unknown token — validate-filter-tokens' |
| 148 | + // verdict (FILTER_TOKEN_UNKNOWN), not this rule's: a preset name carries |
| 149 | + // no braces, so the two vocabularies cannot collide on one value. |
| 150 | + expect(validatePresetComparands({ |
| 151 | + dashboards: [{ |
| 152 | + name: 'd', |
| 153 | + widgets: [{ id: 'w', type: 'metric', dataset: 'x', values: ['v'], filter: { closed_at: { $gte: '{last_30_days}' } } }], |
| 154 | + }], |
| 155 | + })).toEqual([]); |
| 156 | + }); |
| 157 | +}); |
0 commit comments