|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#29881] A `type: 'flow'` action declared `execution: 'perRecord'` called |
| 5 | + * with no record id is refused at the door — `400 VALIDATION_ERROR` naming |
| 6 | + * `recordId` — before the flow starts, on both doors (REST `/actions` and MCP |
| 7 | + * `run_action`; both reach `dispatchFlowAction`). Before, the flow ran and the |
| 8 | + * caller got the first node's diagnostic (`400 FLOW_FAILED`, naming the node). |
| 9 | + * |
| 10 | + * The controls: the same action with a real id runs; one that declares no |
| 11 | + * `execution`, or `aggregate`, runs record-less as before; an unknown id keeps |
| 12 | + * the door's `404 RECORD_NOT_FOUND` (#16370). The api-arm half of the card is |
| 13 | + * in `domains/api-action-forward.test.ts`. |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, expect, vi } from 'vitest'; |
| 17 | + |
| 18 | +import { HttpDispatcher } from './http-dispatcher.js'; |
| 19 | +import { callData, invokeBusinessAction, perRecordActionRecordIdRefusal } from './action-execution.js'; |
| 20 | + |
| 21 | +const OBJECT = 'crm_case'; |
| 22 | +const RECORD_ID = 'case_1'; |
| 23 | +const GHOST_ID = 'case_none'; |
| 24 | +const FLOW_NAME = 'crm_case_recalc'; |
| 25 | + |
| 26 | +const flowAction = (name: string, extra: Record<string, unknown> = {}) => ({ |
| 27 | + name, label: name, objectName: OBJECT, type: 'flow', target: FLOW_NAME, |
| 28 | + ai: { exposed: true, description: `Run ${name}.` }, ...extra, |
| 29 | +}); |
| 30 | +const PER_RECORD = flowAction('recalc_case', { execution: 'perRecord' }); |
| 31 | +const AGGREGATE = flowAction('recalc_cases', { execution: 'aggregate' }); |
| 32 | +const UNDECLARED = flowAction('recalc_any'); |
| 33 | +const OBJECT_DEF = { name: OBJECT, actions: [PER_RECORD, AGGREGATE, UNDECLARED] }; |
| 34 | + |
| 35 | +function harness() { |
| 36 | + const schemaOf = (n: string) => (n === OBJECT ? OBJECT_DEF : undefined); |
| 37 | + const ql: any = { |
| 38 | + executeAction: vi.fn(), |
| 39 | + getSchema: schemaOf, |
| 40 | + registry: { getObject: schemaOf, getItem: () => undefined }, |
| 41 | + find: vi.fn(async (_object: string, options?: any) => { |
| 42 | + const wanted = options?.filters?.[0]?.[2] ?? options?.where?.id; |
| 43 | + return wanted === RECORD_ID ? [{ id: RECORD_ID }] : []; |
| 44 | + }), |
| 45 | + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), |
| 46 | + }; |
| 47 | + const automation = { |
| 48 | + execute: vi.fn(async () => ({ success: true, status: 'completed', runId: 'run_1', durationMs: 1 })), |
| 49 | + getFlow: vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null)), |
| 50 | + }; |
| 51 | + const metadata: any = { |
| 52 | + load: vi.fn(async () => null), |
| 53 | + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), |
| 54 | + loadMany: vi.fn(async () => []), |
| 55 | + listObjects: vi.fn(async () => [OBJECT_DEF]), |
| 56 | + getObject: vi.fn(async (n: string) => (n === OBJECT ? OBJECT_DEF : undefined)), |
| 57 | + }; |
| 58 | + return { ql, automation, metadata }; |
| 59 | +} |
| 60 | + |
| 61 | +const EC = { userId: 'usr_1', tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [] }; |
| 62 | + |
| 63 | +async function rest(name: string, body: Record<string, unknown>) { |
| 64 | + const h = harness(); |
| 65 | + const resolve = (n: string) => |
| 66 | + n === 'objectql' || n === 'data' ? h.ql : n === 'metadata' ? h.metadata : n === 'automation' ? h.automation : null; |
| 67 | + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; |
| 68 | + const context: any = { request: {}, environmentId: 'platform', executionContext: EC }; |
| 69 | + const res: any = await (new HttpDispatcher(kernel) as any).handleActions(`/${OBJECT}/${name}`, 'POST', body, context); |
| 70 | + return { res: res.response, automation: h.automation }; |
| 71 | +} |
| 72 | + |
| 73 | +async function mcp(name: string, input: Record<string, unknown>) { |
| 74 | + const h = harness(); |
| 75 | + const deps: any = { |
| 76 | + resolveService: async (_ctx: any, service: string) => (service === 'automation' ? h.automation : undefined), |
| 77 | + getObjectQL: async () => h.ql, |
| 78 | + }; |
| 79 | + const requestContext: any = { request: {}, environmentId: 'platform' }; |
| 80 | + const outcome = await invokeBusinessAction(deps, requestContext, name, input as any, { |
| 81 | + driver: undefined, envId: 'platform', ec: EC, getMeta: () => h.metadata, |
| 82 | + callData: (a, p, d, s, e) => callData(deps, requestContext, a, p, d, s, e), |
| 83 | + }).catch((e) => e); |
| 84 | + return { outcome, automation: h.automation }; |
| 85 | +} |
| 86 | + |
| 87 | +const bodyText = (res: any) => JSON.stringify(res?.body ?? {}); |
| 88 | + |
| 89 | +describe('[#29881] a per-record flow action with no record id is refused before the flow starts', () => { |
| 90 | + it('REST: no `recordId`, or an empty one, answers 400 VALIDATION_ERROR naming it, and the flow never runs', async () => { |
| 91 | + for (const body of [{}, { params: {} }, { recordId: '' }]) { |
| 92 | + const { res, automation } = await rest(PER_RECORD.name, body); |
| 93 | + expect(res.status, JSON.stringify(body)).toBe(400); |
| 94 | + expect(res.body?.error?.code).toBe('VALIDATION_ERROR'); |
| 95 | + expect(String(res.body?.error?.message)).toContain('`recordId`'); |
| 96 | + expect(bodyText(res)).not.toContain('FLOW_FAILED'); |
| 97 | + expect(automation.execute).not.toHaveBeenCalled(); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | + it('MCP `run_action`: the same refusal is thrown, and the flow never runs', async () => { |
| 102 | + for (const input of [{}, { recordId: '' }]) { |
| 103 | + const { outcome, automation } = await mcp(PER_RECORD.name, input); |
| 104 | + expect(outcome?.status, JSON.stringify(input)).toBe(400); |
| 105 | + expect(outcome?.code).toBe('VALIDATION_ERROR'); |
| 106 | + expect(String(outcome?.message)).toContain('`recordId`'); |
| 107 | + expect(automation.execute).not.toHaveBeenCalled(); |
| 108 | + } |
| 109 | + }); |
| 110 | + |
| 111 | + it('REST: an unknown id keeps the door\'s 404 RECORD_NOT_FOUND, and the flow never runs', async () => { |
| 112 | + const { res, automation } = await rest(PER_RECORD.name, { recordId: GHOST_ID }); |
| 113 | + expect(res.status).toBe(404); |
| 114 | + expect(res.body?.error?.code).toBe('RECORD_NOT_FOUND'); |
| 115 | + expect(automation.execute).not.toHaveBeenCalled(); |
| 116 | + }); |
| 117 | + |
| 118 | + it('the controls: a real id runs the flow; an aggregate or undeclared flow action runs with no id', async () => { |
| 119 | + const real = await rest(PER_RECORD.name, { recordId: RECORD_ID }); |
| 120 | + expect(real.res.status, bodyText(real.res)).toBe(200); |
| 121 | + expect(real.automation.execute).toHaveBeenCalledTimes(1); |
| 122 | + |
| 123 | + for (const action of [AGGREGATE, UNDECLARED]) { |
| 124 | + const { res, automation } = await rest(action.name, {}); |
| 125 | + expect(res.status, `${action.name} ${bodyText(res)}`).toBe(200); |
| 126 | + expect(automation.execute, action.name).toHaveBeenCalledTimes(1); |
| 127 | + |
| 128 | + const viaMcp = await mcp(action.name, {}); |
| 129 | + expect(viaMcp.outcome?.ok, action.name).toBe(true); |
| 130 | + expect(viaMcp.automation.execute, action.name).toHaveBeenCalledTimes(1); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + it('perRecordActionRecordIdRefusal reads only the declaration and the id', () => { |
| 135 | + expect(perRecordActionRecordIdRefusal(PER_RECORD, undefined)?.status).toBe(400); |
| 136 | + expect(perRecordActionRecordIdRefusal(PER_RECORD, '')?.status).toBe(400); |
| 137 | + expect(perRecordActionRecordIdRefusal(PER_RECORD, null)?.status).toBe(400); |
| 138 | + expect(perRecordActionRecordIdRefusal(PER_RECORD, RECORD_ID)).toBeUndefined(); |
| 139 | + expect(perRecordActionRecordIdRefusal(AGGREGATE, undefined)).toBeUndefined(); |
| 140 | + expect(perRecordActionRecordIdRefusal(UNDECLARED, undefined)).toBeUndefined(); |
| 141 | + expect(perRecordActionRecordIdRefusal(undefined, undefined)).toBeUndefined(); |
| 142 | + }); |
| 143 | +}); |
0 commit comments