Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions src/endpoints/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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 };
}

Expand Down
57 changes: 51 additions & 6 deletions test/scenarios.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,32 +194,77 @@ 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: [],
},
});
});

const result = await make.scenarios.update(123456, { folderId: null });
const result = await make.scenarios.update(123456, {
blueprint: '{"flow":[],"interface":{"input":[],"output":[]},"name":"Test Scenario"}',
});
Comment thread
Copilot marked this conversation as resolved.
expect(result).toStrictEqual(scenarioUpdateMock.scenario);
});

Expand Down