@firedrill-tools/hubspot 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.
- package/LICENSE +201 -0
- package/README.md +263 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +20 -0
- package/firedrill/hubspot-deactivated-user.drill.json +53 -0
- package/firedrill/hubspot-denied.drill.json +74 -0
- package/firedrill/hubspot-fresh-actor.drill.json +128 -0
- package/firedrill/hubspot-invalid-auth.drill.json +433 -0
- package/firedrill/hubspot-mcp-aliases.drill.json +265 -0
- package/firedrill/hubspot-no-scopes.drill.json +433 -0
- package/firedrill/hubspot-owned-visibility.drill.json +113 -0
- package/firedrill/hubspot-rate-limited.drill.json +446 -0
- package/firedrill/hubspot-rest-flow.drill.json +963 -0
- package/firedrill/hubspot-scopes.drill.json +166 -0
- package/firedrill/hubspot-size-bounds.drill.json +156 -0
- package/firedrill/hubspot-write-committed-lost.drill.json +166 -0
- package/firedrill/hubspot-write-unavailable.drill.json +292 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/hubspot/behavior.mjs +1318 -0
- package/firedrill/tools/hubspot/hubspot.tool.json +5524 -0
- package/firedrill/tools/hubspot/lib/bytes.mjs +32 -0
- package/firedrill/tools/hubspot/lib/json-depth.mjs +17 -0
- package/firedrill/tools/hubspot/lib/object-types.mjs +47 -0
- package/firedrill/tools/hubspot/lib/properties.mjs +275 -0
- package/firedrill/tools/hubspot/lib/search-matchers.mjs +85 -0
- package/firedrill/tools/hubspot/lib/search.mjs +301 -0
- package/firedrill/tools/hubspot/lib/state.mjs +248 -0
- package/firedrill/tools/hubspot/lib/wire.mjs +125 -0
- package/firedrill/world.json +3128 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2540 -0
- package/test/conformance.mjs +1007 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
// HubSpot Tool conformance target. A scripted Tool test, not a model-driven agent.
|
|
2
|
+
// Node built-ins only: fetch against the HubSpot-shaped REST routes and raw MCP JSON-RPC (Streamable HTTP)
|
|
3
|
+
// for the HubSpot MCP server tool-name aliases. Every flow fails loudly on an unexpected status, header or body.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
let task = "";
|
|
7
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
8
|
+
const invocation = JSON.parse(task);
|
|
9
|
+
const instruction = String(invocation.instruction ?? "");
|
|
10
|
+
|
|
11
|
+
const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
12
|
+
const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
13
|
+
const MCP = process.env.FIREDRILL_MCP_URL;
|
|
14
|
+
const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
|
|
15
|
+
assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
|
|
16
|
+
|
|
17
|
+
const ALL_OPERATIONS = [
|
|
18
|
+
"account.get-details",
|
|
19
|
+
"objects.list",
|
|
20
|
+
"objects.get",
|
|
21
|
+
"objects.create",
|
|
22
|
+
"objects.update",
|
|
23
|
+
"objects.archive",
|
|
24
|
+
"objects.batch-read",
|
|
25
|
+
"objects.batch-create",
|
|
26
|
+
"objects.batch-update",
|
|
27
|
+
"objects.search",
|
|
28
|
+
"associations.list",
|
|
29
|
+
"associations.create-default",
|
|
30
|
+
"associations.create",
|
|
31
|
+
"associations.batch-create",
|
|
32
|
+
"associations.archive",
|
|
33
|
+
"associations.batch-read",
|
|
34
|
+
"associations.list-labels",
|
|
35
|
+
"owners.list",
|
|
36
|
+
"owners.get",
|
|
37
|
+
"pipelines.list",
|
|
38
|
+
"pipelines.get",
|
|
39
|
+
"pipelines.list-stages",
|
|
40
|
+
"properties.list",
|
|
41
|
+
"properties.get",
|
|
42
|
+
];
|
|
43
|
+
const ALIASES = [
|
|
44
|
+
"hubspot-get-user-details",
|
|
45
|
+
"hubspot-list-objects",
|
|
46
|
+
"hubspot-batch-read-objects",
|
|
47
|
+
"hubspot-batch-create-objects",
|
|
48
|
+
"hubspot-batch-update-objects",
|
|
49
|
+
"hubspot-search-objects",
|
|
50
|
+
"hubspot-list-associations",
|
|
51
|
+
"hubspot-batch-create-associations",
|
|
52
|
+
"hubspot-get-association-definitions",
|
|
53
|
+
"hubspot-list-properties",
|
|
54
|
+
"hubspot-get-property",
|
|
55
|
+
];
|
|
56
|
+
const HS = "HUBSPOT_DEFINED";
|
|
57
|
+
const C = "/crm/v3/objects/contacts";
|
|
58
|
+
const CO = "/crm/v3/objects/companies";
|
|
59
|
+
const D = "/crm/v3/objects/deals";
|
|
60
|
+
const A = "/crm/v4/objects";
|
|
61
|
+
|
|
62
|
+
/** One representative request per operation (used by the auth, scope and fault drills). */
|
|
63
|
+
const CALLS = [
|
|
64
|
+
["GET", "/account-info/v3/details"],
|
|
65
|
+
["GET", C],
|
|
66
|
+
["GET", `${C}/101`],
|
|
67
|
+
["POST", C, { properties: { firstname: "Probe" } }],
|
|
68
|
+
["PATCH", `${C}/101`, { properties: { firstname: "Probe" } }],
|
|
69
|
+
["DELETE", `${C}/101`],
|
|
70
|
+
["POST", `${C}/batch/read`, { inputs: [{ id: "101" }] }],
|
|
71
|
+
["POST", `${CO}/batch/create`, { inputs: [{ properties: { name: "Probe Co" } }] }],
|
|
72
|
+
["POST", `${D}/batch/update`, { inputs: [{ id: "301", properties: { amount: "1" } }] }],
|
|
73
|
+
["POST", `${C}/search`, {}],
|
|
74
|
+
["GET", `${A}/contacts/101/associations/companies`],
|
|
75
|
+
["PUT", `${A}/contacts/102/associations/default/companies/203`],
|
|
76
|
+
["PUT", `${A}/contacts/102/associations/companies/203`, [{ associationCategory: HS, associationTypeId: 279 }]],
|
|
77
|
+
["POST", "/crm/v4/associations/contacts/deals/batch/create", { inputs: [{ from: { id: "113" }, to: { id: "303" }, types: [{ associationCategory: HS, associationTypeId: 4 }] }] }],
|
|
78
|
+
["DELETE", `${A}/contacts/101/associations/companies/201`],
|
|
79
|
+
["POST", "/crm/v4/associations/contacts/companies/batch/read", { inputs: [{ id: "101" }] }],
|
|
80
|
+
["GET", "/crm/v4/associations/contacts/companies/labels"],
|
|
81
|
+
["GET", "/crm/v3/owners"],
|
|
82
|
+
["GET", "/crm/v3/owners/41001"],
|
|
83
|
+
["GET", "/crm/v3/pipelines/deals"],
|
|
84
|
+
["GET", "/crm/v3/pipelines/deals/default"],
|
|
85
|
+
["GET", "/crm/v3/pipelines/deals/default/stages"],
|
|
86
|
+
["GET", "/crm/v3/properties/contacts"],
|
|
87
|
+
["GET", "/crm/v3/properties/contacts/email"],
|
|
88
|
+
];
|
|
89
|
+
const WRITE_CALLS = [3, 4, 5, 7, 8, 11, 12, 13, 14].map((index) => CALLS[index]);
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------------------------
|
|
92
|
+
// Transport helpers
|
|
93
|
+
// ---------------------------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** HubSpot-shaped request. `status` is asserted; the JSON body (if any) and headers are returned. */
|
|
96
|
+
async function api(method, path, { body, status = 200, headers = {}, contentType, rawBody } = {}) {
|
|
97
|
+
const hasBody = body !== undefined || rawBody !== undefined;
|
|
98
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
99
|
+
method,
|
|
100
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, accept: "application/json", ...(hasBody ? { "content-type": contentType ?? "application/json" } : {}), ...headers },
|
|
101
|
+
...(hasBody ? { body: rawBody ?? JSON.stringify(body) } : {}),
|
|
102
|
+
});
|
|
103
|
+
const text = await response.text();
|
|
104
|
+
const json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
105
|
+
assert.equal(response.status, status, `${method} ${path} ${body === undefined ? "" : JSON.stringify(body).slice(0, 200)} -> ${response.status} ${text.slice(0, 400)}`);
|
|
106
|
+
if (response.status !== 204) {
|
|
107
|
+
assert.ok(response.headers.get("x-hubspot-correlation-id"), `${method} ${path}: missing X-HubSpot-Correlation-Id`);
|
|
108
|
+
assert.equal(response.headers.get("x-hubspot-ratelimit-daily"), "250000", `${method} ${path}: missing rate-limit headers`);
|
|
109
|
+
}
|
|
110
|
+
return { json, headers: response.headers, status: response.status };
|
|
111
|
+
}
|
|
112
|
+
const get = (path, options) => api("GET", path, options);
|
|
113
|
+
const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
114
|
+
const patch = (path, body, options) => api("PATCH", path, { ...options, body });
|
|
115
|
+
const put = (path, body, options) => api("PUT", path, { ...options, body });
|
|
116
|
+
const del = (path, options) => api("DELETE", path, { ...options, status: 204 });
|
|
117
|
+
|
|
118
|
+
/** Expect a HubSpot error envelope: status, `category`, and a message (or errors[].message) containing `text`. */
|
|
119
|
+
async function apiError(method, path, status, category, text, options = {}) {
|
|
120
|
+
const result = await api(method, path, { ...options, status });
|
|
121
|
+
const body = result.json;
|
|
122
|
+
assert.ok(body && body.status === "error" && typeof body.message === "string", `${method} ${path}: no HubSpot error envelope: ${JSON.stringify(body)}`);
|
|
123
|
+
assert.equal(body.category, category, `${method} ${path}: ${JSON.stringify(body)}`);
|
|
124
|
+
assert.ok(typeof body.correlationId === "string" && body.correlationId.length > 0, `${method} ${path}: no correlationId`);
|
|
125
|
+
if (text !== undefined) {
|
|
126
|
+
const texts = [body.message, ...(Array.isArray(body.errors) ? body.errors.map((error) => error.message ?? "") : [])];
|
|
127
|
+
assert.ok(texts.some((candidate) => candidate.includes(text)), `${method} ${path}: expected text ${JSON.stringify(text)}, got ${JSON.stringify(body)}`);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
const call = ([method, path, body], options) => api(method, path, { ...options, ...(body === undefined ? {} : { body }) });
|
|
132
|
+
const callError = ([method, path, body], status, category, text) => apiError(method, path, status, category, text, body === undefined ? {} : { body });
|
|
133
|
+
|
|
134
|
+
let rpcId = 0;
|
|
135
|
+
async function rpc(method, params) {
|
|
136
|
+
const response = await fetch(MCP, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { authorization: `Bearer ${MCP_TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
139
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
|
140
|
+
});
|
|
141
|
+
assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
|
|
142
|
+
const text = await response.text();
|
|
143
|
+
const type = response.headers.get("content-type") ?? "";
|
|
144
|
+
const messages = type.includes("text/event-stream")
|
|
145
|
+
? text
|
|
146
|
+
.split(/\r?\n/)
|
|
147
|
+
.filter((line) => line.startsWith("data:"))
|
|
148
|
+
.map((line) => JSON.parse(line.slice(5).trim()))
|
|
149
|
+
: [JSON.parse(text)];
|
|
150
|
+
const reply = messages.find((message) => message.id === rpcId);
|
|
151
|
+
assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
|
|
152
|
+
if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
|
|
153
|
+
return reply.result;
|
|
154
|
+
}
|
|
155
|
+
async function mcpInit() {
|
|
156
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "hubspot-conformance", version: "0.1.0" } });
|
|
157
|
+
}
|
|
158
|
+
async function mcp(name, args) {
|
|
159
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
160
|
+
assert.ok(!result.isError, `${name} ${JSON.stringify(args)}: ${JSON.stringify(result).slice(0, 500)}`);
|
|
161
|
+
return result.structuredContent;
|
|
162
|
+
}
|
|
163
|
+
async function mcpError(name, args, code) {
|
|
164
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
165
|
+
assert.ok(result.isError, `${name} ${JSON.stringify(args)} unexpectedly succeeded`);
|
|
166
|
+
if (code) {
|
|
167
|
+
const error = result.structuredContent?.error;
|
|
168
|
+
assert.ok(error, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
|
|
169
|
+
assert.equal(error.code, code, JSON.stringify(error));
|
|
170
|
+
}
|
|
171
|
+
return result.structuredContent;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ids = (items) => items.map((item) => item.id);
|
|
175
|
+
const targets = (items) => items.map((item) => item.toObjectId);
|
|
176
|
+
const NEW_CONTACT = { email: "nils.holm@harborlane.example.com", firstname: "Nils", lastname: "Holm", lifecyclestage: "lead", hubspot_owner_id: "41002" };
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------------------------
|
|
179
|
+
// Drill: rest-flow (rep = Maya, baseline)
|
|
180
|
+
// ---------------------------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
async function restFlow() {
|
|
183
|
+
// Account ----------------------------------------------------------------------------------
|
|
184
|
+
const account = (await get("/account-info/v3/details")).json;
|
|
185
|
+
assert.equal(account.portalId, 24871365);
|
|
186
|
+
assert.equal(account.dataHostingLocation, "eu1");
|
|
187
|
+
assert.equal(account.user, undefined, "the REST route does not expose the user block");
|
|
188
|
+
|
|
189
|
+
// Listing ----------------------------------------------------------------------------------
|
|
190
|
+
const first = (await get(C)).json;
|
|
191
|
+
assert.equal(first.results.length, 10);
|
|
192
|
+
assert.deepEqual(Object.keys(first.results[0].properties), ["createdate", "email", "firstname", "hs_object_id", "lastmodifieddate", "lastname"]);
|
|
193
|
+
assert.ok(first.results.every((record) => record.archived === false && record.id !== "110"));
|
|
194
|
+
assert.equal(first.paging.next.after, "111", "the archived contact 110 is skipped");
|
|
195
|
+
const pages = [];
|
|
196
|
+
let next = `${C}?limit=5`;
|
|
197
|
+
while (next !== undefined) {
|
|
198
|
+
const page = (await get(next)).json;
|
|
199
|
+
pages.push(page.results.length);
|
|
200
|
+
next = page.paging === undefined ? undefined : page.paging.next.link;
|
|
201
|
+
}
|
|
202
|
+
assert.deepEqual(pages, [5, 5, 3], "limit=5 walks three pages through paging.next.link");
|
|
203
|
+
await apiError("GET", `${C}?limit=0`, 400, "VALIDATION_ERROR", "limit");
|
|
204
|
+
await apiError("GET", `${C}?limit=101`, 400, "VALIDATION_ERROR", "limit");
|
|
205
|
+
await apiError("GET", `${C}?after=zzz`, 400, "VALIDATION_ERROR", "Invalid after cursor");
|
|
206
|
+
await apiError("GET", `${C}?limit=abc`, 400, "VALIDATION_ERROR", "limit");
|
|
207
|
+
await apiError("GET", `${C}?archived=maybe`, 400, "VALIDATION_ERROR", "archived");
|
|
208
|
+
await apiError("GET", `${C}?propertiesWithHistory=email`, 400, "VALIDATION_ERROR", "propertiesWithHistory");
|
|
209
|
+
const archivedList = (await get(`${C}?archived=true`)).json;
|
|
210
|
+
assert.deepEqual(ids(archivedList.results), ["110"]);
|
|
211
|
+
assert.ok(archivedList.results[0].archivedAt);
|
|
212
|
+
const selected = (await get(`${C}?properties=email,plan_tier,doesnotexist&limit=1`)).json.results[0];
|
|
213
|
+
assert.deepEqual(Object.keys(selected.properties), ["createdate", "email", "hs_object_id", "lastmodifieddate", "plan_tier"], "unknown properties are dropped, system ones are always present");
|
|
214
|
+
assert.equal(selected.properties.plan_tier, "enterprise");
|
|
215
|
+
const withAssociations = (await get(`${C}?associations=companies,deals&limit=1`)).json.results[0];
|
|
216
|
+
assert.deepEqual(withAssociations.associations.companies.results, [
|
|
217
|
+
{ id: "201", type: "contact_to_company_primary" },
|
|
218
|
+
{ id: "201", type: "contact_to_company" },
|
|
219
|
+
]);
|
|
220
|
+
assert.deepEqual(ids(withAssociations.associations.deals.results), ["301", "307"]);
|
|
221
|
+
assert.equal((await get("/crm/v3/objects/0-1?limit=2")).json.results.length, 2, "numeric object type ids resolve");
|
|
222
|
+
assert.equal((await get("/crm/v3/objects/Contact?limit=1")).json.results.length, 1, "singular spelling resolves");
|
|
223
|
+
await apiError("GET", "/crm/v3/objects/tickets", 404, "OBJECT_NOT_FOUND", "Unable to infer object type from: tickets");
|
|
224
|
+
|
|
225
|
+
// Reading ----------------------------------------------------------------------------------
|
|
226
|
+
const alice = (await get(`${C}/101`)).json;
|
|
227
|
+
assert.equal(alice.properties.email, "alice.nordin@fjordbyte.example.com");
|
|
228
|
+
assert.equal(alice.properties.hs_object_id, "101");
|
|
229
|
+
const zoe = (await get(`${C}/zoe.mueller@kestrelpay.example.org?idProperty=email`)).json;
|
|
230
|
+
assert.equal(zoe.id, "103", "idProperty=email matches case-insensitively");
|
|
231
|
+
await apiError("GET", `${C}/101?idProperty=firstname`, 400, "VALIDATION_ERROR", "Invalid idProperty");
|
|
232
|
+
await apiError("GET", `${C}/110`, 404, "OBJECT_NOT_FOUND");
|
|
233
|
+
assert.equal((await get(`${C}/110?archived=true`)).json.archived, true);
|
|
234
|
+
await apiError("GET", `${C}/201`, 404, "OBJECT_NOT_FOUND", "resource not found");
|
|
235
|
+
await apiError("GET", `${C}/not-an-id`, 404, "OBJECT_NOT_FOUND");
|
|
236
|
+
const owned = (await get(`${C}/101?properties=hubspot_owner_id,hubspot_owner_assigneddate`)).json.properties;
|
|
237
|
+
assert.equal(owned.hubspot_owner_id, "41001");
|
|
238
|
+
assert.ok(owned.hubspot_owner_assigneddate);
|
|
239
|
+
|
|
240
|
+
// Creating ---------------------------------------------------------------------------------
|
|
241
|
+
const created = (
|
|
242
|
+
await post(C, { properties: NEW_CONTACT, associations: [{ to: { id: "201" }, types: [{ associationCategory: HS, associationTypeId: 279 }] }] }, { status: 201 })
|
|
243
|
+
).json;
|
|
244
|
+
assert.equal(created.id, "1001");
|
|
245
|
+
assert.equal(created.properties.hs_object_id, "1001");
|
|
246
|
+
assert.equal(created.properties.createdate, created.createdAt);
|
|
247
|
+
assert.equal(created.properties.hubspot_owner_assigneddate, created.createdAt);
|
|
248
|
+
assert.equal(created.properties.lifecyclestage, "lead");
|
|
249
|
+
assert.equal(created.archived, false);
|
|
250
|
+
assert.deepEqual(targets((await get(`${A}/contacts/1001/associations/companies`)).json.results), ["201"]);
|
|
251
|
+
await apiError("POST", C, 409, "CONFLICT", "Contact already exists. Existing ID: 1001", { body: { properties: { email: "NILS.HOLM@harborlane.example.com" } } });
|
|
252
|
+
const invalidEnum = await apiError("POST", C, 400, "VALIDATION_ERROR", "lifecyclestage", { body: { properties: { email: "x@example.org", lifecyclestage: "vip" } } });
|
|
253
|
+
assert.deepEqual(invalidEnum.json.errors[0].context.propertyName, ["lifecyclestage"]);
|
|
254
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", 'Property "hs_object_id" is read only', { body: { properties: { hs_object_id: "1" } } });
|
|
255
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", 'Property "bogus" does not exist', { body: { properties: { bogus: "x" } } });
|
|
256
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "Owner 41004 is not valid", { body: { properties: { firstname: "x", hubspot_owner_id: "41004" } } });
|
|
257
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "Owner 99999 is not valid", { body: { properties: { firstname: "x", hubspot_owner_id: "99999" } } });
|
|
258
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "properties", { body: { properties: {} } });
|
|
259
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "Invalid input JSON", { body: { properties: { firstname: ["array"] } } });
|
|
260
|
+
await apiError("POST", C, 404, "OBJECT_NOT_FOUND", "No company with ID 999999 exists", {
|
|
261
|
+
body: { properties: { email: "orphan@example.org" }, associations: [{ to: { id: "999999" }, types: [{ associationCategory: HS, associationTypeId: 279 }] }] },
|
|
262
|
+
});
|
|
263
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "Association type 999", { body: { properties: { email: "orphan@example.org" }, associations: [{ to: { id: "201" }, types: [{ associationCategory: HS, associationTypeId: 999 }] }] } });
|
|
264
|
+
assert.equal((await post(`${C}/search`, { filterGroups: [{ filters: [{ propertyName: "email", operator: "EQ", value: "orphan@example.org" }] }] })).json.total, 0, "a failed create writes nothing");
|
|
265
|
+
await apiError("POST", "/crm/v3/objects/tickets", 404, "OBJECT_NOT_FOUND", "tickets", { body: { properties: { subject: "x" } } });
|
|
266
|
+
|
|
267
|
+
// Updating ---------------------------------------------------------------------------------
|
|
268
|
+
const updated = (await patch(`${C}/1001`, { properties: { phone: "+46 8 123 456", lifecyclestage: null } })).json;
|
|
269
|
+
assert.equal(updated.properties.phone, "+46 8 123 456");
|
|
270
|
+
assert.equal(updated.properties.lifecyclestage, undefined, "a cleared property is omitted from the write response");
|
|
271
|
+
assert.equal((await get(`${C}/1001?properties=lifecyclestage`)).json.properties.lifecyclestage, null, "...and reads back as null");
|
|
272
|
+
assert.ok(updated.properties.lastmodifieddate >= updated.properties.createdate);
|
|
273
|
+
await apiError("PATCH", `${C}/1001`, 409, "CONFLICT", "Contact already exists. Existing ID: 101", { body: { properties: { email: "Alice.Nordin@fjordbyte.example.com" } } });
|
|
274
|
+
await apiError("PATCH", `${C}/1001`, 400, "VALIDATION_ERROR", "lifecyclestage", { body: { properties: { lifecyclestage: "vip" } } });
|
|
275
|
+
await apiError("PATCH", `${C}/110`, 404, "OBJECT_NOT_FOUND", undefined, { body: { properties: { firstname: "x" } } });
|
|
276
|
+
await apiError("PATCH", `${C}/999999`, 404, "OBJECT_NOT_FOUND", undefined, { body: { properties: { firstname: "x" } } });
|
|
277
|
+
await apiError("PATCH", "/crm/v3/objects/tickets/1", 404, "OBJECT_NOT_FOUND", "tickets", { body: { properties: { firstname: "x" } } });
|
|
278
|
+
assert.equal((await patch(`${C}/nils.holm@harborlane.example.com?idProperty=email`, { properties: { jobtitle: "Fleet Manager" } })).json.id, "1001");
|
|
279
|
+
|
|
280
|
+
// Archiving --------------------------------------------------------------------------------
|
|
281
|
+
await del(`${C}/1001`);
|
|
282
|
+
await apiError("DELETE", `${C}/1001`, 404, "OBJECT_NOT_FOUND");
|
|
283
|
+
const archived = (await get(`${C}/1001?archived=true`)).json;
|
|
284
|
+
assert.equal(archived.archived, true);
|
|
285
|
+
assert.ok(archived.archivedAt);
|
|
286
|
+
await apiError("GET", `${C}/1001`, 404, "OBJECT_NOT_FOUND");
|
|
287
|
+
await apiError("DELETE", "/crm/v3/objects/tickets/1", 404, "OBJECT_NOT_FOUND", "tickets");
|
|
288
|
+
|
|
289
|
+
// Batch objects ----------------------------------------------------------------------------
|
|
290
|
+
const batchRead = (await post(`${C}/batch/read`, { inputs: [{ id: "101" }, { id: "999" }, { id: 102 }], properties: ["email"] })).json;
|
|
291
|
+
assert.equal(batchRead.status, "COMPLETE");
|
|
292
|
+
assert.deepEqual(ids(batchRead.results), ["101", "102"]);
|
|
293
|
+
assert.equal(batchRead.numErrors, 1);
|
|
294
|
+
assert.equal(batchRead.errors[0].category, "OBJECT_NOT_FOUND");
|
|
295
|
+
assert.deepEqual(batchRead.errors[0].context.ids, ["999"]);
|
|
296
|
+
assert.ok(batchRead.startedAt && batchRead.completedAt);
|
|
297
|
+
await apiError("POST", `${C}/batch/read`, 400, "VALIDATION_ERROR", "inputs", { body: { inputs: [] } });
|
|
298
|
+
await apiError("POST", `${C}/batch/read`, 400, "VALIDATION_ERROR", "At most 100", { body: { inputs: Array.from({ length: 101 }, (_, index) => ({ id: String(index + 1) })) } });
|
|
299
|
+
// Route fuzz retrofit: JSON bodies nested past 512 levels answer 400 before argument validation; normal nesting still works.
|
|
300
|
+
const deepObject = (depth) => `${'{"a":'.repeat(depth)}1${"}".repeat(depth)}`;
|
|
301
|
+
const deepArray = (depth) => `${"[".repeat(depth)}1${"]".repeat(depth)}`;
|
|
302
|
+
const deep = async (method, path, rawBody) => {
|
|
303
|
+
const response = await fetch(`${HTTP}${path}`, { method, headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, body: rawBody });
|
|
304
|
+
const text = await response.text();
|
|
305
|
+
assert.equal(response.status, 400, `${method} ${path} deep body -> ${response.status} ${text.slice(0, 300)}`);
|
|
306
|
+
};
|
|
307
|
+
await deep("POST", "/crm/v4/associations/contacts/companies/batch/read", `{"inputs":${deepObject(2750)}}`);
|
|
308
|
+
await deep("POST", "/crm/v4/associations/contacts/companies/batch/read", `{"inputs":[{"id":"101","x":${deepArray(513)}}]}`);
|
|
309
|
+
await deep("POST", `${C}/batch/update`, `{"inputs":${deepObject(3105)}}`);
|
|
310
|
+
await deep("POST", `${C}/search`, `{"filterGroups":${deepArray(2995)}}`);
|
|
311
|
+
await deep("PUT", "/crm/v4/objects/contacts/101/associations/companies/201", deepArray(3000));
|
|
312
|
+
await deep("POST", C, `{"properties":${deepObject(3150)}}`);
|
|
313
|
+
await apiError("POST", "/crm/v4/associations/contacts/companies/batch/read", 400, "VALIDATION_ERROR", undefined, { body: { inputs: { a: { b: { c: [1, 2, { d: 3 }] } } } } });
|
|
314
|
+
const shallow = (await post("/crm/v4/associations/contacts/companies/batch/read", { inputs: [{ id: "101" }] })).json;
|
|
315
|
+
assert.equal(shallow.status, "COMPLETE", "a normal batch read still succeeds after the depth guard");
|
|
316
|
+
await apiError("POST", "/crm/v3/objects/tickets/batch/read", 404, "OBJECT_NOT_FOUND", undefined, { body: { inputs: [{ id: "1" }] } });
|
|
317
|
+
assert.deepEqual(ids((await post(`${C}/batch/read`, { inputs: [{ id: "ZOE.MUELLER@kestrelpay.example.org" }], idProperty: "email" })).json.results), ["103"]);
|
|
318
|
+
|
|
319
|
+
const batchCreate = (
|
|
320
|
+
await post(`${CO}/batch/create`, { inputs: [{ properties: { name: "Aurora Freight", domain: "aurorafreight.example.com", type: "PROSPECT" } }, { properties: { name: "Bergstrom Analytics", domain: "bergstrom.example.org" } }] }, { status: 201 })
|
|
321
|
+
).json;
|
|
322
|
+
assert.deepEqual(ids(batchCreate.results), ["1002", "1003"]);
|
|
323
|
+
assert.equal(batchCreate.results[0].properties.name, "Aurora Freight");
|
|
324
|
+
await apiError("POST", `${CO}/batch/create`, 400, "VALIDATION_ERROR", "type", { body: { inputs: [{ properties: { name: "Good Co" } }, { properties: { name: "Bad Co", type: "FRIEND" } }] } });
|
|
325
|
+
await apiError("POST", `${C}/batch/create`, 409, "CONFLICT", "Contact already exists", { body: { inputs: [{ properties: { email: "dup@example.org" } }, { properties: { email: "DUP@example.org" } }] } });
|
|
326
|
+
await apiError("POST", `${C}/batch/create`, 400, "VALIDATION_ERROR", "inputs", { body: { inputs: [] } });
|
|
327
|
+
await apiError("POST", "/crm/v3/objects/tickets/batch/create", 404, "OBJECT_NOT_FOUND", undefined, { body: { inputs: [{ properties: { subject: "x" } }] } });
|
|
328
|
+
assert.equal((await get(`${CO}?limit=100`)).json.results.length, 7, "the failed batch added no company");
|
|
329
|
+
|
|
330
|
+
const batchUpdate = (await post(`${D}/batch/update`, { inputs: [{ id: "301", properties: { amount: "12000" } }, { id: "999", properties: { amount: "1" } }] })).json;
|
|
331
|
+
assert.deepEqual(ids(batchUpdate.results), ["301"]);
|
|
332
|
+
assert.equal(batchUpdate.results[0].properties.amount, "12000");
|
|
333
|
+
assert.equal(batchUpdate.numErrors, 1);
|
|
334
|
+
assert.equal(batchUpdate.errors[0].context.id[0], "999");
|
|
335
|
+
await apiError("POST", `${D}/batch/update`, 400, "VALIDATION_ERROR", "amount", { body: { inputs: [{ id: "302", properties: { amount: "lots" } }] } });
|
|
336
|
+
await apiError("POST", `${C}/batch/update`, 409, "CONFLICT", "Existing ID: 101", { body: { inputs: [{ id: "102", properties: { email: "alice.nordin@fjordbyte.example.com" } }] } });
|
|
337
|
+
await apiError("POST", `${D}/batch/update`, 400, "VALIDATION_ERROR", "inputs", { body: { inputs: [] } });
|
|
338
|
+
await apiError("POST", "/crm/v3/objects/tickets/batch/update", 404, "OBJECT_NOT_FOUND", undefined, { body: { inputs: [{ id: "1", properties: { subject: "x" } }] } });
|
|
339
|
+
assert.equal((await get(`${D}/302`)).json.properties.amount, "42000", "the rejected batch changed nothing");
|
|
340
|
+
|
|
341
|
+
// Deals, notes, tasks ----------------------------------------------------------------------
|
|
342
|
+
await apiError("POST", D, 400, "VALIDATION_ERROR", "renewal-outreach", { body: { properties: { dealname: "Wrong pipeline", dealstage: "renewal-outreach" } } });
|
|
343
|
+
await apiError("POST", D, 400, "VALIDATION_ERROR", "dealstage", { body: { properties: { dealname: "No stage" } } });
|
|
344
|
+
await apiError("POST", D, 400, "VALIDATION_ERROR", "Pipeline ID nope is not valid", { body: { properties: { dealname: "x", pipeline: "nope", dealstage: "renewed" } } });
|
|
345
|
+
const deal = (await post(D, { properties: { dealname: "Lumenworks — 2027 renewal", pipeline: "5f2a9c1e", dealstage: "renewal-outreach", amount: 15000, closedate: "1798675200000" } }, { status: 201 })).json;
|
|
346
|
+
assert.equal(deal.id, "1004");
|
|
347
|
+
assert.equal(deal.properties.hs_is_closed, "false");
|
|
348
|
+
assert.equal(deal.properties.hs_deal_stage_probability, "0.3");
|
|
349
|
+
assert.equal(deal.properties.amount, "15000");
|
|
350
|
+
assert.equal(deal.properties.closedate, "2026-12-31T00:00:00.000Z", "epoch milliseconds are stored as ISO-8601");
|
|
351
|
+
const renewed = (await patch(`${D}/1004`, { properties: { dealstage: "renewed" } })).json;
|
|
352
|
+
assert.equal(renewed.properties.hs_is_closed, "true");
|
|
353
|
+
assert.equal(renewed.properties.hs_is_closed_won, "true");
|
|
354
|
+
assert.equal(renewed.properties.hs_deal_stage_probability, "1.0");
|
|
355
|
+
await apiError("POST", "/crm/v3/objects/notes", 400, "VALIDATION_ERROR", "hs_timestamp", { body: { properties: { hs_note_body: "no timestamp" } } });
|
|
356
|
+
const note = (await post("/crm/v3/objects/notes", { properties: { hs_note_body: "Follow-up sent.", hs_timestamp: "1789380000000", hubspot_owner_id: "41001" } }, { status: 201 })).json;
|
|
357
|
+
assert.equal(note.id, "1005");
|
|
358
|
+
assert.equal(note.properties.hs_timestamp, "2026-09-14T10:00:00.000Z");
|
|
359
|
+
await apiError("POST", "/crm/v3/objects/tasks", 400, "VALIDATION_ERROR", "hs_task_status", { body: { properties: { hs_task_subject: "x", hs_timestamp: "2026-09-20T09:00:00Z", hs_task_status: "DONE" } } });
|
|
360
|
+
const taskRecord = (await post("/crm/v3/objects/tasks", { properties: { hs_task_subject: "Send the renewal quote", hs_timestamp: "2026-09-20T09:00:00Z", hs_task_status: "COMPLETED", hs_task_priority: "HIGH" } }, { status: 201 })).json;
|
|
361
|
+
assert.equal(taskRecord.id, "1006");
|
|
362
|
+
assert.equal(taskRecord.properties.hs_task_status, "COMPLETED");
|
|
363
|
+
// Datetime coercion is host-independent: zone-less ISO is UTC, date-only is midnight UTC, offsets are applied,
|
|
364
|
+
// and anything that is not epoch milliseconds or a real ISO-8601 calendar value is rejected (INVALID_DATE).
|
|
365
|
+
const T = "/crm/v3/objects/tasks/1006";
|
|
366
|
+
assert.equal((await patch(T, { properties: { hs_timestamp: "2026-09-20T09:00:00" } })).json.properties.hs_timestamp, "2026-09-20T09:00:00.000Z", "zone-less datetime is UTC");
|
|
367
|
+
assert.equal((await patch(T, { properties: { hs_timestamp: "2026-09-20" } })).json.properties.hs_timestamp, "2026-09-20T00:00:00.000Z", "date-only is midnight UTC");
|
|
368
|
+
assert.equal((await patch(T, { properties: { hs_timestamp: "2026-09-20T11:30:00.25+02:30" } })).json.properties.hs_timestamp, "2026-09-20T09:00:00.250Z", "offsets are applied");
|
|
369
|
+
for (const bad of ["September 20, 2026", "2026-02-30T09:00:00Z", "2026-09-20T24:00:00", "20/09/2026", "2026-09-20T09:00:00+25:00"]) {
|
|
370
|
+
await apiError("PATCH", T, 400, "VALIDATION_ERROR", "not a valid date or datetime", { body: { properties: { hs_timestamp: bad } } });
|
|
371
|
+
}
|
|
372
|
+
assert.equal((await patch(T, { properties: { hs_timestamp: "2026-09-20T09:00:00" } })).json.properties.hs_timestamp, "2026-09-20T09:00:00.000Z");
|
|
373
|
+
assert.equal((await get(`${T}?properties=hs_timestamp`)).json.properties.hs_timestamp, "2026-09-20T09:00:00.000Z", "rejected values changed nothing");
|
|
374
|
+
|
|
375
|
+
// Search -----------------------------------------------------------------------------------
|
|
376
|
+
const search = (path, body) => post(`${path}/search`, body).then((result) => result.json);
|
|
377
|
+
const filters = (...list) => ({ filterGroups: [{ filters: list }] });
|
|
378
|
+
assert.equal((await search(C, filters({ propertyName: "lifecyclestage", operator: "EQ", value: "customer" }))).total, 2);
|
|
379
|
+
assert.deepEqual(ids((await search(D, filters({ propertyName: "amount", operator: "GT", value: "50000" }))).results), ["304", "305"], "deal 301 was batch-updated to 12000 above");
|
|
380
|
+
assert.equal((await search(D, filters({ propertyName: "amount", operator: "BETWEEN", value: 30000, highValue: "80000" }))).total, 4);
|
|
381
|
+
assert.equal((await search(D, filters({ propertyName: "dealstage", operator: "IN", values: ["closedwon", "closedlost"] }))).total, 2);
|
|
382
|
+
assert.equal((await search(D, filters({ propertyName: "dealstage", operator: "NOT_IN", values: ["closedwon", "closedlost"] }))).total, 7, "eight starter deals plus the renewal created above, minus the two closed ones");
|
|
383
|
+
assert.equal((await search(D, filters({ propertyName: "amount", operator: "HAS_PROPERTY" }))).total, 8, "deal 307 has no amount");
|
|
384
|
+
assert.deepEqual(ids((await search(C, filters({ propertyName: "email", operator: "NOT_HAS_PROPERTY" }))).results), ["106"]);
|
|
385
|
+
assert.deepEqual(ids((await search(C, filters({ propertyName: "firstname", operator: "CONTAINS_TOKEN", value: "zo*" }))).results), ["103"]);
|
|
386
|
+
assert.equal((await search(C, filters({ propertyName: "firstname", operator: "NOT_CONTAINS_TOKEN", value: "zo*" }))).total, 12);
|
|
387
|
+
assert.equal((await search(C, filters({ propertyName: "createdate", operator: "GTE", value: "2026-08-01T00:00:00Z" }))).total, 4);
|
|
388
|
+
assert.equal((await search(C, filters({ propertyName: "createdate", operator: "GTE", value: "2026-08-01T00:00:00" }))).total, 4, "a zone-less filter value is UTC");
|
|
389
|
+
assert.equal((await search(C, filters({ propertyName: "createdate", operator: "GTE", value: "2026-08-01" }))).total, 4);
|
|
390
|
+
assert.deepEqual(ids((await search("/crm/v3/objects/tasks", filters({ propertyName: "hs_timestamp", operator: "EQ", value: "2026-09-20T09:00:00" }))).results), ["1006"]);
|
|
391
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "is not a valid date", { body: filters({ propertyName: "createdate", operator: "GTE", value: "August 1, 2026" }) });
|
|
392
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "is not a valid date", { body: filters({ propertyName: "createdate", operator: "BETWEEN", value: "2026-08-01", highValue: "2026-02-30" }) });
|
|
393
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "is not a valid date", { body: filters({ propertyName: "createdate", operator: "IN", values: ["2026-08-01", "soon"] }) });
|
|
394
|
+
assert.equal((await search(C, filters({ propertyName: "lifecyclestage", operator: "NEQ", value: "lead" }))).total, 9);
|
|
395
|
+
assert.equal((await search(C, { filterGroups: [{ filters: [{ propertyName: "lifecyclestage", operator: "EQ", value: "customer" }] }, { filters: [{ propertyName: "hs_lead_status", operator: "EQ", value: "NEW" }] }] })).total, 4, "filterGroups are OR-ed");
|
|
396
|
+
assert.equal((await search(C, filters({ propertyName: "lifecyclestage", operator: "EQ", value: "customer" }, { propertyName: "plan_tier", operator: "EQ", value: "team" }))).total, 1, "filters in a group are AND-ed");
|
|
397
|
+
assert.deepEqual(ids((await search(C, { query: "kjær" })).results), ["104"]);
|
|
398
|
+
assert.deepEqual(ids((await search(C, { query: "søren kj" })).results), ["104"]);
|
|
399
|
+
assert.equal((await search(C, { query: "fjordbyte" })).total, 3);
|
|
400
|
+
assert.equal((await search(C, { query: "nobody-matches" })).total, 0);
|
|
401
|
+
assert.equal((await search(C, { sorts: [{ propertyName: "createdate", direction: "DESCENDING" }], limit: 1 })).results[0].id, "114");
|
|
402
|
+
assert.equal((await search(C, { sorts: ["-createdate"], limit: 1 })).results[0].id, "114");
|
|
403
|
+
assert.equal((await search(D, { sorts: ["amount"], limit: 1 })).results[0].id, "306");
|
|
404
|
+
const walked = [];
|
|
405
|
+
let after = "0";
|
|
406
|
+
let total;
|
|
407
|
+
while (after !== undefined) {
|
|
408
|
+
const page = await search(C, { limit: 3, after, properties: ["firstname"] });
|
|
409
|
+
total = page.total;
|
|
410
|
+
walked.push(...ids(page.results));
|
|
411
|
+
after = page.paging?.next.after;
|
|
412
|
+
}
|
|
413
|
+
assert.equal(total, 13);
|
|
414
|
+
assert.equal(walked.length, 13);
|
|
415
|
+
assert.ok(!walked.includes("110") && !walked.includes("1001"), "search never returns archived records");
|
|
416
|
+
assert.equal((await search(C, { limit: 3, after: 3 })).results[0].id, "104", "a numeric after is accepted");
|
|
417
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "after", { body: { after: "10000" } });
|
|
418
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "At most 6 filters", { body: { filterGroups: [{ filters: Array.from({ length: 7 }, () => ({ propertyName: "email", operator: "HAS_PROPERTY" })) }] } });
|
|
419
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "Invalid operator", { body: filters({ propertyName: "email", operator: "LIKE", value: "x" }) });
|
|
420
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "requires a value", { body: filters({ propertyName: "email", operator: "EQ" }) });
|
|
421
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", 'Property "nope" does not exist', { body: filters({ propertyName: "nope", operator: "EQ", value: "x" }) });
|
|
422
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "Only one sort", { body: { sorts: ["createdate", "-email"] } });
|
|
423
|
+
// CONTAINS_TOKEN wildcards match in linear time: patterns that make a backtracking regex explode answer at once.
|
|
424
|
+
assert.deepEqual(ids((await search(C, filters({ propertyName: "email", operator: "CONTAINS_TOKEN", value: "z*mue*@*.org" }))).results), ["103"], "wildcards in the middle of a token");
|
|
425
|
+
for (const value of ["*a*a*a*a*a*a*a*a*q", `*${"a*".repeat(1400)}q`, `${"*".repeat(3000)}`]) {
|
|
426
|
+
const started = performance.now();
|
|
427
|
+
const hit = await search(C, filters({ propertyName: "email", operator: "CONTAINS_TOKEN", value }));
|
|
428
|
+
const missed = await search(C, filters({ propertyName: "email", operator: "NOT_CONTAINS_TOKEN", value }));
|
|
429
|
+
const elapsed = performance.now() - started;
|
|
430
|
+
assert.ok(elapsed < 2000, `wildcard pattern of ${value.length} characters took ${Math.round(elapsed)} ms`);
|
|
431
|
+
assert.equal(hit.total + missed.total, (await search(C, {})).total, "CONTAINS_TOKEN and NOT_CONTAINS_TOKEN partition the records");
|
|
432
|
+
if (value.endsWith("q")) assert.equal(hit.total, 0);
|
|
433
|
+
}
|
|
434
|
+
assert.ok((await get("/account-info/v3/details")).json.portalId, "the server still answers after pathological patterns");
|
|
435
|
+
// Search text is bounded like HubSpot's 3,000-character query limit.
|
|
436
|
+
const long = "a".repeat(3001);
|
|
437
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "maximum length of 3000", { body: filters({ propertyName: "email", operator: "CONTAINS_TOKEN", value: `*${long}*` }) });
|
|
438
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "maximum length of 3000", { body: filters({ propertyName: "email", operator: "EQ", value: long }) });
|
|
439
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "maximum length of 3000", { body: filters({ propertyName: "email", operator: "IN", values: ["x", long] }) });
|
|
440
|
+
await apiError("POST", `${D}/search`, 400, "VALIDATION_ERROR", "maximum length of 3000", { body: filters({ propertyName: "amount", operator: "BETWEEN", value: "1", highValue: long }) });
|
|
441
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "maximum length of 3000", { body: { query: long } });
|
|
442
|
+
assert.equal((await search(C, filters({ propertyName: "email", operator: "CONTAINS_TOKEN", value: `*${"a".repeat(2998)}` }))).total, 0, "3000 characters is accepted");
|
|
443
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "limit", { body: { limit: 201 } });
|
|
444
|
+
await apiError("POST", "/crm/v3/objects/tickets/search", 404, "OBJECT_NOT_FOUND", undefined, { body: {} });
|
|
445
|
+
|
|
446
|
+
// Associations -----------------------------------------------------------------------------
|
|
447
|
+
const aliceCompanies = (await get(`${A}/contacts/101/associations/companies`)).json.results;
|
|
448
|
+
assert.deepEqual(aliceCompanies, [{ toObjectId: "201", associationTypes: [{ category: HS, typeId: 1, label: "Primary" }, { category: HS, typeId: 279, label: null }] }]);
|
|
449
|
+
assert.deepEqual(targets((await get(`${A}/companies/201/associations/contacts`)).json.results), ["101", "102"], "the archived contact 1001 is omitted although its link rows remain");
|
|
450
|
+
const zoeCompanies = (await get(`${A}/contacts/103/associations/companies`)).json.results[0];
|
|
451
|
+
assert.deepEqual(zoeCompanies.associationTypes.map((type) => type.label), ["Champion", null]);
|
|
452
|
+
await apiError("GET", `${A}/companies/206/associations/contacts`, 404, "OBJECT_NOT_FOUND");
|
|
453
|
+
await apiError("GET", `${A}/contacts/101/associations/tickets`, 404, "OBJECT_NOT_FOUND", "tickets");
|
|
454
|
+
await apiError("GET", `${A}/contacts/101/associations/companies?limit=0`, 400, "VALIDATION_ERROR", "limit");
|
|
455
|
+
const limited = (await get(`${A}/companies/201/associations/contacts?limit=1`)).json;
|
|
456
|
+
assert.equal(limited.results[0].toObjectId, "101");
|
|
457
|
+
assert.equal(limited.paging.next.after, "101");
|
|
458
|
+
assert.deepEqual(targets((await get(limited.paging.next.link)).json.results), ["102"]);
|
|
459
|
+
assert.deepEqual((await get(`${A}/contacts/112/associations/companies`)).json.results, [], "links to an archived company are omitted");
|
|
460
|
+
|
|
461
|
+
const defaults = (await put(`${A}/contacts/101/associations/default/companies/203`, undefined)).json;
|
|
462
|
+
assert.deepEqual(defaults.map((entry) => entry.associationSpec.associationTypeId), [279, 280]);
|
|
463
|
+
assert.equal(defaults[1].fromObjectTypeId, "0-2");
|
|
464
|
+
assert.deepEqual((await put(`${A}/contacts/101/associations/default/companies/203`, undefined)).json, defaults, "creating the default association twice is idempotent");
|
|
465
|
+
await apiError("PUT", `${A}/notes/401/associations/default/tasks/501`, 400, "VALIDATION_ERROR", "No default association type");
|
|
466
|
+
await apiError("PUT", `${A}/contacts/999999/associations/default/companies/201`, 404, "OBJECT_NOT_FOUND", "No contact with ID 999999 exists");
|
|
467
|
+
await apiError("PUT", `${A}/contacts/101/associations/default/companies/206`, 404, "OBJECT_NOT_FOUND", "No company with ID 206 exists");
|
|
468
|
+
const labelled = (await put(`${A}/contacts/101/associations/companies/203`, [{ associationCategory: HS, associationTypeId: 1 }], { status: 201 })).json;
|
|
469
|
+
assert.deepEqual(labelled.labels, ["Primary"]);
|
|
470
|
+
assert.deepEqual((await get(`${A}/contacts/101/associations/companies`)).json.results.map((link) => [link.toObjectId, link.associationTypes.map((type) => type.typeId)]), [
|
|
471
|
+
["201", [279]],
|
|
472
|
+
["203", [1, 279]],
|
|
473
|
+
], "the Primary label moved from company 201 to company 203");
|
|
474
|
+
assert.deepEqual((await get(`${A}/companies/201/associations/contacts`)).json.results[0].associationTypes.map((type) => type.typeId), [280], "...in both directions");
|
|
475
|
+
await apiError("PUT", `${A}/contacts/101/associations/companies/203`, 400, "VALIDATION_ERROR", "Association type 999", { body: [{ associationCategory: HS, associationTypeId: 999 }] });
|
|
476
|
+
await apiError("PUT", `${A}/contacts/101/associations/companies/203`, 400, "VALIDATION_ERROR", "USER_DEFINED", { body: [{ associationCategory: "USER_DEFINED", associationTypeId: 279 }] });
|
|
477
|
+
await apiError("PUT", `${A}/contacts/101/associations/companies/999999`, 404, "OBJECT_NOT_FOUND", undefined, { body: [{ associationCategory: HS, associationTypeId: 279 }] });
|
|
478
|
+
await del(`${A}/contacts/101/associations/companies/203`);
|
|
479
|
+
assert.deepEqual(targets((await get(`${A}/contacts/101/associations/companies`)).json.results), ["201"]);
|
|
480
|
+
assert.deepEqual(targets((await get(`${A}/companies/203/associations/contacts`)).json.results), ["104"]);
|
|
481
|
+
await del(`${A}/contacts/101/associations/companies/203`);
|
|
482
|
+
await apiError("DELETE", `${A}/contacts/999999/associations/companies/203`, 404, "OBJECT_NOT_FOUND");
|
|
483
|
+
|
|
484
|
+
const batchAssociations = (
|
|
485
|
+
await post(
|
|
486
|
+
"/crm/v4/associations/contacts/deals/batch/create",
|
|
487
|
+
{
|
|
488
|
+
inputs: [
|
|
489
|
+
{ from: { id: "113" }, to: { id: "303" }, types: [{ associationCategory: HS, associationTypeId: 4 }] },
|
|
490
|
+
{ from: { id: "114" }, to: { id: "302" }, types: [{ associationCategory: HS, associationTypeId: 4 }] },
|
|
491
|
+
{ from: { id: "999" }, to: { id: "301" }, types: [{ associationCategory: HS, associationTypeId: 4 }] },
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
{ status: 201 },
|
|
495
|
+
)
|
|
496
|
+
).json;
|
|
497
|
+
assert.equal(batchAssociations.results.length, 2);
|
|
498
|
+
assert.equal(batchAssociations.numErrors, 1);
|
|
499
|
+
assert.equal(batchAssociations.errors[0].message, "No contact with ID 999 exists");
|
|
500
|
+
await apiError("POST", "/crm/v4/associations/contacts/deals/batch/create", 400, "VALIDATION_ERROR", "Association type 279", { body: { inputs: [{ from: { id: "113" }, to: { id: "303" }, types: [{ associationCategory: HS, associationTypeId: 279 }] }] } });
|
|
501
|
+
await apiError("POST", "/crm/v4/associations/contacts/deals/batch/create", 400, "VALIDATION_ERROR", "inputs", { body: { inputs: [] } });
|
|
502
|
+
await apiError("POST", "/crm/v4/associations/contacts/tickets/batch/create", 404, "OBJECT_NOT_FOUND", "tickets", { body: { inputs: [{ from: { id: "113" }, to: { id: "1" }, types: [] }] } });
|
|
503
|
+
const batchLinks = (await post("/crm/v4/associations/contacts/companies/batch/read", { inputs: [{ id: "101" }, { id: "113" }, { id: "999" }] })).json;
|
|
504
|
+
assert.deepEqual(batchLinks.results.map((entry) => entry.from.id), ["101"], "records without associations are absent");
|
|
505
|
+
assert.equal(batchLinks.results[0].to[0].toObjectId, "201");
|
|
506
|
+
assert.equal(batchLinks.numErrors, 1);
|
|
507
|
+
await apiError("POST", "/crm/v4/associations/contacts/companies/batch/read", 400, "VALIDATION_ERROR", "inputs", { body: { inputs: [] } });
|
|
508
|
+
await apiError("POST", "/crm/v4/associations/contacts/tickets/batch/read", 404, "OBJECT_NOT_FOUND", undefined, { body: { inputs: [{ id: "101" }] } });
|
|
509
|
+
const labels = (await get("/crm/v4/associations/contacts/companies/labels")).json.results;
|
|
510
|
+
assert.deepEqual(labels, [
|
|
511
|
+
{ category: HS, typeId: 1, label: "Primary" },
|
|
512
|
+
{ category: "USER_DEFINED", typeId: 57, label: "Champion" },
|
|
513
|
+
{ category: HS, typeId: 279, label: null },
|
|
514
|
+
]);
|
|
515
|
+
assert.deepEqual((await get("/crm/v4/associations/notes/tasks/labels")).json.results, []);
|
|
516
|
+
await apiError("GET", "/crm/v4/associations/contacts/tickets/labels", 404, "OBJECT_NOT_FOUND", "tickets");
|
|
517
|
+
|
|
518
|
+
// Owners -----------------------------------------------------------------------------------
|
|
519
|
+
const owners = (await get("/crm/v3/owners")).json;
|
|
520
|
+
assert.deepEqual(ids(owners.results), ["41001", "41002", "41003"]);
|
|
521
|
+
assert.equal(owners.results[0].teams[0].name, "Sales");
|
|
522
|
+
assert.deepEqual(ids((await get("/crm/v3/owners?archived=true")).json.results), ["41004"]);
|
|
523
|
+
assert.deepEqual(ids((await get("/crm/v3/owners?email=Tom.Achebe@brightline.example.com")).json.results), ["41002"]);
|
|
524
|
+
const ownerPage = (await get("/crm/v3/owners?limit=2")).json;
|
|
525
|
+
assert.equal(ownerPage.paging.next.after, "41002");
|
|
526
|
+
assert.deepEqual(ids((await get(ownerPage.paging.next.link)).json.results), ["41003"]);
|
|
527
|
+
await apiError("GET", "/crm/v3/owners?limit=0", 400, "VALIDATION_ERROR", "limit");
|
|
528
|
+
assert.equal((await get("/crm/v3/owners/41001")).json.email, "maya.lindqvist@brightline.example.com");
|
|
529
|
+
assert.equal((await get("/crm/v3/owners/10002?idProperty=userId")).json.id, "41002");
|
|
530
|
+
await apiError("GET", "/crm/v3/owners/41004", 404, "OBJECT_NOT_FOUND");
|
|
531
|
+
assert.equal((await get("/crm/v3/owners/41004?archived=true")).json.archived, true);
|
|
532
|
+
await apiError("GET", "/crm/v3/owners/41001?idProperty=email", 400, "VALIDATION_ERROR", "Invalid idProperty");
|
|
533
|
+
|
|
534
|
+
// Pipelines --------------------------------------------------------------------------------
|
|
535
|
+
const pipelines = (await get("/crm/v3/pipelines/deals")).json.results;
|
|
536
|
+
assert.deepEqual(ids(pipelines), ["default", "5f2a9c1e"]);
|
|
537
|
+
assert.equal(pipelines[0].label, "Sales Pipeline");
|
|
538
|
+
assert.equal((await get("/crm/v3/pipelines/deal")).json.results.length, 2);
|
|
539
|
+
await apiError("GET", "/crm/v3/pipelines/contacts", 404, "OBJECT_NOT_FOUND", "Unable to infer object type from: contacts");
|
|
540
|
+
assert.equal((await get("/crm/v3/pipelines/deals/5f2a9c1e")).json.stages.length, 4);
|
|
541
|
+
await apiError("GET", "/crm/v3/pipelines/deals/nope", 404, "OBJECT_NOT_FOUND", "Pipeline nope not found");
|
|
542
|
+
const stages = (await get("/crm/v3/pipelines/deals/default/stages")).json.results;
|
|
543
|
+
assert.equal(stages.length, 7);
|
|
544
|
+
assert.deepEqual(stages.map((stage) => stage.metadata.probability), ["0.2", "0.4", "0.6", "0.8", "0.9", "1.0", "0.0"]);
|
|
545
|
+
await apiError("GET", "/crm/v3/pipelines/deals/nope/stages", 404, "OBJECT_NOT_FOUND");
|
|
546
|
+
|
|
547
|
+
// Properties -------------------------------------------------------------------------------
|
|
548
|
+
const contactProperties = (await get("/crm/v3/properties/contacts")).json.results;
|
|
549
|
+
const email = contactProperties.find((property) => property.name === "email");
|
|
550
|
+
assert.equal(email.hasUniqueValue, true);
|
|
551
|
+
assert.equal(email.hubspotDefined, true);
|
|
552
|
+
assert.equal(contactProperties.find((property) => property.name === "plan_tier").hubspotDefined, false);
|
|
553
|
+
assert.ok((await get("/crm/v3/properties/companies")).json.results.some((property) => property.name === "csm_health" && property.hidden === true), "the REST route lists hidden properties");
|
|
554
|
+
assert.equal((await get("/crm/v3/properties/companies?archived=true")).json.results.length, 0);
|
|
555
|
+
assert.equal((await get("/crm/v3/properties/deals/arr")).json.type, "number");
|
|
556
|
+
await apiError("GET", "/crm/v3/properties/contacts/nope", 404, "OBJECT_NOT_FOUND", "Property nope does not exist");
|
|
557
|
+
await apiError("GET", "/crm/v3/properties/tickets", 404, "OBJECT_NOT_FOUND", "tickets");
|
|
558
|
+
|
|
559
|
+
// Framework-owned negative paths -----------------------------------------------------------
|
|
560
|
+
const formEncoded = await fetch(`${HTTP}${C}`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/x-www-form-urlencoded" }, body: "email=x" });
|
|
561
|
+
assert.equal(formEncoded.status, 415);
|
|
562
|
+
await formEncoded.text();
|
|
563
|
+
for (const [method, path] of [
|
|
564
|
+
["GET", `${C}/101/merge`],
|
|
565
|
+
["POST", `${C}/gdpr-delete`],
|
|
566
|
+
["POST", `${C}/batch/upsert`],
|
|
567
|
+
["GET", "/crm/v3/schemas"],
|
|
568
|
+
["GET", "/crm/v3/objects/tickets/1/associations"],
|
|
569
|
+
]) {
|
|
570
|
+
const response = await fetch(`${HTTP}${path}`, { method, headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, ...(method === "POST" ? { body: "{}" } : {}) });
|
|
571
|
+
assert.ok(response.status === 404 || response.status === 405, `${method} ${path} must not exist (got ${response.status})`);
|
|
572
|
+
await response.text();
|
|
573
|
+
}
|
|
574
|
+
const unauthenticated = await fetch(`${HTTP}${C}`);
|
|
575
|
+
assert.equal(unauthenticated.status, 401, "a missing bearer token is rejected by the framework");
|
|
576
|
+
await unauthenticated.text();
|
|
577
|
+
|
|
578
|
+
await malformedQueryAndLongIds();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Route-fuzz regressions: malformed query flags answer HubSpot's VALIDATION_ERROR envelope, ids of any length
|
|
583
|
+
* (around the framework's 512-character row-id bound) answer HubSpot's 404/400 envelope, mangled percent-encoding
|
|
584
|
+
* (U+FFFD) in a search or filter answers VALIDATION_ERROR instead of an empty result, and invalid datetimes report
|
|
585
|
+
* exactly one error. Apart from one contact created and archived to prove a long idProperty lookup, nothing changes.
|
|
586
|
+
*/
|
|
587
|
+
async function malformedQueryAndLongIds() {
|
|
588
|
+
for (const [path, name] of [
|
|
589
|
+
[`${C}?limit=1e2`, "limit"],
|
|
590
|
+
[`${C}/101?archived=maybe`, "archived"],
|
|
591
|
+
[`${A}/contacts/101/associations/companies?limit=abc`, "limit"],
|
|
592
|
+
["/crm/v3/owners?limit=abc", "limit"],
|
|
593
|
+
["/crm/v3/owners?archived=yes", "archived"],
|
|
594
|
+
["/crm/v3/owners/41001?archived=1", "archived"],
|
|
595
|
+
["/crm/v3/properties/contacts?archived=maybe", "archived"],
|
|
596
|
+
["/crm/v3/properties/contacts/plan_tier?archived=maybe", "archived"],
|
|
597
|
+
]) {
|
|
598
|
+
await apiError("GET", path, 400, "VALIDATION_ERROR", name);
|
|
599
|
+
}
|
|
600
|
+
// Object, owner and association ids are bounded to 512 characters by the operation input schemas (the state
|
|
601
|
+
// store's own row-id bound). An id within the bound reaches the handler, which answers HubSpot's 404; only a
|
|
602
|
+
// longer id is rejected before the handler with the VALIDATION_ERROR envelope. Pipeline ids and property names
|
|
603
|
+
// have no schema bound and reach the handler too.
|
|
604
|
+
const invalid = (method, path, body) => apiError(method, path, 400, "VALIDATION_ERROR", "Invalid input JSON", body === undefined ? {} : { body });
|
|
605
|
+
for (const length of [500, 511, 512, 513, 1000, 4096]) {
|
|
606
|
+
const id = "a".repeat(length);
|
|
607
|
+
const digits = "9".repeat(length);
|
|
608
|
+
await apiError("GET", `/crm/v3/pipelines/deals/${id}`, 404, "OBJECT_NOT_FOUND", "not found");
|
|
609
|
+
await apiError("GET", `/crm/v3/pipelines/deals/${id}/stages`, 404, "OBJECT_NOT_FOUND", "not found");
|
|
610
|
+
await apiError("GET", `/crm/v3/properties/contacts/${id}`, 404, "OBJECT_NOT_FOUND", "does not exist");
|
|
611
|
+
// Caller values that become row ids inside a handler: a deal pipeline and an owner id property value.
|
|
612
|
+
await apiError("POST", D, 400, "VALIDATION_ERROR", "is not valid", { body: { properties: { dealname: "x", pipeline: id, dealstage: "renewed" } } });
|
|
613
|
+
await apiError("PATCH", `${C}/101`, 400, "VALIDATION_ERROR", "is not valid", { body: { properties: { hubspot_owner_id: digits } } });
|
|
614
|
+
}
|
|
615
|
+
for (const length of [513, 1000, 4096]) {
|
|
616
|
+
const id = "a".repeat(length);
|
|
617
|
+
const digits = "9".repeat(length);
|
|
618
|
+
await invalid("GET", `${C}/${digits}`);
|
|
619
|
+
await invalid("GET", `${C}/${id}@example.com?idProperty=email`);
|
|
620
|
+
await invalid("PATCH", `${C}/${digits}`, { properties: { firstname: "x" } });
|
|
621
|
+
await invalid("DELETE", `${C}/${id}`);
|
|
622
|
+
await invalid("GET", `/crm/v3/owners/${digits}`);
|
|
623
|
+
await invalid("GET", `${A}/contacts/${digits}/associations/companies`);
|
|
624
|
+
await invalid("PUT", `${A}/contacts/101/associations/default/companies/${digits}`);
|
|
625
|
+
await invalid("DELETE", `${A}/contacts/${id}/associations/companies/201`);
|
|
626
|
+
await invalid("POST", `${C}/batch/read`, { inputs: [{ id: digits }] });
|
|
627
|
+
await invalid("POST", "/crm/v4/associations/contacts/companies/batch/read", { inputs: [{ id: digits }] });
|
|
628
|
+
}
|
|
629
|
+
// Within the bound every id route answers HubSpot's 404 (an unknown id is missing, not malformed).
|
|
630
|
+
for (const length of [64, 65, 200, 500, 512]) {
|
|
631
|
+
const id = "a".repeat(length);
|
|
632
|
+
const digits = "9".repeat(length);
|
|
633
|
+
await apiError("GET", `${C}/${digits}`, 404, "OBJECT_NOT_FOUND");
|
|
634
|
+
await apiError("PATCH", `${C}/${digits}`, 404, "OBJECT_NOT_FOUND", undefined, { body: { properties: { firstname: "x" } } });
|
|
635
|
+
await apiError("DELETE", `${C}/${id}`, 404, "OBJECT_NOT_FOUND");
|
|
636
|
+
await apiError("GET", `/crm/v3/owners/${digits}`, 404, "OBJECT_NOT_FOUND");
|
|
637
|
+
await apiError("GET", `${A}/contacts/${digits}/associations/companies`, 404, "OBJECT_NOT_FOUND");
|
|
638
|
+
await apiError("PUT", `${A}/contacts/101/associations/default/companies/${digits}`, 404, "OBJECT_NOT_FOUND");
|
|
639
|
+
await apiError("DELETE", `${A}/contacts/${id}/associations/companies/201`, 404, "OBJECT_NOT_FOUND");
|
|
640
|
+
const batch = (await post(`${C}/batch/read`, { inputs: [{ id: digits }] })).json;
|
|
641
|
+
assert.deepEqual(batch.errors[0].context.ids, [digits], `batch/read with a ${length}-character id`);
|
|
642
|
+
}
|
|
643
|
+
// A legitimate idProperty=email lookup of an address longer than 64 characters resolves instead of being rejected.
|
|
644
|
+
const longEmail = `${"a".repeat(60)}.long@fjordbyte.example.com`;
|
|
645
|
+
const longContact = (await post(C, { properties: { email: longEmail, firstname: "Long" } }, { status: 201 })).json;
|
|
646
|
+
assert.equal((await get(`${C}/${longEmail}?idProperty=email&properties=email`)).json.id, longContact.id, "a >64-character e-mail resolves");
|
|
647
|
+
await apiError("GET", `${C}/${"b".repeat(60)}.missing@example.com?idProperty=email`, 404, "OBJECT_NOT_FOUND");
|
|
648
|
+
await del(`${C}/${longContact.id}`);
|
|
649
|
+
// Mangled percent-encoding: serve decodes `%E0%A4%A` to U+FFFD. A search expression or free-text filter carrying
|
|
650
|
+
// U+FFFD is a corrupted request and answers HubSpot's VALIDATION_ERROR, never a silently empty result. A
|
|
651
|
+
// correctly encoded U+FFFD (%EF%BF%BD) is rejected the same way; `%ZZ` stays literal and searches normally.
|
|
652
|
+
for (const query of ["email=alice%E0%A4%A", "email=alice%EF%BF%BD"]) {
|
|
653
|
+
await apiError("GET", `/crm/v3/owners?${query}`, 400, "VALIDATION_ERROR", "U+FFFD");
|
|
654
|
+
}
|
|
655
|
+
assert.deepEqual((await get("/crm/v3/owners?email=alice%ZZ")).json.results, [], "%ZZ stays literal and filters normally");
|
|
656
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "U+FFFD", { body: { query: "alice\uFFFD" } });
|
|
657
|
+
for (const filter of [
|
|
658
|
+
{ propertyName: "email", operator: "CONTAINS_TOKEN", value: "a\uFFFD" },
|
|
659
|
+
{ propertyName: "email", operator: "IN", values: ["ok@example.com", "a\uFFFD"] },
|
|
660
|
+
{ propertyName: "createdate", operator: "BETWEEN", value: "0", highValue: "1\uFFFD" },
|
|
661
|
+
]) {
|
|
662
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "U+FFFD", { body: { filterGroups: [{ filters: [filter] }] } });
|
|
663
|
+
}
|
|
664
|
+
// A legitimate non-ASCII search still works (the guard is about U+FFFD, not about non-ASCII text).
|
|
665
|
+
const accented = (await post(`${C}/search`, { filterGroups: [{ filters: [{ propertyName: "firstname", operator: "EQ", value: "Zoë" }] }] })).json;
|
|
666
|
+
assert.ok(accented.total >= 1, `an accented search must match: ${JSON.stringify(accented)}`);
|
|
667
|
+
assert.ok((await post(`${C}/search`, { query: "Müller" })).json.total >= 1, "a non-ASCII free-text query must match");
|
|
668
|
+
|
|
669
|
+
// includeHidden is decoded from the query string: a malformed flag is rejected, not silently treated as true.
|
|
670
|
+
await apiError("GET", "/crm/v3/properties/contacts?includeHidden=maybe", 400, "VALIDATION_ERROR", "includeHidden");
|
|
671
|
+
const shown = (await get("/crm/v3/properties/companies?includeHidden=false")).json.results;
|
|
672
|
+
assert.ok(!shown.some((property) => property.name === "csm_health"), "includeHidden=false hides hidden definitions");
|
|
673
|
+
const all = (await get("/crm/v3/properties/companies?includeHidden=true")).json.results;
|
|
674
|
+
assert.ok(all.some((property) => property.name === "csm_health"), "includeHidden=true keeps hidden definitions");
|
|
675
|
+
|
|
676
|
+
// Error messages clip caller text on code points, so a long value keeps its closing quote and no surrogate splits.
|
|
677
|
+
const emoji = "\u{1F600}".repeat(120);
|
|
678
|
+
const cursor = (await apiError("GET", `${C}?after=${encodeURIComponent(emoji)}`, 400, "VALIDATION_ERROR", "not a valid cursor")).json;
|
|
679
|
+
const message = cursor.errors[0].message;
|
|
680
|
+
assert.match(message, /^after "\u{1F600}+…" is not a valid cursor$/u, message);
|
|
681
|
+
assert.ok(![...message].some((character) => character.codePointAt(0) >= 0xd800 && character.codePointAt(0) <= 0xdfff), "no lone surrogate in the message");
|
|
682
|
+
|
|
683
|
+
// The framework caps a Tool error message at 1000 UTF-16 units. A message that outgrows the cap (here the
|
|
684
|
+
// aggregated "Property values were not valid" summary over several 100-code-point emoji property names) is cut
|
|
685
|
+
// on a code point boundary too: it ends in an ellipsis, fits the cap, and never leaves a lone surrogate, while
|
|
686
|
+
// errors[] still lists every offending property in full.
|
|
687
|
+
const emojiNames = Object.fromEntries(["a", "b", "c", "d"].map((suffix) => [`${emoji}${suffix}`, "x"]));
|
|
688
|
+
const summary = (await apiError("POST", C, 400, "VALIDATION_ERROR", "Property values were not valid", { body: { properties: emojiNames } })).json;
|
|
689
|
+
assert.ok(summary.message.length <= 1000 && summary.message.length >= 998, `message must fit the 1000-unit cap: ${summary.message.length}`);
|
|
690
|
+
assert.ok(summary.message.endsWith("\u2026"), "an over-long message ends in an ellipsis");
|
|
691
|
+
assert.ok(![...summary.message].some((character) => character.codePointAt(0) >= 0xd800 && character.codePointAt(0) <= 0xdfff), "no lone surrogate in the clipped message");
|
|
692
|
+
assert.equal(summary.errors.length, 4, JSON.stringify(summary.errors.map((error) => error.code)));
|
|
693
|
+
assert.ok(summary.errors.every((error) => error.code === "PROPERTY_DOESNT_EXIST"), "every unknown property is reported in errors[]");
|
|
694
|
+
|
|
695
|
+
// An unknown association type is echoed through clipJson (the input schema already bounds it to an integer).
|
|
696
|
+
const typeError = (await apiError("PUT", `${A}/contacts/101/associations/companies/201`, 400, "VALIDATION_ERROR", "is not defined between", {
|
|
697
|
+
body: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 987654321 }],
|
|
698
|
+
})).json;
|
|
699
|
+
assert.match(typeError.message, /^Association type 987654321 \(HUBSPOT_DEFINED\) is not defined between contact and company$/u, typeError.message);
|
|
700
|
+
assert.deepEqual(typeError.errors[0].context.associationTypeId, ["987654321"], JSON.stringify(typeError.errors));
|
|
701
|
+
|
|
702
|
+
// Datetimes beyond year 9999 are invalid, and an invalid-only required datetime reports one error, not two.
|
|
703
|
+
for (const value of ["253402300800000", "999999999999999", "9999-12-31T23:00:00-05:00"]) {
|
|
704
|
+
const body = (await apiError("POST", "/crm/v3/objects/notes", 400, "VALIDATION_ERROR", "hs_timestamp", { body: { properties: { hs_timestamp: value } } })).json;
|
|
705
|
+
assert.deepEqual(body.errors.map((error) => error.code), ["INVALID_DATE"], JSON.stringify(body));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------------------------
|
|
710
|
+
// Drill: mcp-aliases (rep, baseline)
|
|
711
|
+
// ---------------------------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
async function mcpAliases() {
|
|
714
|
+
await mcpInit();
|
|
715
|
+
const tools = (await rpc("tools/list", {})).tools.map((tool) => tool.name);
|
|
716
|
+
for (const alias of ALIASES) assert.ok(tools.includes(alias), `alias ${alias} missing`);
|
|
717
|
+
for (const operationId of ALL_OPERATIONS) assert.ok(tools.includes(`hubspot.${operationId}`), `canonical hubspot.${operationId} missing`);
|
|
718
|
+
|
|
719
|
+
const me = await mcp("hubspot-get-user-details", {});
|
|
720
|
+
assert.equal(me.user.email, "maya.lindqvist@brightline.example.com");
|
|
721
|
+
assert.equal(me.user.ownerId, "41001");
|
|
722
|
+
assert.equal(me.portalId, 24871365);
|
|
723
|
+
assert.ok(me.scopes.includes("crm.objects.contacts.write"));
|
|
724
|
+
|
|
725
|
+
const page = await mcp("hubspot-list-objects", { objectType: "contacts", limit: 3 });
|
|
726
|
+
assert.deepEqual(ids(page.results), ["101", "102", "103"]);
|
|
727
|
+
assert.deepEqual(ids((await mcp("hubspot-list-objects", { objectType: "contacts", limit: 3, after: page.paging.next.after })).results), ["104", "105", "106"]);
|
|
728
|
+
const companies = await mcp("hubspot-list-objects", { objectType: "0-2", properties: ["name", "domain"] });
|
|
729
|
+
assert.equal(companies.results.length, 5);
|
|
730
|
+
assert.equal(companies.results[0].properties.domain, "fjordbyte.example.com");
|
|
731
|
+
|
|
732
|
+
const search = await mcp("hubspot-search-objects", {
|
|
733
|
+
objectType: "deals",
|
|
734
|
+
filterGroups: [{ filters: [{ propertyName: "amount", operator: "GTE", value: "50000" }] }],
|
|
735
|
+
sorts: [{ propertyName: "amount", direction: "DESCENDING" }],
|
|
736
|
+
limit: 2,
|
|
737
|
+
});
|
|
738
|
+
assert.equal(search.total, 3);
|
|
739
|
+
assert.deepEqual(ids(search.results), ["301", "304"]);
|
|
740
|
+
assert.equal(search.paging.next.after, "2");
|
|
741
|
+
|
|
742
|
+
const created = await mcp("hubspot-batch-create-objects", { objectType: "companies", inputs: [{ properties: { name: "Alias Co", domain: "alias.example.org" } }] });
|
|
743
|
+
const id = created.results[0].id;
|
|
744
|
+
assert.equal(id, "1001");
|
|
745
|
+
const updated = await mcp("hubspot-batch-update-objects", { objectType: "companies", inputs: [{ id, properties: { industry: "COMPUTER_SOFTWARE" } }] });
|
|
746
|
+
assert.equal(updated.results[0].properties.industry, "COMPUTER_SOFTWARE");
|
|
747
|
+
const read = await mcp("hubspot-batch-read-objects", { objectType: "companies", inputs: [{ id }, { id: "999" }], properties: ["name", "industry"] });
|
|
748
|
+
assert.equal(read.results[0].properties.industry, "COMPUTER_SOFTWARE");
|
|
749
|
+
assert.equal(read.numErrors, 1);
|
|
750
|
+
|
|
751
|
+
const definitions = await mcp("hubspot-get-association-definitions", { fromObjectType: "contacts", toObjectType: "companies" });
|
|
752
|
+
assert.deepEqual(definitions.results.map((entry) => entry.typeId), [1, 57, 279]);
|
|
753
|
+
const linked = await mcp("hubspot-batch-create-associations", { fromObjectType: "contacts", toObjectType: "companies", inputs: [{ from: { id: "113" }, to: { id }, types: [{ associationCategory: HS, associationTypeId: 279 }] }] });
|
|
754
|
+
assert.equal(linked.results.length, 1);
|
|
755
|
+
assert.equal(linked.numErrors, undefined);
|
|
756
|
+
const links = await mcp("hubspot-list-associations", { objectType: "contacts", objectId: "113", toObjectType: "companies" });
|
|
757
|
+
assert.deepEqual(links.results.map((link) => link.toObjectId), [id]);
|
|
758
|
+
|
|
759
|
+
const visible = await mcp("hubspot-list-properties", { objectType: "companies" });
|
|
760
|
+
assert.ok(!visible.results.some((property) => property.name === "csm_health"), "hidden properties are filtered unless includeHidden");
|
|
761
|
+
const hidden = await mcp("hubspot-list-properties", { objectType: "companies", includeHidden: true });
|
|
762
|
+
assert.ok(hidden.results.some((property) => property.name === "csm_health"));
|
|
763
|
+
const planTier = await mcp("hubspot-get-property", { objectType: "contacts", propertyName: "plan_tier" });
|
|
764
|
+
assert.deepEqual(planTier.options.map((option) => option.value), ["free", "team", "enterprise"]);
|
|
765
|
+
|
|
766
|
+
await mcpError("hubspot-list-objects", { objectType: "tickets" }, "tool.NOT_FOUND");
|
|
767
|
+
await mcpError("hubspot-search-objects", { objectType: "contacts", filterGroups: [{ filters: [{ propertyName: "email", operator: "LIKE", value: "x" }] }] }, "tool.VALIDATION_ERROR");
|
|
768
|
+
await mcpError("hubspot-list-objects", {});
|
|
769
|
+
|
|
770
|
+
assert.equal((await get(`${CO}/${id}`)).json.properties.name, "Alias Co", "the MCP-created company is visible over REST");
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------------------------
|
|
774
|
+
// Drill: owned-visibility (sdr = Tom, 'owned records only')
|
|
775
|
+
// ---------------------------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
async function ownedVisibility() {
|
|
778
|
+
assert.deepEqual(ids((await get(C)).json.results), ["103", "105", "106", "108", "113"], "Tom sees his own and unassigned contacts only");
|
|
779
|
+
await apiError("GET", `${C}/101`, 404, "OBJECT_NOT_FOUND");
|
|
780
|
+
await apiError("PATCH", `${C}/101`, 404, "OBJECT_NOT_FOUND", undefined, { body: { properties: { firstname: "Hacked" } } });
|
|
781
|
+
await apiError("DELETE", `${C}/101`, 404, "OBJECT_NOT_FOUND");
|
|
782
|
+
assert.equal((await post(`${C}/search`, {})).json.total, 5);
|
|
783
|
+
assert.deepEqual((await get(`${A}/companies/204/associations/contacts`)).json.results.map((link) => link.toObjectId), ["105"], "hidden contacts are omitted from association listings");
|
|
784
|
+
await apiError("PUT", `${A}/contacts/101/associations/default/companies/202`, 404, "OBJECT_NOT_FOUND");
|
|
785
|
+
const mayas = (await post(C, { properties: { email: "created.by.tom@example.org", firstname: "Created", hubspot_owner_id: "41001" } }, { status: 201 })).json;
|
|
786
|
+
assert.equal(mayas.id, "1001");
|
|
787
|
+
await apiError("GET", `${C}/1001`, 404, "OBJECT_NOT_FOUND");
|
|
788
|
+
const mine = (await post(C, { properties: { email: "unassigned.by.tom@example.org", firstname: "Unassigned" } }, { status: 201 })).json;
|
|
789
|
+
assert.equal(mine.id, "1002");
|
|
790
|
+
assert.equal((await get(`${C}/1002`)).json.properties.firstname, "Unassigned");
|
|
791
|
+
assert.deepEqual(ids((await get("/crm/v3/objects/tasks")).json.results), ["502", "504"]);
|
|
792
|
+
assert.deepEqual(ids((await get(D)).json.results), ["303", "304"]);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ---------------------------------------------------------------------------------------------
|
|
796
|
+
// Drill: scopes (readonly = Priya with read scopes only)
|
|
797
|
+
// ---------------------------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
async function scopes() {
|
|
800
|
+
const missing = async (method, path, scope, body) => {
|
|
801
|
+
const result = await apiError(method, path, 403, "MISSING_SCOPES", "hasn't been granted all required scopes", body === undefined ? {} : { body });
|
|
802
|
+
assert.deepEqual(result.json.errors[0].context.requiredGranularScopes, [scope]);
|
|
803
|
+
};
|
|
804
|
+
assert.equal((await get(C)).json.results.length, 10);
|
|
805
|
+
await missing("POST", C, "crm.objects.contacts.write", { properties: { firstname: "x" } });
|
|
806
|
+
await missing("PATCH", `${D}/301`, "crm.objects.deals.write", { properties: { amount: "1" } });
|
|
807
|
+
await missing("DELETE", `${CO}/201`, "crm.objects.companies.write");
|
|
808
|
+
await missing("PUT", `${A}/contacts/101/associations/default/companies/201`, "crm.objects.contacts.write");
|
|
809
|
+
await missing("POST", "/crm/v3/objects/notes", "crm.objects.contacts.write", { properties: { hs_note_body: "x", hs_timestamp: "2026-09-14T09:00:00Z" } });
|
|
810
|
+
assert.equal((await get("/crm/v3/owners")).json.results.length, 3);
|
|
811
|
+
assert.ok((await get("/crm/v3/properties/contacts")).json.results.length > 10);
|
|
812
|
+
await mcpInit();
|
|
813
|
+
const error = (await mcpError("hubspot-batch-create-objects", { objectType: "contacts", inputs: [{ properties: { firstname: "x" } }] }, "tool.MISSING_SCOPES")).error;
|
|
814
|
+
assert.ok(error.message.includes("scopes"));
|
|
815
|
+
assert.equal((await get(`${D}/301`)).json.properties.amount, "184000", "nothing was written");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ---------------------------------------------------------------------------------------------
|
|
819
|
+
// Drills that run one representative request per operation
|
|
820
|
+
// ---------------------------------------------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
async function noScopes() {
|
|
823
|
+
for (const entry of CALLS) {
|
|
824
|
+
const result = await callError(entry, 403, "MISSING_SCOPES", "hasn't been granted all required scopes");
|
|
825
|
+
assert.ok(Array.isArray(result.json.errors[0].context.requiredGranularScopes), `${entry[0]} ${entry[1]}: requiredGranularScopes`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function denied() {
|
|
830
|
+
const message = "not granted to the calling actor";
|
|
831
|
+
await apiError("GET", "/account-info/v3/details", 403, "MISSING_SCOPES", message);
|
|
832
|
+
await apiError("GET", C, 403, "MISSING_SCOPES", message);
|
|
833
|
+
await apiError("POST", C, 403, "MISSING_SCOPES", message, { body: { properties: { firstname: "x" } } });
|
|
834
|
+
await mcpInit();
|
|
835
|
+
const result = await rpc("tools/call", { name: "hubspot-list-objects", arguments: { objectType: "contacts" } });
|
|
836
|
+
assert.ok(result.isError);
|
|
837
|
+
assert.equal(result.structuredContent.status, "denied");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function invalidAuth() {
|
|
841
|
+
for (const entry of CALLS) {
|
|
842
|
+
const result = await callError(entry, 401, "INVALID_AUTHENTICATION", "Authentication credentials not found");
|
|
843
|
+
assert.ok(result.json.links["oauth-overview"]);
|
|
844
|
+
}
|
|
845
|
+
await mcpInit();
|
|
846
|
+
await mcpError("hubspot-get-user-details", {}, "tool.UNAUTHORIZED");
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function deactivatedUser() {
|
|
850
|
+
await apiError("GET", "/account-info/v3/details", 401, "INVALID_AUTHENTICATION", "Authentication credentials not found");
|
|
851
|
+
await apiError("GET", C, 401, "INVALID_AUTHENTICATION");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function rateLimited() {
|
|
855
|
+
for (const entry of CALLS) {
|
|
856
|
+
const result = await callError(entry, 429, "RATE_LIMITS", "You have reached your secondly limit.");
|
|
857
|
+
assert.equal(result.headers.get("x-hubspot-ratelimit-secondly-remaining"), "0");
|
|
858
|
+
assert.equal(result.headers.get("retry-after"), "1");
|
|
859
|
+
}
|
|
860
|
+
await mcpInit();
|
|
861
|
+
const error = (await mcpError("hubspot-list-objects", { objectType: "contacts" }, "tool.RATE_LIMITED")).error;
|
|
862
|
+
assert.equal(error.message, "You have reached your secondly limit.");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function writeUnavailable() {
|
|
866
|
+
for (const entry of WRITE_CALLS) await callError(entry, 503, "INTERNAL_ERROR", "temporarily unavailable");
|
|
867
|
+
const list = await get(C);
|
|
868
|
+
assert.equal(list.json.results.length, 10, "reads keep working");
|
|
869
|
+
assert.equal(list.headers.get("x-hubspot-ratelimit-secondly-remaining"), "99");
|
|
870
|
+
assert.equal((await post(`${C}/search`, {})).json.total, 13);
|
|
871
|
+
assert.equal((await get(`${C}/101?properties=firstname`)).json.properties.firstname, "Alice", "nothing was written");
|
|
872
|
+
assert.deepEqual(targets((await get(`${A}/contacts/101/associations/companies`)).json.results), ["201"], "the archive did not happen");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function writeCommittedLost() {
|
|
876
|
+
const lost = { properties: { email: "lost@example.org", firstname: "Lena" } };
|
|
877
|
+
await apiError("POST", C, 503, "INTERNAL_ERROR", "temporarily unavailable", { body: lost });
|
|
878
|
+
const found = (await post(`${C}/search`, { filterGroups: [{ filters: [{ propertyName: "email", operator: "EQ", value: "lost@example.org" }] }] })).json;
|
|
879
|
+
assert.equal(found.total, 1, "the create committed although the caller saw 503");
|
|
880
|
+
assert.equal(found.results[0].id, "1001");
|
|
881
|
+
assert.equal((await get(`${C}/1001`)).json.properties.firstname, "Lena");
|
|
882
|
+
await apiError("POST", C, 409, "CONFLICT", "Contact already exists. Existing ID: 1001", { body: lost });
|
|
883
|
+
await apiError("POST", CO, 503, "INTERNAL_ERROR", "temporarily unavailable", { body: { properties: { name: "Ghost Co" } } });
|
|
884
|
+
const companies = (await get(`${CO}?properties=name&limit=100`)).json.results;
|
|
885
|
+
assert.ok(companies.some((company) => company.id === "1002" && company.properties.name === "Ghost Co"), "the company create committed too (a retry would duplicate it)");
|
|
886
|
+
await apiError("POST", `${C}/batch/create`, 503, "INTERNAL_ERROR", "temporarily unavailable", { body: { inputs: [{ properties: { email: "lost.batch@example.org" } }] } });
|
|
887
|
+
assert.equal((await get(`${C}/1003`)).json.properties.email, "lost.batch@example.org");
|
|
888
|
+
assert.equal((await patch(`${C}/1001`, { properties: { lastname: "Berg" } })).json.properties.lastname, "Berg", "updates are not covered by the fault");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** An actor without identity attributes (as `firedrill tool add` creates) acts as the portal's default user. */
|
|
892
|
+
async function freshActor() {
|
|
893
|
+
await get("/account-info/v3/details");
|
|
894
|
+
const contacts = (await get(C)).json;
|
|
895
|
+
assert.equal(contacts.results.length, 10, "a fresh actor sees the seeded portal");
|
|
896
|
+
const note = (await post("/crm/v3/objects/notes", { properties: { hs_note_body: "Fresh actor note.", hs_timestamp: "2026-09-14T10:00:00" } }, { status: 201 })).json;
|
|
897
|
+
assert.equal(note.id, "1001");
|
|
898
|
+
assert.equal(note.properties.hs_timestamp, "2026-09-14T10:00:00.000Z", "zone-less datetime is stored as UTC");
|
|
899
|
+
assert.equal((await get(`/crm/v3/objects/notes/1001?properties=hs_timestamp`)).json.properties.hs_timestamp, "2026-09-14T10:00:00.000Z");
|
|
900
|
+
const latest = (await post("/crm/v3/objects/notes", { properties: { hs_note_body: null, hs_timestamp: "253402300799999" } }, { status: 201 })).json;
|
|
901
|
+
assert.equal(latest.properties.hs_timestamp, "9999-12-31T23:59:59.999Z", "the last representable instant is accepted");
|
|
902
|
+
assert.equal(latest.properties.hs_note_body, undefined, "a null value on create stores nothing");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Size bounds: HubSpot's 65,536-character property limit, linear wildcard matching, byte-bounded pages and batches. */
|
|
906
|
+
async function sizeBounds() {
|
|
907
|
+
const CAP = 65_536;
|
|
908
|
+
const MIB = 1_048_576;
|
|
909
|
+
const search = (body) => post(`${C}/search`, body).then((result) => result.json);
|
|
910
|
+
/** A 200 request whose raw response size is measured in bytes. */
|
|
911
|
+
async function sized(method, path, body) {
|
|
912
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
913
|
+
method,
|
|
914
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" },
|
|
915
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
916
|
+
});
|
|
917
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
918
|
+
const text = new TextDecoder().decode(bytes);
|
|
919
|
+
assert.equal(response.status, 200, `${method} ${path} -> ${response.status} ${text.slice(0, 300)}`);
|
|
920
|
+
assert.ok(bytes.length < MIB, `${method} ${path}: ${bytes.length} bytes is over 1 MiB`);
|
|
921
|
+
return { json: JSON.parse(text), bytes: bytes.length };
|
|
922
|
+
}
|
|
923
|
+
const started = Date.now();
|
|
924
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "above the maximum of 65536", { body: { properties: { firstname: "a".repeat(CAP + 1) } } });
|
|
925
|
+
await apiError("POST", `${C}/batch/create`, 400, "VALIDATION_ERROR", "above the maximum of 65536", { body: { inputs: [{ properties: { firstname: "a".repeat(200_000) } }] } });
|
|
926
|
+
await apiError("POST", D, 400, "VALIDATION_ERROR", "not a valid number", { body: { properties: { dealname: "x", dealstage: "renewed", amount: `${"1".repeat(60_000)}x` } } });
|
|
927
|
+
const before = (await search({})).total;
|
|
928
|
+
for (let batch = 0; batch < 4; batch += 1) {
|
|
929
|
+
const inputs = Array.from({ length: 10 }, (_, index) => ({ properties: { firstname: "a".repeat(CAP), lastname: `long${batch}${index}` } }));
|
|
930
|
+
assert.equal((await post(`${C}/batch/create`, { inputs }, { status: 201 })).json.results.length, 10, "65,536 characters is accepted");
|
|
931
|
+
}
|
|
932
|
+
// Pathological wildcard patterns over 65,536-character tokens answer quickly (KMP, no backtracking).
|
|
933
|
+
const pattern = `*${"a".repeat(1490)}b${"a".repeat(1490)}*`;
|
|
934
|
+
const hit = { propertyName: "firstname", operator: "CONTAINS_TOKEN", value: pattern };
|
|
935
|
+
const miss = { ...hit, operator: "NOT_CONTAINS_TOKEN" };
|
|
936
|
+
const timed = Date.now();
|
|
937
|
+
assert.equal((await search({ filterGroups: [{ filters: [hit] }], limit: 1 })).total, 0);
|
|
938
|
+
assert.equal((await search({ filterGroups: [{ filters: [miss] }], limit: 1 })).total, before + 40);
|
|
939
|
+
assert.equal((await search({ filterGroups: Array.from({ length: 3 }, () => ({ filters: Array(6).fill(hit) })), limit: 1 })).total, 0);
|
|
940
|
+
await apiError("POST", `${C}/search`, 400, "VALIDATION_ERROR", "characters of matching work", {
|
|
941
|
+
body: { filterGroups: Array.from({ length: 3 }, () => ({ filters: [miss, miss, miss, miss, miss, hit] })), limit: 1 },
|
|
942
|
+
});
|
|
943
|
+
assert.ok(Date.now() - timed < 3_000, `wildcard searches took ${Date.now() - timed} ms`);
|
|
944
|
+
await get("/crm/v3/owners");
|
|
945
|
+
assert.equal((await search({ filterGroups: [{ filters: [{ propertyName: "firstname", operator: "CONTAINS_TOKEN", value: "a*a" }] }], limit: 1 })).total, 40);
|
|
946
|
+
assert.equal((await search({ query: "aaaa", limit: 1 })).total, 40, "free-text prefix match over long tokens");
|
|
947
|
+
// 60 contacts with 11,000-character CJK names (about 33 KB each on the wire).
|
|
948
|
+
const created = [];
|
|
949
|
+
for (let batch = 0; batch < 3; batch += 1) {
|
|
950
|
+
const inputs = Array.from({ length: 20 }, (_, index) => ({ properties: { firstname: `${String.fromCodePoint(0x4e00 + batch * 20 + index)}${"漢".repeat(10_999)}`, lastname: `cjk${batch * 20 + index}` } }));
|
|
951
|
+
created.push(...ids((await post(`${C}/batch/create`, { inputs }, { status: 201 })).json.results));
|
|
952
|
+
}
|
|
953
|
+
const expected = before + 100;
|
|
954
|
+
const seen = new Map();
|
|
955
|
+
let path = `${C}?limit=100&properties=firstname`;
|
|
956
|
+
let pages = 0;
|
|
957
|
+
while (path !== undefined) {
|
|
958
|
+
const page = await sized("GET", path);
|
|
959
|
+
pages += 1;
|
|
960
|
+
for (const record of page.json.results) seen.set(record.id, (seen.get(record.id) ?? 0) + 1);
|
|
961
|
+
path = page.json.paging?.next?.link;
|
|
962
|
+
}
|
|
963
|
+
assert.ok(pages > 1, "large rows split the list into byte-bounded pages");
|
|
964
|
+
assert.equal(seen.size, expected);
|
|
965
|
+
assert.ok([...seen.values()].every((count) => count === 1), "every contact is listed exactly once");
|
|
966
|
+
const found = new Map();
|
|
967
|
+
let after;
|
|
968
|
+
pages = 0;
|
|
969
|
+
do {
|
|
970
|
+
const page = await sized("POST", `${C}/search`, { limit: 200, properties: ["firstname"], ...(after === undefined ? {} : { after }) });
|
|
971
|
+
pages += 1;
|
|
972
|
+
assert.equal(page.json.total, expected);
|
|
973
|
+
for (const record of page.json.results) found.set(record.id, (found.get(record.id) ?? 0) + 1);
|
|
974
|
+
after = page.json.paging?.next?.after;
|
|
975
|
+
} while (after !== undefined);
|
|
976
|
+
assert.ok(pages > 1, "large rows split the search into byte-bounded pages");
|
|
977
|
+
assert.equal(found.size, expected);
|
|
978
|
+
assert.ok([...found.values()].every((count) => count === 1), "every contact is found exactly once");
|
|
979
|
+
await apiError("POST", `${C}/batch/read`, 400, "VALIDATION_ERROR", "response limit", { body: { inputs: created.map((id) => ({ id })), properties: ["firstname"] } });
|
|
980
|
+
assert.equal((await sized("POST", `${C}/batch/read`, { inputs: created.slice(0, 20).map((id) => ({ id })), properties: ["firstname"] })).json.results.length, 20);
|
|
981
|
+
const wide = "漢".repeat(CAP);
|
|
982
|
+
await apiError("POST", C, 400, "VALIDATION_ERROR", "response limit", { body: { properties: { firstname: wide, lastname: wide, company: wide, jobtitle: wide, city: "漢".repeat(40_000) } } });
|
|
983
|
+
assert.equal((await search({ limit: 1 })).total, expected, "a create whose response would be too large writes nothing");
|
|
984
|
+
assert.ok(Date.now() - started < 60_000);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ---------------------------------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
const flows = {
|
|
990
|
+
"fresh-actor": freshActor,
|
|
991
|
+
"rest-flow": restFlow,
|
|
992
|
+
"mcp-aliases": mcpAliases,
|
|
993
|
+
"owned-visibility": ownedVisibility,
|
|
994
|
+
"no-scopes": noScopes,
|
|
995
|
+
scopes,
|
|
996
|
+
denied,
|
|
997
|
+
"invalid-auth": invalidAuth,
|
|
998
|
+
"deactivated-user": deactivatedUser,
|
|
999
|
+
"rate-limited": rateLimited,
|
|
1000
|
+
"write-unavailable": writeUnavailable,
|
|
1001
|
+
"write-committed-lost": writeCommittedLost,
|
|
1002
|
+
"size-bounds": sizeBounds,
|
|
1003
|
+
};
|
|
1004
|
+
const selected = Object.keys(flows).find((name) => instruction.includes(`the ${name} conformance flow`));
|
|
1005
|
+
if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
|
|
1006
|
+
await flows[selected]();
|
|
1007
|
+
process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
|