@firedrill-tools/unified 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +242 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounds.scenario.json +23 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/large-channels.scenario.json +4251 -0
  8. package/firedrill/many-workspaces.scenario.json +1623 -0
  9. package/firedrill/rate-limited.scenario.json +11 -0
  10. package/firedrill/tools/unified/behavior.mjs +69 -0
  11. package/firedrill/tools/unified/lib/assoc.mjs +61 -0
  12. package/firedrill/tools/unified/lib/enums.mjs +14 -0
  13. package/firedrill/tools/unified/lib/errors.mjs +50 -0
  14. package/firedrill/tools/unified/lib/events.mjs +27 -0
  15. package/firedrill/tools/unified/lib/identity.mjs +59 -0
  16. package/firedrill/tools/unified/lib/query.mjs +154 -0
  17. package/firedrill/tools/unified/lib/shapes.mjs +176 -0
  18. package/firedrill/tools/unified/lib/store.mjs +75 -0
  19. package/firedrill/tools/unified/lib/time.mjs +59 -0
  20. package/firedrill/tools/unified/lib/util.mjs +92 -0
  21. package/firedrill/tools/unified/lib/validate.mjs +114 -0
  22. package/firedrill/tools/unified/lib/wire.mjs +120 -0
  23. package/firedrill/tools/unified/ops/channels.mjs +45 -0
  24. package/firedrill/tools/unified/ops/connections.mjs +151 -0
  25. package/firedrill/tools/unified/ops/crm.mjs +126 -0
  26. package/firedrill/tools/unified/ops/deals.mjs +123 -0
  27. package/firedrill/tools/unified/ops/messages.mjs +133 -0
  28. package/firedrill/tools/unified/ops/pipelines.mjs +20 -0
  29. package/firedrill/tools/unified/ops/resource.mjs +114 -0
  30. package/firedrill/tools/unified/unified.tool.json +14068 -0
  31. package/firedrill/unified-bounds.drill.json +148 -0
  32. package/firedrill/unified-connections-flow.drill.json +226 -0
  33. package/firedrill/unified-crm-flow.drill.json +576 -0
  34. package/firedrill/unified-denied.drill.json +83 -0
  35. package/firedrill/unified-error-coverage.drill.json +528 -0
  36. package/firedrill/unified-fresh-actor.drill.json +83 -0
  37. package/firedrill/unified-invalid-workspace.drill.json +933 -0
  38. package/firedrill/unified-large-channels.drill.json +68 -0
  39. package/firedrill/unified-many-workspaces.drill.json +943 -0
  40. package/firedrill/unified-mcp-core-aliases.drill.json +113 -0
  41. package/firedrill/unified-messaging-flow.drill.json +326 -0
  42. package/firedrill/unified-rate-limited.drill.json +933 -0
  43. package/firedrill/unified-scoped-token.drill.json +113 -0
  44. package/firedrill/unified-size-bounds.drill.json +263 -0
  45. package/firedrill/unified-write-committed-lost.drill.json +107 -0
  46. package/firedrill/unified-write-unavailable.drill.json +528 -0
  47. package/firedrill/world.json +2958 -0
  48. package/firedrill/write-committed-lost.scenario.json +11 -0
  49. package/firedrill/write-unavailable.scenario.json +11 -0
  50. package/firedrill.json +5 -0
  51. package/package.json +52 -0
  52. package/starter.json +2446 -0
  53. package/test/conformance.mjs +39 -0
  54. package/test/flows/connections.mjs +87 -0
  55. package/test/flows/coverage.mjs +64 -0
  56. package/test/flows/crm.mjs +124 -0
  57. package/test/flows/faults.mjs +82 -0
  58. package/test/flows/identity.mjs +84 -0
  59. package/test/flows/messaging.mjs +82 -0
  60. package/test/flows/size.mjs +71 -0
  61. package/test/lib.mjs +133 -0
@@ -0,0 +1,39 @@
1
+ // Unified.to Tool conformance target: a scripted Tool test, not a model-driven agent. Node built-ins only.
2
+ // Reads the drill task from stdin, picks the flow named in the instruction and runs it against the provider-shaped
3
+ // REST routes (FIREDRILL_HTTP_URL, bearer FIREDRILL_HTTP_TOKEN) and the MCP endpoint (FIREDRILL_MCP_URL).
4
+ import { connections, mcpAliases } from "./flows/connections.mjs";
5
+ import { errorCoverage } from "./flows/coverage.mjs";
6
+ import { crm } from "./flows/crm.mjs";
7
+ import { bounds, largeChannels, manyWorkspaces, rateLimited, writeCommittedLost, writeUnavailable } from "./flows/faults.mjs";
8
+ import { denied, fresh, ghost, scoped } from "./flows/identity.mjs";
9
+ import { messaging } from "./flows/messaging.mjs";
10
+ import { sizeBounds } from "./flows/size.mjs";
11
+
12
+ let task = "";
13
+ for await (const chunk of process.stdin) task += chunk;
14
+ const invocation = JSON.parse(task);
15
+ const instruction = String(invocation.instruction ?? invocation.task?.instruction ?? "");
16
+
17
+ const flows = {
18
+ crm,
19
+ messaging,
20
+ connections,
21
+ mcp: mcpAliases,
22
+ scoped,
23
+ ghost,
24
+ denied,
25
+ fresh,
26
+ "error-coverage": errorCoverage,
27
+ "rate-limited": rateLimited,
28
+ "write-unavailable": writeUnavailable,
29
+ "write-committed-lost": writeCommittedLost,
30
+ bounds,
31
+ "many-workspaces": manyWorkspaces,
32
+ "large-channels": largeChannels,
33
+ "size-bounds": sizeBounds,
34
+ };
35
+
36
+ const selected = /Run the ([a-z-]+) flow/.exec(instruction)?.[1];
37
+ if (selected === undefined || !Object.hasOwn(flows, selected)) throw new Error(`Unknown drill instruction: ${instruction}`);
38
+ await flows[selected]();
39
+ process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
@@ -0,0 +1,87 @@
1
+ // Drills unified-connections-flow and unified-mcp-core-aliases (admin, baseline).
2
+ import { ALIASES, ALL_OPERATIONS, C, NEW, NONE, NOW, WS, apiError, assert, del, get, ids, mcp, mcpError, mcpInit, patch, post, put, rpc, walk } from "../lib.mjs";
3
+
4
+ const U = "/unified/connection";
5
+
6
+ export async function connections() {
7
+ const listed = (await get(U)).json;
8
+ assert.deepEqual(ids(listed), [C.main, C.readonly, C.chat, C.mail, C.paused, C.broken, C.hris], "Sandbox connections are hidden by default");
9
+ for (const row of listed) assert.ok(!Object.hasOwn(row.auth, "token") && !Object.hasOwn(row.auth, "access_token"), "auth carries no secrets");
10
+ assert.deepEqual(ids((await get(`${U}?env=Sandbox`)).json), [C.sandbox, C.omni]);
11
+ assert.deepEqual(ids((await get(`${U}?env=Sandbox&categories=crm,messaging`)).json), [C.omni], "a connection may carry several categories");
12
+ assert.deepEqual((await get(`/crm/${C.omni}/contact`)).json, [], "the omni Sandbox connection holds no data");
13
+ assert.deepEqual((await get(`/messaging/${C.omni}/channel`)).json, []);
14
+ assert.deepEqual(ids((await get(`${U}?categories=messaging`)).json), [C.chat, C.mail]);
15
+ assert.deepEqual((await get(`${U}?categories=crm,messaging`)).json, [], "every listed category must be present");
16
+ assert.deepEqual(ids((await get(`${U}?external_xref=acct-northwind`)).json), [C.readonly]);
17
+ assert.equal((await get(`${U}?sort=name&order=asc`)).json[0].integration_name, "BambooHR");
18
+ assert.deepEqual(ids((await get(`${U}?updated_gte=2026-09-12T00:00:00Z`)).json), [C.main, C.chat, C.mail]);
19
+ assert.equal((await walk(U, 2, [2, 2, 2, 1])).length, 7);
20
+ await apiError("GET", `${U}?env=Staging`, 400, "env must be Production or Sandbox");
21
+ await apiError("GET", `${U}?categories=parking`, 400, "not a supported category");
22
+ const chat = (await get(`${U}/${C.chat}`)).json;
23
+ assert.equal(chat.integration_type, "slack");
24
+ assert.equal(chat.workspace_id, WS);
25
+ await apiError("GET", `${U}/${NONE}`, 404, "Connection not found");
26
+
27
+ await apiError("POST", U, 400, "integration_type is required", { body: {} });
28
+ await apiError("POST", U, 400, "requires the messaging category", { body: { integration_type: "zoho", categories: ["crm"], permissions: ["messaging_message_read"] } });
29
+ await apiError("POST", U, 400, "not a supported category", { body: { integration_type: "zoho", categories: ["parking"] } });
30
+ await apiError("POST", U, 400, "Invalid value for integration_type", { body: { integration_type: "Zoho CRM", categories: ["crm"] } });
31
+ const zoho = (await post(U, { integration_type: "zoho", categories: ["crm"], permissions: ["crm_contact_read", "crm_contact_write"], external_xref: "acct-zoho", auth: { name: "Zed", emails: ["zed@example.org"], user_id: "u-zed" } })).json;
32
+ assert.equal(zoho.id, NEW(1));
33
+ assert.equal(zoho.integration_name, "Zoho");
34
+ assert.equal(zoho.is_paused, false);
35
+ assert.equal(zoho.environment, "Production");
36
+ assert.equal(zoho.last_healthy_at, NOW);
37
+ assert.equal(zoho.created_at, NOW);
38
+ assert.deepEqual(zoho.auth, { name: "Zed", emails: ["zed@example.org"], user_id: "u-zed" });
39
+ assert.equal((await get(U)).json.length, 8, "seven Production connections plus the new one");
40
+
41
+ const Z = `/crm/${NEW(1)}`;
42
+ assert.deepEqual((await get(`${Z}/contact`)).json, [], "a new connection starts empty");
43
+ const first = (await post(`${Z}/contact`, { name: "First" })).json;
44
+ assert.equal(first.id, NEW(2));
45
+ const narrowed = (await patch(`${U}/${NEW(1)}`, { permissions: ["crm_contact_read"] })).json;
46
+ assert.deepEqual(narrowed.permissions, ["crm_contact_read"]);
47
+ await apiError("POST", `${Z}/contact`, 403, "crm_contact_write", { body: { name: "Second" } });
48
+ assert.equal((await get(`${Z}/contact`)).json.length, 1);
49
+ await apiError("PATCH", `${U}/${NEW(1)}`, 400, "cannot be changed", { body: { categories: ["hris"] } });
50
+ await apiError("PATCH", `${U}/${NEW(1)}`, 400, "requires the messaging category", { body: { permissions: ["messaging_message_read"] } });
51
+ await apiError("PATCH", `${U}/${NONE}`, 404, "Connection not found", { body: { external_xref: "x" } });
52
+ const relabelled = (await put(`${U}/${NEW(1)}`, { external_xref: "acct-z2", environment: "Sandbox" })).json;
53
+ assert.equal(relabelled.external_xref, "acct-z2");
54
+ assert.equal(relabelled.environment, "Sandbox");
55
+ assert.deepEqual(relabelled.permissions, ["crm_contact_read"], "PUT merges like PATCH");
56
+ assert.equal((await get(U)).json.length, 7, "the connection moved to Sandbox");
57
+ await apiError("DELETE", `${U}/${NONE}`, 404, "Connection not found");
58
+ assert.deepEqual((await del(`${U}/${NEW(1)}`)).json, {});
59
+ await apiError("GET", `${Z}/contact`, 404, "Connection not found");
60
+ await apiError("GET", `${U}/${NEW(1)}`, 404, "Connection not found");
61
+ assert.equal((await get(`${U}?env=Sandbox`)).json.length, 2);
62
+ }
63
+
64
+ export async function mcpAliases() {
65
+ await mcpInit();
66
+ const tools = (await rpc("tools/list", {})).tools.map((tool) => tool.name);
67
+ for (const alias of ALIASES) assert.ok(tools.includes(alias), `alias ${alias} missing`);
68
+ for (const operationId of ALL_OPERATIONS) assert.ok(tools.includes(`unified.${operationId}`), `canonical unified.${operationId} missing`);
69
+ assert.equal(tools.length, ALIASES.length + ALL_OPERATIONS.length);
70
+
71
+ const first = (await mcp("list_unified_connections", { limit: 3 })).result;
72
+ assert.deepEqual(ids(first), [C.main, C.readonly, C.chat]);
73
+ const rest = (await mcp("list_unified_connections", { limit: 3, offset: 3 })).result;
74
+ assert.deepEqual(ids(rest), [C.mail, C.paused, C.broken]);
75
+ assert.deepEqual(ids((await mcp("list_unified_connections", { offset: 6 })).result), [C.hris]);
76
+ assert.equal((await mcp("get_unified_connection", { id: C.mail })).integration_type, "gmail");
77
+ const created = await mcp("create_unified_connection", { integration_type: "zoho", categories: ["crm"], permissions: ["crm_contact_read"], external_xref: "acct-mcp" });
78
+ assert.equal(created.id, NEW(1));
79
+ assert.equal(created.integration_name, "Zoho");
80
+ const updated = await mcp("update_unified_connection", { id: NEW(1), external_xref: "acct-mcp-2" });
81
+ assert.equal(updated.external_xref, "acct-mcp-2");
82
+ assert.equal((await mcp("unified.connections.get", { id: NEW(1) })).external_xref, "acct-mcp-2", "canonical name sees the alias write");
83
+ assert.deepEqual(await mcp("remove_unified_connection", { id: NEW(1) }), {});
84
+ await mcpError("get_unified_connection", { id: NONE }, "NOT_FOUND");
85
+ await mcpError("get_unified_connection", { id: NEW(1) }, "NOT_FOUND");
86
+ assert.equal((await mcp("list_unified_connections", {})).result.length, 7);
87
+ }
@@ -0,0 +1,64 @@
1
+ // Drill unified-error-coverage (admin, baseline): every data operation answers 404 (unknown connection), 501 (wrong
2
+ // category), 403 (paused CRM connection / permission-less messaging connection) and 400 (malformed connection_id);
3
+ // connection operations answer 400 and 404.
4
+ import { C, CH, CO, D, K, MSG, NEW, NONE, PL, apiError, assert, del, post } from "../lib.mjs";
5
+
6
+ /** [operation, method, path builder (connection id -> path), body] for the 24 connection-scoped operations. */
7
+ const DATA_CALLS = [
8
+ ["contacts.list", "GET", (c) => `/crm/${c}/contact`],
9
+ ["contacts.get", "GET", (c) => `/crm/${c}/contact/${K.elena}`],
10
+ ["contacts.create", "POST", (c) => `/crm/${c}/contact`, { name: "Probe" }],
11
+ ["contacts.update", "PATCH", (c) => `/crm/${c}/contact/${K.elena}`, { title: "Probe" }],
12
+ ["contacts.remove", "DELETE", (c) => `/crm/${c}/contact/${K.elena}`],
13
+ ["companies.list", "GET", (c) => `/crm/${c}/company`],
14
+ ["companies.get", "GET", (c) => `/crm/${c}/company/${CO.northwind}`],
15
+ ["companies.create", "POST", (c) => `/crm/${c}/company`, { name: "Probe Co" }],
16
+ ["companies.update", "PUT", (c) => `/crm/${c}/company/${CO.northwind}`, { industry: "Probe" }],
17
+ ["companies.remove", "DELETE", (c) => `/crm/${c}/company/${CO.northwind}`],
18
+ ["deals.list", "GET", (c) => `/crm/${c}/deal`],
19
+ ["deals.get", "GET", (c) => `/crm/${c}/deal/${D.q4}`],
20
+ ["deals.create", "POST", (c) => `/crm/${c}/deal`, { name: "Probe deal" }],
21
+ ["deals.update", "PATCH", (c) => `/crm/${c}/deal/${D.q4}`, { description: "Probe" }],
22
+ ["deals.remove", "DELETE", (c) => `/crm/${c}/deal/${D.q4}`],
23
+ ["pipelines.list", "GET", (c) => `/crm/${c}/pipeline`],
24
+ ["pipelines.get", "GET", (c) => `/crm/${c}/pipeline/${PL.sales}`],
25
+ ["channels.list", "GET", (c) => `/messaging/${c}/channel`],
26
+ ["channels.get", "GET", (c) => `/messaging/${c}/channel/${CH.general}`],
27
+ ["messages.list", "GET", (c) => `/messaging/${c}/message`],
28
+ ["messages.get", "GET", (c) => `/messaging/${c}/message/${MSG(1)}`],
29
+ ["messages.create", "POST", (c) => `/messaging/${c}/message`, { message: "probe", channels: [{ id: CH.sales }] }],
30
+ ["messages.update", "PATCH", (c) => `/messaging/${c}/message/${MSG(1)}`, { message: "probe" }],
31
+ ["messages.remove", "DELETE", (c) => `/messaging/${c}/message/${MSG(1)}`],
32
+ ];
33
+
34
+ const isCrm = (path) => path.startsWith("/crm/");
35
+
36
+ export async function errorCoverage() {
37
+ // A messaging connection without any permission: every messaging operation answers 403 on it.
38
+ const bare = (await post("/unified/connection", { integration_type: "teams", categories: ["messaging"], permissions: [] })).json;
39
+ assert.equal(bare.id, NEW(1));
40
+ for (const [, method, pathFor, body] of DATA_CALLS) {
41
+ const options = body === undefined ? {} : { body };
42
+ await apiError(method, pathFor(NONE), 404, "Connection not found", options);
43
+ await apiError(method, pathFor(C.hris), 501, "not supported by this integration", options);
44
+ await apiError(method, pathFor("notahexid"), 400, "Invalid connection_id", options);
45
+ const sample = pathFor(C.main);
46
+ if (isCrm(sample)) await apiError(method, pathFor(C.paused), 403, "paused", options);
47
+ else await apiError(method, pathFor(NEW(1)), 403, "lacks the required permissions", options);
48
+ }
49
+ // Connection operations: malformed ids and unknown ids.
50
+ await apiError("GET", "/unified/connection?limit=0", 400, "limit must be a positive integer");
51
+ await apiError("GET", "/unified/connection/notahexid", 400, "Invalid connection_id");
52
+ await apiError("GET", `/unified/connection/${NONE}`, 404, "Connection not found");
53
+ await apiError("POST", "/unified/connection", 400, "integration_type is required", { body: {} });
54
+ await apiError("POST", "/unified/connection", 400, "Unknown field", { body: { integration_type: "zoho", categories: ["crm"], token: "secret" } });
55
+ await apiError("PATCH", "/unified/connection/notahexid", 400, "Invalid connection_id", { body: { external_xref: "x" } });
56
+ await apiError("PATCH", `/unified/connection/${NONE}`, 404, "Connection not found", { body: { external_xref: "x" } });
57
+ await apiError("PATCH", `/unified/connection/${C.chat}`, 400, "cannot be changed", { body: { integration_type: "zoho" } });
58
+ await apiError("DELETE", "/unified/connection/notahexid", 400, "Invalid connection_id");
59
+ await apiError("DELETE", `/unified/connection/${NONE}`, 404, "Connection not found");
60
+ // Broken connection: 401 from the Tool for a CRM read and a CRM write.
61
+ await apiError("GET", `/crm/${C.broken}/company`, 401, "likely broken");
62
+ await apiError("POST", `/crm/${C.broken}/deal`, 401, "likely broken", { body: { name: "x" } });
63
+ assert.deepEqual((await del(`/unified/connection/${NEW(1)}`)).json, {});
64
+ }
@@ -0,0 +1,124 @@
1
+ // Drill unified-crm-flow (admin, baseline): contacts, companies, deals, pipelines, permissions and connection states.
2
+ import { C, CO, D, K, NEW, NONE, NOW, PL, apiError, assert, del, get, ids, noneHas, patch, post, put, walk } from "../lib.mjs";
3
+
4
+ const M = `/crm/${C.main}`;
5
+
6
+ export async function crm() {
7
+ // Listing, paging and parameter validation.
8
+ const all = (await get(`${M}/contact`)).json;
9
+ assert.deepEqual(ids(all), [K.elena, K.tomas, K.priya, K.jonas, K.ravi, K.ana, K.liwei, K.sam]);
10
+ assert.ok(noneHas(all, "raw") && noneHas(all, "connection_id"), "default projection hides raw and connection_id");
11
+ assert.equal((await walk(`${M}/contact`, 3, [3, 3, 2])).length, 8);
12
+ assert.deepEqual((await get(`${M}/contact?offset=50`)).json, []);
13
+ for (const bad of ["limit=0", "limit=x", "offset=-1", "sort=email", "order=up", "updated_gte=2026-13-01", "query=%ZZ%FF", "company_id=notanid"]) {
14
+ await apiError("GET", `${M}/contact?${bad}`, 400, "");
15
+ }
16
+ assert.equal((await get(`${M}/contact?limit=500`)).json.length, 8, "limit above 100 is capped, not rejected");
17
+ assert.deepEqual(ids((await get(`${M}/contact?query=northwind`)).json), [K.elena, K.tomas], "query matches e-mail domains");
18
+ assert.deepEqual(ids((await get(`${M}/contact?query=JONAS`)).json), [K.jonas], "query is case-folded and matches names");
19
+ assert.deepEqual(ids((await get(`${M}/contact?company_id=${CO.northwind}`)).json), [K.elena, K.tomas]);
20
+ assert.deepEqual(ids((await get(`${M}/contact?deal_id=${D.pilot}`)).json), [K.priya]);
21
+ assert.deepEqual(ids((await get(`${M}/contact?user_id=u-maya`)).json), [K.tomas]);
22
+ assert.deepEqual(ids((await get(`${M}/contact?updated_gte=2026-09-10`)).json), [K.elena, K.tomas, K.liwei, K.sam], "zone-less updated_gte is UTC midnight");
23
+ assert.equal(ids((await get(`${M}/contact?sort=name&order=desc`)).json)[0], K.liwei, "CJK name sorts last by code point");
24
+ assert.equal(ids((await get(`${M}/contact?sort=updated_at&order=asc`)).json)[0], K.ravi);
25
+ const projected = (await get(`${M}/contact?fields=id,name,emails`)).json;
26
+ for (const row of projected) assert.deepEqual(Object.keys(row).sort(), ["emails", "id", "name"]);
27
+ const withRaw = (await get(`${M}/contact?fields=raw&limit=1`)).json[0];
28
+ assert.deepEqual(withRaw, { id: K.elena, raw: { hs_object_id: "501" } });
29
+
30
+ // Point reads.
31
+ const elena = (await get(`${M}/contact/${K.elena}`)).json;
32
+ assert.equal(elena.name, "Elena Marsh");
33
+ assert.equal(elena.emails.length, 2);
34
+ assert.equal(elena.address.city, "Bristol");
35
+ assert.deepEqual(elena.deal_ids, [D.q4, D.renewal]);
36
+ await apiError("GET", `${M}/contact/${NONE}`, 404, "Contact not found");
37
+ await apiError("GET", `${M}/contact/notahexid`, 400, "Invalid contact id");
38
+
39
+ // Create with symmetric associations, merge updates (PATCH and PUT), remove with detachment.
40
+ await apiError("POST", `${M}/contact`, 400, "At least one of name", { body: {} });
41
+ await apiError("POST", `${M}/contact`, 400, "Unknown company id", { body: { name: "Ghost", company_ids: [NONE] } });
42
+ await apiError("POST", `${M}/contact`, 400, "emails[0].email", { body: { name: "Bad mail", emails: [{ email: "nope" }] } });
43
+ const lena = (await post(`${M}/contact`, { first_name: "Lena", emails: [{ email: "lena@example.org", type: "WORK" }], company_ids: [CO.contoso] })).json;
44
+ assert.equal(lena.id, NEW(1));
45
+ assert.equal(lena.name, "Lena", "name derives from first_name");
46
+ assert.equal(lena.created_at, NOW);
47
+ assert.deepEqual(lena.company_ids, [CO.contoso]);
48
+ assert.deepEqual((await get(`${M}/company/${CO.contoso}`)).json.contact_ids, [K.priya, NEW(1)], "company gained the reverse link");
49
+ const renamed = (await patch(`${M}/contact/${NEW(1)}`, { last_name: "Ortiz", company_ids: [] })).json;
50
+ assert.equal(renamed.name, "Lena Ortiz");
51
+ assert.deepEqual((await get(`${M}/company/${CO.contoso}`)).json.contact_ids, [K.priya], "company lost the reverse link");
52
+ const jonas = (await put(`${M}/contact/${K.jonas}`, { title: "Director" })).json;
53
+ assert.equal(jonas.title, "Director");
54
+ assert.equal(jonas.name, "Jonas Weber", "PUT merges instead of replacing");
55
+ assert.deepEqual((await del(`${M}/contact/${K.elena}`)).json, {});
56
+ assert.deepEqual((await get(`${M}/deal/${D.q4}`)).json.contact_ids, [K.tomas], "deal dropped the removed contact");
57
+ assert.deepEqual((await get(`${M}/company/${CO.northwind}`)).json.contact_ids, [K.tomas]);
58
+ await apiError("GET", `${M}/contact/${K.elena}`, 404, "Contact not found");
59
+ assert.deepEqual((await get(`${M}/contact?deal_id=${D.q4}`)).json.map((row) => row.id), [K.tomas]);
60
+
61
+ // Companies.
62
+ await apiError("POST", `${M}/company`, 400, "name", { body: {} });
63
+ const woodgrove = (await post(`${M}/company`, { name: "Woodgrove", domains: ["woodgrove.example.org"] })).json;
64
+ assert.equal(woodgrove.id, NEW(2));
65
+ assert.equal(woodgrove.is_active, true);
66
+ assert.deepEqual(ids((await get(`${M}/company?query=woodgrove.example`)).json), [NEW(2)], "query matches company domains");
67
+ assert.deepEqual((await get(`${M}/company?contact_id=${K.elena}`)).json, []);
68
+ assert.equal((await get(`${M}/company`)).json.length, 6);
69
+ assert.equal((await get(`${M}/company?query=山田`)).json[0].id, CO.yamada);
70
+ assert.equal((await patch(`${M}/company/${NEW(2)}`, { employees: 12 })).json.employees, 12);
71
+ await apiError("PUT", `${M}/company/${CO.fabrikam}`, 400, "Invalid value for name", { body: { name: null } });
72
+ await apiError("GET", `${M}/company/${NONE}`, 404, "Company not found");
73
+ assert.deepEqual((await del(`${M}/company/${NEW(2)}`)).json, {});
74
+
75
+ // Deals: pipeline and stage derivation.
76
+ await apiError("POST", `${M}/deal`, 400, "name", { body: {} });
77
+ const deal = (await post(`${M}/deal`, { name: "New", stages: [{ id: PL.negotiation }], company_ids: [CO.contoso] })).json;
78
+ assert.equal(deal.id, NEW(3));
79
+ assert.deepEqual(deal.pipelines, [{ id: PL.sales, name: "Sales Pipeline", type: null }], "pipeline derived from the stage");
80
+ assert.equal(deal.stages[0].name, "Negotiation");
81
+ assert.equal(deal.probability, 70, "probability defaults from the stage");
82
+ assert.deepEqual((await get(`${M}/company/${CO.contoso}`)).json.deal_ids, [D.pilot, NEW(3)]);
83
+ await apiError("POST", `${M}/deal`, 400, "does not belong to pipeline", { body: { name: "Bad", pipelines: [{ id: PL.renewals }], stages: [{ id: PL.negotiation }] } });
84
+ const won = (await patch(`${M}/deal/${D.q4}`, { stages: [{ id: PL.won }] })).json;
85
+ assert.equal(won.closed_at, NOW, "entering a closed stage stamps closed_at with virtual time");
86
+ assert.equal(won.probability, 100);
87
+ const reopened = (await patch(`${M}/deal/${D.q4}`, { stages: [{ id: PL.proposal }] })).json;
88
+ assert.equal(reopened.closed_at, null);
89
+ assert.equal(reopened.probability, 40);
90
+ await apiError("PATCH", `${M}/deal/${D.q4}`, 400, "currency", { body: { currency: "usd" } });
91
+ assert.deepEqual(ids((await get(`${M}/deal?pipeline_id=${PL.renewals}`)).json), [D.renewal]);
92
+ assert.deepEqual(ids((await get(`${M}/deal?company_id=${CO.northwind}`)).json), [D.q4, D.renewal]);
93
+ assert.equal((await get(`${M}/deal`)).json.length, 7);
94
+ await apiError("GET", `${M}/deal?sort=amount`, 400, "sort must be one of");
95
+ assert.equal((await put(`${M}/deal/${NEW(3)}`, { amount: 1000, currency: "USD" })).json.amount, 1000);
96
+ assert.deepEqual((await del(`${M}/deal/${NEW(3)}`)).json, {});
97
+ assert.deepEqual((await get(`${M}/company/${CO.contoso}`)).json.deal_ids, [D.pilot]);
98
+ await apiError("GET", `${M}/deal/${NEW(3)}`, 404, "Deal not found");
99
+
100
+ // Pipelines (read-only reference data).
101
+ const pipelines = (await get(`${M}/pipeline`)).json;
102
+ assert.deepEqual(ids(pipelines), [PL.sales, PL.renewals]);
103
+ assert.deepEqual(pipelines[0].stages.map((stage) => stage.name), ["Qualification", "Proposal", "Negotiation", "Closed Won", "Closed Lost"]);
104
+ assert.equal((await get(`${M}/pipeline/${PL.sales}`)).json.name, "Sales Pipeline");
105
+ await apiError("GET", `${M}/pipeline/${NONE}`, 404, "Pipeline not found");
106
+
107
+ // Connection states: permissions, paused, broken, wrong category, Sandbox, unknown, absent route.
108
+ assert.deepEqual(ids((await get(`/crm/${C.readonly}/contact`)).json), [K.dana]);
109
+ await apiError("POST", `/crm/${C.readonly}/contact`, 403, "crm_contact_write", { body: { name: "Nope" } });
110
+ await apiError("GET", `/crm/${C.readonly}/deal`, 403, "crm_deal_read");
111
+ await apiError("GET", `/crm/${C.readonly}/pipeline`, 403, "crm_pipeline_read");
112
+ await apiError("GET", `/crm/${C.paused}/contact`, 403, "paused");
113
+ await apiError("GET", `/crm/${C.broken}/contact`, 401, "likely broken");
114
+ await apiError("GET", `/crm/${C.hris}/contact`, 501, "not supported by this integration");
115
+ assert.deepEqual(ids((await get(`/crm/${C.sandbox}/contact`)).json), [K.sandbox]);
116
+ await apiError("GET", `/crm/${NONE}/contact`, 404, "Connection not found");
117
+ assert.equal((await api404(`${M}/lead`)), 404);
118
+ }
119
+
120
+ async function api404(path) {
121
+ const response = await fetch(`${process.env.FIREDRILL_HTTP_URL}${path}`, { method: "POST", headers: { authorization: `Bearer ${process.env.FIREDRILL_HTTP_TOKEN}`, "content-type": "application/json" }, body: "{}" });
122
+ await response.text();
123
+ return response.status;
124
+ }
@@ -0,0 +1,82 @@
1
+ // Fault and bound drills: rate-limited, write-unavailable, write-committed-lost, bounds, many-workspaces, large-channels.
2
+ import { C, CH, CO, NEW, apiError, assert, get, ids, mcpError, mcpInit, post } from "../lib.mjs";
3
+ import { ALL_CALLS, expectAll } from "./identity.mjs";
4
+
5
+ const M = `/crm/${C.main}`;
6
+ const H = `/messaging/${C.chat}`;
7
+
8
+ export async function rateLimited() {
9
+ const results = await expectAll(429, "Too many requests to Unified.to");
10
+ for (const [operationId, result] of results) {
11
+ assert.equal(result.headers.get("retry-after"), "1", `${operationId}: Retry-After header`);
12
+ assert.equal(result.json.error, "Too Many Requests");
13
+ }
14
+ await mcpInit();
15
+ await mcpError("list_unified_connections", {}, "RATE_LIMITED");
16
+ }
17
+
18
+ export async function writeUnavailable() {
19
+ const writes = ALL_CALLS.filter(([operationId]) => /\.(create|update|remove)$/.test(operationId));
20
+ assert.equal(writes.length, 15);
21
+ for (const [, method, path, body] of writes) {
22
+ const result = await apiError(method, path, 500, "Internal Server Error", body === undefined ? {} : { body });
23
+ assert.equal(result.json.error, "Internal Server Error");
24
+ }
25
+ // PUT routes share the operation with PATCH: both are unavailable.
26
+ await apiError("PUT", `${M}/contact/68c800000000000000000501`, 500, "Internal Server Error", { body: { title: "x" } });
27
+ assert.equal((await get(`${M}/contact`)).json.length, 8, "reads keep working and nothing was written");
28
+ assert.equal((await get("/unified/connection")).json.length, 7);
29
+ }
30
+
31
+ export async function writeCommittedLost() {
32
+ await apiError("POST", `${H}/message`, 500, "Internal Server Error", { body: { message: "Ship it", channels: [{ id: CH.sales }] } });
33
+ const latest = (await get(`${H}/message?channel_id=${CH.sales}&sort=created_at&order=desc&limit=1`)).json;
34
+ assert.deepEqual(ids(latest), [NEW(1)], "the message was committed although the response was lost");
35
+ assert.equal(latest[0].message, "Ship it");
36
+ // A blind client retry posts a duplicate.
37
+ await apiError("POST", `${H}/message`, 500, "Internal Server Error", { body: { message: "Ship it", channels: [{ id: CH.sales }] } });
38
+ assert.deepEqual(ids((await get(`${H}/message?channel_id=${CH.sales}&query=ship`)).json), [NEW(1), NEW(2)]);
39
+ assert.equal((await get(`${H}/message?channel_id=${CH.sales}`)).json.length, 7);
40
+ }
41
+
42
+ export async function bounds() {
43
+ const contacts = await apiError("GET", `${M}/contact`, 500, "State exceeds the supported bound of 5 rows for contacts");
44
+ assert.equal(contacts.json.error, "Internal Server Error");
45
+ await apiError("GET", `${H}/message?limit=1`, 500, "bound of 5 rows for messages");
46
+ await apiError("GET", "/unified/connection", 500, "bound of 5 rows for connections");
47
+ assert.equal((await get(`${M}/company`)).json.length, 5, "a namespace within the bound still lists");
48
+ await apiError("POST", `${M}/contact`, 500, "bound of 5 rows for contacts", { body: { name: "Over" } });
49
+ await apiError("DELETE", `/unified/connection/${C.main}`, 500, "bound of 5 rows", {});
50
+ assert.equal((await get(`/unified/connection/${C.main}`)).json.id, C.main, "the cascade refused instead of half-deleting");
51
+ }
52
+
53
+ export async function manyWorkspaces() {
54
+ const results = await expectAll(500, "State exceeds the supported bound of 100 rows for workspaces");
55
+ assert.equal(results.length, 29);
56
+ await mcpInit();
57
+ await mcpError("list_unified_connections", {}, "FAILED_PRECONDITION");
58
+ }
59
+
60
+ export async function largeChannels() {
61
+ const tooLarge = await apiError("GET", `${H}/channel`, 413, "Response exceeds 1 MB");
62
+ assert.equal(tooLarge.json.error, "Payload Too Large");
63
+ await apiError("GET", `${H}/channel?limit=100`, 413, "Response exceeds 1 MB");
64
+ const sizes = [];
65
+ const seen = [];
66
+ for (let offset = 0; ; offset += 1) {
67
+ const result = await get(`${H}/channel?limit=1&offset=${offset}`);
68
+ assert.ok(Buffer.byteLength(result.text) <= 900000, "each page stays within the budget");
69
+ sizes.push(result.json.length);
70
+ seen.push(...ids(result.json));
71
+ if (result.json.length < 1) break;
72
+ }
73
+ assert.equal(seen.length, 6, "every channel appears exactly once when paged");
74
+ assert.equal(new Set(seen).size, 6);
75
+ const projected = (await get(`${H}/channel?fields=id,name,description`)).json;
76
+ assert.equal(projected.length, 6, "a projection without members fits one page");
77
+ assert.equal(projected.filter((row) => row.name.startsWith("all-hands-")).length, 2);
78
+ assert.equal((await get(`${H}/channel?fields=id,name&limit=100`)).json.length, 6);
79
+ assert.equal((await get(`${H}/channel?limit=1&offset=4`)).json[0].members.length, 350);
80
+ assert.equal((await get(`${H}/channel/68c800000000000000070000`)).json.members.length, 350);
81
+ assert.equal((await get(`${M}/company`)).json.length, 5, CO.northwind);
82
+ }
@@ -0,0 +1,84 @@
1
+ // Drills unified-scoped-token, unified-invalid-workspace, unified-denied, unified-fresh-actor, plus the table of one
2
+ // representative request per operation shared by the whole-surface drills (ghost, rate-limited, many-workspaces).
3
+ import { C, CH, CO, D, K, MSG, NEW, NONE, PL, apiError, assert, get, ids, mcpError, mcpInit, post } from "../lib.mjs";
4
+
5
+ const M = `/crm/${C.main}`;
6
+ const H = `/messaging/${C.chat}`;
7
+
8
+ /** One request per operation that succeeds against baseline for the admin actor (used where a fault or identity rule fires first). */
9
+ export const ALL_CALLS = [
10
+ ["connections.list", "GET", "/unified/connection"],
11
+ ["connections.get", "GET", `/unified/connection/${C.chat}`],
12
+ ["connections.create", "POST", "/unified/connection", { integration_type: "zoho", categories: ["crm"], permissions: [] }],
13
+ ["connections.update", "PATCH", `/unified/connection/${C.chat}`, { external_xref: "probe" }],
14
+ ["connections.remove", "DELETE", `/unified/connection/${C.hris}`],
15
+ ["contacts.list", "GET", `${M}/contact`],
16
+ ["contacts.get", "GET", `${M}/contact/${K.elena}`],
17
+ ["contacts.create", "POST", `${M}/contact`, { name: "Probe" }],
18
+ ["contacts.update", "PATCH", `${M}/contact/${K.elena}`, { title: "Probe" }],
19
+ ["contacts.remove", "DELETE", `${M}/contact/${K.ravi}`],
20
+ ["companies.list", "GET", `${M}/company`],
21
+ ["companies.get", "GET", `${M}/company/${CO.northwind}`],
22
+ ["companies.create", "POST", `${M}/company`, { name: "Probe Co" }],
23
+ ["companies.update", "PUT", `${M}/company/${CO.northwind}`, { industry: "Probe" }],
24
+ ["companies.remove", "DELETE", `${M}/company/${CO.tailspin}`],
25
+ ["deals.list", "GET", `${M}/deal`],
26
+ ["deals.get", "GET", `${M}/deal/${D.q4}`],
27
+ ["deals.create", "POST", `${M}/deal`, { name: "Probe deal" }],
28
+ ["deals.update", "PATCH", `${M}/deal/${D.q4}`, { description: "Probe" }],
29
+ ["deals.remove", "DELETE", `${M}/deal/${D.inbound}`],
30
+ ["pipelines.list", "GET", `${M}/pipeline`],
31
+ ["pipelines.get", "GET", `${M}/pipeline/${PL.sales}`],
32
+ ["channels.list", "GET", `${H}/channel`],
33
+ ["channels.get", "GET", `${H}/channel/${CH.general}`],
34
+ ["messages.list", "GET", `${H}/message`],
35
+ ["messages.get", "GET", `${H}/message/${MSG(1)}`],
36
+ ["messages.create", "POST", `${H}/message`, { message: "probe", channels: [{ id: CH.sales }] }],
37
+ ["messages.update", "PATCH", `${H}/message/${MSG(2)}`, { message: "probe" }],
38
+ ["messages.remove", "DELETE", `${H}/message/${MSG(4)}`],
39
+ ];
40
+
41
+ /** Sends every representative request expecting the same status and message fragment; returns the responses. */
42
+ export async function expectAll(status, fragment) {
43
+ const results = [];
44
+ for (const [operationId, method, path, body] of ALL_CALLS) {
45
+ const result = await apiError(method, path, status, fragment, body === undefined ? {} : { body });
46
+ results.push([operationId, result]);
47
+ }
48
+ return results;
49
+ }
50
+
51
+ export async function scoped() {
52
+ assert.deepEqual(ids((await get("/unified/connection")).json), [C.chat], "a scoped key lists only its connections");
53
+ await apiError("GET", `/unified/connection/${C.main}`, 404, "Connection not found");
54
+ await apiError("GET", `${M}/contact`, 404, "Connection not found");
55
+ assert.equal((await get(`${H}/message`)).json.length, 16);
56
+ await apiError("POST", "/unified/connection", 403, "Connection-scoped tokens cannot create connections", { body: { integration_type: "zoho", categories: ["crm"] } });
57
+ await apiError("DELETE", `/unified/connection/${C.main}`, 404, "Connection not found");
58
+ }
59
+
60
+ export async function ghost() {
61
+ const results = await expectAll(401, "Unauthorized");
62
+ assert.equal(results.length, 29);
63
+ for (const [, result] of results) assert.equal(result.json.error, "Unauthorized");
64
+ await mcpInit();
65
+ const failure = await mcpError("list_unified_connections", {}, "UNAUTHORIZED");
66
+ assert.equal(failure.status, "tool_error");
67
+ }
68
+
69
+ export async function denied() {
70
+ for (const [method, path, body] of [["GET", "/unified/connection"], ["GET", `${M}/contact`], ["POST", `${H}/message`, { message: "x", channels: [{ id: CH.sales }] }]]) {
71
+ const result = await apiError(method, path, 403, "Forbidden", body === undefined ? {} : { body });
72
+ assert.equal(result.json.error, "Forbidden");
73
+ }
74
+ }
75
+
76
+ export async function fresh() {
77
+ assert.equal((await get("/unified/connection")).json.length, 7, "a fresh-install actor sees the seeded workspace");
78
+ assert.equal((await get(`${M}/contact`)).json.length, 8);
79
+ const created = (await post(`${M}/contact`, { name: "Fresh", emails: [{ email: "fresh@example.org" }] })).json;
80
+ assert.equal(created.id, NEW(1));
81
+ assert.equal((await get(`${M}/contact/${NEW(1)}`)).json.name, "Fresh");
82
+ assert.deepEqual(ids((await get(`${M}/contact?updated_gte=2026-09-15`)).json), [K.sam, NEW(1)], "zone-less updated_gte is accepted as UTC");
83
+ assert.notEqual(NONE, NEW(1));
84
+ }
@@ -0,0 +1,82 @@
1
+ // Drill unified-messaging-flow (admin, baseline): channels, messages, aliases, threads, filters and author identity.
2
+ import { C, CH, MSG, NEW, NONE, apiError, assert, del, get, ids, noneHas, patch, post, walk } from "../lib.mjs";
3
+
4
+ const H = `/messaging/${C.chat}`;
5
+ const MAIL = `/messaging/${C.mail}`;
6
+
7
+ export async function messaging() {
8
+ // Channels.
9
+ const channels = (await get(`${H}/channel`)).json;
10
+ assert.deepEqual(ids(channels), [CH.general, CH.sales, CH.exec, CH.emea]);
11
+ assert.equal(channels.find((row) => row.id === CH.sales).has_subchannels, true, "has_subchannels computed from parent_id rows");
12
+ assert.equal(channels.find((row) => row.id === CH.general).has_subchannels, false);
13
+ assert.ok(noneHas(channels, "alias") && noneHas(channels, "connection_id"), "internal alias field is never returned");
14
+ assert.deepEqual(ids((await get(`${H}/channel?type=PRIVATE`)).json), [CH.exec]);
15
+ assert.deepEqual(ids((await get(`${H}/channel?parent_id=${CH.sales}`)).json), [CH.emea]);
16
+ assert.deepEqual(ids((await get(`${H}/channel?query=exec`)).json), [CH.exec]);
17
+ assert.deepEqual(ids((await get(`${H}/channel?sort=name&order=desc&limit=2`)).json), [CH.emea, CH.sales]);
18
+ await apiError("GET", `${H}/channel?type=SECRET`, 400, "type must be one of");
19
+ assert.equal((await get(`${H}/channel/${CH.exec}`)).json.is_private, true);
20
+ assert.equal((await get(`${H}/channel/${CH.sales}`)).json.has_subchannels, true);
21
+ await apiError("GET", `${H}/channel/${NONE}`, 404, "Channel not found");
22
+
23
+ // Message filters.
24
+ assert.equal((await get(`${H}/message?channel_id=${CH.general}`)).json.length, 9);
25
+ assert.deepEqual(ids((await get(`${H}/message?channel_id=${CH.general}&parent_id=${MSG(1)}`)).json), [MSG(2), MSG(3), MSG(4)]);
26
+ assert.deepEqual(ids((await get(`${H}/message?type=UNREAD`)).json), [MSG(6), MSG(9)]);
27
+ assert.deepEqual(ids((await get(`${H}/message?user_id=u-opsbot`)).json), [MSG(5), MSG(13)]);
28
+ assert.deepEqual(ids((await get(`${H}/message?user_mentioned_id=u-maya`)).json), [MSG(3)]);
29
+ assert.deepEqual(ids((await get(`${H}/message?start_gte=2026-09-10&end_lt=2026-09-13`)).json), [MSG(7), MSG(8), MSG(16)]);
30
+ assert.deepEqual(ids((await get(`${H}/message?query=%E5%B1%B1%E7%94%B0`)).json), [MSG(9)], "query matches CJK text");
31
+ assert.deepEqual((await get(`${H}/message?channel_id=INBOX`)).json, [], "alias without a matching channel matches nothing");
32
+ assert.deepEqual(ids((await get(`${MAIL}/message?channel_id=INBOX`)).json), [MSG(17), MSG(18)]);
33
+ assert.deepEqual(ids((await get(`${MAIL}/message?channel_id=SENT`)).json), [MSG(19)]);
34
+ assert.deepEqual(ids((await get(`${MAIL}/message?channel_id=DRAFT`)).json), [MSG(20)]);
35
+ assert.deepEqual(ids((await get(`${MAIL}/message?type=UNREAD`)).json), [MSG(17)]);
36
+ assert.deepEqual(ids((await get(`${MAIL}/message?sort=name&order=asc&limit=1`)).json), [MSG(18)], "sort=name orders messages by subject");
37
+ await apiError("GET", `${H}/message?channel_id=notahexid`, 400, "channel_id must be a valid id");
38
+ await apiError("GET", `${H}/message?start_gte=yesterday`, 400, "start_gte");
39
+ assert.equal(ids((await get(`${H}/message?sort=created_at&order=desc`)).json)[0], MSG(9), "newest chat message first");
40
+ assert.equal((await walk(`${H}/message`, 5, [5, 5, 5, 1])).length, 16);
41
+ const long = (await get(`${H}/message/${MSG(8)}`)).json;
42
+ assert.equal(long.message.length, 2982, "the seeded long-form message round-trips intact");
43
+ assert.equal((await get(`${H}/message/${MSG(7)}`)).json.attachments[0].filename, "onboarding-deck.pdf");
44
+ await apiError("GET", `${H}/message/${NONE}`, 404, "Message not found");
45
+
46
+ // Create: author from the connection, reply inherits channels, parent has_children maintained.
47
+ await apiError("POST", `${H}/message`, 400, "One of message", { body: {} });
48
+ await apiError("POST", `${H}/message`, 400, "Unknown channel id", { body: { message: "x", channels: [{ id: NONE }] } });
49
+ await apiError("POST", `${H}/message`, 400, "Unknown parent_id", { body: { message: "y", parent_id: NONE } });
50
+ await apiError("POST", `${H}/message`, 400, "Invalid value for message", { body: { message: "x".repeat(20001), channels: [{ id: CH.sales }] } });
51
+ await apiError("POST", `${H}/message`, 400, "channels[0].id or parent_id", { body: { message: "no channel" } });
52
+ const posted = (await post(`${H}/message`, { message: "Deploy done", channels: [{ id: CH.sales }], author_member: { user_id: "spoofed", name: "Spoof" } })).json;
53
+ assert.equal(posted.id, NEW(1));
54
+ assert.deepEqual(posted.author_member, { user_id: "u-opsbot", email: null, name: "Ops Bot", image_url: null }, "author comes from the connection, not the body");
55
+ assert.deepEqual(posted.channels, [{ id: CH.sales, name: "sales" }]);
56
+ assert.equal(posted.has_children, false);
57
+ assert.equal(posted.is_unread, false);
58
+ assert.equal((await get(`${H}/message/${MSG(5)}`)).json.has_children, false);
59
+ const reply = (await post(`${H}/message`, { message: "re", parent_id: MSG(5) })).json;
60
+ assert.equal(reply.id, NEW(2));
61
+ assert.deepEqual(reply.channels, [{ id: CH.general, name: "general" }], "reply inherits the parent's channels");
62
+ assert.equal((await get(`${H}/message/${MSG(5)}`)).json.has_children, true);
63
+ assert.deepEqual(ids((await get(`${H}/message?parent_id=${MSG(5)}`)).json), [NEW(2)]);
64
+
65
+ // Update and remove.
66
+ const edited = (await patch(`${H}/message/${NEW(1)}`, { is_unread: true, message: "edited" })).json;
67
+ assert.equal(edited.message, "edited");
68
+ assert.equal(edited.is_unread, true);
69
+ await apiError("PATCH", `${H}/message/${NEW(1)}`, 400, "must remain set", { body: { message: null, message_html: null, message_markdown: null } });
70
+ assert.equal((await get(`${H}/message/${NEW(1)}`)).json.message, "edited");
71
+ assert.deepEqual((await del(`${H}/message/${NEW(2)}`)).json, {});
72
+ assert.equal((await get(`${H}/message/${MSG(5)}`)).json.has_children, false, "parent recomputed after the last reply is removed");
73
+ await apiError("GET", `${H}/message/${NEW(2)}`, 404, "Message not found");
74
+ await apiError("DELETE", `${H}/message/${NEW(2)}`, 404, "Message not found");
75
+ assert.equal((await get(`${H}/message`)).json.length, 17);
76
+
77
+ // Category and absent routes.
78
+ await apiError("GET", `/messaging/${C.main}/channel`, 501, "not supported by this integration");
79
+ const absent = await fetch(`${process.env.FIREDRILL_HTTP_URL}${H}/channel`, { method: "POST", headers: { authorization: `Bearer ${process.env.FIREDRILL_HTTP_TOKEN}`, "content-type": "application/json" }, body: "{}" });
80
+ await absent.text();
81
+ assert.ok(absent.status === 404 || absent.status === 405, `POST channel -> ${absent.status}`);
82
+ }