swarm-ui: draft edits for the notification flow editor
Pure edits on a flow (add a child, remove a branch, move a branch, replace a node, new and duplicate flow) that refuse what the controller's tree rules would, plus the save request and what its status means. Nothing uses them yet: the editor page that does comes next.
This commit is contained in:
parent
d5802d16a0
commit
d19bd83aa5
2 changed files with 544 additions and 0 deletions
309
frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts
Normal file
309
frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// The editor's draft of the notification router's flows: pure edits that
|
||||
// each return a new flow or say why not.
|
||||
//
|
||||
// The controller validates a whole document on save and is the authority;
|
||||
// these edits only refuse what could never be saved, so the canvas can
|
||||
// turn a bad drop away with a reason instead of drawing a flow the save
|
||||
// would reject. The tree rules mirror `Flow::validate` in swarm-wire.
|
||||
import type { Flow, FlowNode, FlowsView } from "./FlowTypes.js";
|
||||
|
||||
export interface Limits {
|
||||
maxNodes: number;
|
||||
maxDepth: number;
|
||||
maxFlows: number;
|
||||
}
|
||||
|
||||
// What the controller enforces today. Its `schema.limits`, when it sends
|
||||
// one, replaces these.
|
||||
export const DEFAULT_LIMITS: Limits = {
|
||||
maxNodes: 64,
|
||||
maxDepth: 16,
|
||||
maxFlows: 100,
|
||||
};
|
||||
|
||||
export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string };
|
||||
|
||||
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
export function isId(id: string): boolean {
|
||||
return ID.test(id);
|
||||
}
|
||||
|
||||
function refuse(reason: string): Edit {
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
export function childrenOf(flow: Flow, id: string): string[] {
|
||||
return flow.edges.filter((e) => e.from === id).map((e) => e.to);
|
||||
}
|
||||
|
||||
export function parentOf(flow: Flow, id: string): string | undefined {
|
||||
return flow.edges.find((e) => e.to === id)?.from;
|
||||
}
|
||||
|
||||
// Edges from the source down to `id`; 0 for the source.
|
||||
export function depthOf(flow: Flow, id: string): number {
|
||||
let depth = 0;
|
||||
const seen = new Set<string>([id]);
|
||||
for (let at = parentOf(flow, id); at !== undefined; at = parentOf(flow, at)) {
|
||||
// A cycle never leaves the editor, but a loop here would hang the tab.
|
||||
if (seen.has(at)) break;
|
||||
seen.add(at);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
// `id` and every node below it.
|
||||
export function subtreeIds(flow: Flow, id: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const stack = [id];
|
||||
while (stack.length > 0) {
|
||||
const at = stack.pop() as string;
|
||||
if (seen.has(at)) continue;
|
||||
seen.add(at);
|
||||
out.push(at);
|
||||
stack.push(...childrenOf(flow, at));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// How many levels lie below `id`: 0 for a leaf.
|
||||
export function heightBelow(flow: Flow, id: string): number {
|
||||
let height = 0;
|
||||
let level = [id];
|
||||
const seen = new Set<string>(level);
|
||||
for (;;) {
|
||||
const next = level
|
||||
.flatMap((n) => childrenOf(flow, n))
|
||||
.filter((n) => !seen.has(n));
|
||||
if (next.length === 0) return height;
|
||||
next.forEach((n) => seen.add(n));
|
||||
height += 1;
|
||||
level = next;
|
||||
}
|
||||
}
|
||||
|
||||
function node(flow: Flow, id: string): FlowNode | undefined {
|
||||
return flow.nodes.find((n) => n.id === id);
|
||||
}
|
||||
|
||||
// A node id not yet in `flow`, such as `match2`.
|
||||
export function newNodeId(flow: Flow, prefix: string): string {
|
||||
const taken = new Set(flow.nodes.map((n) => n.id));
|
||||
for (let n = 1; ; n += 1) {
|
||||
if (!taken.has(`${prefix}${n}`)) return `${prefix}${n}`;
|
||||
}
|
||||
}
|
||||
|
||||
// A node of `kind` with the fields a user is asked to fill in left empty.
|
||||
export function blankNode(
|
||||
flow: Flow,
|
||||
kind: "match" | "todo",
|
||||
): Extract<FlowNode, { kind: "match" | "todo" }> {
|
||||
const id = newNodeId(flow, kind);
|
||||
return kind === "match"
|
||||
? { id, kind, when: [] }
|
||||
: {
|
||||
id,
|
||||
kind,
|
||||
to: { field: "assignees" },
|
||||
op: "upsert",
|
||||
key: "",
|
||||
prefix: false,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
|
||||
// `added` under `parentId`.
|
||||
export function addChild(
|
||||
flow: Flow,
|
||||
parentId: string,
|
||||
added: FlowNode,
|
||||
limits: Limits = DEFAULT_LIMITS,
|
||||
): Edit {
|
||||
const parent = node(flow, parentId);
|
||||
if (!parent) return refuse("that parent is not in the flow");
|
||||
if (parent.kind === "todo") return refuse("a todo ends a branch");
|
||||
if (added.kind === "source") return refuse("a flow has one source");
|
||||
if (!isId(added.id) || node(flow, added.id)) {
|
||||
return refuse(`${added.id} is not a free node id`);
|
||||
}
|
||||
if (flow.nodes.length >= limits.maxNodes) {
|
||||
return refuse(`a flow holds at most ${limits.maxNodes} nodes`);
|
||||
}
|
||||
if (depthOf(flow, parentId) + 1 > limits.maxDepth) {
|
||||
return refuse(`a flow is at most ${limits.maxDepth} levels deep`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
flow: {
|
||||
...flow,
|
||||
nodes: [...flow.nodes, added],
|
||||
edges: [...flow.edges, { from: parentId, to: added.id }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `id` and everything below it taken out. The source stays.
|
||||
export function removeNode(flow: Flow, id: string): Edit {
|
||||
const target = node(flow, id);
|
||||
if (!target) return refuse("that node is not in the flow");
|
||||
if (target.kind === "source") return refuse("the source cannot be removed");
|
||||
const gone = new Set(subtreeIds(flow, id));
|
||||
return {
|
||||
ok: true,
|
||||
flow: {
|
||||
...flow,
|
||||
nodes: flow.nodes.filter((n) => !gone.has(n.id)),
|
||||
edges: flow.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `id`, with its subtree, moved under `parentId`.
|
||||
export function reparent(
|
||||
flow: Flow,
|
||||
id: string,
|
||||
parentId: string,
|
||||
limits: Limits = DEFAULT_LIMITS,
|
||||
): Edit {
|
||||
const target = node(flow, id);
|
||||
const parent = node(flow, parentId);
|
||||
if (!target || !parent) return refuse("that node is not in the flow");
|
||||
if (target.kind === "source") return refuse("the source stays the root");
|
||||
if (parent.kind === "todo") return refuse("a todo ends a branch");
|
||||
if (subtreeIds(flow, id).includes(parentId)) {
|
||||
return refuse("a node cannot go under itself or its own branch");
|
||||
}
|
||||
if (depthOf(flow, parentId) + 1 + heightBelow(flow, id) > limits.maxDepth) {
|
||||
return refuse(`a flow is at most ${limits.maxDepth} levels deep`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
flow: {
|
||||
...flow,
|
||||
edges: [
|
||||
...flow.edges.filter((e) => e.to !== id),
|
||||
{ from: parentId, to: id },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The node with `next.id` swapped for `next`. A node keeps its place and its
|
||||
// kind-class: the source stays the source, and nothing with children becomes
|
||||
// a todo.
|
||||
export function replaceNode(flow: Flow, next: FlowNode): Edit {
|
||||
const old = node(flow, next.id);
|
||||
if (!old) return refuse("that node is not in the flow");
|
||||
if ((old.kind === "source") !== (next.kind === "source")) {
|
||||
return refuse(
|
||||
"the source cannot change kind, and no other node can become one",
|
||||
);
|
||||
}
|
||||
if (next.kind === "todo" && childrenOf(flow, next.id).length > 0) {
|
||||
return refuse("a node with children cannot become a todo");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
flow: {
|
||||
...flow,
|
||||
nodes: flow.nodes.map((n) => (n.id === next.id ? next : n)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A flow id made of `name`, not in `taken`: `my-flow`, `my-flow-2`.
|
||||
export function slugId(name: string, taken: Iterable<string>): string {
|
||||
const used = new Set(taken);
|
||||
const base =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 56) || "flow";
|
||||
if (!used.has(base)) return base;
|
||||
for (let n = 2; ; n += 1) {
|
||||
if (!used.has(`${base}-${n}`)) return `${base}-${n}`;
|
||||
}
|
||||
}
|
||||
|
||||
// An empty flow: just a source on issue events.
|
||||
export function newFlow(name: string, taken: Iterable<string>): Flow {
|
||||
return {
|
||||
id: slugId(name, taken),
|
||||
name,
|
||||
enabled: true,
|
||||
nodes: [{ id: "source", kind: "source", events: ["issues"] }],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
// A copy of `flow` (a built-in or a custom one) as a custom flow, on.
|
||||
export function duplicateFlow(
|
||||
flow: Flow,
|
||||
name: string,
|
||||
taken: Iterable<string>,
|
||||
): Flow {
|
||||
return structuredClone({
|
||||
...flow,
|
||||
id: slugId(name, taken),
|
||||
name,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// `flows` with `flow` in place of the one of its id, or added last.
|
||||
export function withFlow(flows: Flow[], flow: Flow): Flow[] {
|
||||
return flows.some((f) => f.id === flow.id)
|
||||
? flows.map((f) => (f.id === flow.id ? flow : f))
|
||||
: [...flows, flow];
|
||||
}
|
||||
|
||||
export function withoutFlow(flows: Flow[], id: string): Flow[] {
|
||||
return flows.filter((f) => f.id !== id);
|
||||
}
|
||||
|
||||
// Whether the draft differs from what the controller sent.
|
||||
export function isDirty(
|
||||
view: FlowsView,
|
||||
flows: Flow[],
|
||||
disabledBuiltins: string[],
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify(flows) !== JSON.stringify(view.flows) ||
|
||||
JSON.stringify(disabledBuiltins) !== JSON.stringify(view.disabled_builtins)
|
||||
);
|
||||
}
|
||||
|
||||
// The `PUT /api/notify/flows` request for the draft: the whole document, with
|
||||
// the revision it was read at.
|
||||
export function saveRequest(
|
||||
view: FlowsView,
|
||||
flows: Flow[],
|
||||
disabledBuiltins: string[],
|
||||
): { method: "PUT"; headers: Record<string, string>; body: string } {
|
||||
return {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
revision: view.revision,
|
||||
disabled_builtins: disabledBuiltins,
|
||||
flows,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type SaveOutcome = "saved" | "conflict" | "invalid" | "error";
|
||||
|
||||
// What a save's HTTP status means to the editor: 409 is a stale revision (read
|
||||
// again, keep the draft or drop it), 400 is a document the controller refuses
|
||||
// and says why.
|
||||
export function saveOutcome(status: number): SaveOutcome {
|
||||
if (status >= 200 && status < 300) return "saved";
|
||||
if (status === 409) return "conflict";
|
||||
if (status === 400) return "invalid";
|
||||
return "error";
|
||||
}
|
||||
235
frontend/packages/swarm-ui/test/flow-draft.test.mjs
Normal file
235
frontend/packages/swarm-ui/test/flow-draft.test.mjs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// The notification flow editor's draft edits. Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
addChild,
|
||||
blankNode,
|
||||
depthOf,
|
||||
duplicateFlow,
|
||||
heightBelow,
|
||||
isDirty,
|
||||
newFlow,
|
||||
newNodeId,
|
||||
removeNode,
|
||||
reparent,
|
||||
replaceNode,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
slugId,
|
||||
subtreeIds,
|
||||
withFlow,
|
||||
withoutFlow,
|
||||
} from "../src/pages/notifications/FlowDraft.ts";
|
||||
|
||||
const todo = (id) => ({
|
||||
id,
|
||||
kind: "todo",
|
||||
to: { agent: "triage" },
|
||||
op: "upsert",
|
||||
key: "k",
|
||||
prefix: false,
|
||||
text: "look",
|
||||
});
|
||||
|
||||
// source -> a -> b -> t1, source -> c
|
||||
const flow = () => ({
|
||||
id: "f",
|
||||
name: "f",
|
||||
enabled: true,
|
||||
nodes: [
|
||||
{ id: "source", kind: "source", events: ["issues"] },
|
||||
{ id: "a", kind: "match", when: [] },
|
||||
{ id: "b", kind: "match", when: [] },
|
||||
{ id: "c", kind: "match", when: [] },
|
||||
todo("t1"),
|
||||
],
|
||||
edges: [
|
||||
{ from: "source", to: "a" },
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "t1" },
|
||||
{ from: "source", to: "c" },
|
||||
],
|
||||
});
|
||||
|
||||
const ids = (f) => f.nodes.map((n) => n.id).sort();
|
||||
|
||||
test("depth, height and subtree", () => {
|
||||
const f = flow();
|
||||
assert.equal(depthOf(f, "source"), 0);
|
||||
assert.equal(depthOf(f, "t1"), 3);
|
||||
assert.equal(heightBelow(f, "source"), 3);
|
||||
assert.equal(heightBelow(f, "c"), 0);
|
||||
assert.deepEqual(subtreeIds(f, "a").sort(), ["a", "b", "t1"]);
|
||||
});
|
||||
|
||||
test("a cycle in the data cannot hang the walks", () => {
|
||||
const f = flow();
|
||||
f.edges.push({ from: "t1", to: "a" });
|
||||
assert.ok(depthOf(f, "b") < 10);
|
||||
assert.ok(heightBelow(f, "a") < 10);
|
||||
assert.ok(subtreeIds(f, "a").length <= f.nodes.length);
|
||||
});
|
||||
|
||||
test("addChild adds the node and its edge", () => {
|
||||
const r = addChild(flow(), "c", todo("t2"));
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.flow.nodes.some((n) => n.id === "t2"));
|
||||
assert.deepEqual(r.flow.edges.at(-1), { from: "c", to: "t2" });
|
||||
});
|
||||
|
||||
test("addChild refuses what a save would", () => {
|
||||
const f = flow();
|
||||
assert.equal(addChild(f, "t1", todo("t2")).ok, false, "under a todo");
|
||||
assert.equal(addChild(f, "nope", todo("t2")).ok, false, "no parent");
|
||||
assert.equal(addChild(f, "c", todo("a")).ok, false, "id in use");
|
||||
assert.equal(addChild(f, "c", todo("bad id")).ok, false, "bad id");
|
||||
assert.equal(
|
||||
addChild(f, "c", { id: "s2", kind: "source", events: ["issues"] }).ok,
|
||||
false,
|
||||
"second source",
|
||||
);
|
||||
const small = { maxNodes: 5, maxDepth: 16, maxFlows: 100 };
|
||||
assert.equal(addChild(f, "c", todo("t2"), small).ok, false, "node cap");
|
||||
const shallow = { maxNodes: 64, maxDepth: 2, maxFlows: 100 };
|
||||
assert.equal(addChild(f, "b", todo("t2"), shallow).ok, false, "depth cap");
|
||||
assert.ok(addChild(f, "a", todo("t2"), shallow).ok, "right at the cap");
|
||||
});
|
||||
|
||||
test("removeNode takes the branch and keeps the rest", () => {
|
||||
const r = removeNode(flow(), "a");
|
||||
assert.ok(r.ok);
|
||||
assert.deepEqual(ids(r.flow), ["c", "source"]);
|
||||
assert.deepEqual(r.flow.edges, [{ from: "source", to: "c" }]);
|
||||
assert.equal(removeNode(flow(), "source").ok, false);
|
||||
assert.equal(removeNode(flow(), "nope").ok, false);
|
||||
});
|
||||
|
||||
test("reparent moves a branch and keeps one parent per node", () => {
|
||||
const r = reparent(flow(), "b", "c");
|
||||
assert.ok(r.ok);
|
||||
assert.equal(r.flow.edges.filter((e) => e.to === "b").length, 1);
|
||||
assert.ok(r.flow.edges.some((e) => e.from === "c" && e.to === "b"));
|
||||
assert.ok(r.flow.edges.some((e) => e.from === "b" && e.to === "t1"));
|
||||
assert.equal(r.flow.nodes.length, 5);
|
||||
});
|
||||
|
||||
test("reparent refuses a cycle, a todo parent and the source", () => {
|
||||
const f = flow();
|
||||
assert.equal(reparent(f, "a", "b").ok, false, "under its own child");
|
||||
assert.equal(reparent(f, "a", "a").ok, false, "under itself");
|
||||
assert.equal(reparent(f, "c", "t1").ok, false, "under a todo");
|
||||
assert.equal(reparent(f, "source", "c").ok, false, "the source");
|
||||
assert.equal(reparent(f, "nope", "c").ok, false, "unknown node");
|
||||
});
|
||||
|
||||
test("reparent counts the whole branch against the depth cap", () => {
|
||||
const limits = { maxNodes: 64, maxDepth: 4, maxFlows: 100 };
|
||||
// a (1) -> b -> t1 is a branch of height 2; under b's child would be deeper.
|
||||
const f = flow();
|
||||
// c is at depth 1; moving a (height 2) under c puts t1 at depth 4.
|
||||
assert.ok(reparent(f, "a", "c", limits).ok);
|
||||
const tight = { ...limits, maxDepth: 3 };
|
||||
assert.equal(reparent(f, "a", "c", tight).ok, false);
|
||||
});
|
||||
|
||||
test("replaceNode keeps the place and the kind class", () => {
|
||||
const f = flow();
|
||||
const r = replaceNode(f, {
|
||||
id: "a",
|
||||
kind: "match",
|
||||
when: [{ field: "action", equals: "x" }],
|
||||
});
|
||||
assert.ok(r.ok);
|
||||
assert.equal(r.flow.nodes.find((n) => n.id === "a").when.length, 1);
|
||||
assert.equal(replaceNode(f, { ...todo("a") }).ok, false, "a has children");
|
||||
assert.ok(replaceNode(f, { ...todo("c") }).ok, "a leaf may become a todo");
|
||||
assert.equal(
|
||||
replaceNode(f, { id: "source", kind: "match", when: [] }).ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
replaceNode(f, { id: "c", kind: "source", events: ["issues"] }).ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(replaceNode(f, todo("nope")).ok, false);
|
||||
});
|
||||
|
||||
test("new ids do not collide", () => {
|
||||
const f = flow();
|
||||
assert.equal(newNodeId(f, "match"), "match1");
|
||||
const r = addChild(f, "c", blankNode(f, "match"));
|
||||
assert.ok(r.ok);
|
||||
assert.equal(newNodeId(r.flow, "match"), "match2");
|
||||
assert.equal(blankNode(f, "todo").kind, "todo");
|
||||
});
|
||||
|
||||
test("slugId builds an id the controller accepts and avoids taken ones", () => {
|
||||
assert.equal(slugId("My Flow!", []), "my-flow");
|
||||
assert.equal(slugId("My Flow!", ["my-flow"]), "my-flow-2");
|
||||
assert.equal(slugId("My Flow!", ["my-flow", "my-flow-2"]), "my-flow-3");
|
||||
assert.equal(slugId("!!!", []), "flow");
|
||||
assert.ok(/^[A-Za-z0-9_-]{1,64}$/.test(slugId("x".repeat(200), [])));
|
||||
});
|
||||
|
||||
test("newFlow is one source; duplicateFlow copies without sharing", () => {
|
||||
const fresh = newFlow("Triage", ["triage"]);
|
||||
assert.equal(fresh.id, "triage-2");
|
||||
assert.equal(fresh.nodes.length, 1);
|
||||
assert.equal(fresh.nodes[0].kind, "source");
|
||||
assert.ok(fresh.enabled);
|
||||
|
||||
const src = flow();
|
||||
const copy = duplicateFlow(src, "f copy", ["f"]);
|
||||
assert.equal(copy.id, "f-copy");
|
||||
assert.equal(copy.nodes.length, src.nodes.length);
|
||||
copy.nodes[1].when.push({ field: "action", equals: "x" });
|
||||
assert.equal(src.nodes[1].when.length, 0, "the original is untouched");
|
||||
const off = { ...src, enabled: false };
|
||||
assert.ok(duplicateFlow(off, "x", []).enabled, "a copy is on");
|
||||
});
|
||||
|
||||
test("withFlow replaces by id or appends; withoutFlow drops", () => {
|
||||
const a = { ...flow(), id: "a" };
|
||||
const b = { ...flow(), id: "b" };
|
||||
assert.deepEqual(
|
||||
withFlow([a], b).map((f) => f.id),
|
||||
["a", "b"],
|
||||
);
|
||||
const renamed = { ...a, name: "renamed" };
|
||||
assert.equal(withFlow([a, b], renamed)[0].name, "renamed");
|
||||
assert.deepEqual(
|
||||
withoutFlow([a, b], "a").map((f) => f.id),
|
||||
["b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("isDirty compares the draft with what was read", () => {
|
||||
const view = {
|
||||
revision: 3,
|
||||
disabled_builtins: ["x"],
|
||||
builtins: [],
|
||||
flows: [flow()],
|
||||
};
|
||||
assert.equal(isDirty(view, [flow()], ["x"]), false);
|
||||
assert.equal(isDirty(view, [], ["x"]), true);
|
||||
assert.equal(isDirty(view, [flow()], []), true);
|
||||
});
|
||||
|
||||
test("saveRequest sends the whole document at the revision read", () => {
|
||||
const view = { revision: 7, disabled_builtins: [], builtins: [], flows: [] };
|
||||
const req = saveRequest(view, [flow()], ["b1"]);
|
||||
assert.equal(req.method, "PUT");
|
||||
const body = JSON.parse(req.body);
|
||||
assert.equal(body.revision, 7);
|
||||
assert.deepEqual(body.disabled_builtins, ["b1"]);
|
||||
assert.equal(body.flows[0].id, "f");
|
||||
assert.equal("builtins" in body, false, "built-ins are never sent");
|
||||
});
|
||||
|
||||
test("saveOutcome tells a stale revision from a refused document", () => {
|
||||
assert.equal(saveOutcome(200), "saved");
|
||||
assert.equal(saveOutcome(409), "conflict");
|
||||
assert.equal(saveOutcome(400), "invalid");
|
||||
assert.equal(saveOutcome(503), "error");
|
||||
assert.equal(saveOutcome(500), "error");
|
||||
});
|
||||
Loading…
Reference in a new issue