diff --git a/src/endpoints/scenarios.ts b/src/endpoints/scenarios.ts index 98c7aa2..dab1c22 100644 --- a/src/endpoints/scenarios.ts +++ b/src/endpoints/scenarios.ts @@ -263,6 +263,17 @@ export type UpdateScenarioBody = { /** * Normalizes the payload for creating or updating a scenario. + * + * The scenario's on-demand input/output specification lives outside the blueprint, in `metadata`, so a + * blueprint-nested `interface` (or the legacy `io`) is hoisted out of the blueprint and into that key. + * + * The `metadata` key is only emitted when the caller actually supplied one of those sources. Sending + * `{input_spec: [], output_spec: []}` unconditionally would WIPE a scenario's configured interface on every + * unrelated PATCH (a rename, a folder move, a scheduling tweak): the backend's `COALESCE(new_value, + * existing_value)` only falls back to the existing value on SQL NULL, and an empty array is not NULL, so it + * counts as an intentional "clear". Clearing an interface on purpose therefore stays possible — pass an + * explicit `interface: { input: [], output: [] }`, whose empty arrays are emitted as before. + * * @param payload The payload to normalize * @returns The normalized payload */ @@ -271,26 +282,29 @@ function normalizePayload(payload: UpdateScenarioBody | CreateScenarioBody) { typeof payload.blueprint === 'string' ? JSON.parse(payload.blueprint) : payload.blueprint; let scheduling: Scheduling | undefined = typeof payload.scheduling === 'string' ? JSON.parse(payload.scheduling) : payload.scheduling; - const metadata: { - input_spec: DataStructureField[]; - output_spec: DataStructureField[]; - } = { - input_spec: [], - output_spec: [], - }; + let metadata: + | { + input_spec: DataStructureField[]; + output_spec: DataStructureField[]; + } + | undefined; if (blueprint?.scheduling) { scheduling = blueprint.scheduling; blueprint = { ...blueprint, scheduling: undefined }; } if (blueprint?.interface) { - metadata.input_spec = blueprint.interface.input ?? []; - metadata.output_spec = blueprint.interface.output ?? []; + metadata = { + input_spec: blueprint.interface.input ?? [], + output_spec: blueprint.interface.output ?? [], + }; blueprint = { ...blueprint, interface: undefined }; } if (blueprint?.io) { - metadata.input_spec = blueprint.io.input_spec ?? []; - metadata.output_spec = blueprint.io.output_spec ?? []; + metadata = { + input_spec: blueprint.io.input_spec ?? [], + output_spec: blueprint.io.output_spec ?? [], + }; blueprint = { ...blueprint, io: undefined }; } diff --git a/test/scenarios.spec.ts b/test/scenarios.spec.ts index 9bdc5dc..ca5cc8c 100644 --- a/test/scenarios.spec.ts +++ b/test/scenarios.spec.ts @@ -194,24 +194,67 @@ describe('Endpoints: Scenarios', () => { folderId: 100, }; + mockFetch('PATCH https://make.local/api/v2/scenarios/123456', scenarioUpdateMock, req => { + expect(req.body).toStrictEqual({ ...body }); + }); + + const result = await make.scenarios.update(123456, body); + expect(result).toStrictEqual(scenarioUpdateMock.scenario); + }); + + it('Should move a scenario out of its folder with folderId null', async () => { + mockFetch('PATCH https://make.local/api/v2/scenarios/123456', scenarioUpdateMock, req => { + expect(req.body).toStrictEqual({ folderId: null }); + }); + + const result = await make.scenarios.update(123456, { folderId: null }); + expect(result).toStrictEqual(scenarioUpdateMock.scenario); + }); + + it('Should not send metadata when updating without an interface', async () => { + mockFetch('PATCH https://make.local/api/v2/scenarios/123456', scenarioUpdateMock, req => { + expect(req.body).not.toHaveProperty('metadata'); + }); + + const result = await make.scenarios.update(123456, { name: 'Updated Test Scenario' }); + expect(result).toStrictEqual(scenarioUpdateMock.scenario); + }); + + it('Should not send metadata when updating a blueprint that carries no interface', async () => { mockFetch('PATCH https://make.local/api/v2/scenarios/123456', scenarioUpdateMock, req => { expect(req.body).toStrictEqual({ - ...body, + blueprint: '{"flow":[],"metadata":{},"name":"Test Scenario"}', + }); + }); + + const result = await make.scenarios.update(123456, { + blueprint: '{"flow":[],"metadata":{},"name":"Test Scenario"}', + }); + expect(result).toStrictEqual(scenarioUpdateMock.scenario); + }); + + it('Should send the interface hoisted out of the blueprint when updating', async () => { + mockFetch('PATCH https://make.local/api/v2/scenarios/123456', scenarioUpdateMock, req => { + expect(req.body).toStrictEqual({ + blueprint: '{"flow":[],"name":"Test Scenario"}', metadata: { - input_spec: [], + input_spec: [{ name: 'param', type: 'text' }], output_spec: [], }, }); }); - const result = await make.scenarios.update(123456, body); + const result = await make.scenarios.update(123456, { + blueprint: + '{"flow":[],"interface":{"input":[{"name":"param","type":"text"}],"output":[]},"name":"Test Scenario"}', + }); expect(result).toStrictEqual(scenarioUpdateMock.scenario); }); - it('Should move a scenario out of its folder with folderId null', async () => { + it('Should clear the interface when an explicitly empty one is supplied', async () => { mockFetch('PATCH https://make.local/api/v2/scenarios/123456', scenarioUpdateMock, req => { expect(req.body).toStrictEqual({ - folderId: null, + blueprint: '{"flow":[],"name":"Test Scenario"}', metadata: { input_spec: [], output_spec: [], @@ -219,7 +262,9 @@ describe('Endpoints: Scenarios', () => { }); }); - const result = await make.scenarios.update(123456, { folderId: null }); + const result = await make.scenarios.update(123456, { + blueprint: '{"flow":[],"interface":{"input":[],"output":[]},"name":"Test Scenario"}', + }); expect(result).toStrictEqual(scenarioUpdateMock.scenario); });