|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core'; |
| 5 | + |
| 6 | +import { evaluateValidationRules } from './rule-validator.js'; |
| 7 | +import { ValidationError } from './record-validator.js'; |
| 8 | + |
| 9 | +// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE |
| 10 | +// LOAD, not inside a clocked `it()` body. |
| 11 | +import '@objectstack/spec'; |
| 12 | +import '@objectstack/formula'; |
| 13 | + |
| 14 | +/** |
| 15 | + * [#24782] What the operator log says about a validation rule that could not be |
| 16 | + * EVALUATED, by severity. |
| 17 | + * |
| 18 | + * #4649 fails such a rule closed — but `severity` still governs blocking, so an |
| 19 | + * unevaluable `warning` / `info` rule never refuses the write. Until #24782 its |
| 20 | + * log said the opposite, twice: the predicate evaluator's fail-closed sentence |
| 21 | + * ("— write rejected: a rule that cannot be evaluated fails closed"), logged |
| 22 | + * before the rule's severity was consulted, and the advisory branch's per-write |
| 23 | + * line, which embedded the unevaluable violation's own message (ending |
| 24 | + * "— write rejected."). An operator reading the log for a disputed write was |
| 25 | + * told it was refused when it was stored. |
| 26 | + * |
| 27 | + * The pins: |
| 28 | + * - an unevaluable advisory rule logs exactly ONE line, naming the rule, its |
| 29 | + * severity and the fault, and saying the write proceeded — never "write |
| 30 | + * rejected", never "fails closed" — on each predicate slot: a `script` |
| 31 | + * condition, a `conditional`'s `when`, and the relationship-traversal refusal; |
| 32 | + * - the seed / boot aggregation (#13889) folds that same advisory text; |
| 33 | + * - CONTROL: an unevaluable `error` rule is refused exactly as before, and its |
| 34 | + * log still says the write was rejected and the rule fails closed. |
| 35 | + */ |
| 36 | + |
| 37 | +const FAULTED_CONDITION = 'int(record.title) > 1'; |
| 38 | + |
| 39 | +const probe = (severity: 'error' | 'warning' | 'info') => ({ |
| 40 | + name: 'qa_vr_probe', |
| 41 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 42 | + validations: [ |
| 43 | + { type: 'script', name: 'qa_unevaluable', severity, message: 'x', condition: FAULTED_CONDITION }, |
| 44 | + ], |
| 45 | +}); |
| 46 | + |
| 47 | +const conditionalProbe = (severity: 'error' | 'warning') => ({ |
| 48 | + name: 'qa_vr_probe', |
| 49 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 50 | + validations: [ |
| 51 | + { |
| 52 | + type: 'conditional', |
| 53 | + name: 'qa_unevaluable_when', |
| 54 | + severity, |
| 55 | + message: 'x', |
| 56 | + when: FAULTED_CONDITION, |
| 57 | + then: { type: 'script', name: 'qa_then', message: 'then', condition: 'false' }, |
| 58 | + }, |
| 59 | + ], |
| 60 | +}); |
| 61 | + |
| 62 | +/** A rule reading one hop through a lookup whose parent row was unreadable. */ |
| 63 | +const traversalProbe = (severity: 'error' | 'warning') => ({ |
| 64 | + name: 'crm_opportunity', |
| 65 | + fields: { |
| 66 | + amount: { type: 'currency', label: 'Amount' }, |
| 67 | + account: { type: 'lookup', reference: 'crm_account', label: 'Account' }, |
| 68 | + }, |
| 69 | + validations: [ |
| 70 | + { |
| 71 | + type: 'script', |
| 72 | + name: 'partner_cap', |
| 73 | + severity, |
| 74 | + message: 'Partner accounts are capped at 10000.', |
| 75 | + fields: ['amount'], |
| 76 | + condition: "record.account.type == 'partner' && record.amount > 10000", |
| 77 | + }, |
| 78 | + ], |
| 79 | +}); |
| 80 | +const unreadableAccount = { account: { object: 'crm_account', unavailable: 'unreadable' } }; |
| 81 | + |
| 82 | +function run( |
| 83 | + schema: unknown, |
| 84 | + data: Record<string, unknown>, |
| 85 | + related?: Record<string, unknown>, |
| 86 | +): { warns: string[]; thrown: unknown; returned: unknown } { |
| 87 | + const warns: string[] = []; |
| 88 | + let thrown: unknown; |
| 89 | + let returned: unknown; |
| 90 | + try { |
| 91 | + returned = evaluateValidationRules(schema as never, data, 'insert', { |
| 92 | + logger: { warn: (m: unknown) => { warns.push(String(m)); } }, |
| 93 | + ...(related ? { related: related as never } : {}), |
| 94 | + }); |
| 95 | + } catch (err) { |
| 96 | + thrown = err; |
| 97 | + } |
| 98 | + return { warns, thrown, returned }; |
| 99 | +} |
| 100 | + |
| 101 | +function expectOneAdvisoryLine(warns: string[], rule: string, severity: string, fault: RegExp): void { |
| 102 | + expect(warns).toHaveLength(1); |
| 103 | + const [line] = warns; |
| 104 | + expect(line).toContain(`Validation rule '${rule}' (${severity}):`); |
| 105 | + expect(line).toMatch(fault); |
| 106 | + expect(line).toContain('write proceeded'); |
| 107 | + expect(line).not.toMatch(/write rejected/); |
| 108 | + expect(line).not.toMatch(/fails closed/); |
| 109 | +} |
| 110 | + |
| 111 | +describe('[#24782] an unevaluable ADVISORY rule logs one line, and it says the write proceeded', () => { |
| 112 | + it.each(['warning', 'info'] as const)('a `script` condition at severity %s', (severity) => { |
| 113 | + const { warns, thrown, returned } = run(probe(severity), { title: 'abc' }); |
| 114 | + |
| 115 | + expect(thrown).toBeUndefined(); |
| 116 | + // #22726: an unevaluable rule has no verdict, so it is not an advisory hit. |
| 117 | + expect(returned).toEqual([]); |
| 118 | + expectOneAdvisoryLine(warns, 'qa_unevaluable', severity, /could not be evaluated \(.+\)/); |
| 119 | + }); |
| 120 | + |
| 121 | + it('a `conditional` whose `when` cannot be evaluated', () => { |
| 122 | + const { warns, thrown, returned } = run(conditionalProbe('warning'), { title: 'abc' }); |
| 123 | + |
| 124 | + expect(thrown).toBeUndefined(); |
| 125 | + expect(returned).toEqual([]); |
| 126 | + expectOneAdvisoryLine(warns, 'qa_unevaluable_when', 'warning', /could not be evaluated \(.+\)/); |
| 127 | + }); |
| 128 | + |
| 129 | + it('the relationship-traversal refusal (the related row was unreadable)', () => { |
| 130 | + const { warns, thrown, returned } = run( |
| 131 | + traversalProbe('warning'), { amount: 20000, account: 'acc_1' }, unreadableAccount, |
| 132 | + ); |
| 133 | + |
| 134 | + expect(thrown).toBeUndefined(); |
| 135 | + expect(returned).toEqual([]); |
| 136 | + expectOneAdvisoryLine(warns, 'partner_cap', 'warning', /could not be evaluated \(.+\)/); |
| 137 | + }); |
| 138 | + |
| 139 | + it('on the seed / boot path the hit folds into the summary with the same advisory text, and no per-row line', async () => { |
| 140 | + const warns: string[] = []; |
| 141 | + let groups: AdvisoryGroup[] = []; |
| 142 | + await runWithAdvisoryAggregation(async () => { |
| 143 | + evaluateValidationRules(probe('warning') as never, { title: 'abc' }, 'insert', { |
| 144 | + logger: { warn: (m: unknown) => { warns.push(String(m)); } }, |
| 145 | + }); |
| 146 | + }, (g) => { groups = g; }); |
| 147 | + |
| 148 | + expect(warns).toEqual([]); |
| 149 | + expect(groups).toHaveLength(1); |
| 150 | + expect(groups[0]).toMatchObject({ rule: 'qa_unevaluable', severity: 'warning', rows: 1 }); |
| 151 | + expect(groups[0]!.message).toMatch(/could not be evaluated \(.+\) — write proceeded/); |
| 152 | + expect(groups[0]!.message).not.toMatch(/write rejected|fails closed/); |
| 153 | + }); |
| 154 | +}); |
| 155 | + |
| 156 | +describe('[#24782] CONTROL — an unevaluable `error` rule is refused, and its log says so', () => { |
| 157 | + const refusalOf = (thrown: unknown) => { |
| 158 | + expect(thrown).toBeInstanceOf(ValidationError); |
| 159 | + return (thrown as ValidationError).fields; |
| 160 | + }; |
| 161 | + |
| 162 | + it('a `script` condition', () => { |
| 163 | + const { warns, thrown } = run(probe('error'), { title: 'abc' }); |
| 164 | + |
| 165 | + const fields = refusalOf(thrown); |
| 166 | + expect(fields).toHaveLength(1); |
| 167 | + expect(fields[0]).toMatchObject({ |
| 168 | + field: '_record', |
| 169 | + code: 'rule_violation', |
| 170 | + constraint: { rule: 'qa_unevaluable', reason: 'unevaluable' }, |
| 171 | + }); |
| 172 | + expect(fields[0]!.message).toMatch(/^Validation rule 'qa_unevaluable' could not be evaluated \(.+\) — write rejected\./); |
| 173 | + expect(warns).toHaveLength(1); |
| 174 | + expect(warns[0]).toMatch( |
| 175 | + /^Validation rule 'qa_unevaluable' predicate failed to evaluate \(.+\) — write rejected: a rule that cannot be evaluated fails closed, it is never skipped$/s, |
| 176 | + ); |
| 177 | + }); |
| 178 | + |
| 179 | + it('a `conditional` whose `when` cannot be evaluated', () => { |
| 180 | + const { warns, thrown } = run(conditionalProbe('error'), { title: 'abc' }); |
| 181 | + |
| 182 | + expect(refusalOf(thrown)[0]).toMatchObject({ constraint: { rule: 'qa_unevaluable_when', reason: 'unevaluable' } }); |
| 183 | + expect(warns).toHaveLength(1); |
| 184 | + expect(warns[0]).toMatch(/^Validation rule 'qa_unevaluable_when' when-predicate failed to evaluate \(.+\) — write rejected: a rule that cannot be evaluated fails closed/s); |
| 185 | + }); |
| 186 | + |
| 187 | + it('the relationship-traversal refusal', () => { |
| 188 | + const { warns, thrown } = run(traversalProbe('error'), { amount: 20000, account: 'acc_1' }, unreadableAccount); |
| 189 | + |
| 190 | + expect(refusalOf(thrown)[0]).toMatchObject({ constraint: { rule: 'partner_cap', reason: 'unevaluable' } }); |
| 191 | + expect(warns).toHaveLength(1); |
| 192 | + expect(warns[0]).toMatch(/^Validation rule 'partner_cap' predicate could not be evaluated \(.+\) — write rejected$/); |
| 193 | + }); |
| 194 | +}); |
0 commit comments