@firedrill-tools/salesforce 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 +156 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-limit-exceeded.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +2213 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/row-locked.scenario.json +11 -0
- package/firedrill/salesforce-api-limit-exceeded.drill.json +336 -0
- package/firedrill/salesforce-collections-composite.drill.json +277 -0
- package/firedrill/salesforce-denied.drill.json +74 -0
- package/firedrill/salesforce-fresh-install.drill.json +86 -0
- package/firedrill/salesforce-inactive-user.drill.json +43 -0
- package/firedrill/salesforce-invalid-session.drill.json +336 -0
- package/firedrill/salesforce-large-responses.drill.json +138 -0
- package/firedrill/salesforce-mcp-aliases.drill.json +156 -0
- package/firedrill/salesforce-profile-permissions.drill.json +241 -0
- package/firedrill/salesforce-read-only.drill.json +154 -0
- package/firedrill/salesforce-rest-flow.drill.json +714 -0
- package/firedrill/salesforce-row-locked.drill.json +227 -0
- package/firedrill/salesforce-sharing.drill.json +201 -0
- package/firedrill/salesforce-soql.drill.json +139 -0
- package/firedrill/salesforce-tight-limits.drill.json +336 -0
- package/firedrill/salesforce-write-committed-lost.drill.json +176 -0
- package/firedrill/tight-limits.scenario.json +41 -0
- package/firedrill/tools/salesforce/behavior.mjs +637 -0
- package/firedrill/tools/salesforce/lib/bytes.mjs +56 -0
- package/firedrill/tools/salesforce/lib/ids.mjs +68 -0
- package/firedrill/tools/salesforce/lib/match.mjs +352 -0
- package/firedrill/tools/salesforce/lib/records.mjs +506 -0
- package/firedrill/tools/salesforce/lib/schema.mjs +429 -0
- package/firedrill/tools/salesforce/lib/search.mjs +92 -0
- package/firedrill/tools/salesforce/lib/soql.mjs +1119 -0
- package/firedrill/tools/salesforce/lib/state.mjs +378 -0
- package/firedrill/tools/salesforce/lib/wire.mjs +109 -0
- package/firedrill/tools/salesforce/salesforce.tool.json +3939 -0
- package/firedrill/world.json +2705 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2212 -0
- package/test/conformance.mjs +1122 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
// Salesforce Tool conformance target. A scripted Tool test, not a model-driven agent.
|
|
2
|
+
// Node built-ins only: fetch against the Salesforce-shaped REST routes (plus the canonical Firedrill
|
|
3
|
+
// operation endpoint for the route-less sobjects.describe) and raw MCP JSON-RPC (Streamable HTTP)
|
|
4
|
+
// for the @salesforce/mcp tool-name aliases. Every flow fails loudly on an unexpected status, header
|
|
5
|
+
// or body.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
|
|
8
|
+
let task = "";
|
|
9
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
10
|
+
const invocation = JSON.parse(task);
|
|
11
|
+
const instruction = String(invocation.instruction ?? "");
|
|
12
|
+
|
|
13
|
+
const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
14
|
+
const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
15
|
+
const MCP = process.env.FIREDRILL_MCP_URL;
|
|
16
|
+
const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
|
|
17
|
+
assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
|
|
18
|
+
|
|
19
|
+
const V = "v62.0";
|
|
20
|
+
const D = `/services/data/${V}`;
|
|
21
|
+
const MAREN = "005Fd0000000001IAA";
|
|
22
|
+
const DIEGO = "005Fd0000000002IAA";
|
|
23
|
+
const AIKO = "005Fd0000000003IAA";
|
|
24
|
+
const BEN = "005Fd0000000004IAA";
|
|
25
|
+
const JONAS = "005Fd0000000006IAA";
|
|
26
|
+
const ORG = "00DFd0000000001MAA";
|
|
27
|
+
const ACC = (n) => `001Fd0000000${n}IAA`;
|
|
28
|
+
const CON = (n) => `003Fd0000000${n}IAA`;
|
|
29
|
+
const LEAD = (n) => `00QFd0000000${n}MAA`;
|
|
30
|
+
const OPP = (n) => `006Fd0000000${n}IAA`;
|
|
31
|
+
const TASK = (n) => `00TFd0000000${n}MAA`;
|
|
32
|
+
const NEW_ACC = (n) => `001Fd000000${n}IAA`;
|
|
33
|
+
const SESSION_MESSAGE = "Session expired or invalid";
|
|
34
|
+
const NOT_FOUND_MESSAGE = "The requested resource does not exist";
|
|
35
|
+
const INSUFFICIENT = "insufficient access rights on object id";
|
|
36
|
+
const FAULT_CODES = new Set(["REQUEST_LIMIT_EXCEEDED", "UNABLE_TO_LOCK_ROW", "SERVER_UNAVAILABLE", "API_DISABLED_FOR_ORG"]);
|
|
37
|
+
|
|
38
|
+
const ALL_OPERATIONS = [
|
|
39
|
+
"versions.list", "userinfo.get", "limits.get", "sobjects.list", "sobjects.basic-info", "sobjects.describe", "records.create", "records.retrieve",
|
|
40
|
+
"records.update", "records.delete", "records.upsert", "query.execute", "query.more", "search.parameterized", "collections.create",
|
|
41
|
+
"collections.retrieve", "collections.update", "collections.delete", "collections.upsert", "composite.execute",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Self-contained query locator exactly as the Tool mints it (base64url of the JSON payload, no padding). */
|
|
45
|
+
function locator(payload) {
|
|
46
|
+
return `01g${Buffer.from(JSON.stringify(payload)).toString("base64url")}-${payload.offset}`;
|
|
47
|
+
}
|
|
48
|
+
const MAREN_LOCATOR = locator({ q: "SELECT Id FROM Contact", includeDeleted: false, offset: 5, batchSize: 5, userId: MAREN });
|
|
49
|
+
|
|
50
|
+
/** One representative request per operation, in ALL_OPERATIONS order (`sobjects.describe` goes through the canonical endpoint). */
|
|
51
|
+
const CALLS = [
|
|
52
|
+
["GET", "/services/data"],
|
|
53
|
+
["GET", "/services/oauth2/userinfo"],
|
|
54
|
+
["GET", `${D}/limits`],
|
|
55
|
+
["GET", `${D}/sobjects`],
|
|
56
|
+
["GET", `${D}/sobjects/Account`],
|
|
57
|
+
["OP", "sobjects.describe", { sobjectType: "Account" }],
|
|
58
|
+
["POST", `${D}/sobjects/Account`, { Name: "Probe Co" }],
|
|
59
|
+
["GET", `${D}/sobjects/Account/${ACC(101)}`],
|
|
60
|
+
["PATCH", `${D}/sobjects/Account/${ACC(101)}`, { Phone: "+1 555 0100" }],
|
|
61
|
+
["DELETE", `${D}/sobjects/Account/${ACC(103)}`],
|
|
62
|
+
["PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0001`, { Phone: "+1 555 0100" }],
|
|
63
|
+
["GET", `${D}/query?q=${encodeURIComponent("SELECT Id FROM Account")}`],
|
|
64
|
+
["GET", `${D}/query/${MAREN_LOCATOR}`],
|
|
65
|
+
["POST", `${D}/parameterizedSearch`, { q: "nordlicht" }],
|
|
66
|
+
["POST", `${D}/composite/sobjects`, { records: [{ attributes: { type: "Lead" }, LastName: "Probe", Company: "Probe Co" }] }],
|
|
67
|
+
["POST", `${D}/composite/sobjects/Account`, { ids: [ACC(101)], fields: ["Id"] }],
|
|
68
|
+
["PATCH", `${D}/composite/sobjects`, { records: [{ attributes: { type: "Account" }, Id: ACC(101), Phone: "+1 555 0100" }] }],
|
|
69
|
+
["DELETE", `${D}/composite/sobjects?ids=${ACC(103)}`],
|
|
70
|
+
["PATCH", `${D}/composite/sobjects/Account/External_Id__c`, { records: [{ attributes: { type: "Account" }, External_Id__c: "BW-ACC-0001", Phone: "+1 555 0100" }] }],
|
|
71
|
+
["POST", `${D}/composite`, { compositeRequest: [{ method: "GET", url: `${D}/limits`, referenceId: "limits" }] }],
|
|
72
|
+
];
|
|
73
|
+
const WRITE_INDEXES = [6, 8, 9, 10, 14, 16, 17, 18, 19];
|
|
74
|
+
const CREATE_INDEXES = [6, 10, 14, 18];
|
|
75
|
+
const VERSIONED_INDEXES = CALLS.map((_, index) => index).filter((index) => index > 1);
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------------------------
|
|
78
|
+
// Transport helpers
|
|
79
|
+
// ---------------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function checkLimitHeader(method, path, status, headers, json) {
|
|
82
|
+
const header = headers.get("sforce-limit-info");
|
|
83
|
+
const objectBody = typeof json === "object" && json !== null && !Array.isArray(json);
|
|
84
|
+
if (status < 300 && objectBody) assert.match(header ?? "", /^api-usage=\d+\/\d+$/, `${method} ${path}: missing Sforce-Limit-Info on a success body`);
|
|
85
|
+
if (status >= 400 && Array.isArray(json) && json[0] && !FAULT_CODES.has(json[0].errorCode) && json[0].errorCode !== "JSON_PARSER_ERROR") {
|
|
86
|
+
assert.match(header ?? "", /^api-usage=\d+\/\d+$/, `${method} ${path}: missing Sforce-Limit-Info on ${json[0].errorCode}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Salesforce-shaped request; `status` is asserted, headers checked, the JSON body (if any) returned. */
|
|
91
|
+
async function api(method, path, { body, status = 200, headers = {}, contentType, rawBody, scheme = "Bearer" } = {}) {
|
|
92
|
+
const hasBody = body !== undefined || rawBody !== undefined;
|
|
93
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
94
|
+
method,
|
|
95
|
+
headers: { authorization: `${scheme} ${HTTP_TOKEN}`, accept: "application/json", ...(hasBody ? { "content-type": contentType ?? "application/json" } : {}), ...headers },
|
|
96
|
+
...(hasBody ? { body: rawBody ?? JSON.stringify(body) } : {}),
|
|
97
|
+
});
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
const json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
100
|
+
assert.equal(response.status, status, `${method} ${path} ${body === undefined ? "" : JSON.stringify(body).slice(0, 200)} -> ${response.status} ${text.slice(0, 400)}`);
|
|
101
|
+
if (status === 204) assert.equal(text, "", `${method} ${path}: 204 must have no body`);
|
|
102
|
+
else if (json !== undefined) assert.match(response.headers.get("content-type") ?? "", /^application\/json/, `${method} ${path}: content type`);
|
|
103
|
+
checkLimitHeader(method, path, response.status, response.headers, json);
|
|
104
|
+
return { json, headers: response.headers, status: response.status };
|
|
105
|
+
}
|
|
106
|
+
const get = (path, options) => api("GET", path, options);
|
|
107
|
+
const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
108
|
+
const patch = (path, body, options) => api("PATCH", path, { ...options, status: 204, body });
|
|
109
|
+
const del = (path, options) => api("DELETE", path, { ...options, status: 204 });
|
|
110
|
+
const q = (soql, all = false) => `${D}/${all ? "queryAll" : "query"}?q=${encodeURIComponent(soql)}`;
|
|
111
|
+
const query = async (soql, options) => (await get(q(soql), options)).json;
|
|
112
|
+
const ids = (result) => result.records.map((record) => record.Id);
|
|
113
|
+
|
|
114
|
+
/** Expect Salesforce's error array with the given code (and message fragment / fields). */
|
|
115
|
+
async function apiError(method, path, status, code, { body, text, fields, headers, contentType, rawBody, scheme } = {}) {
|
|
116
|
+
const result = await api(method, path, { status, body, headers, contentType, rawBody, scheme });
|
|
117
|
+
const errors = result.json;
|
|
118
|
+
assert.ok(Array.isArray(errors) && errors.length === 1 && typeof errors[0].errorCode === "string", `${method} ${path}: not a Salesforce error array: ${JSON.stringify(errors)}`);
|
|
119
|
+
assert.equal(errors[0].errorCode, code, `${method} ${path}: ${JSON.stringify(errors)}`);
|
|
120
|
+
if (text !== undefined) assert.ok(String(errors[0].message).includes(text), `${method} ${path}: expected ${JSON.stringify(text)} in ${JSON.stringify(errors[0])}`);
|
|
121
|
+
if (fields !== undefined) assert.deepEqual(errors[0].fields, fields, `${method} ${path}: fields ${JSON.stringify(errors[0])}`);
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Canonical Firedrill operation endpoint (used for the route-less sobjects.describe). */
|
|
126
|
+
async function operation(id, args, expected) {
|
|
127
|
+
const response = await fetch(`${HTTP}/v1/operations/salesforce/${id}`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ arguments: args }),
|
|
131
|
+
});
|
|
132
|
+
const result = await response.json();
|
|
133
|
+
assert.ok(result.outcome, `operation ${id}: ${response.status} ${JSON.stringify(result).slice(0, 300)}`);
|
|
134
|
+
if (expected === undefined) assert.equal(result.outcome.status, "ok", `operation ${id}: ${JSON.stringify(result.outcome).slice(0, 400)}`);
|
|
135
|
+
else assert.equal(result.outcome.error?.code ?? result.outcome.status, expected, `operation ${id}: ${JSON.stringify(result.outcome).slice(0, 400)}`);
|
|
136
|
+
return result.outcome;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function call([method, path, body], options = {}) {
|
|
140
|
+
if (method === "OP") return operation(path, body);
|
|
141
|
+
return api(method, path, { ...options, ...(body === undefined ? {} : { body }) });
|
|
142
|
+
}
|
|
143
|
+
async function callError([method, path, body], status, code, text) {
|
|
144
|
+
if (method === "OP") {
|
|
145
|
+
const outcome = await operation(path, body, `tool.${code}`);
|
|
146
|
+
if (text) assert.ok(outcome.error.message.includes(text), JSON.stringify(outcome));
|
|
147
|
+
return outcome;
|
|
148
|
+
}
|
|
149
|
+
return apiError(method, path, status, code, { text, body });
|
|
150
|
+
}
|
|
151
|
+
function withVersion([method, path, body], version) {
|
|
152
|
+
if (method === "OP") return [method, path, { ...body, version }];
|
|
153
|
+
return [method, path.replace(`/${V}/`, `/${version}/`), body];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let rpcId = 0;
|
|
157
|
+
async function rpc(method, params) {
|
|
158
|
+
const response = await fetch(MCP, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { authorization: `Bearer ${MCP_TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
161
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
|
162
|
+
});
|
|
163
|
+
assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
|
|
164
|
+
const text = await response.text();
|
|
165
|
+
const type = response.headers.get("content-type") ?? "";
|
|
166
|
+
const messages = type.includes("text/event-stream")
|
|
167
|
+
? text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5).trim()))
|
|
168
|
+
: [JSON.parse(text)];
|
|
169
|
+
const reply = messages.find((message) => message.id === rpcId);
|
|
170
|
+
assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
|
|
171
|
+
if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
|
|
172
|
+
return reply.result;
|
|
173
|
+
}
|
|
174
|
+
async function mcpInit() {
|
|
175
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "salesforce-conformance", version: "0.1.0" } });
|
|
176
|
+
}
|
|
177
|
+
/** The tool result value; array-shaped results (and their error outcomes) arrive wrapped as `{ result }` in structuredContent. */
|
|
178
|
+
function mcpValue(result) {
|
|
179
|
+
assert.ok(Array.isArray(result.content) && result.content[0]?.type === "text", `MCP result without a text block: ${JSON.stringify(result).slice(0, 300)}`);
|
|
180
|
+
if (result.isError && result.structuredContent === undefined) return { status: "invalid", error: { code: "invalid", message: result.content[0].text } };
|
|
181
|
+
const value = JSON.parse(result.content[0].text);
|
|
182
|
+
const structured = result.structuredContent;
|
|
183
|
+
assert.ok(structured !== undefined, "MCP result without structuredContent");
|
|
184
|
+
const wrapped = Object.keys(structured).length === 1 && "result" in structured && !(typeof value === "object" && value !== null && !Array.isArray(value) && "result" in value);
|
|
185
|
+
assert.deepEqual(wrapped ? structured.result : structured, value, "structuredContent must mirror the text block");
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
async function mcp(name, args) {
|
|
189
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
190
|
+
assert.ok(!result.isError, `${name} ${JSON.stringify(args)}: ${JSON.stringify(result).slice(0, 500)}`);
|
|
191
|
+
return mcpValue(result);
|
|
192
|
+
}
|
|
193
|
+
async function mcpError(name, args, code, text) {
|
|
194
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
195
|
+
assert.ok(result.isError, `${name} ${JSON.stringify(args)} unexpectedly succeeded`);
|
|
196
|
+
const outcome = mcpValue(result);
|
|
197
|
+
if (code) {
|
|
198
|
+
assert.ok(outcome.error, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
|
|
199
|
+
assert.equal(outcome.error.code, code, JSON.stringify(outcome.error));
|
|
200
|
+
if (text) assert.ok(String(outcome.error.message).includes(text), JSON.stringify(outcome.error));
|
|
201
|
+
}
|
|
202
|
+
return outcome;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isId = (value, prefix) => typeof value === "string" && value.length === 18 && value.startsWith(prefix);
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------------------------
|
|
208
|
+
// Drill: rest-flow (admin = Maren, baseline)
|
|
209
|
+
// ---------------------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async function restFlow() {
|
|
212
|
+
// Handshake and identity
|
|
213
|
+
const versions = (await get("/services/data")).json;
|
|
214
|
+
assert.ok(versions.some((entry) => entry.version === "62.0" && entry.url === "/services/data/v62.0"));
|
|
215
|
+
assert.equal(versions[versions.length - 1].version, "66.0");
|
|
216
|
+
assert.equal(versions[0].version, "46.0");
|
|
217
|
+
const me = (await get("/services/oauth2/userinfo")).json;
|
|
218
|
+
assert.equal(me.preferred_username, "maren.solberg@brightwater.example.com");
|
|
219
|
+
assert.equal(me.user_id, MAREN);
|
|
220
|
+
assert.equal(me.organization_id, ORG);
|
|
221
|
+
assert.ok(me.urls.rest.endsWith("/services/data/v{version}/"));
|
|
222
|
+
assert.equal((await get("/services/oauth2/userinfo", { scheme: "OAuth" })).json.user_id, MAREN, "the legacy OAuth scheme is accepted");
|
|
223
|
+
const limits = (await get(`${D}/limits`)).json;
|
|
224
|
+
assert.deepEqual(limits.DailyApiRequests, { Max: 15000, Remaining: 14862 });
|
|
225
|
+
assert.deepEqual(limits.DataStorageMB, { Max: 200, Remaining: 197 });
|
|
226
|
+
await apiError("GET", "/services/data/v99.0/limits", 404, "NOT_FOUND", { text: NOT_FOUND_MESSAGE });
|
|
227
|
+
await apiError("GET", "/services/data/v45.0/limits", 404, "NOT_FOUND");
|
|
228
|
+
for (const index of VERSIONED_INDEXES) await callError(withVersion(CALLS[index], "v99.0"), 404, "NOT_FOUND", NOT_FOUND_MESSAGE);
|
|
229
|
+
await operation("userinfo.get", { defaultDevHub: true }, "tool.NOT_FOUND");
|
|
230
|
+
|
|
231
|
+
// Describe
|
|
232
|
+
const global = (await get(`${D}/sobjects`)).json;
|
|
233
|
+
assert.equal(global.encoding, "UTF-8");
|
|
234
|
+
assert.deepEqual(global.sobjects.map((entry) => entry.name), ["Account", "Contact", "Lead", "Opportunity", "Profile", "Task", "User"]);
|
|
235
|
+
assert.equal(global.sobjects[0].keyPrefix, "001");
|
|
236
|
+
assert.equal(global.sobjects[6].createable, false);
|
|
237
|
+
assert.equal(global.sobjects[0].urls.describe, `${D}/sobjects/Account/describe`);
|
|
238
|
+
const basic = (await get(`${D}/sobjects/Account`)).json;
|
|
239
|
+
assert.equal(basic.objectDescribe.name, "Account");
|
|
240
|
+
assert.equal(basic.recentItems.length, 5);
|
|
241
|
+
assert.equal(basic.recentItems[0].Id, ACC(109), "most recently modified account first");
|
|
242
|
+
assert.equal(basic.recentItems[0].attributes.type, "Account");
|
|
243
|
+
await apiError("GET", `${D}/sobjects/Case`, 404, "NOT_FOUND");
|
|
244
|
+
const describe = (await operation("sobjects.describe", { sobjectType: "Opportunity" })).value;
|
|
245
|
+
assert.equal(describe.name, "Opportunity");
|
|
246
|
+
assert.equal(describe.fields.find((field) => field.name === "StageName").picklistValues.length, 10);
|
|
247
|
+
assert.equal(describe.fields.find((field) => field.name === "ARR__c").custom, true);
|
|
248
|
+
assert.equal(describe.fields.find((field) => field.name === "AccountId").referenceTo[0], "Account");
|
|
249
|
+
assert.ok(describe.childRelationships.some((entry) => entry.relationshipName === "Tasks"));
|
|
250
|
+
assert.match(describe._limitInfo, /^api-usage=138\/15000$/, "canonical outputs carry the limit carrier");
|
|
251
|
+
await operation("sobjects.describe", { sobjectType: "Foo__c" }, "tool.NOT_FOUND");
|
|
252
|
+
await apiError("GET", `${D}/sobjects/Account/describe`, 400, "MALFORMED_ID", { text: "malformed id describe" });
|
|
253
|
+
|
|
254
|
+
// Create
|
|
255
|
+
const created = (await post(`${D}/sobjects/Account`, { Name: "Nordlicht Labs GmbH", Industry: "Biotechnology", NumberOfEmployees: 40, External_Id__c: "BW-ACC-0099", Website: "https://nordlicht-labs.example.com/munich" }, { status: 201 }));
|
|
256
|
+
const newId = created.json.id;
|
|
257
|
+
assert.ok(isId(newId, "001"), newId);
|
|
258
|
+
assert.equal(newId, NEW_ACC("1001"), "ids come from the counter row");
|
|
259
|
+
assert.deepEqual({ success: created.json.success, errors: created.json.errors }, { success: true, errors: [] });
|
|
260
|
+
assert.equal(created.headers.get("location"), `${D}/sobjects/Account/${newId}`);
|
|
261
|
+
assert.equal(created.headers.get("sforce-limit-info"), "api-usage=138/15000");
|
|
262
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "DUPLICATE_VALUE", { body: { Name: "Dup", External_Id__c: "BW-ACC-0099" }, text: "External_Id__c", fields: ["External_Id__c"] });
|
|
263
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "REQUIRED_FIELD_MISSING", { body: { Industry: "Biotechnology" }, text: "[Name]", fields: ["Name"] });
|
|
264
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST", { body: { Name: "x", Industry: "Space" }, text: "bad value for restricted picklist field: Space", fields: ["Industry"] });
|
|
265
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_FIELD", { body: { Name: "x", Foo: 1 }, text: "No such column 'Foo' on sobject of type Account" });
|
|
266
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_FIELD_FOR_INSERT_UPDATE", { body: { Name: "x", CreatedDate: "2026-01-01T00:00:00.000+0000" }, text: "Unable to create/update fields: CreatedDate", fields: ["CreatedDate"] });
|
|
267
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "JSON_PARSER_ERROR", { body: { Name: "x", NumberOfEmployees: "forty" }, text: "Cannot deserialize instance of int from VALUE_STRING value forty" });
|
|
268
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_CROSS_REFERENCE_KEY", { body: { Name: "x", OwnerId: JONAS }, text: "invalid cross reference id", fields: ["OwnerId"] });
|
|
269
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "FIELD_INTEGRITY_EXCEPTION", { body: { Name: "x", ParentId: CON(201) }, text: "id value of incorrect type", fields: ["ParentId"] });
|
|
270
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_CROSS_REFERENCE_KEY", { body: { Name: "x", ParentId: ACC(999) } });
|
|
271
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "MALFORMED_ID", { body: { Name: "x", ParentId: "abc" }, text: "malformed id abc" });
|
|
272
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_FIELD", { body: { Name: "x", Website: "y".repeat(300) }, text: "data value too large" });
|
|
273
|
+
await apiError("POST", `${D}/sobjects/Contact`, 400, "INVALID_FIELD", { body: { LastName: "x", Email: "not-an-email" }, text: "invalid email address" });
|
|
274
|
+
await apiError("POST", `${D}/sobjects/Opportunity`, 400, "FIELD_CUSTOM_VALIDATION_EXCEPTION", { body: { Name: "Won without amount", StageName: "Closed Won", CloseDate: "2026-09-30" }, text: "Closed Won opportunities must have an Amount.", fields: ["Amount"] });
|
|
275
|
+
await apiError("POST", `${D}/sobjects/User`, 400, "INVALID_TYPE_FOR_OPERATION", { body: { Username: "a@b.test" } });
|
|
276
|
+
await apiError("POST", `${D}/sobjects/Case`, 404, "INVALID_TYPE", { body: {}, text: "sObject type 'Case' is not supported" });
|
|
277
|
+
const form = await fetch(`${HTTP}${D}/sobjects/Account`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/x-www-form-urlencoded" }, body: "Name=x" });
|
|
278
|
+
assert.equal(form.status, 415, "form bodies are the framework's 415");
|
|
279
|
+
|
|
280
|
+
// Retrieve
|
|
281
|
+
const full = (await get(`${D}/sobjects/Account/${newId}`)).json;
|
|
282
|
+
assert.equal(full.attributes.url, `${D}/sobjects/Account/${newId}`);
|
|
283
|
+
assert.equal(full.Name, "Nordlicht Labs GmbH");
|
|
284
|
+
assert.equal(full.OwnerId, MAREN);
|
|
285
|
+
assert.equal(full.CreatedById, MAREN);
|
|
286
|
+
assert.equal(full.CreatedDate, "2026-09-14T09:00:00.000+0000", "timestamps come from virtual time");
|
|
287
|
+
assert.equal(full.IsDeleted, false);
|
|
288
|
+
assert.equal(full.Customer_Tier__c, null);
|
|
289
|
+
const projected = (await get(`${D}/sobjects/Account/${newId}?fields=Name,Owner.Name,NumberOfEmployees`)).json;
|
|
290
|
+
assert.deepEqual(Object.keys(projected).sort(), ["Id", "Name", "NumberOfEmployees", "Owner", "attributes"]);
|
|
291
|
+
assert.equal(projected.Owner.Name, "Maren Solberg");
|
|
292
|
+
assert.equal(projected.Owner.attributes.type, "User");
|
|
293
|
+
await apiError("GET", `${D}/sobjects/Account/${newId}?fields=Nope`, 400, "INVALID_FIELD");
|
|
294
|
+
await apiError("GET", `${D}/sobjects/Account/${newId}?fields=Account.Owner.Name`, 400, "INVALID_FIELD");
|
|
295
|
+
assert.equal((await get(`${D}/sobjects/Account/${newId.slice(0, 15)}`)).json.Id, newId, "15-character ids resolve to 18");
|
|
296
|
+
const body15 = newId.slice(0, 15);
|
|
297
|
+
assert.equal(body15.slice(3, 5), "Fd", "minted ids carry the Fd0 marker");
|
|
298
|
+
assert.equal(newId.slice(15), "IAA", "the case-safe suffix of an Fd0 id with a digit body is IAA");
|
|
299
|
+
assert.equal((await get(`${D}/sobjects/Account/${body15.toLowerCase()}IAA`)).json.Id, newId, "18-character ids are case-insensitive: the suffix re-cases the body");
|
|
300
|
+
assert.equal((await get(`${D}/sobjects/Account/${body15}iaa`)).json.Id, newId, "a lower-case suffix is accepted");
|
|
301
|
+
await apiError("GET", `${D}/sobjects/Account/${body15}IAB`, 400, "MALFORMED_ID", { text: `malformed id ${body15}IAB` });
|
|
302
|
+
await apiError("GET", `${D}/sobjects/Account/${body15}IA9`, 400, "MALFORMED_ID");
|
|
303
|
+
await apiError("GET", `${D}/sobjects/Account/${body15}AAA`, 404, "NOT_FOUND");
|
|
304
|
+
await operation("records.retrieve", { sobjectType: "Account", id: `${body15}IAB` }, "tool.MALFORMED_ID");
|
|
305
|
+
assert.equal((await operation("records.retrieve", { sobjectType: "Account", id: `${body15.toLowerCase()}iaa` })).value.Id, newId);
|
|
306
|
+
await apiError("POST", `${D}/sobjects/Contact`, 400, "MALFORMED_ID", { body: { LastName: "x", AccountId: `${body15}IAB` }, fields: ["AccountId"] });
|
|
307
|
+
await apiError("GET", `${D}/sobjects/Account/${CON(201)}`, 404, "NOT_FOUND");
|
|
308
|
+
await apiError("GET", `${D}/sobjects/Account/${ACC(110)}`, 404, "ENTITY_IS_DELETED", { text: "entity is deleted" });
|
|
309
|
+
await apiError("GET", `${D}/sobjects/Account/${ACC(999)}`, 404, "NOT_FOUND");
|
|
310
|
+
await apiError("GET", `${D}/sobjects/Account/zzz`, 400, "MALFORMED_ID");
|
|
311
|
+
await apiError("GET", `${D}/sobjects/Case/${ACC(101)}`, 404, "INVALID_TYPE");
|
|
312
|
+
assert.equal((await get(`${D}/sobjects/Account/External_Id__c/BW-ACC-0001`)).json.Id, ACC(101));
|
|
313
|
+
assert.equal((await get(`${D}/sobjects/account/external_id__c/bw-acc-0001?fields=Id`)).json.Id, ACC(101), "type, field and value are case-insensitive");
|
|
314
|
+
await apiError("GET", `${D}/sobjects/Account/External_Id__c/nope`, 404, "NOT_FOUND");
|
|
315
|
+
await apiError("GET", `${D}/sobjects/Account/Customer_Tier__c/Team`, 404, "NOT_FOUND", { text: "Provided external ID field does not exist or is not accessible: Customer_Tier__c" });
|
|
316
|
+
|
|
317
|
+
// Update
|
|
318
|
+
await patch(`${D}/sobjects/Account/${newId}`, { Phone: "+31 20 123 4567", Industry: null });
|
|
319
|
+
const updated = (await get(`${D}/sobjects/Account/${newId}`)).json;
|
|
320
|
+
assert.equal(updated.Phone, "+31 20 123 4567");
|
|
321
|
+
assert.equal(updated.Industry, null);
|
|
322
|
+
assert.ok(updated.LastModifiedDate >= updated.CreatedDate);
|
|
323
|
+
await patch(`${D}/sobjects/Account/${newId}`, { Phone: "+31 20 123 4567" });
|
|
324
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "REQUIRED_FIELD_MISSING", { body: { Name: null }, fields: ["Name"] });
|
|
325
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "INVALID_FIELD_FOR_INSERT_UPDATE", { body: { Id: ACC(101) } });
|
|
326
|
+
// A 32,000-character stored Description (the long text area maximum) proves search stays linear on long values;
|
|
327
|
+
// the Website carries LIKE wildcard characters for the escape checks below.
|
|
328
|
+
await patch(`${D}/sobjects/Account/${newId}`, { Id: newId, Description: `${"a".repeat(31900)} Id in the body is accepted when it matches; rate 100% a_b`, Website: "https://nordlicht-labs.example.com/rate-100%-a_b" });
|
|
329
|
+
// Long text areas cannot be filtered or sorted in SOQL, as in Salesforce.
|
|
330
|
+
await apiError("GET", q(`SELECT Id FROM Account WHERE Description LIKE '%${"a".repeat(3990)}b%'`), 400, "INVALID_FIELD", { text: "field 'Description' can not be filtered in query call", fields: ["Description"] });
|
|
331
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE Description = 'x'"), 400, "INVALID_FIELD", { text: "can not be filtered in query call" });
|
|
332
|
+
await apiError("GET", q("SELECT Id FROM Contact WHERE Account.Description IN ('x')"), 400, "INVALID_FIELD", { text: "can not be filtered in query call" });
|
|
333
|
+
await apiError("GET", q("SELECT Id FROM Account ORDER BY Description"), 400, "INVALID_FIELD", { text: "field 'Description' can not be sorted in a query call" });
|
|
334
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "INVALID_FIELD", { body: { q: "aaaa*", sobjects: [{ name: "Account", where: `Description LIKE '${"%a_".repeat(16)}%q'` }] }, text: "can not be filtered in query call" });
|
|
335
|
+
// Escaped LIKE wildcards against the Website just written: \% and \_ are literal, bare % and _ stay wildcards.
|
|
336
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Website LIKE '%100\\%%'")), [newId]);
|
|
337
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Website LIKE '%A\\_B'")), [newId]);
|
|
338
|
+
assert.equal((await query(`SELECT Id FROM Account WHERE Id = '${newId}' AND Website LIKE '%a\\_x%'`)).totalSize, 0);
|
|
339
|
+
assert.deepEqual(ids(await query(`SELECT Id FROM Account WHERE Id = '${newId}' AND Website LIKE '%rate-100_-a_b'`)), [newId]);
|
|
340
|
+
assert.equal((await query(`SELECT Id FROM Account WHERE Id = '${newId}' AND Website LIKE '%rate-100\\_-a_b'`)).totalSize, 0);
|
|
341
|
+
// Wildcard-heavy and long near-miss patterns (string literals up to Salesforce's 4,000 characters), several
|
|
342
|
+
// clauses over the same column, through composite: answered well under a second.
|
|
343
|
+
const longStarted = performance.now();
|
|
344
|
+
const longLike = await post(`${D}/composite`, {
|
|
345
|
+
compositeRequest: [
|
|
346
|
+
`SELECT Id FROM Account WHERE Name LIKE '%${"a".repeat(3990)}b%'`,
|
|
347
|
+
`SELECT Id FROM Account WHERE Website LIKE '%${"a_".repeat(127)}ab%' OR Website LIKE 'https://%' AND Website LIKE '%100\\%-a\\_b'`,
|
|
348
|
+
`SELECT Id FROM Account WHERE Name LIKE '${"%a".repeat(16)}%q' OR Name LIKE '${"%a_".repeat(16)}%q'`,
|
|
349
|
+
`SELECT Id FROM Account WHERE ${Array(300).fill("Website LIKE '%h%t%t%p%100\\%%'").join(" AND ")}`,
|
|
350
|
+
`SELECT Id FROM Account WHERE Name LIKE '${"x".repeat(4001)}'`,
|
|
351
|
+
].map((statement, index) => ({ method: "GET", url: `${D}/query?q=${encodeURIComponent(statement)}`, referenceId: `long${index}` })),
|
|
352
|
+
});
|
|
353
|
+
assert.deepEqual(longLike.json.compositeResponse.map((item) => item.httpStatusCode), [200, 200, 200, 200, 400]);
|
|
354
|
+
assert.deepEqual(longLike.json.compositeResponse.slice(0, 4).map((item) => item.body.records.map((record) => record.Id)), [[], [newId], [], [newId]]);
|
|
355
|
+
assert.equal(longLike.json.compositeResponse[4].body[0].errorCode, "MALFORMED_QUERY");
|
|
356
|
+
assert.match(longLike.json.compositeResponse[4].body[0].message, /String literal exceeds the maximum length of 4000 characters/);
|
|
357
|
+
assert.ok(performance.now() - longStarted < 1000, `long LIKE segments answered in ${performance.now() - longStarted} ms`);
|
|
358
|
+
await apiError("GET", q(`SELECT Id FROM Account WHERE Website LIKE '%${"a_".repeat(129)}b%'`), 400, "MALFORMED_QUERY", { text: "exceeds the supported length of 256" });
|
|
359
|
+
const longSearchStarted = performance.now();
|
|
360
|
+
assert.deepEqual((await post(`${D}/parameterizedSearch`, { q: `${"a*".repeat(16)}q` })).json.searchRecords, []);
|
|
361
|
+
assert.deepEqual((await post(`${D}/parameterizedSearch`, { q: `${"a?".repeat(99)}b` })).json.searchRecords, []);
|
|
362
|
+
assert.deepEqual((await post(`${D}/parameterizedSearch`, { q: "aaaa* rate", sobjects: [{ name: "Account" }] })).json.searchRecords.map((record) => record.Id), [newId]);
|
|
363
|
+
assert.ok(performance.now() - longSearchStarted < 1000, `wildcard-heavy search answered in ${performance.now() - longSearchStarted} ms`);
|
|
364
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "INVALID_FIELD", { body: { Foo: 1 } });
|
|
365
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST", { body: { Customer_Tier__c: "Platinum" }, fields: ["Customer_Tier__c"] });
|
|
366
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "INVALID_CROSS_REFERENCE_KEY", { body: { OwnerId: JONAS } });
|
|
367
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "FIELD_INTEGRITY_EXCEPTION", { body: { ParentId: newId }, text: "cannot be its own parent" });
|
|
368
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "DUPLICATE_VALUE", { body: { External_Id__c: "BW-ACC-0001" } });
|
|
369
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "JSON_PARSER_ERROR", { body: { AnnualRevenue: "lots" } });
|
|
370
|
+
await apiError("PATCH", `${D}/sobjects/Account/${newId}`, 400, "MALFORMED_ID", { body: { ParentId: "12" } });
|
|
371
|
+
await apiError("PATCH", `${D}/sobjects/Account/${ACC(110)}`, 404, "ENTITY_IS_DELETED", { body: { Phone: "1" } });
|
|
372
|
+
await apiError("PATCH", `${D}/sobjects/Account/${ACC(999)}`, 404, "NOT_FOUND", { body: { Phone: "1" } });
|
|
373
|
+
await apiError("PATCH", `${D}/sobjects/Account/zzz`, 400, "MALFORMED_ID", { body: { Phone: "1" } });
|
|
374
|
+
await apiError("PATCH", `${D}/sobjects/User/${MAREN}`, 400, "INVALID_TYPE_FOR_OPERATION", { body: { Title: "x" } });
|
|
375
|
+
await apiError("PATCH", `${D}/sobjects/Case/${ACC(101)}`, 404, "INVALID_TYPE", { body: { Phone: "1" } });
|
|
376
|
+
await apiError("PATCH", `${D}/sobjects/Lead/${LEAD(308)}`, 400, "CANNOT_UPDATE_CONVERTED_LEAD", { body: { Rating: "Hot" } });
|
|
377
|
+
await apiError("PATCH", `${D}/sobjects/Lead/${LEAD(301)}`, 400, "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST", { body: { Status: "Closed - Converted" }, fields: ["Status"] });
|
|
378
|
+
await apiError("PATCH", `${D}/sobjects/Opportunity/${OPP(404)}`, 400, "FIELD_CUSTOM_VALIDATION_EXCEPTION", { body: { StageName: "Closed Won" }, fields: ["Amount"] });
|
|
379
|
+
await patch(`${D}/sobjects/Opportunity/${OPP(404)}`, { StageName: "Closed Won", Amount: 42000 });
|
|
380
|
+
const won = (await get(`${D}/sobjects/Opportunity/${OPP(404)}`)).json;
|
|
381
|
+
assert.deepEqual({ IsClosed: won.IsClosed, IsWon: won.IsWon, Probability: won.Probability, ForecastCategoryName: won.ForecastCategoryName }, { IsClosed: true, IsWon: true, Probability: 100, ForecastCategoryName: "Closed" });
|
|
382
|
+
await patch(`${D}/sobjects/Opportunity/${OPP(405)}`, { StageName: "Negotiation/Review" });
|
|
383
|
+
assert.equal((await get(`${D}/sobjects/Opportunity/${OPP(405)}?fields=Probability,ForecastCategory`)).json.Probability, 90);
|
|
384
|
+
await patch(`${D}/sobjects/Opportunity/${OPP(407)}`, { StageName: "Value Proposition", Probability: 55 });
|
|
385
|
+
assert.equal((await get(`${D}/sobjects/Opportunity/${OPP(407)}?fields=Probability`)).json.Probability, 55, "an explicit Probability is kept");
|
|
386
|
+
|
|
387
|
+
// Tasks
|
|
388
|
+
await apiError("POST", `${D}/sobjects/Task`, 400, "FIELD_INTEGRITY_EXCEPTION", { body: { Subject: "Call", WhoId: ACC(101) }, fields: ["WhoId"] });
|
|
389
|
+
const taskId = (await post(`${D}/sobjects/Task`, { Subject: "Call", WhoId: CON(201), WhatId: OPP(401) }, { status: 201 })).json.id;
|
|
390
|
+
assert.ok(isId(taskId, "00T"));
|
|
391
|
+
const createdTask = (await get(`${D}/sobjects/Task/${taskId}`)).json;
|
|
392
|
+
assert.deepEqual({ Status: createdTask.Status, Priority: createdTask.Priority, IsClosed: createdTask.IsClosed }, { Status: "Not Started", Priority: "Normal", IsClosed: false });
|
|
393
|
+
await patch(`${D}/sobjects/Task/${taskId}`, { Status: "Completed" });
|
|
394
|
+
assert.equal((await get(`${D}/sobjects/Task/${taskId}?fields=IsClosed,Who.Name,What.Type`)).json.IsClosed, true);
|
|
395
|
+
|
|
396
|
+
// Upsert
|
|
397
|
+
const upsertExisting = (await api("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0001`, { body: { Phone: "+1 555 0100" } })).json;
|
|
398
|
+
assert.deepEqual({ id: upsertExisting.id, created: upsertExisting.created, success: upsertExisting.success }, { id: ACC(101), created: false, success: true });
|
|
399
|
+
const upsertNew = (await api("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0777`, { body: { Name: "Upserted Co" } })).json;
|
|
400
|
+
assert.equal(upsertNew.created, true);
|
|
401
|
+
assert.ok(isId(upsertNew.id, "001"));
|
|
402
|
+
assert.equal((await get(`${D}/sobjects/Account/${upsertNew.id}?fields=External_Id__c,Name`)).json.External_Id__c, "BW-ACC-0777");
|
|
403
|
+
assert.equal((await api("PATCH", `${D}/sobjects/Account/Id/${ACC(101)}`, { body: { Website: "https://x.example.org" } })).json.created, false);
|
|
404
|
+
await apiError("PATCH", `${D}/sobjects/Account/Id/${ACC(999)}`, 404, "NOT_FOUND", { body: { Website: "https://x.example.org" } });
|
|
405
|
+
await apiError("PATCH", `${D}/sobjects/Account/Id/${ACC(110)}`, 404, "ENTITY_IS_DELETED", { body: { Website: "https://x.example.org" } });
|
|
406
|
+
await apiError("PATCH", `${D}/sobjects/Account/Id/zzz`, 400, "MALFORMED_ID", { body: { Website: "https://x.example.org" } });
|
|
407
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0001`, 400, "INVALID_FIELD", { body: { External_Id__c: "OTHER" } });
|
|
408
|
+
await apiError("PATCH", `${D}/sobjects/Account/Customer_Tier__c/Team`, 404, "NOT_FOUND", { body: { Name: "x" } });
|
|
409
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0778`, 400, "REQUIRED_FIELD_MISSING", { body: { Industry: "Energy" }, fields: ["Name"] });
|
|
410
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0778`, 400, "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST", { body: { Name: "x", Industry: "Space" } });
|
|
411
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0778`, 400, "INVALID_FIELD_FOR_INSERT_UPDATE", { body: { Name: "x", CreatedDate: "2026-01-01T00:00:00.000+0000" } });
|
|
412
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0778`, 400, "INVALID_CROSS_REFERENCE_KEY", { body: { Name: "x", OwnerId: JONAS } });
|
|
413
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0778`, 400, "FIELD_INTEGRITY_EXCEPTION", { body: { Name: "x", ParentId: CON(201) } });
|
|
414
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0778`, 400, "JSON_PARSER_ERROR", { body: { Name: "x", NumberOfEmployees: "ten" } });
|
|
415
|
+
await apiError("PATCH", `${D}/sobjects/Account/Id/${newId}`, 400, "DUPLICATE_VALUE", { body: { External_Id__c: "BW-ACC-0001" } });
|
|
416
|
+
await apiError("PATCH", `${D}/sobjects/Opportunity/Id/${OPP(402)}`, 400, "FIELD_CUSTOM_VALIDATION_EXCEPTION", { body: { StageName: "Closed Won", Amount: 0 } });
|
|
417
|
+
await apiError("PATCH", `${D}/sobjects/Lead/Id/${LEAD(308)}`, 400, "CANNOT_UPDATE_CONVERTED_LEAD", { body: { Rating: "Cold" } });
|
|
418
|
+
await apiError("PATCH", `${D}/sobjects/User/Id/${MAREN}`, 400, "INVALID_TYPE_FOR_OPERATION", { body: { Title: "x" } });
|
|
419
|
+
await apiError("PATCH", `${D}/sobjects/Case/External_Id__c/x`, 404, "INVALID_TYPE", { body: { Name: "x" } });
|
|
420
|
+
|
|
421
|
+
// Delete (with cascade) and queryAll
|
|
422
|
+
await del(`${D}/sobjects/Account/${newId}`);
|
|
423
|
+
await apiError("DELETE", `${D}/sobjects/Account/${newId}`, 404, "ENTITY_IS_DELETED");
|
|
424
|
+
await apiError("GET", `${D}/sobjects/Account/${newId}`, 404, "ENTITY_IS_DELETED");
|
|
425
|
+
const deletedRow = await query(`SELECT Id, IsDeleted FROM Account WHERE Id = '${newId}'`);
|
|
426
|
+
assert.equal(deletedRow.totalSize, 0, "query hides deleted rows");
|
|
427
|
+
const allRows = (await get(q(`SELECT Id, IsDeleted FROM Account WHERE Id = '${newId}'`, true))).json;
|
|
428
|
+
assert.equal(allRows.records[0].IsDeleted, true);
|
|
429
|
+
await del(`${D}/sobjects/Account/${ACC(102)}`);
|
|
430
|
+
const cascaded = (await get(q(`SELECT Id, IsDeleted FROM Contact WHERE AccountId = '${ACC(102)}'`, true))).json;
|
|
431
|
+
assert.equal(cascaded.totalSize, 3);
|
|
432
|
+
assert.ok(cascaded.records.every((record) => record.IsDeleted === true));
|
|
433
|
+
assert.equal((await get(q(`SELECT COUNT() FROM Opportunity WHERE AccountId = '${ACC(102)}' AND IsDeleted = true`, true))).json.totalSize, 2);
|
|
434
|
+
assert.equal((await get(q(`SELECT COUNT() FROM Task WHERE IsDeleted = true`, true))).json.totalSize, 2, "tasks on the account, its contacts and its opportunities cascade");
|
|
435
|
+
assert.equal((await query(`SELECT COUNT() FROM Contact WHERE AccountId = '${ACC(102)}'`)).totalSize, 0);
|
|
436
|
+
await apiError("DELETE", `${D}/sobjects/User/${DIEGO}`, 400, "INVALID_TYPE_FOR_OPERATION");
|
|
437
|
+
await apiError("DELETE", `${D}/sobjects/Case/${ACC(101)}`, 404, "INVALID_TYPE");
|
|
438
|
+
await apiError("DELETE", `${D}/sobjects/Account/${ACC(999)}`, 404, "NOT_FOUND");
|
|
439
|
+
await apiError("DELETE", `${D}/sobjects/Account/zzz`, 400, "MALFORMED_ID");
|
|
440
|
+
|
|
441
|
+
// Routes that do not exist are framework 404/405, never stubs
|
|
442
|
+
const traversal = await fetch(`${HTTP}${D}/sobjects/Account/${ACC(101)}/Contacts`, { headers: { authorization: `Bearer ${HTTP_TOKEN}` } });
|
|
443
|
+
assert.equal(traversal.status, 404);
|
|
444
|
+
const tree = await fetch(`${HTTP}${D}/composite/tree/Account`, { method: "POST", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" }, body: "{}" });
|
|
445
|
+
assert.ok(tree.status === 404 || tree.status === 405, `composite/tree: ${tree.status}`);
|
|
446
|
+
const noAuth = await fetch(`${HTTP}${D}/limits`);
|
|
447
|
+
assert.equal(noAuth.status, 401, "every route requires the world token");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------------------------
|
|
451
|
+
// Drill: soql (admin, baseline)
|
|
452
|
+
// ---------------------------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
async function soqlFlow() {
|
|
455
|
+
const accounts = await query("SELECT Id, Name, Industry FROM Account");
|
|
456
|
+
assert.deepEqual({ totalSize: accounts.totalSize, done: accounts.done, length: accounts.records.length, next: accounts.nextRecordsUrl }, { totalSize: 9, done: true, length: 9, next: undefined });
|
|
457
|
+
assert.equal(accounts.records[0].attributes.type, "Account");
|
|
458
|
+
assert.equal(accounts.records[0].attributes.url, `${D}/sobjects/Account/${ACC(101)}`);
|
|
459
|
+
assert.deepEqual(await query("SELECT COUNT() FROM Contact"), { totalSize: 13, done: true, records: [] });
|
|
460
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account ORDER BY Name DESC NULLS LAST LIMIT 3 OFFSET 2")), [ACC(105), ACC(101), ACC(107)]);
|
|
461
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Opportunity ORDER BY Amount NULLS FIRST, Id LIMIT 2")), [OPP(404), OPP(406)]);
|
|
462
|
+
const related = await query("SELECT Name, Account.Name, Owner.Username FROM Contact WHERE Account.Industry = 'Biotechnology' ORDER BY Id");
|
|
463
|
+
assert.equal(related.totalSize, 4);
|
|
464
|
+
assert.equal(related.records[0].Account.Name, "Nordlicht Labs GmbH");
|
|
465
|
+
assert.equal(related.records[0].Account.attributes.type, "Account");
|
|
466
|
+
assert.equal(related.records[0].Owner.Username, "diego.alvarez@brightwater.example.com");
|
|
467
|
+
const nested = await query(`SELECT Id, (SELECT Id, LastName FROM Contacts), (SELECT Id FROM Opportunities WHERE IsClosed = false) FROM Account WHERE Id IN ('${ACC(101)}', '${ACC(109)}') ORDER BY Id`);
|
|
468
|
+
assert.equal(nested.records[0].Contacts.totalSize, 3);
|
|
469
|
+
assert.equal(nested.records[0].Contacts.done, true);
|
|
470
|
+
assert.equal(nested.records[0].Contacts.records[0].LastName, "Müller");
|
|
471
|
+
assert.equal(nested.records[0].Opportunities.totalSize, 2);
|
|
472
|
+
assert.equal(nested.records[1].Opportunities, null, "an empty child relationship is null");
|
|
473
|
+
const tasks = await query("SELECT Subject, Who.Name, Who.Type, What.Name, What.Type FROM Task ORDER BY Id");
|
|
474
|
+
assert.equal(tasks.totalSize, 8);
|
|
475
|
+
assert.deepEqual({ who: tasks.records[0].Who.Name, whoType: tasks.records[0].Who.Type, what: tasks.records[0].What.Name, whatType: tasks.records[0].What.Type }, { who: "Zoë Müller", whoType: "Contact", what: "Nordlicht Labs - Spectrometer Fleet Renewal", whatType: "Opportunity" });
|
|
476
|
+
assert.equal(tasks.records[2].Who.Type, "Lead");
|
|
477
|
+
assert.equal(tasks.records[7].Who, null);
|
|
478
|
+
const allLeadFields = await query("SELECT FIELDS(ALL) FROM Lead LIMIT 200");
|
|
479
|
+
assert.equal(allLeadFields.totalSize, 8);
|
|
480
|
+
assert.ok("Company" in allLeadFields.records[0] && "IsConverted" in allLeadFields.records[0] && "SystemModstamp" in allLeadFields.records[0]);
|
|
481
|
+
await apiError("GET", q("SELECT FIELDS(ALL) FROM Lead"), 400, "MALFORMED_QUERY");
|
|
482
|
+
await apiError("GET", q("SELECT Id FROM Account LIMIT 99999999999999999999"), 400, "MALFORMED_QUERY", { text: "numeric value out of range: '99999999999999999999'" });
|
|
483
|
+
await apiError("GET", q("SELECT Id FROM Account LIMIT 2147483648"), 400, "MALFORMED_QUERY", { text: "numeric value out of range" });
|
|
484
|
+
await apiError("GET", q("SELECT Id FROM Account OFFSET 99999999999999999999"), 400, "MALFORMED_QUERY", { text: "numeric value out of range" });
|
|
485
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE CreatedDate = LAST_N_DAYS:99999999999999999999"), 400, "MALFORMED_QUERY", { text: "numeric value out of range" });
|
|
486
|
+
await operation("query.execute", { q: "SELECT Id FROM Account LIMIT 99999999999999999999" }, "tool.MALFORMED_QUERY");
|
|
487
|
+
assert.equal((await get(q("SELECT Id FROM Account LIMIT 2147483647"))).json.totalSize, (await get(q("SELECT Id FROM Account"))).json.totalSize, "LIMIT 2147483647 is accepted");
|
|
488
|
+
const custom = await query("SELECT FIELDS(CUSTOM) FROM Opportunity LIMIT 5");
|
|
489
|
+
assert.deepEqual(Object.keys(custom.records[0]).sort(), ["ARR__c", "attributes"]);
|
|
490
|
+
assert.equal((await query("SELECT FIELDS(STANDARD) FROM Task")).records[0].ARR__c, undefined);
|
|
491
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Name LIKE 'nord%'")), [ACC(101), ACC(107)]);
|
|
492
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE '%_labs gmbh'")).totalSize, 2);
|
|
493
|
+
// LIKE semantics (linear matcher): prefix, suffix, `_`, bare `%`, empty pattern, escapes, case-insensitivity.
|
|
494
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Name LIKE 'Nordlicht%'")), [ACC(101), ACC(107)]);
|
|
495
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Name LIKE 'NORDLICHT%'")), [ACC(101), ACC(107)]);
|
|
496
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE '%Diagnostics'")).totalSize, 1);
|
|
497
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE '%diagnostics%'")).totalSize, 2);
|
|
498
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Name LIKE 'N_rdlicht%'")), [ACC(101), ACC(107)]);
|
|
499
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE '%'")).totalSize, 9);
|
|
500
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE ''")).totalSize, 0);
|
|
501
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE 'O\\'Neill%'")).totalSize, 1);
|
|
502
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE 'Nordlicht\\_%'")).totalSize, 0, "\\_ is a literal underscore");
|
|
503
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE '%\\%'")).totalSize, 0, "\\% is a literal percent sign");
|
|
504
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE '100\\%'")).totalSize, 0);
|
|
505
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name LIKE 'a\\_b'")).totalSize, 0);
|
|
506
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE Name LIKE 'a\\qb'"), 400, "MALFORMED_QUERY");
|
|
507
|
+
// Wildcard-heavy patterns, deep nesting, long ORDER BY: bounded work and Salesforce errors, never a stall.
|
|
508
|
+
const heavy = `${"%a".repeat(16)}%q`;
|
|
509
|
+
for (const statement of [`SELECT Id FROM Account WHERE Name LIKE '${heavy}'`, `SELECT Id FROM Account WHERE Name LIKE '${"%a_".repeat(16)}%q'`, `SELECT Id FROM Account WHERE Name LIKE '${heavy}' OR Name LIKE '${"%_a".repeat(16)}%q'`]) {
|
|
510
|
+
const started = performance.now();
|
|
511
|
+
assert.equal((await query(statement)).totalSize, 0);
|
|
512
|
+
assert.ok(performance.now() - started < 1000, `wildcard-heavy LIKE answered in ${performance.now() - started} ms`);
|
|
513
|
+
}
|
|
514
|
+
assert.equal((await query(`SELECT Id FROM Account WHERE ${"(".repeat(100)}Name LIKE 'nord%'${")".repeat(100)}`)).totalSize, 2);
|
|
515
|
+
const deep = await post(`${D}/composite`, { compositeRequest: [{ method: "GET", url: `${D}/query?q=${encodeURIComponent(`SELECT Id FROM Account WHERE ${"(".repeat(1000)}Name = 'x'${")".repeat(1000)}`)}`, referenceId: "deep" }] });
|
|
516
|
+
assert.equal(deep.json.compositeResponse[0].httpStatusCode, 400);
|
|
517
|
+
assert.equal(deep.json.compositeResponse[0].body[0].errorCode, "MALFORMED_QUERY");
|
|
518
|
+
await apiError("GET", q(`SELECT Id FROM Account ORDER BY ${Array(33).fill("Name").join(", ")}`), 400, "MALFORMED_QUERY");
|
|
519
|
+
await apiError("GET", q("SELECT Id, Account.Name, account.name FROM Contact"), 400, "MALFORMED_QUERY", { text: "duplicate field selected" });
|
|
520
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Name IN ('nordlicht labs gmbh', 'HELIX DIAGNOSTICS LTD')")).totalSize, 3);
|
|
521
|
+
assert.equal((await query("SELECT Id FROM Contact WHERE Email = 'zoe.mueller@nordlicht-labs.example.com'")).totalSize, 1, "text equality is case-insensitive");
|
|
522
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Industry IN ('Biotechnology','Healthcare') AND NumberOfEmployees > 100")), [ACC(101), ACC(102)]);
|
|
523
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Opportunity WHERE (Amount >= 50000 OR StageName = 'Closed Won') AND NOT IsClosed = true")), [OPP(401), OPP(408), OPP(409)]);
|
|
524
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Opportunity WHERE Amount = null")), [OPP(404)]);
|
|
525
|
+
assert.equal((await query("SELECT Id FROM Opportunity WHERE Amount != null AND Amount <= 8500")).totalSize, 2);
|
|
526
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Opportunity WHERE CloseDate = THIS_MONTH")), [OPP(401), OPP(405)]);
|
|
527
|
+
assert.equal((await query("SELECT Id FROM Opportunity WHERE CloseDate = NEXT_N_MONTHS:3")).totalSize, 5);
|
|
528
|
+
assert.equal((await query("SELECT Id FROM Opportunity WHERE CloseDate > THIS_MONTH AND CloseDate >= 2026-10-01")).totalSize, 5);
|
|
529
|
+
assert.equal((await query("SELECT Id FROM Opportunity WHERE CloseDate < NEXT_N_MONTHS:3 AND CloseDate > LAST_MONTH")).totalSize, 2);
|
|
530
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Lead WHERE CreatedDate >= LAST_N_DAYS:30")), [LEAD(301), LEAD(303), LEAD(305), LEAD(306)]);
|
|
531
|
+
assert.equal((await query("SELECT Id FROM Lead WHERE CreatedDate < 2026-06-01T00:00:00Z")).totalSize, 2);
|
|
532
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Task WHERE ActivityDate < TODAY AND IsClosed = false")), [TASK(505)]);
|
|
533
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Task WHERE ActivityDate = TODAY")), [TASK(501)]);
|
|
534
|
+
assert.equal((await query("SELECT Id FROM Lead WHERE Status NOT IN ('Open - Not Contacted') AND Rating != null")).totalSize, 4);
|
|
535
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Account WHERE Name = 'O\\'Neill Diagnostics'")), [ACC(105)]);
|
|
536
|
+
assert.equal((await query("SELECT Id FROM Account WHERE Owner.Alias = 'dalva' AND Parent.Name != null")).totalSize, 1);
|
|
537
|
+
assert.equal((await query("select id from account where industry = 'biotechnology'")).totalSize, 3, "keywords and names are case-insensitive");
|
|
538
|
+
|
|
539
|
+
// Paging
|
|
540
|
+
const onePage = await query("SELECT Id FROM Contact", { headers: { "sforce-query-options": "batchSize=200" } });
|
|
541
|
+
assert.equal(onePage.done, true);
|
|
542
|
+
assert.equal(onePage.records.length, 13);
|
|
543
|
+
const page1 = await query("SELECT Id FROM Contact", { headers: { "sforce-query-options": "batchSize=5" } });
|
|
544
|
+
assert.deepEqual({ done: page1.done, totalSize: page1.totalSize, length: page1.records.length }, { done: false, totalSize: 13, length: 5 });
|
|
545
|
+
assert.match(page1.nextRecordsUrl, new RegExp(`^${D.replace(/\./g, "\\.")}/query/01g[A-Za-z0-9_-]+-5$`));
|
|
546
|
+
const page2 = (await get(page1.nextRecordsUrl)).json;
|
|
547
|
+
assert.equal(page2.records.length, 5);
|
|
548
|
+
assert.equal(page2.records[0].Id, CON(206));
|
|
549
|
+
const page3 = (await get(page2.nextRecordsUrl)).json;
|
|
550
|
+
assert.deepEqual({ done: page3.done, length: page3.records.length, next: page3.nextRecordsUrl }, { done: true, length: 3, next: undefined });
|
|
551
|
+
await apiError("GET", `${D}/query/01gBOGUS-2000`, 400, "INVALID_QUERY_LOCATOR", { text: "invalid query locator" });
|
|
552
|
+
// Locators whose base64url decodes to bytes that are not valid UTF-8 fail cleanly, never crash:
|
|
553
|
+
// "notalocator" starts with a stray 0x9E continuation byte, "9JCAgA" encodes U+110000, the others are a stray
|
|
554
|
+
// continuation byte, an overlong "/", an encoded surrogate and a lead byte above 0xF7.
|
|
555
|
+
for (const bad of ["01gnotalocator-5", "01g9JCAgA-0", "01ggA-0", "01gwK8-0", "01g7aCA-0", "01g-A-0"]) {
|
|
556
|
+
await apiError("GET", `${D}/query/${bad}`, 400, "INVALID_QUERY_LOCATOR");
|
|
557
|
+
await apiError("GET", `${D}/queryAll/${bad}`, 400, "INVALID_QUERY_LOCATOR");
|
|
558
|
+
await operation("query.more", { locator: bad }, "tool.INVALID_QUERY_LOCATOR");
|
|
559
|
+
}
|
|
560
|
+
await apiError("GET", `${D}/query/${locator({ q: "SELECT Id FROM Contact", includeDeleted: false, offset: 5, batchSize: 5, userId: DIEGO })}`, 400, "INVALID_QUERY_LOCATOR");
|
|
561
|
+
await apiError("GET", `${D}/query/${locator({ q: "SELECT Id FROM Contact", includeDeleted: false, offset: 500, batchSize: 5, userId: MAREN })}`, 400, "INVALID_QUERY_LOCATOR");
|
|
562
|
+
await apiError("GET", `${D}/query/${locator({ q: "SELECT Id FROM Contact LIMIT", includeDeleted: false, offset: 5, batchSize: 5, userId: MAREN })}`, 400, "MALFORMED_QUERY");
|
|
563
|
+
await apiError("GET", `${D}/query/${locator({ q: "SELECT Foo FROM Contact", includeDeleted: false, offset: 5, batchSize: 5, userId: MAREN })}`, 400, "INVALID_FIELD");
|
|
564
|
+
await apiError("GET", `${D}/query/${locator({ q: "SELECT Id FROM Widget__c", includeDeleted: false, offset: 5, batchSize: 5, userId: MAREN })}`, 400, "INVALID_TYPE");
|
|
565
|
+
const all1 = (await get(q("SELECT Id FROM Account", true), { headers: { "sforce-query-options": "batchSize=5" } })).json;
|
|
566
|
+
assert.deepEqual({ totalSize: all1.totalSize, done: all1.done }, { totalSize: 10, done: false });
|
|
567
|
+
assert.ok(all1.nextRecordsUrl.includes("/queryAll/"));
|
|
568
|
+
const all2 = (await get(all1.nextRecordsUrl)).json;
|
|
569
|
+
assert.equal(all2.records[4].Id, ACC(110), "queryAll includes the deleted account");
|
|
570
|
+
|
|
571
|
+
// Rejections
|
|
572
|
+
await apiError("GET", q("SELECT Id FROM Account GROUP BY Industry"), 400, "MALFORMED_QUERY", { text: "GROUP BY" });
|
|
573
|
+
await apiError("GET", q("SELECT SUM(Amount) FROM Opportunity"), 400, "MALFORMED_QUERY", { text: "SUM()" });
|
|
574
|
+
await apiError("GET", q("SELECT COUNT(Id) FROM Opportunity"), 400, "MALFORMED_QUERY");
|
|
575
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE Id IN (SELECT AccountId FROM Contact)"), 400, "MALFORMED_QUERY", { text: "semi-join" });
|
|
576
|
+
await apiError("GET", q("SELECT Account.Owner.Name FROM Contact"), 400, "MALFORMED_QUERY");
|
|
577
|
+
await apiError("GET", q("SELECT Id FROM Account LIMIT"), 400, "MALFORMED_QUERY", { text: "unexpected token" });
|
|
578
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE Name = 'unterminated"), 400, "MALFORMED_QUERY");
|
|
579
|
+
await apiError("GET", q("SELECT Id FROM Account FOR UPDATE"), 400, "MALFORMED_QUERY");
|
|
580
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE NumberOfEmployees > '10'"), 400, "MALFORMED_QUERY", { text: "must be of type number" });
|
|
581
|
+
await apiError("GET", q("SELECT Id FROM Account WHERE CreatedDate = LAST_N_WEEKS:2"), 400, "MALFORMED_QUERY");
|
|
582
|
+
await apiError("GET", q("SELECT Foo FROM Account"), 400, "INVALID_FIELD", { text: "No such column 'Foo' on entity 'Account'" });
|
|
583
|
+
await apiError("GET", q("SELECT Id, (SELECT Id FROM Widgets) FROM Account"), 400, "INVALID_TYPE");
|
|
584
|
+
await apiError("GET", q("SELECT Id FROM Widget__c"), 400, "INVALID_TYPE", { text: "sObject type 'Widget__c' is not supported" });
|
|
585
|
+
await apiError("GET", `${D}/query`, 400, "MALFORMED_QUERY");
|
|
586
|
+
await apiError("GET", q("SELECT Id FROM Account OFFSET 2001"), 400, "MALFORMED_QUERY");
|
|
587
|
+
|
|
588
|
+
// Parameterized search
|
|
589
|
+
const grouped = (await post(`${D}/parameterizedSearch`, { q: "nordlicht", sobjects: [{ name: "Account", fields: ["Id", "Name"] }, { name: "Contact", fields: ["Id", "Name", "Email"], where: "HasOptedOutOfEmail = false", limit: 5 }] })).json.searchRecords;
|
|
590
|
+
assert.deepEqual(grouped.map((record) => record.attributes.type), ["Account", "Account", "Contact", "Contact"]);
|
|
591
|
+
assert.deepEqual(grouped.map((record) => record.Id), [ACC(101), ACC(107), CON(201), CON(202)]);
|
|
592
|
+
assert.equal(grouped[2].Email, "Zoe.Mueller@nordlicht-labs.example.com");
|
|
593
|
+
const byName = (await post(`${D}/parameterizedSearch`, { q: "zoë", in: "NAME" })).json.searchRecords;
|
|
594
|
+
assert.equal(byName.length, 1);
|
|
595
|
+
assert.deepEqual(Object.keys(byName[0]).sort(), ["Id", "Name", "attributes"]);
|
|
596
|
+
const byPhone = (await post(`${D}/parameterizedSearch`, { q: "+31 20", in: "PHONE" })).json.searchRecords;
|
|
597
|
+
assert.deepEqual(byPhone.map((record) => record.Id), [CON(206)]);
|
|
598
|
+
assert.deepEqual((await post(`${D}/parameterizedSearch`, { q: "søren kj*" })).json.searchRecords.map((record) => record.Id), [CON(202)]);
|
|
599
|
+
// Search wildcards (linear automaton): prefix `*`, leading `*`, `?`, case-insensitivity.
|
|
600
|
+
for (const term of ["nordl*", "NORDL*", "n?rdlicht", "*licht", "nord*ht"]) {
|
|
601
|
+
assert.deepEqual((await post(`${D}/parameterizedSearch`, { q: term, in: "NAME", sobjects: [{ name: "Account" }] })).json.searchRecords.map((record) => record.Id), [ACC(101), ACC(107)], term);
|
|
602
|
+
}
|
|
603
|
+
assert.deepEqual((await post(`${D}/parameterizedSearch`, { q: "licht*", in: "NAME", sobjects: [{ name: "Account" }] })).json.searchRecords, [], "terms match token prefixes only");
|
|
604
|
+
assert.equal((await post(`${D}/parameterizedSearch`, { q: "diagnostics", sobjects: [{ name: "Opportunity", orderBy: "Amount DESC NULLS LAST", limit: 1 }] })).json.searchRecords[0].Id, OPP(403));
|
|
605
|
+
assert.equal((await post(`${D}/parameterizedSearch`, { q: "diagnostics", overallLimit: 2 })).json.searchRecords.length, 2);
|
|
606
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "MALFORMED_QUERY", { body: { q: "x" } });
|
|
607
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "MALFORMED_QUERY", { body: { q: "acme", sobjects: [{ name: "Account", where: "Name LIKE" }] } });
|
|
608
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "INVALID_TYPE", { body: { q: "acme", sobjects: [{ name: "Widget__c" }] } });
|
|
609
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "INVALID_FIELD", { body: { q: "acme", sobjects: [{ name: "Account", where: "Bogus = 1" }] } });
|
|
610
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "JSON_PARSER_ERROR", { body: { q: "acme", sobjects: [{ fields: ["Id"] }] } });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---------------------------------------------------------------------------------------------
|
|
614
|
+
// Drill: collections-composite (admin, baseline)
|
|
615
|
+
// ---------------------------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
async function collectionsComposite() {
|
|
618
|
+
const created = (await post(`${D}/composite/sobjects`, { records: [{ attributes: { type: "Account" }, Name: "Batch A" }, { attributes: { type: "Contact" }, LastName: "Batch", AccountId: "@wrong" }, { attributes: { type: "Lead" }, LastName: "L", Company: "C" }] })).json;
|
|
619
|
+
assert.deepEqual(created.map((entry) => entry.success), [true, false, true]);
|
|
620
|
+
assert.equal(created[1].errors[0].statusCode, "MALFORMED_ID");
|
|
621
|
+
assert.equal(created[1].id, null);
|
|
622
|
+
const batchA = created[0].id;
|
|
623
|
+
const leadL = created[2].id;
|
|
624
|
+
assert.ok(isId(batchA, "001") && isId(leadL, "00Q"));
|
|
625
|
+
const rolled = (await post(`${D}/composite/sobjects`, { allOrNone: true, records: [{ attributes: { type: "Account" }, Name: "Batch B" }, { attributes: { type: "Contact" }, LastName: "Batch", AccountId: "@wrong" }, { attributes: { type: "Lead" }, LastName: "L2", Company: "C" }] })).json;
|
|
626
|
+
assert.deepEqual(rolled.map((entry) => entry.errors[0].statusCode), ["ALL_OR_NONE_OPERATION_ROLLED_BACK", "MALFORMED_ID", "ALL_OR_NONE_OPERATION_ROLLED_BACK"]);
|
|
627
|
+
assert.equal((await query("SELECT COUNT() FROM Account WHERE Name = 'Batch B'")).totalSize, 0, "allOrNone wrote nothing");
|
|
628
|
+
assert.equal((await query("SELECT COUNT() FROM Lead")).totalSize, 9);
|
|
629
|
+
const mixed = (await post(`${D}/composite/sobjects`, { records: [{ attributes: { type: "Widget__c" }, Name: "x" }, { attributes: { type: "User" }, Username: "x@example.test" }, { attributes: { type: "Account" }, Industry: "Energy" }] })).json;
|
|
630
|
+
assert.deepEqual(mixed.map((entry) => entry.errors[0].statusCode), ["INVALID_TYPE", "INVALID_TYPE_FOR_OPERATION", "REQUIRED_FIELD_MISSING"]);
|
|
631
|
+
await apiError("POST", `${D}/composite/sobjects`, 400, "JSON_PARSER_ERROR", { body: { records: [] } });
|
|
632
|
+
await apiError("POST", `${D}/composite/sobjects`, 400, "JSON_PARSER_ERROR", { body: { records: [{ Name: "no attributes" }] } });
|
|
633
|
+
await apiError("POST", `${D}/composite/sobjects`, 400, "LIMIT_EXCEEDED", { body: { records: Array.from({ length: 201 }, () => ({ attributes: { type: "Lead" }, LastName: "x", Company: "y" })) }, text: "Cannot process more than 200 records" });
|
|
634
|
+
|
|
635
|
+
const retrieved = (await post(`${D}/composite/sobjects/Account`, { ids: [ACC(101), ACC(999), ACC(110), CON(201)], fields: ["Id", "Name"] })).json;
|
|
636
|
+
assert.equal(retrieved[0].Name, "Nordlicht Labs GmbH");
|
|
637
|
+
assert.deepEqual(retrieved.slice(1), [null, null, null]);
|
|
638
|
+
const viaGet = (await get(`${D}/composite/sobjects/Account?ids=${ACC(101)},${ACC(103)}&fields=Id,Name,Owner.Alias`)).json;
|
|
639
|
+
assert.equal(viaGet.length, 2);
|
|
640
|
+
assert.equal(viaGet[1].Owner.Alias, "dalva");
|
|
641
|
+
await apiError("POST", `${D}/composite/sobjects/Account`, 400, "JSON_PARSER_ERROR", { body: { ids: [], fields: ["Id"] } });
|
|
642
|
+
await apiError("POST", `${D}/composite/sobjects/Account`, 400, "JSON_PARSER_ERROR", { body: { ids: [ACC(101)], fields: [] } });
|
|
643
|
+
await apiError("POST", `${D}/composite/sobjects/Account`, 400, "INVALID_FIELD", { body: { ids: [ACC(101)], fields: ["Nope"] } });
|
|
644
|
+
await apiError("POST", `${D}/composite/sobjects/Account`, 400, "MALFORMED_ID", { body: { ids: ["zzz"], fields: ["Id"] } });
|
|
645
|
+
await apiError("POST", `${D}/composite/sobjects/Widget__c`, 400, "INVALID_TYPE", { body: { ids: [ACC(101)], fields: ["Id"] } });
|
|
646
|
+
await apiError("POST", `${D}/composite/sobjects/Account`, 400, "LIMIT_EXCEEDED", { body: { ids: Array.from({ length: 2001 }, () => ACC(101)), fields: ["Id"] } });
|
|
647
|
+
|
|
648
|
+
const updated = (await api("PATCH", `${D}/composite/sobjects`, { body: { records: [{ attributes: { type: "Account" }, Id: ACC(101), Phone: "1" }, { attributes: { type: "Account" }, Id: ACC(999), Phone: "2" }, { attributes: { type: "Account" }, Phone: "3" }] } })).json;
|
|
649
|
+
assert.deepEqual(updated.map((entry) => entry.success), [true, false, false]);
|
|
650
|
+
assert.equal(updated[0].id, ACC(101));
|
|
651
|
+
assert.deepEqual([updated[1].errors[0].statusCode, updated[2].errors[0].statusCode], ["NOT_FOUND", "MISSING_ARGUMENT"]);
|
|
652
|
+
const updateRolled = (await api("PATCH", `${D}/composite/sobjects`, { body: { allOrNone: true, records: [{ attributes: { type: "Account" }, Id: ACC(103), Phone: "9" }, { attributes: { type: "Account" }, Id: ACC(999), Phone: "2" }] } })).json;
|
|
653
|
+
assert.equal(updateRolled[0].errors[0].statusCode, "ALL_OR_NONE_OPERATION_ROLLED_BACK");
|
|
654
|
+
assert.equal((await get(`${D}/sobjects/Account/${ACC(103)}?fields=Phone`)).json.Phone, "+1 503 555 0103");
|
|
655
|
+
await apiError("PATCH", `${D}/composite/sobjects`, 400, "JSON_PARSER_ERROR", { body: { records: [] } });
|
|
656
|
+
await apiError("PATCH", `${D}/composite/sobjects`, 400, "JSON_PARSER_ERROR", { body: { records: [{ Id: ACC(101), Phone: "no attributes" }] } });
|
|
657
|
+
await apiError("PATCH", `${D}/composite/sobjects`, 400, "LIMIT_EXCEEDED", { body: { records: Array.from({ length: 201 }, () => ({ attributes: { type: "Account" }, Id: ACC(101) })) } });
|
|
658
|
+
|
|
659
|
+
const upserted = (await api("PATCH", `${D}/composite/sobjects/Account/External_Id__c`, { body: { records: [{ attributes: { type: "Account" }, External_Id__c: "BW-ACC-0001", Website: "https://a.example.org" }, { attributes: { type: "Account" }, External_Id__c: "BW-ACC-0555", Name: "Upsert Batch" }, { attributes: { type: "Account" }, Name: "No external id" }, { attributes: { type: "Contact" }, External_Id__c: "BW-CON-0001" }] } })).json;
|
|
660
|
+
assert.deepEqual(upserted.map((entry) => entry.created), [false, true, undefined, undefined]);
|
|
661
|
+
assert.deepEqual([upserted[2].errors[0].statusCode, upserted[3].errors[0].statusCode], ["MISSING_ARGUMENT", "INVALID_TYPE"]);
|
|
662
|
+
assert.equal(upserted[0].id, ACC(101));
|
|
663
|
+
await apiError("PATCH", `${D}/composite/sobjects/Account/Customer_Tier__c`, 400, "INVALID_FIELD", { body: { records: [{ attributes: { type: "Account" }, Customer_Tier__c: "Team" }] } });
|
|
664
|
+
await apiError("PATCH", `${D}/composite/sobjects/Account/Nope__c`, 400, "INVALID_FIELD", { body: { records: [{ attributes: { type: "Account" }, Nope__c: "x" }] } });
|
|
665
|
+
await apiError("PATCH", `${D}/composite/sobjects/Widget__c/External_Id__c`, 400, "INVALID_TYPE", { body: { records: [{ attributes: { type: "Widget__c" }, External_Id__c: "x" }] } });
|
|
666
|
+
await apiError("PATCH", `${D}/composite/sobjects/User/Id`, 400, "INVALID_TYPE_FOR_OPERATION", { body: { records: [{ attributes: { type: "User" }, Id: MAREN }] } });
|
|
667
|
+
await apiError("PATCH", `${D}/composite/sobjects/Account/External_Id__c`, 400, "JSON_PARSER_ERROR", { body: { records: [] } });
|
|
668
|
+
await apiError("PATCH", `${D}/composite/sobjects/Account/External_Id__c`, 400, "LIMIT_EXCEEDED", { body: { records: Array.from({ length: 201 }, (_, index) => ({ attributes: { type: "Account" }, External_Id__c: `X-${index}`, Name: "x" })) } });
|
|
669
|
+
|
|
670
|
+
const deleted = (await api("DELETE", `${D}/composite/sobjects?ids=${batchA},${leadL},${ACC(999)},zzz`)).json;
|
|
671
|
+
assert.deepEqual(deleted.map((entry) => entry.success), [true, true, false, false]);
|
|
672
|
+
assert.deepEqual([deleted[2].errors[0].statusCode, deleted[3].errors[0].statusCode], ["NOT_FOUND", "MALFORMED_ID"]);
|
|
673
|
+
const deleteRolled = (await api("DELETE", `${D}/composite/sobjects?ids=${ACC(103)},${ACC(999)}&allOrNone=true`)).json;
|
|
674
|
+
assert.equal(deleteRolled[0].errors[0].statusCode, "ALL_OR_NONE_OPERATION_ROLLED_BACK");
|
|
675
|
+
assert.equal((await get(`${D}/sobjects/Account/${ACC(103)}?fields=IsDeleted`)).json.IsDeleted, false);
|
|
676
|
+
await apiError("DELETE", `${D}/composite/sobjects`, 400, "JSON_PARSER_ERROR");
|
|
677
|
+
await apiError("DELETE", `${D}/composite/sobjects?ids=${Array.from({ length: 201 }, () => ACC(101)).join(",")}`, 400, "LIMIT_EXCEEDED");
|
|
678
|
+
|
|
679
|
+
// Composite
|
|
680
|
+
const compositeOk = (await post(`${D}/composite`, {
|
|
681
|
+
allOrNone: true,
|
|
682
|
+
compositeRequest: [
|
|
683
|
+
{ method: "POST", url: `${D}/sobjects/Account`, referenceId: "acc", body: { Name: "Composite Co" } },
|
|
684
|
+
{ method: "POST", url: `${D}/sobjects/Contact`, referenceId: "con", body: { LastName: "Composite", AccountId: "@{acc.id}" } },
|
|
685
|
+
{ method: "GET", url: `${D}/query?q=SELECT+Id,+Name+FROM+Contact+WHERE+AccountId+=+'@{acc.id}'`, referenceId: "q" },
|
|
686
|
+
{ method: "PATCH", url: `${D}/sobjects/Contact/@{q.records[0].Id}`, referenceId: "upd", body: { Title: "CTO" } },
|
|
687
|
+
{ method: "GET", url: `${D}/sobjects/Account/describe`, referenceId: "desc" },
|
|
688
|
+
{ method: "GET", url: `${D}/sobjects/Account/@{acc.id}?fields=Name,Owner.Name`, referenceId: "get" },
|
|
689
|
+
{ method: "GET", url: `${D}/sobjects/Contact`, referenceId: "info" },
|
|
690
|
+
{ method: "GET", url: `${D}/limits`, referenceId: "lim" },
|
|
691
|
+
{ method: "GET", url: `${D}/sobjects/Account/External_Id__c/BW-ACC-0001`, referenceId: "ext" },
|
|
692
|
+
{ method: "PATCH", url: `${D}/sobjects/Account/External_Id__c/BW-ACC-0888`, referenceId: "ups", body: { Name: "Composite Upsert" } },
|
|
693
|
+
{ method: "DELETE", url: `${D}/sobjects/Account/@{ups.id}`, referenceId: "delx" },
|
|
694
|
+
],
|
|
695
|
+
})).json.compositeResponse;
|
|
696
|
+
assert.deepEqual(compositeOk.map((entry) => entry.httpStatusCode), [201, 201, 200, 204, 200, 200, 200, 200, 200, 200, 204]);
|
|
697
|
+
assert.deepEqual(compositeOk.map((entry) => entry.referenceId), ["acc", "con", "q", "upd", "desc", "get", "info", "lim", "ext", "ups", "delx"]);
|
|
698
|
+
assert.equal(compositeOk[0].httpHeaders.Location, `${D}/sobjects/Account/${compositeOk[0].body.id}`);
|
|
699
|
+
assert.equal(compositeOk[2].body.totalSize, 1);
|
|
700
|
+
assert.equal(compositeOk[3].body, null);
|
|
701
|
+
assert.ok(Array.isArray(compositeOk[4].body.fields));
|
|
702
|
+
assert.equal(compositeOk[5].body.Owner.Name, "Maren Solberg");
|
|
703
|
+
assert.equal(compositeOk[6].body.objectDescribe.name, "Contact");
|
|
704
|
+
assert.equal(compositeOk[7].body.DailyApiRequests.Max, 15000);
|
|
705
|
+
assert.equal(compositeOk[8].body.Id, ACC(101));
|
|
706
|
+
assert.equal(compositeOk[9].body.created, true);
|
|
707
|
+
assert.equal(compositeOk[0].body._limitInfo, undefined, "subrequest bodies are wire-shaped");
|
|
708
|
+
assert.equal((await get(`${D}/sobjects/Contact/${compositeOk[1].body.id}?fields=Title`)).json.Title, "CTO");
|
|
709
|
+
|
|
710
|
+
const halted = (await post(`${D}/composite`, {
|
|
711
|
+
allOrNone: true,
|
|
712
|
+
compositeRequest: [
|
|
713
|
+
{ method: "POST", url: `${D}/sobjects/Account`, referenceId: "acc", body: { Name: "Halted Co" } },
|
|
714
|
+
{ method: "POST", url: `${D}/sobjects/Opportunity`, referenceId: "opp", body: { Name: "No stage" } },
|
|
715
|
+
{ method: "GET", url: `${D}/limits`, referenceId: "lim" },
|
|
716
|
+
],
|
|
717
|
+
})).json.compositeResponse;
|
|
718
|
+
assert.deepEqual(halted.map((entry) => entry.httpStatusCode), [400, 400, 400]);
|
|
719
|
+
assert.deepEqual(halted.map((entry) => entry.body[0].errorCode), ["PROCESSING_HALTED", "REQUIRED_FIELD_MISSING", "PROCESSING_HALTED"]);
|
|
720
|
+
assert.equal((await query("SELECT COUNT() FROM Account WHERE Name = 'Halted Co'")).totalSize, 0);
|
|
721
|
+
const partial = (await post(`${D}/composite`, {
|
|
722
|
+
compositeRequest: [
|
|
723
|
+
{ method: "POST", url: `${D}/sobjects/Account`, referenceId: "acc", body: { Name: "Partial Co" } },
|
|
724
|
+
{ method: "POST", url: `${D}/sobjects/Opportunity`, referenceId: "opp", body: { Name: "No stage" } },
|
|
725
|
+
{ method: "POST", url: `${D}/sobjects/Contact`, referenceId: "con", body: { LastName: "Partial", AccountId: "@{acc.id}" } },
|
|
726
|
+
{ method: "GET", url: `${D}/sobjects/Account/updated`, referenceId: "upd" },
|
|
727
|
+
{ method: "GET", url: `${D}/sobjects/Account/@{nope.id}`, referenceId: "ref" },
|
|
728
|
+
{ method: "GET", url: `${D}/query?q=SELECT+Id+FROM+Widget__c`, referenceId: "badq" },
|
|
729
|
+
{ method: "GET", url: `/services/data/v99.0/limits`, referenceId: "badv" },
|
|
730
|
+
{ method: "GET", url: `${D}/sobjects/Account/@{opp.id}`, referenceId: "failedref" },
|
|
731
|
+
],
|
|
732
|
+
})).json.compositeResponse;
|
|
733
|
+
assert.deepEqual(partial.map((entry) => entry.httpStatusCode), [201, 400, 201, 400, 400, 400, 404, 400]);
|
|
734
|
+
assert.deepEqual([partial[3].body[0].errorCode, partial[4].body[0].errorCode, partial[5].body[0].errorCode, partial[6].body[0].errorCode, partial[7].body[0].errorCode], ["MALFORMED_ID", "INVALID_INPUT", "INVALID_TYPE", "NOT_FOUND", "INVALID_INPUT"]);
|
|
735
|
+
assert.equal((await query("SELECT COUNT() FROM Contact WHERE LastName = 'Partial'")).totalSize, 1);
|
|
736
|
+
// Malformed percent-encoding in a subrequest url is a per-item Salesforce error, never a handler crash.
|
|
737
|
+
const malformed = (await post(`${D}/composite`, {
|
|
738
|
+
compositeRequest: [
|
|
739
|
+
{ method: "GET", url: `${D}/query?q=%E0%A4%A`, referenceId: "badq" },
|
|
740
|
+
{ method: "GET", url: `${D}/sobjects/Acc%ZZount`, referenceId: "badtype" },
|
|
741
|
+
{ method: "GET", url: `${D}/sobjects/Account/001%E0%A4%A`, referenceId: "badid" },
|
|
742
|
+
{ method: "GET", url: `${D}/sobjects/Account/${ACC(101)}?fields=Name,%ZZ`, referenceId: "badfields" },
|
|
743
|
+
{ method: "GET", url: `${D}/sobjects/Account/${ACC(101)}?fields=Name&%ZZ=1`, referenceId: "unusedparam" },
|
|
744
|
+
],
|
|
745
|
+
})).json.compositeResponse;
|
|
746
|
+
assert.deepEqual(malformed.map((entry) => [entry.referenceId, entry.httpStatusCode, entry.body?.[0]?.errorCode ?? null]), [["badq", 400, "MALFORMED_QUERY"], ["badtype", 404, "NOT_FOUND"], ["badid", 404, "NOT_FOUND"], ["badfields", 400, "INVALID_FIELD"], ["unusedparam", 200, null]]);
|
|
747
|
+
assert.equal(typeof malformed[4].body.Name, "string", "an undecodable parameter the route does not read is ignored");
|
|
748
|
+
const malformedHalted = (await post(`${D}/composite`, {
|
|
749
|
+
allOrNone: true,
|
|
750
|
+
compositeRequest: [
|
|
751
|
+
{ method: "POST", url: `${D}/sobjects/Account`, referenceId: "acc", body: { Name: "Malformed Co" } },
|
|
752
|
+
{ method: "GET", url: `${D}/query?q=%E0%A4%A`, referenceId: "badq" },
|
|
753
|
+
],
|
|
754
|
+
})).json.compositeResponse;
|
|
755
|
+
assert.deepEqual(malformedHalted.map((entry) => [entry.httpStatusCode, entry.body[0].errorCode]), [[400, "PROCESSING_HALTED"], [400, "MALFORMED_QUERY"]]);
|
|
756
|
+
assert.equal((await query("SELECT COUNT() FROM Account WHERE Name = 'Malformed Co'")).totalSize, 0, "the halted composite left no partial state");
|
|
757
|
+
await apiError("GET", `${D}/query?q=%E0%A4%A`, 400, "MALFORMED_QUERY");
|
|
758
|
+
// Undecodable bytes inside a string literal would otherwise parse and silently match nothing.
|
|
759
|
+
const badLiteral = "SELECT%20Id%20FROM%20Account%20WHERE%20Name%20%3D%20'%E0%A4%A'";
|
|
760
|
+
await apiError("GET", `${D}/query?q=${badLiteral}`, 400, "MALFORMED_QUERY", { text: "not valid percent-encoded UTF-8" });
|
|
761
|
+
await apiError("GET", `${D}/queryAll?q=${badLiteral}`, 400, "MALFORMED_QUERY", { text: "not valid percent-encoded UTF-8" });
|
|
762
|
+
await apiError("POST", `${D}/composite`, 400, "LIMIT_EXCEEDED", { body: { compositeRequest: Array.from({ length: 26 }, (_, index) => ({ method: "GET", url: `${D}/limits`, referenceId: `r${index}` })) } });
|
|
763
|
+
await apiError("POST", `${D}/composite`, 400, "LIMIT_EXCEEDED", { body: { compositeRequest: Array.from({ length: 6 }, (_, index) => ({ method: "GET", url: `${D}/query?q=SELECT+Id+FROM+Account`, referenceId: `q${index}` })) }, text: "query subrequests" });
|
|
764
|
+
await apiError("POST", `${D}/composite`, 400, "JSON_PARSER_ERROR", { body: { compositeRequest: [{ method: "GET", url: `${D}/limits`, referenceId: "a" }, { method: "GET", url: `${D}/limits`, referenceId: "a" }] }, text: "Duplicate referenceId" });
|
|
765
|
+
await apiError("POST", `${D}/composite`, 400, "JSON_PARSER_ERROR", { body: { compositeRequest: [{ method: "PUT", url: `${D}/limits`, referenceId: "a" }] } });
|
|
766
|
+
await apiError("POST", `${D}/composite`, 400, "JSON_PARSER_ERROR", { body: { compositeRequest: [] } });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------------------------------------
|
|
770
|
+
// Drill: sharing (rep = Diego, baseline)
|
|
771
|
+
// ---------------------------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
async function sharing() {
|
|
774
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Opportunity")), [OPP(401), OPP(402), OPP(405), OPP(407)], "Private OWD: own opportunities only");
|
|
775
|
+
assert.deepEqual(ids(await query("SELECT Id FROM Task")), [TASK(501), TASK(508)]);
|
|
776
|
+
assert.equal((await query("SELECT Id FROM Account")).totalSize, 9, "Public Read Only: every account");
|
|
777
|
+
const contacts = ids(await query("SELECT Id FROM Contact"));
|
|
778
|
+
assert.equal(contacts.length, 12);
|
|
779
|
+
assert.ok(!contacts.includes(CON(212)), "a contact without an account is private to its owner");
|
|
780
|
+
assert.equal((await query("SELECT Id FROM Lead")).totalSize, 8);
|
|
781
|
+
await apiError("GET", `${D}/sobjects/Opportunity/${OPP(403)}`, 404, "NOT_FOUND");
|
|
782
|
+
await apiError("GET", `${D}/sobjects/Contact/${CON(212)}`, 404, "NOT_FOUND");
|
|
783
|
+
await apiError("PATCH", `${D}/sobjects/Account/${ACC(104)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Phone: "x" }, text: INSUFFICIENT });
|
|
784
|
+
assert.equal((await get(`${D}/sobjects/Account/${ACC(104)}?fields=Phone`)).json.Phone, "+41 61 555 0104", "the denied update left the row unchanged");
|
|
785
|
+
await apiError("DELETE", `${D}/sobjects/Account/${ACC(104)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY");
|
|
786
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0003`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Phone: "x" } });
|
|
787
|
+
await patch(`${D}/sobjects/Account/${ACC(101)}`, { Phone: "+49 30 5550 1199" });
|
|
788
|
+
await patch(`${D}/sobjects/Lead/${LEAD(301)}`, { Rating: "Warm" });
|
|
789
|
+
await apiError("PATCH", `${D}/sobjects/Contact/${CON(204)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Title: "x" } });
|
|
790
|
+
await patch(`${D}/sobjects/Contact/${CON(201)}`, { Title: "Head of Laboratory Operations" });
|
|
791
|
+
const hidden = (await post(`${D}/sobjects/Opportunity`, { Name: "Handed over", StageName: "Prospecting", CloseDate: "2026-12-01", OwnerId: AIKO }, { status: 201 })).json.id;
|
|
792
|
+
await apiError("GET", `${D}/sobjects/Opportunity/${hidden}`, 404, "NOT_FOUND");
|
|
793
|
+
assert.deepEqual((await post(`${D}/composite/sobjects/Opportunity`, { ids: [OPP(403), OPP(401)], fields: ["Id"] })).json.map((entry) => entry?.Id ?? null), [null, OPP(401)]);
|
|
794
|
+
assert.deepEqual((await get(`${D}/sobjects/Opportunity`)).json.recentItems.map((entry) => entry.Id).sort(), [OPP(401), OPP(402), OPP(405), OPP(407)]);
|
|
795
|
+
assert.equal((await post(`${D}/parameterizedSearch`, { q: "Clinical Analyzer", in: "NAME" })).json.searchRecords.length, 0, "Aiko's opportunity name is hidden from Diego");
|
|
796
|
+
assert.equal((await post(`${D}/parameterizedSearch`, { q: "Spectrometer", in: "NAME" })).json.searchRecords.length, 1);
|
|
797
|
+
const halted = (await post(`${D}/composite`, { allOrNone: true, compositeRequest: [{ method: "PATCH", url: `${D}/sobjects/Account/${ACC(104)}`, referenceId: "a", body: { Phone: "x" } }, { method: "GET", url: `${D}/limits`, referenceId: "b" }] })).json.compositeResponse;
|
|
798
|
+
assert.deepEqual(halted.map((entry) => [entry.httpStatusCode, entry.body[0].errorCode]), [[403, "INSUFFICIENT_ACCESS_OR_READONLY"], [400, "PROCESSING_HALTED"]]);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------------------------
|
|
802
|
+
// Drill: profile-permissions (sdr = Ben, baseline)
|
|
803
|
+
// ---------------------------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
async function profilePermissions() {
|
|
806
|
+
const global = (await get(`${D}/sobjects`)).json.sobjects.map((entry) => entry.name);
|
|
807
|
+
assert.deepEqual(global, ["Account", "Contact", "Lead", "Profile", "Task", "User"]);
|
|
808
|
+
await apiError("GET", q("SELECT Id FROM Opportunity"), 400, "INVALID_TYPE", { text: "sObject type 'Opportunity' is not supported. If you are attempting to use a custom object, be sure to append the '__c' after the entity name. Please reference your WSDL or the describe call for the appropriate names." });
|
|
809
|
+
await apiError("GET", `${D}/sobjects/Opportunity`, 404, "NOT_FOUND");
|
|
810
|
+
await operation("sobjects.describe", { sobjectType: "Opportunity" }, "tool.NOT_FOUND");
|
|
811
|
+
await apiError("GET", `${D}/sobjects/Opportunity/${OPP(401)}`, 404, "INVALID_TYPE");
|
|
812
|
+
await apiError("POST", `${D}/sobjects/Opportunity`, 404, "INVALID_TYPE", { body: { Name: "x", StageName: "Prospecting", CloseDate: "2026-12-01" } });
|
|
813
|
+
await apiError("PATCH", `${D}/sobjects/Opportunity/${OPP(401)}`, 404, "INVALID_TYPE", { body: { Name: "x" } });
|
|
814
|
+
await apiError("DELETE", `${D}/sobjects/Opportunity/${OPP(401)}`, 404, "INVALID_TYPE");
|
|
815
|
+
await apiError("PATCH", `${D}/sobjects/Opportunity/Id/${OPP(401)}`, 404, "INVALID_TYPE", { body: { Name: "x" } });
|
|
816
|
+
await apiError("GET", q("SELECT Id, (SELECT Id FROM Opportunities) FROM Account"), 400, "INVALID_TYPE");
|
|
817
|
+
await apiError("POST", `${D}/composite/sobjects/Opportunity`, 400, "INVALID_TYPE", { body: { ids: [OPP(401)], fields: ["Id"] } });
|
|
818
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "INVALID_TYPE", { body: { q: "renewal", sobjects: [{ name: "Opportunity" }] } });
|
|
819
|
+
assert.equal((await post(`${D}/parameterizedSearch`, { q: "renewal" })).json.searchRecords.length, 0, "default search scope skips the unreadable type; Diego's task is Private");
|
|
820
|
+
assert.equal((await post(`${D}/parameterizedSearch`, { q: "Intro email" })).json.searchRecords[0].Id, "00TFd0000000507MAA", "own tasks are searchable");
|
|
821
|
+
await apiError("POST", `${D}/sobjects/Account`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Name: "SDR Co" } });
|
|
822
|
+
const lead = (await post(`${D}/sobjects/Lead`, { LastName: "New", Company: "Co" }, { status: 201 })).json.id;
|
|
823
|
+
assert.equal((await get(`${D}/sobjects/Lead/${lead}?fields=Status,OwnerId`)).json.OwnerId, BEN);
|
|
824
|
+
await apiError("DELETE", `${D}/sobjects/Contact/${CON(201)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY");
|
|
825
|
+
await apiError("PATCH", `${D}/sobjects/Account/${ACC(101)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Phone: "x" } });
|
|
826
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-0001`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Phone: "x" } });
|
|
827
|
+
const items = (await post(`${D}/composite/sobjects`, { records: [{ attributes: { type: "Account" }, Name: "x" }, { attributes: { type: "Lead" }, LastName: "Batch", Company: "Co" }] })).json;
|
|
828
|
+
assert.deepEqual([items[0].errors[0].statusCode, items[1].success], ["INSUFFICIENT_ACCESS_OR_READONLY", true]);
|
|
829
|
+
const composite = (await post(`${D}/composite`, { compositeRequest: [{ method: "GET", url: `${D}/query?q=SELECT+Id+FROM+Opportunity`, referenceId: "q" }, { method: "GET", url: `${D}/sobjects/Opportunity/${OPP(401)}`, referenceId: "g" }] })).json.compositeResponse;
|
|
830
|
+
assert.deepEqual(composite.map((entry) => [entry.httpStatusCode, entry.body[0].errorCode]), [[400, "INVALID_TYPE"], [404, "INVALID_TYPE"]]);
|
|
831
|
+
assert.equal((await query("SELECT COUNT() FROM Lead")).totalSize, 10);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---------------------------------------------------------------------------------------------
|
|
835
|
+
// Drill: read-only (analyst = Lena, baseline)
|
|
836
|
+
// ---------------------------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
async function readOnly() {
|
|
839
|
+
assert.equal((await query("SELECT Id FROM Opportunity")).totalSize, 0, "Private OWD without viewAll");
|
|
840
|
+
assert.equal((await query("SELECT Id FROM Account")).totalSize, 9);
|
|
841
|
+
assert.equal((await query("SELECT Id FROM Lead")).totalSize, 8);
|
|
842
|
+
assert.equal((await query("SELECT Id FROM User WHERE IsActive = true")).totalSize, 5);
|
|
843
|
+
assert.equal((await query("SELECT Id, Name, Profile.Name FROM User WHERE Username = 'lena.fischer@brightwater.example.com'")).records[0].Profile.Name, "Read Only");
|
|
844
|
+
assert.equal((await query("SELECT Id, Name, UserLicense FROM Profile ORDER BY Name")).totalSize, 4);
|
|
845
|
+
await apiError("POST", `${D}/sobjects/Account`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Name: "x" } });
|
|
846
|
+
await apiError("PATCH", `${D}/sobjects/Lead/${LEAD(301)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Rating: "Cold" } });
|
|
847
|
+
await apiError("DELETE", `${D}/sobjects/Lead/${LEAD(301)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY");
|
|
848
|
+
await apiError("PATCH", `${D}/sobjects/Lead/Id/${LEAD(301)}`, 403, "INSUFFICIENT_ACCESS_OR_READONLY", { body: { Rating: "Cold" } });
|
|
849
|
+
const items = (await api("PATCH", `${D}/composite/sobjects`, { body: { records: [{ attributes: { type: "Lead" }, Id: LEAD(301), Rating: "Cold" }] } })).json;
|
|
850
|
+
assert.equal(items[0].errors[0].statusCode, "INSUFFICIENT_ACCESS_OR_READONLY");
|
|
851
|
+
const composite = (await post(`${D}/composite`, { compositeRequest: [{ method: "POST", url: `${D}/sobjects/Account`, referenceId: "a", body: { Name: "x" } }] })).json.compositeResponse;
|
|
852
|
+
assert.deepEqual([composite[0].httpStatusCode, composite[0].body[0].errorCode], [403, "INSUFFICIENT_ACCESS_OR_READONLY"]);
|
|
853
|
+
await mcpInit();
|
|
854
|
+
assert.equal((await mcp("run_soql_query", { query: "SELECT Id FROM Lead" })).totalSize, 8);
|
|
855
|
+
assert.equal((await get(`${D}/sobjects/Lead/${LEAD(301)}?fields=Rating`)).json.Rating, "Hot", "nothing changed");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---------------------------------------------------------------------------------------------
|
|
859
|
+
// Drill: mcp-aliases (admin, baseline)
|
|
860
|
+
// ---------------------------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
async function mcpAliases() {
|
|
863
|
+
await mcpInit();
|
|
864
|
+
const listed = (await rpc("tools/list", {})).tools.map((tool) => tool.name);
|
|
865
|
+
for (const name of ["run_soql_query", "get_username", ...ALL_OPERATIONS.map((id) => `salesforce.${id}`)]) assert.ok(listed.includes(name), `tools/list lacks ${name}`);
|
|
866
|
+
const who = await mcp("get_username", {});
|
|
867
|
+
assert.deepEqual({ username: who.preferred_username, user: who.user_id, org: who.organization_id }, { username: "maren.solberg@brightwater.example.com", user: MAREN, org: ORG });
|
|
868
|
+
assert.equal((await mcp("get_username", { directory: "/tmp/project", defaultTargetOrg: true })).user_id, MAREN);
|
|
869
|
+
await mcpError("get_username", { defaultDevHub: true }, "tool.NOT_FOUND");
|
|
870
|
+
const top = await mcp("run_soql_query", { query: "SELECT Id, Name FROM Account ORDER BY Name LIMIT 3" });
|
|
871
|
+
assert.equal(top.records.length, 3);
|
|
872
|
+
assert.equal(top.records[0].Name, "Alder Creek Analytical");
|
|
873
|
+
assert.match(top._limitInfo, /^api-usage=/);
|
|
874
|
+
assert.equal((await mcp("run_soql_query", { query: "SELECT COUNT() FROM Lead", usernameOrAlias: "maren.solberg@brightwater.example.com" })).totalSize, 8);
|
|
875
|
+
assert.equal((await mcp("run_soql_query", { query: "SELECT Id FROM Lead", usernameOrAlias: "msolb", directory: "/tmp/project" })).totalSize, 8);
|
|
876
|
+
assert.equal((await mcp("run_soql_query", { q: "SELECT Id FROM Lead LIMIT 1" })).totalSize, 1);
|
|
877
|
+
await mcpError("run_soql_query", { query: "SELECT Id FROM Lead", usernameOrAlias: "someone-else" }, "tool.NOT_FOUND", "No authorization information found for someone-else.");
|
|
878
|
+
await mcpError("run_soql_query", { query: "SELECT Id FROM ApexClass", useToolingApi: true }, "tool.INVALID_TYPE");
|
|
879
|
+
await mcpError("run_soql_query", { q: "SELECT Id FROM Account", query: "SELECT Id FROM Account" }, "tool.MALFORMED_QUERY");
|
|
880
|
+
const invalid = await mcpError("run_soql_query", { query: 42 });
|
|
881
|
+
assert.equal(invalid.status, "invalid", "schema-invalid MCP arguments are rejected before the Tool runs");
|
|
882
|
+
const created = await mcp("salesforce.records.create", { sobjectType: "Lead", record: { LastName: "Alias", Company: "MCP Co" } });
|
|
883
|
+
assert.ok(isId(created.id, "00Q"));
|
|
884
|
+
assert.equal((await get(`${D}/sobjects/Lead/${created.id}?fields=Company`)).json.Company, "MCP Co", "the MCP-created lead is visible over REST");
|
|
885
|
+
const describe = await mcp("salesforce.sobjects.describe", { sobjectType: "Task" });
|
|
886
|
+
assert.equal(describe.fields.find((field) => field.name === "WhoId").referenceTo.length, 2);
|
|
887
|
+
const versions = await mcp("salesforce.versions.list", {});
|
|
888
|
+
assert.equal(versions.length, 21);
|
|
889
|
+
const framework = await fetch(`${HTTP}${D}/composite/sobjects?ids=${ACC(101)}&allOrNone=maybe`, { method: "DELETE", headers: { authorization: `Bearer ${HTTP_TOKEN}` } });
|
|
890
|
+
assert.equal(framework.status, 400, "schema-invalid arguments are the framework's 400");
|
|
891
|
+
const frameworkBody = await framework.json();
|
|
892
|
+
assert.equal(frameworkBody[0].errorCode, "JSON_PARSER_ERROR");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---------------------------------------------------------------------------------------------
|
|
896
|
+
// Drills: identities
|
|
897
|
+
// ---------------------------------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
async function freshInstall() {
|
|
900
|
+
const me = (await get("/services/oauth2/userinfo")).json;
|
|
901
|
+
assert.equal(me.user_id, MAREN, "an actor without a username is the org's default identity");
|
|
902
|
+
assert.equal((await query("SELECT Id FROM Opportunity")).totalSize, 9);
|
|
903
|
+
const created = (await post(`${D}/sobjects/Account`, { Name: "Fresh Co" }, { status: 201 })).json;
|
|
904
|
+
assert.equal(created.id, NEW_ACC("1001"));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function denied() {
|
|
908
|
+
await apiError("GET", "/services/oauth2/userinfo", 403, "API_DISABLED_FOR_ORG", { text: "The REST API is not enabled for this User" });
|
|
909
|
+
await apiError("GET", q("SELECT Id FROM Account"), 403, "API_DISABLED_FOR_ORG");
|
|
910
|
+
await apiError("POST", `${D}/sobjects/Account`, 403, "API_DISABLED_FOR_ORG", { body: { Name: "x" } });
|
|
911
|
+
await mcpInit();
|
|
912
|
+
const result = await rpc("tools/call", { name: "run_soql_query", arguments: { query: "SELECT Id FROM Account" } });
|
|
913
|
+
assert.ok(result.isError);
|
|
914
|
+
assert.equal(mcpValue(result).status, "denied");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function invalidSession() {
|
|
918
|
+
for (const entry of CALLS) await callError(entry, 401, "INVALID_SESSION_ID", SESSION_MESSAGE);
|
|
919
|
+
await mcpInit();
|
|
920
|
+
await mcpError("get_username", {}, "tool.INVALID_SESSION_ID");
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function inactiveUser() {
|
|
924
|
+
await apiError("GET", "/services/oauth2/userinfo", 401, "INVALID_SESSION_ID", { text: SESSION_MESSAGE });
|
|
925
|
+
await apiError("GET", q("SELECT Id FROM Account"), 401, "INVALID_SESSION_ID");
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ---------------------------------------------------------------------------------------------
|
|
929
|
+
// Drills: faults and bounds
|
|
930
|
+
// ---------------------------------------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
async function apiLimitExceeded() {
|
|
933
|
+
for (const entry of CALLS) await callError(entry, 403, "REQUEST_LIMIT_EXCEEDED", "TotalRequests Limit exceeded.");
|
|
934
|
+
await mcpInit();
|
|
935
|
+
const error = (await mcpError("run_soql_query", { query: "SELECT Id FROM Account" }, "tool.REQUEST_LIMIT_EXCEEDED")).error;
|
|
936
|
+
assert.equal(error.message, "TotalRequests Limit exceeded.");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function rowLocked() {
|
|
940
|
+
for (const index of WRITE_INDEXES) await callError(CALLS[index], 400, "UNABLE_TO_LOCK_ROW", "unable to obtain exclusive access");
|
|
941
|
+
assert.equal((await query("SELECT Id FROM Account")).totalSize, 9, "reads keep working");
|
|
942
|
+
assert.equal((await get(`${D}/sobjects/Account/${ACC(101)}?fields=Phone`)).json.Phone, "+49 30 5550 1100", "nothing was written");
|
|
943
|
+
assert.equal((await get(`${D}/sobjects/Account/${ACC(103)}?fields=IsDeleted`)).json.IsDeleted, false);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function writeCommittedLost() {
|
|
947
|
+
await apiError("POST", `${D}/sobjects/Account`, 503, "SERVER_UNAVAILABLE", { body: { Name: "Lost Co", External_Id__c: "BW-ACC-LOST" }, text: "Server is currently unavailable" });
|
|
948
|
+
const lost = await query("SELECT Id, Name FROM Account WHERE External_Id__c = 'BW-ACC-LOST'");
|
|
949
|
+
assert.equal(lost.totalSize, 1, "the insert committed although the caller saw 503");
|
|
950
|
+
assert.equal(lost.records[0].Id, NEW_ACC("1001"));
|
|
951
|
+
assert.equal((await get(`${D}/sobjects/Account/${NEW_ACC("1001")}`)).json.Name, "Lost Co");
|
|
952
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "DUPLICATE_VALUE", { body: { Name: "Lost Co", External_Id__c: "BW-ACC-LOST" } });
|
|
953
|
+
await apiError("POST", `${D}/sobjects/Contact`, 503, "SERVER_UNAVAILABLE", { body: { LastName: "Ghost" } });
|
|
954
|
+
assert.equal((await query("SELECT COUNT() FROM Contact WHERE LastName = 'Ghost'")).totalSize, 1, "a plain retry would silently duplicate");
|
|
955
|
+
await apiError("PATCH", `${D}/sobjects/Account/External_Id__c/BW-ACC-NEW`, 503, "SERVER_UNAVAILABLE", { body: { Name: "Upsert Lost" } });
|
|
956
|
+
assert.equal((await query("SELECT COUNT() FROM Account WHERE External_Id__c = 'BW-ACC-NEW'")).totalSize, 1);
|
|
957
|
+
await apiError("POST", `${D}/composite/sobjects`, 503, "SERVER_UNAVAILABLE", { body: { records: [{ attributes: { type: "Lead" }, LastName: "Lost", Company: "Co" }] } });
|
|
958
|
+
assert.equal((await query("SELECT COUNT() FROM Lead")).totalSize, 9);
|
|
959
|
+
await apiError("PATCH", `${D}/composite/sobjects/Contact/External_Id__c`, 503, "SERVER_UNAVAILABLE", { body: { records: [{ attributes: { type: "Contact" }, External_Id__c: "BW-CON-LOST", LastName: "Lost" }] } });
|
|
960
|
+
assert.equal((await query("SELECT COUNT() FROM Contact WHERE External_Id__c = 'BW-CON-LOST'")).totalSize, 1);
|
|
961
|
+
await patch(`${D}/sobjects/Account/${NEW_ACC("1001")}`, { Phone: "1" });
|
|
962
|
+
assert.equal((await get(`${D}/sobjects/Account/${NEW_ACC("1001")}?fields=Phone`)).json.Phone, "1", "updates are not covered by the fault");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function tightLimits() {
|
|
966
|
+
for (const entry of CALLS) await callError(entry, 400, "LIMIT_EXCEEDED", "state exceeds the supported bound of 2 rows for users");
|
|
967
|
+
await mcpInit();
|
|
968
|
+
await mcpError("get_username", {}, "tool.LIMIT_EXCEEDED");
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ---------------------------------------------------------------------------------------------
|
|
972
|
+
// Drill: large-responses (admin, baseline): byte-budgeted pages, search, collections and composite
|
|
973
|
+
// ---------------------------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
const MIB = 1048576;
|
|
976
|
+
const BUDGET_TEXT = "900,000-byte response budget";
|
|
977
|
+
|
|
978
|
+
/** Raw request measuring the encoded response bytes (the framework refuses bodies over 1 MiB). */
|
|
979
|
+
async function sized(method, path, body) {
|
|
980
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
981
|
+
method,
|
|
982
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
983
|
+
...(body === undefined ? {} : { body: typeof body === "string" ? body : JSON.stringify(body) }),
|
|
984
|
+
});
|
|
985
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
986
|
+
const text = new TextDecoder().decode(bytes);
|
|
987
|
+
assert.ok(response.status < 500, `${method} ${path} -> ${response.status} ${text.slice(0, 300)}`);
|
|
988
|
+
assert.ok(bytes.length < MIB, `${method} ${path}: ${bytes.length} bytes`);
|
|
989
|
+
return { status: response.status, bytes: bytes.length, json: text.length > 0 ? JSON.parse(text) : undefined };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Follow nextRecordsUrl to the end; every page under 1 MiB, every row exactly once. */
|
|
993
|
+
async function pageThrough(first, expectedTotal, prior = []) {
|
|
994
|
+
let page = await sized("GET", first);
|
|
995
|
+
const seen = new Set(prior);
|
|
996
|
+
let pages = 0;
|
|
997
|
+
for (;;) {
|
|
998
|
+
assert.equal(page.status, 200, JSON.stringify(page.json).slice(0, 300));
|
|
999
|
+
pages += 1;
|
|
1000
|
+
assert.equal(page.json.totalSize, expectedTotal);
|
|
1001
|
+
for (const record of page.json.records) {
|
|
1002
|
+
assert.ok(!seen.has(record.Id), `row ${record.Id} returned twice`);
|
|
1003
|
+
seen.add(record.Id);
|
|
1004
|
+
}
|
|
1005
|
+
if (page.json.done) break;
|
|
1006
|
+
assert.ok(page.json.records.length > 0 && typeof page.json.nextRecordsUrl === "string");
|
|
1007
|
+
page = await sized("GET", page.json.nextRecordsUrl);
|
|
1008
|
+
}
|
|
1009
|
+
assert.equal(seen.size, expectedTotal);
|
|
1010
|
+
return pages;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function largeResponses() {
|
|
1014
|
+
const kinds = ["a", "漢", "😀"];
|
|
1015
|
+
const created = [];
|
|
1016
|
+
for (const [index, unit] of kinds.entries()) {
|
|
1017
|
+
const Description = unit.repeat(32000 / unit.length);
|
|
1018
|
+
for (let batch = 0; batch < 2; batch += 1) {
|
|
1019
|
+
const records = Array.from({ length: 5 }, (_, n) => ({ attributes: { type: "Account" }, Name: `Large ${index}-${batch}-${n}`, Description }));
|
|
1020
|
+
const result = (await post(`${D}/composite/sobjects`, { allOrNone: true, records })).json;
|
|
1021
|
+
assert.ok(result.every((entry) => entry.success), JSON.stringify(result).slice(0, 300));
|
|
1022
|
+
created.push(...result.map((entry) => entry.id));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
assert.equal(created.length, 30);
|
|
1026
|
+
// Wide rows: pages stop before the encoded body passes the budget and resume at the first row not returned.
|
|
1027
|
+
assert.ok((await pageThrough(q("SELECT Id, Name, Description FROM Account"), 39)) >= 3);
|
|
1028
|
+
assert.ok((await pageThrough(q("SELECT Id, Name, Description FROM Account", true), 40)) >= 3);
|
|
1029
|
+
const small = (await get(q("SELECT Id, Name FROM Account"))).json;
|
|
1030
|
+
assert.deepEqual({ totalSize: small.totalSize, done: small.done, records: small.records.length }, { totalSize: 39, done: true, records: 39 });
|
|
1031
|
+
assert.equal(await pageThrough(q("SELECT Id FROM Account ORDER BY Name"), 39), 1);
|
|
1032
|
+
// A single row beyond the budget (child rows included) fails LIMIT_EXCEEDED, never a truncated row.
|
|
1033
|
+
const contacts = Array.from({ length: 10 }, (_, n) => ({ attributes: { type: "Contact" }, LastName: `Wide ${n}`, AccountId: created[0], Description: "漢".repeat(32000) }));
|
|
1034
|
+
for (let start = 0; start < 10; start += 5) assert.ok((await post(`${D}/composite/sobjects`, { allOrNone: true, records: contacts.slice(start, start + 5) })).json.every((entry) => entry.success));
|
|
1035
|
+
await apiError("GET", q(`SELECT Id, (SELECT Id, Description FROM Contacts) FROM Account WHERE Id = '${created[0]}'`), 400, "LIMIT_EXCEEDED", { text: BUDGET_TEXT });
|
|
1036
|
+
assert.equal((await query(`SELECT Id, (SELECT Id FROM Contacts) FROM Account WHERE Id = '${created[0]}'`)).records[0].Contacts.totalSize, 10);
|
|
1037
|
+
// Search has no paging; collections retrieve answers one array: both refuse bodies past the budget.
|
|
1038
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "LIMIT_EXCEEDED", { body: { q: "Large*", sobjects: [{ name: "Account", fields: ["Id", "Description"] }] }, text: BUDGET_TEXT });
|
|
1039
|
+
const narrow = new Set((await post(`${D}/parameterizedSearch`, { q: "Large*", sobjects: [{ name: "Account", fields: ["Id", "Name"] }] })).json.searchRecords.map((record) => record.Id));
|
|
1040
|
+
assert.ok(created.every((id) => narrow.has(id)));
|
|
1041
|
+
await apiError("POST", `${D}/composite/sobjects/Account`, 400, "LIMIT_EXCEEDED", { body: { ids: created, fields: ["Id", "Description"] }, text: BUDGET_TEXT });
|
|
1042
|
+
const few = (await sized("POST", `${D}/composite/sobjects/Account`, { ids: created.slice(10, 15), fields: ["Id", "Description"] })).json;
|
|
1043
|
+
assert.deepEqual(few.map((record) => record.Id), created.slice(10, 15));
|
|
1044
|
+
// Composite: query pages shrink to the bytes left; reads that no longer fit become LIMIT_EXCEEDED entries.
|
|
1045
|
+
const five = await sized("POST", `${D}/composite`, { compositeRequest: [1, 2, 3, 4, 5].map((n) => ({ method: "GET", url: q("SELECT Id, Description FROM Account"), referenceId: `q${n}` })) });
|
|
1046
|
+
assert.equal(five.status, 200);
|
|
1047
|
+
const entries = five.json.compositeResponse;
|
|
1048
|
+
assert.equal(entries[0].httpStatusCode, 200);
|
|
1049
|
+
assert.equal(entries[0].body.done, false);
|
|
1050
|
+
assert.equal(entries.length, 5);
|
|
1051
|
+
for (const entry of entries) {
|
|
1052
|
+
if (entry.httpStatusCode === 200) assert.ok(entry.body.records.length > 0 && entry.body.done === false && typeof entry.body.nextRecordsUrl === "string");
|
|
1053
|
+
else assert.ok(entry.httpStatusCode === 400 && entry.body[0].errorCode === "LIMIT_EXCEEDED" && entry.body[0].message.includes(BUDGET_TEXT), JSON.stringify(entry.body));
|
|
1054
|
+
}
|
|
1055
|
+
assert.ok(entries.reduce((sum, entry) => sum + (entry.httpStatusCode === 200 ? entry.body.records.length : 0), 0) < 5 * 39, "the pages shrink to the bytes left");
|
|
1056
|
+
const rest = await pageThrough(entries[0].body.nextRecordsUrl, 39, ids(entries[0].body));
|
|
1057
|
+
assert.ok(rest >= 1);
|
|
1058
|
+
const reads = await sized("POST", `${D}/composite`, { compositeRequest: created.slice(10, 25).map((id, n) => ({ method: "GET", url: `${D}/sobjects/Account/${id}`, referenceId: `r${n}` })) });
|
|
1059
|
+
const statuses = reads.json.compositeResponse.map((entry) => entry.httpStatusCode);
|
|
1060
|
+
assert.ok(statuses.includes(200) && statuses.includes(400), statuses.join(","));
|
|
1061
|
+
assert.ok(statuses.indexOf(400) > statuses.lastIndexOf(200), "entries past the budget come after the admitted ones");
|
|
1062
|
+
// allOrNone: an oversized read fails the transaction, so the create before it rolls back.
|
|
1063
|
+
const halted = await sized("POST", `${D}/composite`, { allOrNone: true, compositeRequest: [{ method: "POST", url: `${D}/sobjects/Account`, body: { Name: "Rolled back by budget" }, referenceId: "made" }, ...created.slice(10, 25).map((id, n) => ({ method: "GET", url: `${D}/sobjects/Account/${id}`, referenceId: `r${n}` }))] });
|
|
1064
|
+
assert.equal(halted.json.compositeResponse[0].body[0].errorCode, "PROCESSING_HALTED");
|
|
1065
|
+
assert.equal((await query("SELECT COUNT() FROM Account WHERE Name = 'Rolled back by budget'")).totalSize, 0);
|
|
1066
|
+
// JSON bodies nested past 512 levels are refused at decode time (400), never a 500.
|
|
1067
|
+
const deep = `{"Name":${"[".repeat(3000)}${"]".repeat(3000)}}`;
|
|
1068
|
+
assert.equal((await sized("POST", `${D}/sobjects/Account`, deep)).status, 400);
|
|
1069
|
+
assert.equal((await sized("POST", `${D}/parameterizedSearch`, `{"q":"acme","sobjects":${"[".repeat(3000)}${"]".repeat(3000)}}`)).status, 400);
|
|
1070
|
+
// Caller names quoted in errors are clipped to 255 characters (framework messages cap at 4,000; fields maxLength 255).
|
|
1071
|
+
const long = (n) => "A".repeat(n);
|
|
1072
|
+
const clipped = `${long(254)}…`;
|
|
1073
|
+
for (const n of [3500, 4000, 6000]) {
|
|
1074
|
+
const created = await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_FIELD", { body: { Name: "Long key", [long(n)]: 1 }, text: `No such column '${clipped}' on sobject of type Account`, fields: [clipped] });
|
|
1075
|
+
assert.ok(created.json[0].message.length < 4000);
|
|
1076
|
+
}
|
|
1077
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_FIELD", { body: { Name: "Long key", [long(255)]: 1 }, fields: [long(255)] });
|
|
1078
|
+
const emoji = await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_FIELD", { body: { Name: "Long key", ["😀".repeat(300)]: 1 } });
|
|
1079
|
+
assert.equal([...emoji.json[0].fields[0]].length, 255);
|
|
1080
|
+
await apiError("GET", q(`SELECT ${long(5000)} FROM Account`), 400, "INVALID_FIELD", { text: `'${clipped}'`, fields: [clipped] });
|
|
1081
|
+
await apiError("GET", q(`SELECT Id FROM Account WHERE ${long(6000)} = 'a'`), 400, "INVALID_FIELD", { fields: [clipped] });
|
|
1082
|
+
await apiError("GET", q(`SELECT Id FROM ${long(6000)}`), 400, "INVALID_TYPE", { text: `sObject type '${clipped}'` });
|
|
1083
|
+
await apiError("GET", q(`SELECT Id FROM Account WHERE Name = 'a' ${long(6000)}`), 400, "MALFORMED_QUERY", { text: clipped });
|
|
1084
|
+
await apiError("POST", `${D}/parameterizedSearch`, 400, "INVALID_TYPE", { body: { q: "abc", sobjects: [{ name: long(6000) }] }, text: `sObject type '${clipped}'` });
|
|
1085
|
+
await apiError("POST", `${D}/sobjects/Account`, 400, "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST", { body: { Name: "Long picklist", Industry: long(6000) }, text: clipped });
|
|
1086
|
+
for (const n of [256, 4000]) {
|
|
1087
|
+
const items = (await post(`${D}/composite/sobjects`, { allOrNone: false, records: [{ attributes: { type: "Account" }, Name: "Long item", [long(n)]: 1 }] })).json;
|
|
1088
|
+
assert.deepEqual(items[0].errors[0].fields, [clipped], JSON.stringify(items).slice(0, 300));
|
|
1089
|
+
const upserted = (await api("PATCH", `${D}/composite/sobjects/Account/Id`, { body: { allOrNone: false, records: [{ attributes: { type: "Account" }, Id: created[0], [long(n)]: 1 }] } })).json;
|
|
1090
|
+
assert.deepEqual(upserted[0].errors[0].fields, [clipped], JSON.stringify(upserted).slice(0, 300));
|
|
1091
|
+
const updated = (await api("PATCH", `${D}/composite/sobjects`, { body: { allOrNone: false, records: [{ attributes: { type: "Account" }, id: created[0], [long(n)]: 1 }] } })).json;
|
|
1092
|
+
assert.deepEqual(updated[0].errors[0].fields, [clipped], JSON.stringify(updated).slice(0, 300));
|
|
1093
|
+
}
|
|
1094
|
+
const nested = (await post(`${D}/composite`, { compositeRequest: [{ method: "POST", url: `${D}/sobjects/Account`, body: { Name: "Long sub", [long(4000)]: 1 }, referenceId: "a" }] })).json;
|
|
1095
|
+
assert.deepEqual(nested.compositeResponse[0].body[0].fields, [clipped]);
|
|
1096
|
+
assert.equal((await query("SELECT COUNT() FROM Account")).totalSize, 39);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ---------------------------------------------------------------------------------------------
|
|
1100
|
+
|
|
1101
|
+
const flows = {
|
|
1102
|
+
"rest-flow": restFlow,
|
|
1103
|
+
soql: soqlFlow,
|
|
1104
|
+
"collections-composite": collectionsComposite,
|
|
1105
|
+
sharing,
|
|
1106
|
+
"profile-permissions": profilePermissions,
|
|
1107
|
+
"read-only": readOnly,
|
|
1108
|
+
"mcp-aliases": mcpAliases,
|
|
1109
|
+
"fresh-install": freshInstall,
|
|
1110
|
+
denied,
|
|
1111
|
+
"invalid-session": invalidSession,
|
|
1112
|
+
"inactive-user": inactiveUser,
|
|
1113
|
+
"api-limit-exceeded": apiLimitExceeded,
|
|
1114
|
+
"row-locked": rowLocked,
|
|
1115
|
+
"write-committed-lost": writeCommittedLost,
|
|
1116
|
+
"tight-limits": tightLimits,
|
|
1117
|
+
"large-responses": largeResponses,
|
|
1118
|
+
};
|
|
1119
|
+
const selected = Object.keys(flows).find((name) => instruction.includes(`the ${name} conformance flow`));
|
|
1120
|
+
if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
|
|
1121
|
+
await flows[selected]();
|
|
1122
|
+
process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
|