|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#12206, Option A — ruled 2026-08-26] The two `/automation` definition-write |
| 5 | + * doors answer the CANONICALIZED, PARSED flow the engine stored — the same |
| 6 | + * shape `GET /automation/:name` answers — never an echo of the caller's own |
| 7 | + * pre-parse bytes. |
| 8 | + * |
| 9 | + * Everything here is real end to end, on the pattern of |
| 10 | + * `analytics-automation-json-erasure.test.ts`: the real `AutomationEngine` |
| 11 | + * (`@objectstack/service-automation`), the real `HttpDispatcher` |
| 12 | + * (`@objectstack/runtime`), and the real `ObjectStackClient` reading the |
| 13 | + * result. The only stand-in is the socket: `fetch` hands the request to the |
| 14 | + * dispatcher in-process and hands back the producer's own body untouched — |
| 15 | + * a mocked response body here would assert this file's own assumption, which |
| 16 | + * is exactly the mistake that let the old response schemas sit aspirational. |
| 17 | + * |
| 18 | + * What each leg pins: |
| 19 | + * |
| 20 | + * 1. the answer is NOT the echo — schema defaults the caller never wrote |
| 21 | + * (`version`, `status`, `runAs`, per-edge `type`/`isDefault`) are |
| 22 | + * materialized, and a string `edge.condition` is lowered to its |
| 23 | + * `{dialect, source}` envelope (the one genuine type change the #12206 |
| 24 | + * survey measured, zero consumers); |
| 25 | + * 2. write ≡ read — the write door's `data` deep-equals what the read door |
| 26 | + * then serves for the same resource, so write-then-read is stable; |
| 27 | + * 3. the published `CreateFlowResponseSchema` / `UpdateFlowResponseSchema` |
| 28 | + * parse the REAL wire body (inherited item ①: conformant, not |
| 29 | + * aspirational — the first response these schemas have ever seen); |
| 30 | + * 4. the PUT answer always carries `name`, which the old echo could omit |
| 31 | + * (the name rode the path, not the body). |
| 32 | + * |
| 33 | + * Reverse verification, direction predicted BEFORE running: reverting the two |
| 34 | + * route exits in `packages/runtime/src/domains/automation.ts` back to |
| 35 | + * `deps.success(body)` / `deps.success(definition)` turns legs 1-4 RED (the |
| 36 | + * echo carries no `version`, no lowered condition, and PUT's echo has no |
| 37 | + * `name`); reverting `AutomationEngine.registerFlow` to `void` turns the |
| 38 | + * routes' answer `undefined` and reds leg 2/3 the same way. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, it, expect } from 'vitest'; |
| 42 | +import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; |
| 43 | +import { HttpDispatcher } from '@objectstack/runtime'; |
| 44 | +import { CreateFlowResponseSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api'; |
| 45 | +import type { FlowParsed } from '@objectstack/spec/automation'; |
| 46 | +import { ObjectStackClient } from './index'; |
| 47 | + |
| 48 | +const BASE_URL = 'http://localhost:3000'; |
| 49 | + |
| 50 | +/** The definition writes demand `manage_metadata` (ADR-0066 D1). */ |
| 51 | +const CONTEXT = (): any => ({ |
| 52 | + request: {}, |
| 53 | + executionContext: { userId: 'usr_1', isSystem: false, systemPermissions: ['manage_metadata'] }, |
| 54 | +}); |
| 55 | + |
| 56 | +/** A raw authored condition string — what the schema lowers to a CEL envelope. */ |
| 57 | +const RAW_CONDITION = "record.status == 'approved'"; |
| 58 | + |
| 59 | +/** |
| 60 | + * A raw authored flow body, the way a real HTTP caller writes one: no |
| 61 | + * `version`, no `status`, no `runAs`, no per-edge `type`/`isDefault`, and a |
| 62 | + * bare STRING `edge.condition`. Every one of those is a delta the parsed |
| 63 | + * answer materializes — which is what makes this fixture able to tell the |
| 64 | + * canonicalized answer apart from an echo. |
| 65 | + */ |
| 66 | +const RAW_DEFINITION = { |
| 67 | + label: 'Write Door Flow', |
| 68 | + type: 'autolaunched', |
| 69 | + nodes: [ |
| 70 | + { id: 'start', type: 'start', label: 'Start' }, |
| 71 | + { id: 'end', type: 'end', label: 'End' }, |
| 72 | + ], |
| 73 | + edges: [{ id: 'e1', source: 'start', target: 'end', condition: RAW_CONDITION }], |
| 74 | +}; |
| 75 | + |
| 76 | +function producerBackedClient() { |
| 77 | + const engine = new AutomationEngine( |
| 78 | + { info() {}, warn() {}, error() {}, debug() {}, child() { return this; } } as never, |
| 79 | + new InMemorySuspendedRunStore(), |
| 80 | + ); |
| 81 | + const services: Record<string, unknown> = { automation: engine }; |
| 82 | + const resolve = (name: string): unknown => services[name]; |
| 83 | + const kernel: any = { |
| 84 | + getService: resolve, |
| 85 | + getServiceAsync: async (name: string) => resolve(name), |
| 86 | + context: { getService: resolve }, |
| 87 | + }; |
| 88 | + const dispatcher = new HttpDispatcher(kernel); |
| 89 | + |
| 90 | + /** The last RAW wire body — the envelope `unwrapResponse` strips, kept so |
| 91 | + * the response schemas can be parsed against what really crossed the wire. */ |
| 92 | + const wire: { last: unknown } = { last: undefined }; |
| 93 | + |
| 94 | + const fetchImpl = async (url: string, init: RequestInit = {}): Promise<any> => { |
| 95 | + const parsed = new URL(String(url)); |
| 96 | + const method = init.method ?? 'GET'; |
| 97 | + const body = init.body ? JSON.parse(String(init.body)) : undefined; |
| 98 | + const query = Object.fromEntries(parsed.searchParams); |
| 99 | + const dispatched = await dispatcher.handleAutomation( |
| 100 | + parsed.pathname.slice('/api/v1/automation'.length), method, body, CONTEXT(), query); |
| 101 | + expect(dispatched.handled, `the dispatcher must serve ${method} ${parsed.pathname}`).toBe(true); |
| 102 | + const status = dispatched.response?.status ?? 500; |
| 103 | + wire.last = dispatched.response?.body; |
| 104 | + return { |
| 105 | + ok: status >= 200 && status < 300, |
| 106 | + status, |
| 107 | + statusText: String(status), |
| 108 | + headers: new Headers(), |
| 109 | + json: async () => dispatched.response?.body, |
| 110 | + }; |
| 111 | + }; |
| 112 | + |
| 113 | + const client = new ObjectStackClient({ baseUrl: BASE_URL, fetch: fetchImpl as any }); |
| 114 | + return { client, engine, wire }; |
| 115 | +} |
| 116 | + |
| 117 | +describe('#12206 — POST /automation answers the canonicalized parsed flow, not the echo', () => { |
| 118 | + it('materializes schema defaults, lowers edge.condition, matches the read door, and conforms to CreateFlowResponseSchema', async () => { |
| 119 | + const { client, wire } = producerBackedClient(); |
| 120 | + |
| 121 | + const answered: FlowParsed = await client.automation.create('wd_flow', RAW_DEFINITION); |
| 122 | + |
| 123 | + // ① NOT the echo: the caller never wrote any of these. |
| 124 | + expect(answered.name).toBe('wd_flow'); |
| 125 | + expect(answered.version).toBe(1); |
| 126 | + expect(answered.status).toBe('draft'); |
| 127 | + expect((answered as any).runAs).toBe('user'); |
| 128 | + expect(answered.edges[0]).toMatchObject({ type: 'default', isDefault: false }); |
| 129 | + // The one genuine type change the survey measured: string condition → |
| 130 | + // lowered `{dialect, source}` envelope. |
| 131 | + expect(answered.edges[0].condition).toEqual({ dialect: 'cel', source: RAW_CONDITION }); |
| 132 | + |
| 133 | + // ③ Inherited item ①: the published response schema parses the REAL |
| 134 | + // wire envelope — conformant, no longer aspirational. |
| 135 | + const envelope = CreateFlowResponseSchema.parse(wire.last); |
| 136 | + expect(envelope.success).toBe(true); |
| 137 | + expect(envelope.data.name).toBe('wd_flow'); |
| 138 | + |
| 139 | + // ② Write ≡ read: the write door answered exactly what the read door |
| 140 | + // now serves for the same resource. |
| 141 | + const read = await client.automation.get('wd_flow'); |
| 142 | + expect(answered).toEqual(read); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +describe('#12206 — PUT /automation/:name answers the canonicalized parsed flow, not the echo', () => { |
| 147 | + it('always carries name, matches the read door, and conforms to UpdateFlowResponseSchema', async () => { |
| 148 | + const { client, wire } = producerBackedClient(); |
| 149 | + await client.automation.create('wd_flow', RAW_DEFINITION); |
| 150 | + |
| 151 | + // The SDK sends `{ definition }`; the engine requires a COMPLETE |
| 152 | + // definition (inherited item ② — `UpdateFlowRequestSchema` no longer |
| 153 | + // claims a partial-update capability nothing implements). |
| 154 | + const updated = { name: 'wd_flow', ...RAW_DEFINITION, label: 'Write Door Flow v2' }; |
| 155 | + const answered: FlowParsed = await client.automation.update('wd_flow', updated); |
| 156 | + |
| 157 | + // ④ The old PUT echo answered `body.definition ?? body`, which could |
| 158 | + // omit `name` entirely; the parsed answer always carries it. |
| 159 | + expect(answered.name).toBe('wd_flow'); |
| 160 | + expect(answered.label).toBe('Write Door Flow v2'); |
| 161 | + // ① NOT the echo — same materialized defaults as the POST door. |
| 162 | + expect(answered.version).toBe(1); |
| 163 | + expect(answered.edges[0].condition).toEqual({ dialect: 'cel', source: RAW_CONDITION }); |
| 164 | + |
| 165 | + // ③ Inherited item ①, update half. |
| 166 | + const envelope = UpdateFlowResponseSchema.parse(wire.last); |
| 167 | + expect(envelope.success).toBe(true); |
| 168 | + expect(envelope.data.label).toBe('Write Door Flow v2'); |
| 169 | + |
| 170 | + // ② Write ≡ read. |
| 171 | + const read = await client.automation.get('wd_flow'); |
| 172 | + expect(answered).toEqual(read); |
| 173 | + }); |
| 174 | +}); |
0 commit comments