|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #22301 item 8: the handle's door to the automation engine's predicate |
| 5 | + * evaluator, `stack.automation.evaluateCondition(condition, variables)`. |
| 6 | + * |
| 7 | + * On 17.7.0 the handle had no such door, so an app's suite that tested a |
| 8 | + * flow condition's truth table over variable shapes no write produces reached |
| 9 | + * `kernel.getService('automation').evaluateCondition(…)` by hand (hotcrm's |
| 10 | + * `conditionHolds`). The door is that same call, made by the handle: the |
| 11 | + * booted kernel's own `automation` service, the condition as given, the |
| 12 | + * variables as the `Map` the engine takes, and the engine's answer back. |
| 13 | + * |
| 14 | + * So every claim below is read off the engine itself: a spy on the booted |
| 15 | + * service instance (it was asked, with exactly these arguments, and its |
| 16 | + * answer is the door's), the engine called directly beside the door (same |
| 17 | + * verdict, same fault), and the engine's own dialect decision, which a door |
| 18 | + * that rewrote the condition would change. The no-automation boot answers |
| 19 | + * with the kernel's own rejection, compared against the kernel asked |
| 20 | + * directly. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; |
| 24 | + |
| 25 | +import { isServiceNotRegisteredError, SERVICE_NOT_REGISTERED_CODE } from '@objectstack/core'; |
| 26 | +import { defineStack } from '@objectstack/spec'; |
| 27 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 28 | + |
| 29 | +import { bootStack, type VerifyStack } from './harness.js'; |
| 30 | +import { isVerifyRefusal } from './handle.js'; |
| 31 | + |
| 32 | +// Booting the full in-process stack runs well past vitest's 5s default. |
| 33 | +const BOOT_TIMEOUT = 120_000; |
| 34 | + |
| 35 | +/** The engine's evaluator, as the booted `automation` service exposes it. */ |
| 36 | +interface Evaluator { |
| 37 | + evaluateCondition(condition: unknown, variables: Map<string, unknown>): boolean; |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * The fixture, built anew per boot: one neutral object, and the automation |
| 42 | + * capability declared (`requires`) or not. Two configurations, so the two |
| 43 | + * boots are two stacks rather than a refused second boot of one. |
| 44 | + */ |
| 45 | +function fixture(withAutomation: boolean) { |
| 46 | + return defineStack({ |
| 47 | + manifest: { |
| 48 | + id: `com.objectstack.verify.automation-door${withAutomation ? '' : '-none'}`, |
| 49 | + namespace: 'acd', |
| 50 | + version: '0.0.0', |
| 51 | + type: 'app', |
| 52 | + name: 'Verify Automation Door Fixture', |
| 53 | + description: 'One neutral object; the automation capability declared or not.', |
| 54 | + }, |
| 55 | + ...(withAutomation ? { requires: ['automation'] } : {}), |
| 56 | + objects: [ |
| 57 | + ObjectSchema.create({ |
| 58 | + name: 'acd_deal', |
| 59 | + label: 'Deal', |
| 60 | + pluralLabel: 'Deals', |
| 61 | + fields: { |
| 62 | + name: Field.text({ label: 'Name', required: true }), |
| 63 | + stage: Field.text({ label: 'Stage' }), |
| 64 | + amount: Field.number({ label: 'Amount' }), |
| 65 | + }, |
| 66 | + }), |
| 67 | + ], |
| 68 | + } as never); |
| 69 | +} |
| 70 | + |
| 71 | +/** A record-change predicate over `record` and `previous`, as a flow's start gate holds it. */ |
| 72 | +const MOVED_AND_LARGE = 'record.amount >= 1000 && record.stage != previous.stage'; |
| 73 | +const movedLarge = { record: { id: 'd1', amount: 5000, stage: 'won' }, previous: { id: 'd1', amount: 5000, stage: 'open' } }; |
| 74 | +const unmoved = { record: { id: 'd1', amount: 5000, stage: 'won' }, previous: { id: 'd1', amount: 5000, stage: 'won' } }; |
| 75 | +const small = { record: { id: 'd1', amount: 10, stage: 'won' }, previous: { id: 'd1', amount: 10, stage: 'open' } }; |
| 76 | + |
| 77 | +/** What the door, or the engine asked directly, answered: a verdict or a fault. */ |
| 78 | +async function answer<T>(run: () => T | Promise<T>): Promise<{ value?: T; fault?: unknown }> { |
| 79 | + try { |
| 80 | + return { value: await run() }; |
| 81 | + } catch (fault) { |
| 82 | + return { fault }; |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +describe('stack.automation.evaluateCondition (#22301 item 8)', () => { |
| 87 | + let stack: VerifyStack; |
| 88 | + let bare: VerifyStack; |
| 89 | + let engine: Evaluator; |
| 90 | + |
| 91 | + beforeAll(async () => { |
| 92 | + stack = await bootStack(fixture(true)); |
| 93 | + bare = await bootStack(fixture(false)); |
| 94 | + engine = await stack.kernel.getServiceAsync<Evaluator>('automation'); |
| 95 | + }, BOOT_TIMEOUT * 2); |
| 96 | + |
| 97 | + afterAll(async () => { |
| 98 | + await stack?.stop(); |
| 99 | + await bare?.stop(); |
| 100 | + }); |
| 101 | + |
| 102 | + afterEach(() => { |
| 103 | + vi.restoreAllMocks(); |
| 104 | + }); |
| 105 | + |
| 106 | + /** The door and the engine, asked the same question; the engine's Map built here, independently. */ |
| 107 | + async function both(condition: unknown, variables: Record<string, unknown>) { |
| 108 | + const door = await answer(() => |
| 109 | + stack.automation.evaluateCondition(condition as Parameters<VerifyStack['automation']['evaluateCondition']>[0], variables), |
| 110 | + ); |
| 111 | + const direct = await answer(() => engine.evaluateCondition(condition, new Map(Object.entries(variables)))); |
| 112 | + return { door, direct }; |
| 113 | + } |
| 114 | + |
| 115 | + it('asks the booted automation service, with the condition as given and the variables as its Map, and answers its boolean', async () => { |
| 116 | + const spy = vi.spyOn(engine, 'evaluateCondition'); |
| 117 | + const condition = { dialect: 'cel', source: MOVED_AND_LARGE }; |
| 118 | + |
| 119 | + const verdict = await stack.automation.evaluateCondition(condition, movedLarge); |
| 120 | + |
| 121 | + expect(spy).toHaveBeenCalledTimes(1); |
| 122 | + const [given, variables] = spy.mock.calls[0]; |
| 123 | + // The same envelope object, not a rebuilt one. |
| 124 | + expect(given).toBe(condition); |
| 125 | + expect(variables).toBeInstanceOf(Map); |
| 126 | + expect([...variables.entries()]).toEqual(Object.entries(movedLarge)); |
| 127 | + expect(spy.mock.results[0]).toEqual({ type: 'return', value: true }); |
| 128 | + expect(verdict).toBe(true); |
| 129 | + }); |
| 130 | + |
| 131 | + it('answers the truth table over record-shaped variables: true and false, as the engine does', async () => { |
| 132 | + const condition = { dialect: 'cel', source: MOVED_AND_LARGE }; |
| 133 | + const rows: Array<[Record<string, unknown>, boolean]> = [ |
| 134 | + [movedLarge, true], |
| 135 | + // A pre-image the engine would have read as unchanged, and an amount under the bar. |
| 136 | + [unmoved, false], |
| 137 | + [small, false], |
| 138 | + ]; |
| 139 | + for (const [variables, expected] of rows) { |
| 140 | + const { door, direct } = await both(condition, variables); |
| 141 | + expect(door).toEqual({ value: expected }); |
| 142 | + expect(direct).toEqual({ value: expected }); |
| 143 | + } |
| 144 | + }); |
| 145 | + |
| 146 | + it('takes an envelope and a bare CEL string alike', async () => { |
| 147 | + for (const [variables, expected] of [[movedLarge, true], [unmoved, false]] as const) { |
| 148 | + expect(await stack.automation.evaluateCondition({ dialect: 'cel', source: MOVED_AND_LARGE }, variables)).toBe(expected); |
| 149 | + expect(await stack.automation.evaluateCondition(MOVED_AND_LARGE, variables)).toBe(expected); |
| 150 | + } |
| 151 | + }); |
| 152 | + |
| 153 | + it('never rewrites the condition: a bare `{var}` string keeps the template dialect the engine gives it', async () => { |
| 154 | + const holed = '{amount} > 100'; |
| 155 | + const spy = vi.spyOn(engine, 'evaluateCondition'); |
| 156 | + |
| 157 | + // The engine reads a bare string with a `{…}` hole as the template dialect. |
| 158 | + expect(await stack.automation.evaluateCondition(holed, { amount: 5000 })).toBe(true); |
| 159 | + expect(spy.mock.calls[0][0]).toBe(holed); |
| 160 | + |
| 161 | + // The same text in a CEL envelope is the brace trap, and the engine refuses it. |
| 162 | + const { door, direct } = await both({ dialect: 'cel', source: holed }, { amount: 5000 }); |
| 163 | + expect(direct.fault).toBeInstanceOf(Error); |
| 164 | + expect(door.fault).toBeInstanceOf(Error); |
| 165 | + expect((door.fault as Error).message).toBe((direct.fault as Error).message); |
| 166 | + }); |
| 167 | + |
| 168 | + it("rejects with the engine's own error when the condition faults, never `false`", async () => { |
| 169 | + // A CEL parse error, and a reference to a variable the set does not bind. |
| 170 | + const faulting: Array<[unknown, Record<string, unknown>]> = [ |
| 171 | + [{ dialect: 'cel', source: 'record.amount >=' }, movedLarge], |
| 172 | + [{ dialect: 'cel', source: 'record.amount >= 1000' }, {}], |
| 173 | + ]; |
| 174 | + for (const [condition, variables] of faulting) { |
| 175 | + const { door, direct } = await both(condition, variables); |
| 176 | + expect(direct.fault).toBeInstanceOf(Error); |
| 177 | + expect(door.value).toBeUndefined(); |
| 178 | + expect(door.fault).toBeInstanceOf(Error); |
| 179 | + // Handed back as the engine threw it: its message, and no refusal envelope added. |
| 180 | + expect((door.fault as Error).message).toBe((direct.fault as Error).message); |
| 181 | + expect(isVerifyRefusal(door.fault)).toBe(false); |
| 182 | + expect((door.fault as { code?: unknown }).code).toBeUndefined(); |
| 183 | + } |
| 184 | + }); |
| 185 | + |
| 186 | + it("rejects with the kernel's own SERVICE_NOT_REGISTERED error, naming 'automation', on a stack without the service", async () => { |
| 187 | + const kernelSaid = await answer(() => bare.kernel.getServiceAsync<Evaluator>('automation')); |
| 188 | + expect(isServiceNotRegisteredError(kernelSaid.fault)).toBe(true); |
| 189 | + |
| 190 | + const door = await answer(() => bare.automation.evaluateCondition({ dialect: 'cel', source: 'true' }, {})); |
| 191 | + expect(door.value).toBeUndefined(); |
| 192 | + expect(isServiceNotRegisteredError(door.fault)).toBe(true); |
| 193 | + const fault = door.fault as Error & { code?: unknown; serviceName?: unknown }; |
| 194 | + expect(fault.code).toBe(SERVICE_NOT_REGISTERED_CODE); |
| 195 | + expect(fault.serviceName).toBe('automation'); |
| 196 | + expect(fault.message).toBe((kernelSaid.fault as Error).message); |
| 197 | + }); |
| 198 | +}); |
0 commit comments