@firedrill-tools/netsuite 0.1.2
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 +214 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +6905 -0
- package/firedrill/concurrency-limited.scenario.json +11 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/ns-analytics.drill.json +73 -0
- package/firedrill/ns-concurrency.drill.json +53 -0
- package/firedrill/ns-default-identity.drill.json +43 -0
- package/firedrill/ns-denied.drill.json +38 -0
- package/firedrill/ns-insufficient-permission.drill.json +53 -0
- package/firedrill/ns-invalid-login.drill.json +53 -0
- package/firedrill/ns-order-to-cash.drill.json +93 -0
- package/firedrill/ns-record-locked.drill.json +53 -0
- package/firedrill/ns-records.drill.json +68 -0
- package/firedrill/ns-role-ar-clerk.drill.json +58 -0
- package/firedrill/ns-role-sales-rep.drill.json +58 -0
- package/firedrill/ns-subsidiary-scope.drill.json +58 -0
- package/firedrill/ns-tight-limits.drill.json +58 -0
- package/firedrill/ns-transform-lost.drill.json +63 -0
- package/firedrill/ns-write-outage.drill.json +63 -0
- package/firedrill/record-locked.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +17 -0
- package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
- package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
- package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/app.js +182 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/icons.js +59 -0
- package/firedrill/tools/netsuite/app/site/index.html +68 -0
- package/firedrill/tools/netsuite/app/site/menus.js +111 -0
- package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
- package/firedrill/tools/netsuite/app/site/store.js +95 -0
- package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
- package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
- package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
- package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
- package/firedrill/tools/netsuite/app/site/ui.js +119 -0
- package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
- package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
- package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
- package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
- package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
- package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
- package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
- package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
- package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
- package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
- package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
- package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
- package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
- package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
- package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
- package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
- package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
- package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
- package/firedrill/tools/netsuite/behavior.mjs +13 -0
- package/firedrill/tools/netsuite/lib/body.mjs +185 -0
- package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
- package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
- package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
- package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
- package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
- package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
- package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
- package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
- package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
- package/firedrill/tools/netsuite/lib/project.mjs +178 -0
- package/firedrill/tools/netsuite/lib/q.mjs +280 -0
- package/firedrill/tools/netsuite/lib/session.mjs +188 -0
- package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
- package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
- package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
- package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
- package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
- package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
- package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
- package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
- package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
- package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
- package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
- package/firedrill/transform-response-lost.scenario.json +11 -0
- package/firedrill/world.json +7711 -0
- package/firedrill/write-outage.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +6904 -0
- package/test/conformance.mjs +701 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
// NetSuite Tool conformance target. A scripted Tool test, not a model-driven agent: Node built-ins only,
|
|
2
|
+
// driving the SuiteTalk-shaped routes under /services/rest and the canonical operation endpoint for the
|
|
3
|
+
// MCP-shaped operations that have no provider route. Every flow fails loudly on an unexpected status or body.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
let task = "";
|
|
7
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
8
|
+
const instruction = String(JSON.parse(task).instruction ?? "");
|
|
9
|
+
|
|
10
|
+
const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
11
|
+
const TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
12
|
+
assert.ok(HTTP && TOKEN, "the Firedrill HTTP binding is required");
|
|
13
|
+
|
|
14
|
+
const REC = "/services/rest/record/v1";
|
|
15
|
+
const SUITEQL = "/services/rest/query/v1/suiteql";
|
|
16
|
+
|
|
17
|
+
/** One provider-shaped request; asserts the HTTP status and returns { status, json, headers }. */
|
|
18
|
+
async function api(method, path, { body, status = 200, headers = {} } = {}) {
|
|
19
|
+
const requestHeaders = { authorization: `Bearer ${TOKEN}`, accept: "application/json", ...headers };
|
|
20
|
+
let payload;
|
|
21
|
+
if (body !== undefined) {
|
|
22
|
+
requestHeaders["content-type"] = "application/json";
|
|
23
|
+
payload = typeof body === "string" ? body : JSON.stringify(body);
|
|
24
|
+
}
|
|
25
|
+
const response = await fetch(`${HTTP}${path}`, { method, headers: requestHeaders, body: payload });
|
|
26
|
+
const text = await response.text();
|
|
27
|
+
const json = text.length > 0 && (response.headers.get("content-type") ?? "").includes("json")
|
|
28
|
+
? JSON.parse(text)
|
|
29
|
+
: undefined;
|
|
30
|
+
assert.equal(
|
|
31
|
+
response.status,
|
|
32
|
+
status,
|
|
33
|
+
`${method} ${path} -> ${response.status} ${JSON.stringify(json ?? text).slice(0, 500)}`,
|
|
34
|
+
);
|
|
35
|
+
return { status: response.status, json, headers: response.headers };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const get = (path, options) => api("GET", path, options);
|
|
39
|
+
const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
40
|
+
const patch = (path, body, options) => api("PATCH", path, { ...options, method: "PATCH", body });
|
|
41
|
+
|
|
42
|
+
/** Expect the NetSuite problem envelope with a given status and o:errorCode. */
|
|
43
|
+
async function problem(method, path, status, code, options = {}) {
|
|
44
|
+
const result = await api(method, path, { ...options, status });
|
|
45
|
+
const value = result.json;
|
|
46
|
+
assert.equal(value?.status, status, `${method} ${path}: ${JSON.stringify(value).slice(0, 400)}`);
|
|
47
|
+
assert.ok(Array.isArray(value?.["o:errorDetails"]) && value["o:errorDetails"].length === 1, JSON.stringify(value));
|
|
48
|
+
const detail = value["o:errorDetails"][0];
|
|
49
|
+
assert.equal(detail["o:errorCode"], code, `${method} ${path}: ${JSON.stringify(value).slice(0, 400)}`);
|
|
50
|
+
assert.equal(typeof detail.detail, "string");
|
|
51
|
+
assert.ok(detail.detail.length > 0);
|
|
52
|
+
assert.equal(typeof value.type, "string");
|
|
53
|
+
assert.equal(typeof value.title, "string");
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Call an operation through the canonical endpoint; returns the outcome envelope unchecked. */
|
|
58
|
+
async function call(operationId, args) {
|
|
59
|
+
const response = await fetch(`${HTTP}/v1/operations/netsuite/${operationId}`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ arguments: args }),
|
|
63
|
+
});
|
|
64
|
+
const result = await response.json();
|
|
65
|
+
assert.ok(result?.outcome, `${operationId}: unexpected response ${JSON.stringify(result).slice(0, 400)}`);
|
|
66
|
+
return result.outcome;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function ok(operationId, args) {
|
|
70
|
+
const outcome = await call(operationId, args);
|
|
71
|
+
assert.equal(outcome.status, "ok", `${operationId}: ${JSON.stringify(outcome).slice(0, 400)}`);
|
|
72
|
+
return outcome.value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function failsWith(operationId, args, code) {
|
|
76
|
+
const outcome = await call(operationId, args);
|
|
77
|
+
assert.equal(outcome.status, "tool_error", `${operationId}: expected ${code}, got ${JSON.stringify(outcome).slice(0, 300)}`);
|
|
78
|
+
assert.equal(outcome.error.code, `tool.${code}`, `${operationId}: ${JSON.stringify(outcome.error).slice(0, 300)}`);
|
|
79
|
+
return outcome.error;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every operation with the smallest set of arguments that reaches its handler. */
|
|
83
|
+
const ALL_OPERATIONS = [
|
|
84
|
+
["customer.list", {}],
|
|
85
|
+
["customer.get", { recordId: "1001" }],
|
|
86
|
+
["sales-order.list", {}],
|
|
87
|
+
["sales-order.get", { recordId: "5001" }],
|
|
88
|
+
["invoice.list", {}],
|
|
89
|
+
["invoice.get", { recordId: "5031" }],
|
|
90
|
+
["customer-payment.list", {}],
|
|
91
|
+
["customer-payment.get", { recordId: "5051" }],
|
|
92
|
+
["inventory-item.list", {}],
|
|
93
|
+
["inventory-item.get", { recordId: "2001" }],
|
|
94
|
+
["customer.create", { body: { companyName: "Probe Metrology" } }],
|
|
95
|
+
["customer.update", { recordId: "1001", body: { phone: "+1 206-555-0199" } }],
|
|
96
|
+
["customer.delete", { recordId: "1010" }],
|
|
97
|
+
["sales-order.create", { body: { entity: { id: "1001" }, item: { items: [{ item: { id: "2001" }, quantity: 1 }] } } }],
|
|
98
|
+
["sales-order.update", { recordId: "5003", body: { memo: "probe" } }],
|
|
99
|
+
["sales-order.items.list", { recordId: "5001" }],
|
|
100
|
+
["sales-order.transform", { recordId: "5004", target: "invoice", transformMarker: "!transform", body: {} }],
|
|
101
|
+
["invoice.create", { body: { entity: { id: "1001" }, item: { items: [{ item: { id: "2001" }, quantity: 1 }] } } }],
|
|
102
|
+
["invoice.update", { recordId: "5033", body: { memo: "probe" } }],
|
|
103
|
+
["invoice.transform", { recordId: "5033", target: "customerPayment", transformMarker: "!transform", body: {} }],
|
|
104
|
+
["subsidiary.list", {}],
|
|
105
|
+
["suiteql.query", { q: "SELECT id FROM customer", prefer: "transient" }],
|
|
106
|
+
["suiteql.run", { query: "SELECT id FROM customer" }],
|
|
107
|
+
["record.get", { recordType: "customer", recordId: "1001" }],
|
|
108
|
+
["record.metadata", {}],
|
|
109
|
+
["session.get", {}],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const WRITE_OPERATIONS = new Set([
|
|
113
|
+
"customer.create", "customer.update", "customer.delete",
|
|
114
|
+
"sales-order.create", "sales-order.update", "sales-order.transform",
|
|
115
|
+
"invoice.create", "invoice.update", "invoice.transform",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const LOCKABLE_OPERATIONS = new Set([
|
|
119
|
+
"customer.update", "sales-order.update", "invoice.update",
|
|
120
|
+
"sales-order.transform", "invoice.transform",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/** Every operation that performs a bounded scan, so `tight-limits` makes it fail. */
|
|
124
|
+
const SCANNING_OPERATIONS = new Set(
|
|
125
|
+
ALL_OPERATIONS.map(([id]) => id).filter((id) => id !== "inventory-item.get" && id !== "record.metadata" && id !== "session.get"),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------------------------
|
|
129
|
+
// Flows
|
|
130
|
+
// ---------------------------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** Read every collection and record, and every read error each operation declares. */
|
|
133
|
+
async function records() {
|
|
134
|
+
const customers = (await get(`${REC}/customer?limit=5`)).json;
|
|
135
|
+
assert.equal(customers.totalResults, 12);
|
|
136
|
+
assert.equal(customers.count, 5);
|
|
137
|
+
assert.equal(customers.hasMore, true);
|
|
138
|
+
assert.deepEqual(customers.items.map((row) => row.id), ["1001", "1002", "1003", "1004", "1005"]);
|
|
139
|
+
assert.equal(customers.links[0].href, `${REC}/customer?limit=5&offset=0`);
|
|
140
|
+
const second = (await get(`${REC}/customer?limit=5&offset=5`)).json;
|
|
141
|
+
assert.equal(second.offset, 5);
|
|
142
|
+
assert.deepEqual(second.items.map((row) => row.id), ["1006", "1007", "1008", "1009", "1010"]);
|
|
143
|
+
const filtered = (await get(`${REC}/customer?q=${encodeURIComponent('email START_WITH "ap@"')}`)).json;
|
|
144
|
+
assert.deepEqual(filtered.items.map((row) => row.id), ["1002", "1012"]);
|
|
145
|
+
const inactive = (await get(`${REC}/customer?q=${encodeURIComponent("isInactive IS true")}`)).json;
|
|
146
|
+
assert.deepEqual(inactive.items.map((row) => row.id), ["1004"]);
|
|
147
|
+
const noEmail = (await get(`${REC}/customer?q=${encodeURIComponent("email EMPTY")}`)).json;
|
|
148
|
+
assert.deepEqual(noEmail.items.map((row) => row.id), ["1003"]);
|
|
149
|
+
const canada = (await get(`${REC}/customer?q=${encodeURIComponent("subsidiary ANY_OF [2]")}`)).json;
|
|
150
|
+
assert.deepEqual(canada.items.map((row) => row.id), ["1006", "1007"]);
|
|
151
|
+
|
|
152
|
+
const customer = (await get(`${REC}/customer/1001`)).json;
|
|
153
|
+
assert.equal(customer.entityId, "C1001 Harbourgate Labs");
|
|
154
|
+
assert.equal(customer.subsidiary.refName, "Whitlock Instruments Inc");
|
|
155
|
+
assert.equal(customer.currency.refName, "USD");
|
|
156
|
+
assert.equal(customer.terms.refName, "Net 30");
|
|
157
|
+
assert.equal(typeof customer.balance, "number");
|
|
158
|
+
assert.equal(customer.addressBook.items, undefined, "sublists are stubs unless expanded");
|
|
159
|
+
const expanded = (await get(`${REC}/customer/1001?expandSubResources=true`)).json;
|
|
160
|
+
assert.equal(expanded.addressBook.totalResults, 2);
|
|
161
|
+
assert.equal(expanded.addressBook.items[0].addressBookAddress.city, "Seattle");
|
|
162
|
+
const picked = (await get(`${REC}/customer/1001?fields=entityId,balance`)).json;
|
|
163
|
+
assert.deepEqual(Object.keys(picked).sort(), ["balance", "entityId", "id", "links"]);
|
|
164
|
+
const person = (await get(`${REC}/customer/1008`)).json;
|
|
165
|
+
assert.equal(person.isPerson, true);
|
|
166
|
+
assert.equal(person.lastName, "Renn");
|
|
167
|
+
const noEmailCustomer = (await get(`${REC}/customer/1003`)).json;
|
|
168
|
+
assert.equal(noEmailCustomer.email, undefined, "a null field is omitted from the wire");
|
|
169
|
+
|
|
170
|
+
await problem("GET", `${REC}/customer?limit=abc`, 400, "INVALID_PARAMETER");
|
|
171
|
+
await problem("GET", `${REC}/customer?limit=10&offset=5`, 400, "INVALID_PARAMETER");
|
|
172
|
+
await problem("GET", `${REC}/customer?q=${encodeURIComponent("bogus IS x")}`, 400, "INVALID_REQUEST");
|
|
173
|
+
await problem("GET", `${REC}/customer?q=${encodeURIComponent('entityId BETWEEN "a"')}`, 400, "INVALID_REQUEST");
|
|
174
|
+
await problem("GET", `${REC}/customer/abc`, 400, "INVALID_ID");
|
|
175
|
+
await problem("GET", `${REC}/customer/999999`, 404, "NONEXISTENT_ID");
|
|
176
|
+
await problem("GET", `${REC}/customer/1001?fields=nope`, 400, "INVALID_PARAMETER");
|
|
177
|
+
|
|
178
|
+
// Regression (round 3): values the operation input schema itself rejects never reach a handler. The route must still
|
|
179
|
+
// answer a coherent NetSuite envelope whose body `status` matches the HTTP status line, and the bounds this package
|
|
180
|
+
// documents must be raised by the handlers with their specific detail.
|
|
181
|
+
await problem("GET", `${REC}/customer/${"9".repeat(600)}`, 400, "INVALID_PARAMETER");
|
|
182
|
+
const manyFields = Array.from({ length: 41 }, (_, index) => `field${index}`).join(",");
|
|
183
|
+
const fieldsBound = await problem("GET", `${REC}/customer/1001?fields=${manyFields}`, 400, "INVALID_PARAMETER");
|
|
184
|
+
assert.ok(fieldsBound["o:errorDetails"][0].detail.includes("40"), "the fields bound is named in the detail");
|
|
185
|
+
const qBound = await problem("GET", `${REC}/customer?q=${encodeURIComponent("a".repeat(4001))}`, 400, "INVALID_REQUEST");
|
|
186
|
+
assert.ok(qBound["o:errorDetails"][0]["o:errorQueryParam"] === "q", "the q bound names the query parameter");
|
|
187
|
+
|
|
188
|
+
const orders = (await get(`${REC}/salesOrder`)).json;
|
|
189
|
+
assert.equal(orders.totalResults, 9);
|
|
190
|
+
const order = (await get(`${REC}/salesOrder/5001?expandSubResources=true`)).json;
|
|
191
|
+
assert.equal(order.status.refName, "Billed");
|
|
192
|
+
assert.equal(order.item.totalResults, 3);
|
|
193
|
+
assert.equal(order.item.items[0].item.refName, "HX-2");
|
|
194
|
+
assert.equal(order.total, Math.round((order.subtotal + order.taxTotal) * 100) / 100);
|
|
195
|
+
await problem("GET", `${REC}/salesOrder?limit=0`, 400, "INVALID_PARAMETER");
|
|
196
|
+
await problem("GET", `${REC}/salesOrder?q=${encodeURIComponent("bogus IS x")}`, 400, "INVALID_REQUEST");
|
|
197
|
+
await problem("GET", `${REC}/salesOrder/abc`, 400, "INVALID_ID");
|
|
198
|
+
await problem("GET", `${REC}/salesOrder/999999`, 404, "NONEXISTENT_ID");
|
|
199
|
+
await problem("GET", `${REC}/salesOrder/5001?fields=nope`, 400, "INVALID_PARAMETER");
|
|
200
|
+
|
|
201
|
+
const invoices = (await get(`${REC}/invoice`)).json;
|
|
202
|
+
assert.equal(invoices.totalResults, 11);
|
|
203
|
+
const invoice = (await get(`${REC}/invoice/5031?expandSubResources=true`)).json;
|
|
204
|
+
assert.equal(invoice.createdFrom.refName, "SO1001");
|
|
205
|
+
assert.equal(invoice.status.refName, "Paid In Full");
|
|
206
|
+
assert.equal(invoice.amountRemaining, 0);
|
|
207
|
+
const zero = (await get(`${REC}/invoice/5039`)).json;
|
|
208
|
+
assert.equal(zero.total, 0, "the deliberate zero-total invoice");
|
|
209
|
+
await problem("GET", `${REC}/invoice?limit=abc`, 400, "INVALID_PARAMETER");
|
|
210
|
+
await problem("GET", `${REC}/invoice?q=${encodeURIComponent("bogus IS x")}`, 400, "INVALID_REQUEST");
|
|
211
|
+
await problem("GET", `${REC}/invoice/abc`, 400, "INVALID_ID");
|
|
212
|
+
await problem("GET", `${REC}/invoice/999999`, 404, "NONEXISTENT_ID");
|
|
213
|
+
await problem("GET", `${REC}/invoice/5031?fields=nope`, 400, "INVALID_PARAMETER");
|
|
214
|
+
|
|
215
|
+
const payments = (await get(`${REC}/customerPayment`)).json;
|
|
216
|
+
assert.equal(payments.totalResults, 5);
|
|
217
|
+
const payment = (await get(`${REC}/customerPayment/5054?expandSubResources=true`)).json;
|
|
218
|
+
assert.equal(payment.unapplied, 1500);
|
|
219
|
+
assert.equal(payment.apply.items[0].doc, "5034");
|
|
220
|
+
await problem("GET", `${REC}/customerPayment?limit=abc`, 400, "INVALID_PARAMETER");
|
|
221
|
+
await problem("GET", `${REC}/customerPayment?q=${encodeURIComponent("bogus IS x")}`, 400, "INVALID_REQUEST");
|
|
222
|
+
await problem("GET", `${REC}/customerPayment/abc`, 400, "INVALID_ID");
|
|
223
|
+
await problem("GET", `${REC}/customerPayment/999999`, 404, "NONEXISTENT_ID");
|
|
224
|
+
await problem("GET", `${REC}/customerPayment/5051?fields=nope`, 400, "INVALID_PARAMETER");
|
|
225
|
+
|
|
226
|
+
const items = (await get(`${REC}/inventoryItem`)).json;
|
|
227
|
+
assert.equal(items.totalResults, 7, "service items are not part of the inventoryItem collection");
|
|
228
|
+
const item = (await get(`${REC}/inventoryItem/2001`)).json;
|
|
229
|
+
assert.equal(item.itemId, "HX-2");
|
|
230
|
+
assert.equal(item.subsidiary.totalResults, 2);
|
|
231
|
+
await problem("GET", `${REC}/inventoryItem?limit=abc`, 400, "INVALID_PARAMETER");
|
|
232
|
+
await problem("GET", `${REC}/inventoryItem?q=${encodeURIComponent("bogus IS x")}`, 400, "INVALID_REQUEST");
|
|
233
|
+
await problem("GET", `${REC}/inventoryItem/abc`, 400, "INVALID_ID");
|
|
234
|
+
await problem("GET", `${REC}/inventoryItem/2008`, 404, "NONEXISTENT_ID");
|
|
235
|
+
await problem("GET", `${REC}/inventoryItem/2001?fields=nope`, 400, "INVALID_PARAMETER");
|
|
236
|
+
|
|
237
|
+
const sublist = (await get(`${REC}/salesOrder/5009/item?limit=2&offset=2`)).json;
|
|
238
|
+
assert.equal(sublist.totalResults, 200, "the deliberate 200-line order");
|
|
239
|
+
assert.equal(sublist.count, 2);
|
|
240
|
+
assert.equal(sublist.items[0].line, 3);
|
|
241
|
+
await problem("GET", `${REC}/salesOrder/5001/item?limit=abc`, 400, "INVALID_PARAMETER");
|
|
242
|
+
await problem("GET", `${REC}/salesOrder/abc/item`, 400, "INVALID_ID");
|
|
243
|
+
await problem("GET", `${REC}/salesOrder/999999/item`, 404, "NONEXISTENT_ID");
|
|
244
|
+
|
|
245
|
+
const subsidiaries = (await get(`${REC}/subsidiary`)).json;
|
|
246
|
+
assert.deepEqual(subsidiaries.items.map((row) => row.id), ["1", "2"]);
|
|
247
|
+
assert.equal(subsidiaries.items[0].name, undefined, "the REST route answers the record-collection shape");
|
|
248
|
+
await problem("GET", `${REC}/subsidiary?limit=abc`, 400, "INVALID_PARAMETER");
|
|
249
|
+
const canonical = await ok("subsidiary.list", {});
|
|
250
|
+
assert.equal(canonical.items[1].name, "Whitlock Instruments Canada");
|
|
251
|
+
assert.equal(canonical.items[1].parent.refName, "Whitlock Instruments Inc");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const UUID_KEY = "3f2b6c1e-8d4a-4f07-9b21-5c7d0a1e4b66";
|
|
255
|
+
|
|
256
|
+
/** Create, edit, bill and collect: every write operation and every write error it declares. */
|
|
257
|
+
async function orderToCash() {
|
|
258
|
+
const created = await api("POST", `${REC}/customer`, {
|
|
259
|
+
status: 204,
|
|
260
|
+
body: { companyName: "Cobalt Reef Survey", email: "ap@cobaltreef.example.com", subsidiary: { id: "1" }, terms: { id: "2" }, creditLimit: 40000 },
|
|
261
|
+
headers: { "x-netsuite-idempotency-key": UUID_KEY },
|
|
262
|
+
});
|
|
263
|
+
const customerId = created.headers.get("location").split("/").pop();
|
|
264
|
+
assert.equal(customerId, "1041");
|
|
265
|
+
const customer = (await get(`${REC}/customer/${customerId}`)).json;
|
|
266
|
+
assert.equal(customer.entityId, "C1041 Cobalt Reef Survey");
|
|
267
|
+
assert.equal(customer.balance, 0);
|
|
268
|
+
|
|
269
|
+
await api("PATCH", `${REC}/customer/${customerId}`, { status: 204, body: { phone: "+1 206-555-0150", comments: "Net 30, PO required" } });
|
|
270
|
+
assert.equal((await get(`${REC}/customer/${customerId}`)).json.phone, "+1 206-555-0150");
|
|
271
|
+
await api("PATCH", `${REC}/customer/${customerId}?replace=comments`, { status: 204, body: {} });
|
|
272
|
+
assert.equal((await get(`${REC}/customer/${customerId}`)).json.comments, undefined);
|
|
273
|
+
|
|
274
|
+
await problem("POST", `${REC}/customer`, 400, "INVALID_CONTENT", { body: { companyName: 123 } });
|
|
275
|
+
await problem("POST", `${REC}/customer`, 400, "INVALID_KEY_OR_REF", { body: { companyName: "X", subsidiary: { id: "999" } } });
|
|
276
|
+
await problem("POST", `${REC}/customer`, 400, "INVALID_REQUEST", { body: [1, 2] });
|
|
277
|
+
await problem("POST", `${REC}/customer`, 400, "USER_ERROR", { body: {} });
|
|
278
|
+
await problem("POST", `${REC}/customer`, 400, "INVALID_PARAMETER", {
|
|
279
|
+
body: { companyName: "X" },
|
|
280
|
+
headers: { "x-netsuite-idempotency-key": "not-a-uuid" },
|
|
281
|
+
});
|
|
282
|
+
await problem("PATCH", `${REC}/customer/abc`, 400, "INVALID_ID", { body: {} });
|
|
283
|
+
await problem("PATCH", `${REC}/customer/999999`, 404, "NONEXISTENT_ID", { body: {} });
|
|
284
|
+
await problem("PATCH", `${REC}/customer/${customerId}`, 400, "INVALID_CONTENT", { body: { email: 5 } });
|
|
285
|
+
await problem("PATCH", `${REC}/customer/${customerId}`, 400, "INVALID_KEY_OR_REF", { body: { terms: { id: "99" } } });
|
|
286
|
+
await problem("PATCH", `${REC}/customer/${customerId}?replace=bogus`, 400, "INVALID_PARAMETER", { body: {} });
|
|
287
|
+
await problem("PATCH", `${REC}/customer/${customerId}`, 400, "INVALID_REQUEST", { body: { constructor: {} } });
|
|
288
|
+
await problem("PATCH", `${REC}/customer/${customerId}`, 400, "USER_ERROR", { body: { entityId: "C1001 Harbourgate Labs" } });
|
|
289
|
+
await problem("PATCH", `${REC}/customer/${customerId}`, 409, "RCRD_HAS_BEEN_CHANGED", { body: {}, headers: { "if-match": "99" } });
|
|
290
|
+
|
|
291
|
+
const order = await api("POST", `${REC}/salesOrder`, {
|
|
292
|
+
status: 204,
|
|
293
|
+
body: {
|
|
294
|
+
entity: { id: customerId },
|
|
295
|
+
memo: "Reef array build",
|
|
296
|
+
otherRefNum: "PO-4410",
|
|
297
|
+
item: { items: [{ item: { id: "2001" }, quantity: 2 }, { item: { id: "2004" }, quantity: 5, rate: 60 }] },
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const orderId = order.headers.get("location").split("/").pop();
|
|
301
|
+
const orderRecord = (await get(`${REC}/salesOrder/${orderId}?expandSubResources=true`)).json;
|
|
302
|
+
assert.equal(orderRecord.status.refName, "Pending Fulfillment");
|
|
303
|
+
assert.equal(orderRecord.subtotal, 3200);
|
|
304
|
+
assert.equal(orderRecord.taxTotal, 275.2);
|
|
305
|
+
assert.equal(orderRecord.total, 3475.2);
|
|
306
|
+
assert.equal(orderRecord.item.items[1].rate, 60);
|
|
307
|
+
|
|
308
|
+
await api("PATCH", `${REC}/salesOrder/${orderId}`, {
|
|
309
|
+
status: 204,
|
|
310
|
+
body: { memo: "Reef array build, phase 1", item: { items: [{ line: 1, quantity: 3 }, { item: { id: "2007" }, quantity: 4 }] } },
|
|
311
|
+
});
|
|
312
|
+
const patched = (await get(`${REC}/salesOrder/${orderId}?expandSubResources=true`)).json;
|
|
313
|
+
assert.equal(patched.item.totalResults, 3);
|
|
314
|
+
assert.equal(patched.item.items[0].quantity, 3);
|
|
315
|
+
assert.equal(patched.item.items[2].line, 3);
|
|
316
|
+
assert.equal(patched.subtotal, 4679.6);
|
|
317
|
+
|
|
318
|
+
await problem("POST", `${REC}/salesOrder`, 400, "INVALID_CONTENT", {
|
|
319
|
+
body: { entity: { id: customerId }, item: { items: [{ item: { id: "2001" }, quantity: "two" }] } },
|
|
320
|
+
});
|
|
321
|
+
await problem("POST", `${REC}/salesOrder`, 400, "INVALID_KEY_OR_REF", { body: { entity: { id: "999999" }, item: { items: [] } } });
|
|
322
|
+
await problem("POST", `${REC}/salesOrder`, 400, "INVALID_REQUEST", { body: [1, 2] });
|
|
323
|
+
await problem("POST", `${REC}/salesOrder`, 400, "USER_ERROR", { body: { entity: { id: customerId }, item: { items: [] } } });
|
|
324
|
+
await problem("POST", `${REC}/salesOrder`, 400, "INVALID_PARAMETER", {
|
|
325
|
+
body: { entity: { id: customerId }, item: { items: [{ item: { id: "2001" }, quantity: 1 }] } },
|
|
326
|
+
headers: { "x-netsuite-idempotency-key": "nope" },
|
|
327
|
+
});
|
|
328
|
+
await problem("POST", `${REC}/salesOrder`, 400, "USER_ERROR", {
|
|
329
|
+
body: { entity: { id: "1004" }, item: { items: [{ item: { id: "2001" }, quantity: 1 }] } },
|
|
330
|
+
}); // the seeded inactive customer
|
|
331
|
+
await problem("POST", `${REC}/salesOrder`, 400, "INVALID_KEY_OR_REF", {
|
|
332
|
+
body: { entity: { id: customerId }, item: { items: [{ item: { id: "2005" }, quantity: 1 }] } },
|
|
333
|
+
}); // stocked only in subsidiary 2
|
|
334
|
+
await problem("PATCH", `${REC}/salesOrder/abc`, 400, "INVALID_ID", { body: {} });
|
|
335
|
+
await problem("PATCH", `${REC}/salesOrder/999999`, 404, "NONEXISTENT_ID", { body: {} });
|
|
336
|
+
await problem("PATCH", `${REC}/salesOrder/${orderId}`, 400, "INVALID_CONTENT", { body: { memo: 7 } });
|
|
337
|
+
await problem("PATCH", `${REC}/salesOrder/${orderId}`, 400, "INVALID_KEY_OR_REF", { body: { item: { items: [{ line: 99, quantity: 1 }] } } });
|
|
338
|
+
await problem("PATCH", `${REC}/salesOrder/${orderId}?replace=bogus`, 400, "INVALID_PARAMETER", { body: {} });
|
|
339
|
+
await problem("PATCH", `${REC}/salesOrder/${orderId}`, 400, "INVALID_REQUEST", { body: { constructor: {} } });
|
|
340
|
+
await problem("PATCH", `${REC}/salesOrder/5006`, 400, "USER_ERROR", { body: { memo: "x" } }); // the seeded closed order
|
|
341
|
+
await problem("PATCH", `${REC}/salesOrder/${orderId}`, 409, "RCRD_HAS_BEEN_CHANGED", { body: {}, headers: { "if-match": "42" } });
|
|
342
|
+
await orderToCashBilling(customerId, orderId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Second half of the order-to-cash flow: transforms, invoices, payments and the customer delete. */
|
|
346
|
+
async function orderToCashBilling(customerId, orderId) {
|
|
347
|
+
const billed = await api("POST", `${REC}/salesOrder/${orderId}/${encodeURIComponent("!transform")}/invoice`, {
|
|
348
|
+
status: 204,
|
|
349
|
+
body: { item: { items: [{ orderLine: 1, quantity: 1 }] } },
|
|
350
|
+
});
|
|
351
|
+
const partialInvoiceId = billed.headers.get("location").split("/").pop();
|
|
352
|
+
const partial = (await get(`${REC}/invoice/${partialInvoiceId}?expandSubResources=true`)).json;
|
|
353
|
+
assert.equal(partial.createdFrom.id, orderId);
|
|
354
|
+
assert.equal(partial.item.totalResults, 1);
|
|
355
|
+
assert.equal(partial.item.items[0].orderLine, 1);
|
|
356
|
+
assert.equal(partial.total, 1574.7);
|
|
357
|
+
const afterPartial = (await get(`${REC}/salesOrder/${orderId}?expandSubResources=true`)).json;
|
|
358
|
+
assert.equal(afterPartial.status.refName, "Pending Billing/Partially Fulfilled");
|
|
359
|
+
assert.equal(afterPartial.item.items[0].quantityBilled, 1);
|
|
360
|
+
|
|
361
|
+
const rest = await api("POST", `${REC}/salesOrder/${orderId}/${encodeURIComponent("!transform")}/invoice`, { status: 204, body: {} });
|
|
362
|
+
const restInvoiceId = rest.headers.get("location").split("/").pop();
|
|
363
|
+
assert.equal((await get(`${REC}/salesOrder/${orderId}`)).json.status.refName, "Billed");
|
|
364
|
+
await problem("POST", `${REC}/salesOrder/${orderId}/${encodeURIComponent("!transform")}/invoice`, 400, "USER_ERROR", { body: {} });
|
|
365
|
+
await problem("POST", `${REC}/salesOrder/${orderId}/nottransform/invoice`, 400, "INVALID_REQUEST", { body: {} });
|
|
366
|
+
await problem("POST", `${REC}/salesOrder/abc/${encodeURIComponent("!transform")}/invoice`, 400, "INVALID_ID", { body: {} });
|
|
367
|
+
await problem("POST", `${REC}/salesOrder/999999/${encodeURIComponent("!transform")}/invoice`, 404, "NONEXISTENT_ID", { body: {} });
|
|
368
|
+
await problem("POST", `${REC}/salesOrder/5004/${encodeURIComponent("!transform")}/invoice`, 400, "INVALID_CONTENT", { body: { memo: 9 } });
|
|
369
|
+
await problem("POST", `${REC}/salesOrder/5004/${encodeURIComponent("!transform")}/invoice`, 400, "INVALID_PARAMETER", {
|
|
370
|
+
body: {},
|
|
371
|
+
headers: { "x-netsuite-idempotency-key": "nope" },
|
|
372
|
+
});
|
|
373
|
+
await problem("POST", `${REC}/salesOrder/5004/${encodeURIComponent("!transform")}/invoice`, 409, "RCRD_HAS_BEEN_CHANGED", {
|
|
374
|
+
body: {},
|
|
375
|
+
headers: { "if-match": "77" },
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const standalone = await api("POST", `${REC}/invoice`, {
|
|
379
|
+
status: 204,
|
|
380
|
+
body: {
|
|
381
|
+
entity: { id: customerId },
|
|
382
|
+
memo: "Annual calibration",
|
|
383
|
+
item: { items: [{ item: { id: "2003" }, quantity: 2 }] },
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
const standaloneId = standalone.headers.get("location").split("/").pop();
|
|
387
|
+
const standaloneRecord = (await get(`${REC}/invoice/${standaloneId}`)).json;
|
|
388
|
+
assert.equal(standaloneRecord.status.refName, "Open");
|
|
389
|
+
assert.equal(standaloneRecord.amountRemaining, standaloneRecord.total);
|
|
390
|
+
assert.equal(standaloneRecord.dueDate, "2026-10-16");
|
|
391
|
+
|
|
392
|
+
await api("PATCH", `${REC}/invoice/${standaloneId}`, { status: 204, body: { memo: "Annual calibration, revised", item: { items: [{ line: 1, quantity: 3 }] } } });
|
|
393
|
+
const revised = (await get(`${REC}/invoice/${standaloneId}?expandSubResources=true`)).json;
|
|
394
|
+
assert.equal(revised.item.items[0].quantity, 3);
|
|
395
|
+
assert.equal(revised.memo, "Annual calibration, revised");
|
|
396
|
+
|
|
397
|
+
await problem("POST", `${REC}/invoice`, 400, "INVALID_CONTENT", {
|
|
398
|
+
body: { entity: { id: customerId }, item: { items: [{ item: { id: "2003" }, quantity: "two" }] } },
|
|
399
|
+
});
|
|
400
|
+
await problem("POST", `${REC}/invoice`, 400, "INVALID_KEY_OR_REF", { body: { entity: { id: "999999" }, item: { items: [] } } });
|
|
401
|
+
await problem("POST", `${REC}/invoice`, 400, "INVALID_REQUEST", { body: [1] });
|
|
402
|
+
await problem("POST", `${REC}/invoice`, 400, "USER_ERROR", { body: { entity: { id: customerId }, item: { items: [] } } });
|
|
403
|
+
await problem("POST", `${REC}/invoice`, 400, "INVALID_PARAMETER", {
|
|
404
|
+
body: { entity: { id: customerId }, item: { items: [{ item: { id: "2003" }, quantity: 1 }] } },
|
|
405
|
+
headers: { "x-netsuite-idempotency-key": "nope" },
|
|
406
|
+
});
|
|
407
|
+
await problem("POST", `${REC}/invoice`, 400, "USER_ERROR", {
|
|
408
|
+
body: { entity: { id: customerId }, createdFrom: { id: "5002" }, item: { items: [] } },
|
|
409
|
+
}); // that order belongs to another customer
|
|
410
|
+
await problem("PATCH", `${REC}/invoice/abc`, 400, "INVALID_ID", { body: {} });
|
|
411
|
+
await problem("PATCH", `${REC}/invoice/999999`, 404, "NONEXISTENT_ID", { body: {} });
|
|
412
|
+
await problem("PATCH", `${REC}/invoice/${standaloneId}`, 400, "INVALID_CONTENT", { body: { memo: 7 } });
|
|
413
|
+
await problem("PATCH", `${REC}/invoice/${standaloneId}`, 400, "INVALID_KEY_OR_REF", { body: { terms: { id: "99" } } });
|
|
414
|
+
await problem("PATCH", `${REC}/invoice/${standaloneId}?replace=bogus`, 400, "INVALID_PARAMETER", { body: {} });
|
|
415
|
+
await problem("PATCH", `${REC}/invoice/${standaloneId}`, 400, "INVALID_REQUEST", { body: { constructor: {} } });
|
|
416
|
+
await problem("PATCH", `${REC}/invoice/5031`, 400, "USER_ERROR", { body: { memo: "x" } }); // paid in full
|
|
417
|
+
await problem("PATCH", `${REC}/invoice/${standaloneId}`, 409, "RCRD_HAS_BEEN_CHANGED", { body: {}, headers: { "if-match": "42" } });
|
|
418
|
+
|
|
419
|
+
const paid = await api("POST", `${REC}/invoice/${standaloneId}/${encodeURIComponent("!transform")}/customerPayment`, {
|
|
420
|
+
status: 204,
|
|
421
|
+
body: { payment: 200, memo: "Wire 9001" },
|
|
422
|
+
});
|
|
423
|
+
const paymentId = paid.headers.get("location").split("/").pop();
|
|
424
|
+
const paymentRecord = (await get(`${REC}/customerPayment/${paymentId}?expandSubResources=true`)).json;
|
|
425
|
+
assert.equal(paymentRecord.payment, 200);
|
|
426
|
+
assert.equal(paymentRecord.unapplied, 0);
|
|
427
|
+
assert.equal(paymentRecord.apply.items[0].doc, standaloneId);
|
|
428
|
+
const afterPayment = (await get(`${REC}/invoice/${standaloneId}`)).json;
|
|
429
|
+
assert.equal(afterPayment.amountPaid, 200);
|
|
430
|
+
assert.equal(afterPayment.status.refName, "Open");
|
|
431
|
+
|
|
432
|
+
await api("POST", `${REC}/invoice/${standaloneId}/${encodeURIComponent("!transform")}/customerPayment`, { status: 204, body: {} });
|
|
433
|
+
const settled = (await get(`${REC}/invoice/${standaloneId}`)).json;
|
|
434
|
+
assert.equal(settled.amountRemaining, 0);
|
|
435
|
+
assert.equal(settled.status.refName, "Paid In Full");
|
|
436
|
+
|
|
437
|
+
await problem("POST", `${REC}/invoice/${standaloneId}/${encodeURIComponent("!transform")}/customerPayment`, 400, "USER_ERROR", { body: {} });
|
|
438
|
+
await problem("POST", `${REC}/invoice/${restInvoiceId}/nottransform/customerPayment`, 400, "INVALID_REQUEST", { body: {} });
|
|
439
|
+
await problem("POST", `${REC}/invoice/abc/${encodeURIComponent("!transform")}/customerPayment`, 400, "INVALID_ID", { body: {} });
|
|
440
|
+
await problem("POST", `${REC}/invoice/999999/${encodeURIComponent("!transform")}/customerPayment`, 404, "NONEXISTENT_ID", { body: {} });
|
|
441
|
+
await problem("POST", `${REC}/invoice/${restInvoiceId}/${encodeURIComponent("!transform")}/customerPayment`, 400, "INVALID_CONTENT", { body: { memo: 9 } });
|
|
442
|
+
await problem("POST", `${REC}/invoice/${restInvoiceId}/${encodeURIComponent("!transform")}/customerPayment`, 400, "INVALID_PARAMETER", {
|
|
443
|
+
body: {},
|
|
444
|
+
headers: { "x-netsuite-idempotency-key": "nope" },
|
|
445
|
+
});
|
|
446
|
+
await problem("POST", `${REC}/invoice/${restInvoiceId}/${encodeURIComponent("!transform")}/customerPayment`, 409, "RCRD_HAS_BEEN_CHANGED", {
|
|
447
|
+
body: {},
|
|
448
|
+
headers: { "if-match": "77" },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await api("DELETE", `${REC}/customer/1010`, { status: 204 });
|
|
452
|
+
await problem("GET", `${REC}/customer/1010`, 404, "NONEXISTENT_ID");
|
|
453
|
+
await problem("DELETE", `${REC}/customer/abc`, 400, "INVALID_ID");
|
|
454
|
+
await problem("DELETE", `${REC}/customer/999999`, 404, "NONEXISTENT_ID");
|
|
455
|
+
await problem("DELETE", `${REC}/customer/1001`, 400, "USER_ERROR");
|
|
456
|
+
await problem("DELETE", `${REC}/customer/1012`, 400, "INVALID_PARAMETER", { headers: { "x-netsuite-idempotency-key": "nope" } });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const PREFER = { prefer: "transient" };
|
|
460
|
+
const sql = (q, params) => post(SUITEQL, params === undefined ? { q } : { q, params }, { headers: PREFER });
|
|
461
|
+
|
|
462
|
+
/** SuiteQL, the MCP-shaped record read and the metadata catalog. */
|
|
463
|
+
async function analytics() {
|
|
464
|
+
const simple = (await sql("SELECT id, entityid FROM customer ORDER BY id FETCH FIRST 3 ROWS ONLY")).json;
|
|
465
|
+
assert.deepEqual(simple.items.map((row) => row.id), ["1001", "1002", "1003"]);
|
|
466
|
+
assert.equal(simple.items[0].entityid, "C1001 Harbourgate Labs");
|
|
467
|
+
assert.equal(typeof simple.items[0].id, "string", "SuiteQL renders every scalar as a string");
|
|
468
|
+
|
|
469
|
+
const joined = (await sql(
|
|
470
|
+
"SELECT t.tranid, BUILTIN.DF(t.entity) AS customer, t.foreigntotal FROM transaction t WHERE t.type = 'CustInvc' ORDER BY t.id FETCH FIRST 2 ROWS ONLY",
|
|
471
|
+
)).json;
|
|
472
|
+
assert.equal(joined.items[0].customer, "C1001 Harbourgate Labs");
|
|
473
|
+
assert.equal(joined.items[0].tranid, "INV1001");
|
|
474
|
+
|
|
475
|
+
const grouped = (await sql(
|
|
476
|
+
"SELECT c.entityid, COUNT(*) AS n, SUM(t.foreigntotal) AS total FROM customer c JOIN transaction t ON t.entity = c.id GROUP BY c.entityid ORDER BY total DESC FETCH FIRST 3 ROWS ONLY",
|
|
477
|
+
)).json;
|
|
478
|
+
assert.equal(grouped.items[0].entityid, "C1009 Kestrel Field Systems");
|
|
479
|
+
assert.equal(grouped.items[0].n, "2");
|
|
480
|
+
|
|
481
|
+
const bound = (await sql("SELECT id, NVL(email, 'none') AS mail FROM customer WHERE entityid LIKE ? ORDER BY id", ["%Brightwater%"])).json;
|
|
482
|
+
assert.deepEqual(bound.items.map((row) => row.mail), ["none"]);
|
|
483
|
+
|
|
484
|
+
const distinct = (await sql("SELECT DISTINCT type FROM transaction ORDER BY type")).json;
|
|
485
|
+
assert.deepEqual(distinct.items.map((row) => row.type), ["CustInvc", "CustPymt", "SalesOrd"]);
|
|
486
|
+
|
|
487
|
+
const having = (await sql(
|
|
488
|
+
"SELECT tl.item, SUM(tl.quantity) AS qty FROM transactionLine tl GROUP BY tl.item HAVING SUM(tl.quantity) > 100 ORDER BY qty DESC",
|
|
489
|
+
)).json;
|
|
490
|
+
assert.ok(having.items.length >= 1);
|
|
491
|
+
|
|
492
|
+
const left = (await sql(
|
|
493
|
+
"SELECT s.name, e.entityid FROM subsidiary s LEFT JOIN employee e ON e.subsidiary = s.id ORDER BY s.id, e.id",
|
|
494
|
+
)).json;
|
|
495
|
+
assert.equal(left.items.length, 3);
|
|
496
|
+
|
|
497
|
+
const paged = (await sql("SELECT id FROM customer ORDER BY id", undefined)).json;
|
|
498
|
+
assert.equal(paged.count, 10, "the SuiteQL default limit is 10");
|
|
499
|
+
assert.equal(paged.hasMore, true);
|
|
500
|
+
|
|
501
|
+
await problem("POST", SUITEQL, 400, "INVALID_REQUEST", { body: { q: "SELECT id FROM customer" } }); // no Prefer header
|
|
502
|
+
await problem("POST", SUITEQL, 400, "INVALID_REQUEST", { body: { q: "DELETE FROM customer" }, headers: PREFER });
|
|
503
|
+
await problem("POST", SUITEQL, 400, "INVALID_REQUEST", { body: { q: "SELECT id FROM nosuchtable" }, headers: PREFER });
|
|
504
|
+
await problem("POST", SUITEQL, 400, "INVALID_REQUEST", { body: { q: "SELECT nosuchcolumn FROM customer" }, headers: PREFER });
|
|
505
|
+
await problem("POST", SUITEQL, 400, "INVALID_REQUEST", { body: { q: "SELECT id FROM customer UNION SELECT id FROM item" }, headers: PREFER });
|
|
506
|
+
await problem("POST", SUITEQL, 400, "INVALID_REQUEST", { body: { q: "" }, headers: PREFER });
|
|
507
|
+
await problem("POST", `${SUITEQL}?limit=abc`, 400, "INVALID_PARAMETER", { body: { q: "SELECT id FROM customer" }, headers: PREFER });
|
|
508
|
+
|
|
509
|
+
const run = await ok("suiteql.run", { query: "SELECT COUNT(*) AS n FROM customer" });
|
|
510
|
+
assert.equal(run.items[0].n, "12");
|
|
511
|
+
assert.equal(run.hasMore, false);
|
|
512
|
+
await failsWith("suiteql.run", { query: "UPDATE customer SET email = 'x'" }, "INVALID_REQUEST");
|
|
513
|
+
|
|
514
|
+
const record = await ok("record.get", { recordType: "salesOrder", recordId: "5001" });
|
|
515
|
+
assert.equal(record.tranId, "SO1001");
|
|
516
|
+
assert.equal(record.item.totalResults, 3, "the MCP-shaped read expands sublists");
|
|
517
|
+
const subsidiary = await ok("record.get", { recordType: "subsidiary", recordId: "2" });
|
|
518
|
+
assert.equal(subsidiary.name, "Whitlock Instruments Canada");
|
|
519
|
+
await failsWith("record.get", { recordType: "nosuch", recordId: "1" }, "INVALID_CONTENT");
|
|
520
|
+
await failsWith("record.get", { recordType: "customer", recordId: "abc" }, "INVALID_ID");
|
|
521
|
+
await failsWith("record.get", { recordType: "customer", recordId: "999999" }, "NONEXISTENT_ID");
|
|
522
|
+
|
|
523
|
+
const catalog = (await get(`${REC}/metadata-catalog`)).json;
|
|
524
|
+
assert.equal(catalog.totalResults, 6);
|
|
525
|
+
assert.deepEqual(catalog.items.map((row) => row.name), ["customer", "salesOrder", "invoice", "customerPayment", "inventoryItem", "subsidiary"]);
|
|
526
|
+
const schema = (await get(`${REC}/metadata-catalog/invoice`)).json;
|
|
527
|
+
assert.equal(schema["x-ns-type"], "invoice");
|
|
528
|
+
assert.equal(schema.properties.amountRemaining["x-ns-filterable"], true);
|
|
529
|
+
assert.ok(schema.required.includes("tranId"));
|
|
530
|
+
assert.equal(typeof schema["x-ns-serverTime"], "string");
|
|
531
|
+
await problem("GET", `${REC}/metadata-catalog/bogus`, 400, "INVALID_CONTENT");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The A/R clerk may bill and collect but may not sell or delete. */
|
|
535
|
+
async function roleArClerk() {
|
|
536
|
+
await problem("POST", `${REC}/salesOrder`, 403, "INSUFFICIENT_PERMISSION", {
|
|
537
|
+
body: { entity: { id: "1001" }, item: { items: [{ item: { id: "2001" }, quantity: 1 }] } },
|
|
538
|
+
});
|
|
539
|
+
await problem("DELETE", `${REC}/customer/1012`, 403, "INSUFFICIENT_PERMISSION");
|
|
540
|
+
assert.equal((await get(`${REC}/salesOrder/5001`)).json.tranId, "SO1001", "view level is enough to read orders");
|
|
541
|
+
await api("PATCH", `${REC}/customer/1012`, { status: 204, body: { phone: "+1 206-555-0190" } });
|
|
542
|
+
await api("POST", `${REC}/invoice/5033/${encodeURIComponent("!transform")}/customerPayment`, { status: 204, body: { payment: 100 } });
|
|
543
|
+
assert.equal((await get(`${REC}/invoice/5033`)).json.amountPaid, 100);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The sales rep may sell, but invoices, payments and SuiteQL are closed to that role. */
|
|
547
|
+
async function roleSalesRep() {
|
|
548
|
+
await problem("GET", `${REC}/invoice`, 403, "INSUFFICIENT_PERMISSION");
|
|
549
|
+
await problem("GET", `${REC}/customerPayment`, 403, "INSUFFICIENT_PERMISSION");
|
|
550
|
+
await problem("POST", SUITEQL, 403, "INSUFFICIENT_PERMISSION", { body: { q: "SELECT id FROM customer" }, headers: PREFER });
|
|
551
|
+
const created = await api("POST", `${REC}/salesOrder`, {
|
|
552
|
+
status: 204,
|
|
553
|
+
body: { entity: { id: "1001" }, item: { items: [{ item: { id: "2001" }, quantity: 1 }] } },
|
|
554
|
+
});
|
|
555
|
+
assert.ok(created.headers.get("location").startsWith(`${REC}/salesOrder/`));
|
|
556
|
+
await problem("POST", `${REC}/salesOrder/5004/${encodeURIComponent("!transform")}/invoice`, 403, "INSUFFICIENT_PERMISSION", { body: {} });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A role restricted to subsidiary 2 sees only subsidiary-2 records. */
|
|
560
|
+
async function subsidiaryScope() {
|
|
561
|
+
await problem("GET", `${REC}/customer/1001`, 404, "NONEXISTENT_ID", {});
|
|
562
|
+
assert.equal((await get(`${REC}/customer/1006`)).json.entityId, "C1006 Émile Thibault Instruments");
|
|
563
|
+
const customers = (await get(`${REC}/customer`)).json;
|
|
564
|
+
assert.deepEqual(customers.items.map((row) => row.id), ["1006", "1007"]);
|
|
565
|
+
const orders = (await get(`${REC}/salesOrder`)).json;
|
|
566
|
+
assert.deepEqual(orders.items.map((row) => row.id), ["5005"]);
|
|
567
|
+
const items = (await get(`${REC}/inventoryItem`)).json;
|
|
568
|
+
assert.ok(items.items.some((row) => row.id === "2005"), "the subsidiary-2-only item is visible here");
|
|
569
|
+
assert.ok(!items.items.some((row) => row.id === "2003"), "a subsidiary-1-only item is not");
|
|
570
|
+
const rows = (await sql("SELECT id FROM customer ORDER BY id")).json;
|
|
571
|
+
assert.deepEqual(rows.items.map((row) => row.id), ["1006", "1007"], "SuiteQL is filtered by the same scope");
|
|
572
|
+
const subsidiaries = (await get(`${REC}/subsidiary`)).json;
|
|
573
|
+
assert.deepEqual(subsidiaries.items.map((row) => row.id), ["2"]);
|
|
574
|
+
await problem("POST", `${REC}/invoice`, 400, "INVALID_KEY_OR_REF", {
|
|
575
|
+
body: { entity: { id: "1001" }, item: { items: [{ item: { id: "2005" }, quantity: 1 }] } },
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** An actor with no NetSuite attributes acts as the seeded account's administrator. */
|
|
580
|
+
async function defaultIdentity() {
|
|
581
|
+
assert.equal((await get(`${REC}/customer?limit=1`)).json.totalResults, 12);
|
|
582
|
+
const created = await api("POST", `${REC}/customer`, { status: 204, body: { companyName: "Quiet Harbour Metrology" } });
|
|
583
|
+
const id = created.headers.get("location").split("/").pop();
|
|
584
|
+
const row = (await get(`${REC}/customer/${id}`)).json;
|
|
585
|
+
assert.equal(row.subsidiary.id, "1", "the account's default subsidiary is used");
|
|
586
|
+
assert.equal(row.currency.refName, "USD");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Without operation grants the framework denies the call before the handler runs. */
|
|
590
|
+
async function denied() {
|
|
591
|
+
const outcome = await call("customer.list", {});
|
|
592
|
+
assert.equal(outcome.status, "denied", JSON.stringify(outcome).slice(0, 300));
|
|
593
|
+
assert.equal(outcome.error.code, "world.OPERATION_DENIED");
|
|
594
|
+
const write = await call("customer.create", { body: { companyName: "Never Created" } });
|
|
595
|
+
assert.equal(write.status, "denied");
|
|
596
|
+
// The provider-shaped route must answer a world denial with a coherent 403 envelope that quotes no
|
|
597
|
+
// framework wording and no internal operation identifier.
|
|
598
|
+
const body = await problem("GET", `${REC}/customer`, 403, "INSUFFICIENT_PERMISSION");
|
|
599
|
+
const detail = body["o:errorDetails"][0].detail;
|
|
600
|
+
assert.ok(detail.startsWith("Permission Violation:"), detail);
|
|
601
|
+
assert.ok(!detail.includes("netsuite."), detail);
|
|
602
|
+
assert.ok(!detail.includes("actor"), detail);
|
|
603
|
+
await problem("POST", `${REC}/customer`, 403, "INSUFFICIENT_PERMISSION", {
|
|
604
|
+
body: { companyName: "Never Created" },
|
|
605
|
+
headers: { "x-netsuite-idempotency-key": "6f0c0d2e-4a3b-4c1d-9e2f-8a7b6c5d4e3f" },
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** A token issued for another account fails every operation with 401 INVALID_LOGIN. */
|
|
610
|
+
async function invalidLogin() {
|
|
611
|
+
for (const [operationId, args] of ALL_OPERATIONS) {
|
|
612
|
+
const error = await failsWith(operationId, args, "INVALID_LOGIN");
|
|
613
|
+
assert.ok(error.message.startsWith("Invalid login attempt."), error.message);
|
|
614
|
+
}
|
|
615
|
+
await problem("GET", `${REC}/customer`, 401, "INVALID_LOGIN");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** A role with no permissions fails every operation with 403 INSUFFICIENT_PERMISSION. */
|
|
619
|
+
async function insufficientPermission() {
|
|
620
|
+
for (const [operationId, args] of ALL_OPERATIONS) {
|
|
621
|
+
await failsWith(operationId, args, "INSUFFICIENT_PERMISSION");
|
|
622
|
+
}
|
|
623
|
+
await problem("GET", `${REC}/customer`, 403, "INSUFFICIENT_PERMISSION");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** The concurrency-limit fault blocks every operation with 429 before the handler runs. */
|
|
627
|
+
async function concurrency() {
|
|
628
|
+
for (const [operationId, args] of ALL_OPERATIONS) {
|
|
629
|
+
await failsWith(operationId, args, "CONCURRENCY_LIMIT_EXCEEDED");
|
|
630
|
+
}
|
|
631
|
+
const response = await api("GET", `${REC}/customer`, { status: 429 });
|
|
632
|
+
assert.equal(response.headers.get("retry-after"), "5");
|
|
633
|
+
assert.equal(response.json.title, "Bad Request", "NetSuite really does send this title with 429");
|
|
634
|
+
assert.equal(response.json["o:errorCode"], "USER_ERROR");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** A write outage fails every write and moves no state; reads keep working. */
|
|
638
|
+
async function writeOutage() {
|
|
639
|
+
for (const [operationId, args] of ALL_OPERATIONS) {
|
|
640
|
+
if (!WRITE_OPERATIONS.has(operationId)) continue;
|
|
641
|
+
await failsWith(operationId, args, "UNEXPECTED_ERROR");
|
|
642
|
+
}
|
|
643
|
+
assert.equal((await get(`${REC}/customer`)).json.totalResults, 12, "reads keep working");
|
|
644
|
+
assert.equal((await get(`${REC}/invoice`)).json.totalResults, 11, "no invoice was written");
|
|
645
|
+
await problem("POST", `${REC}/customer`, 500, "UNEXPECTED_ERROR", { body: { companyName: "Never Created" } });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** A concurrent change blocks every edit and transform with 409. */
|
|
649
|
+
async function recordLocked() {
|
|
650
|
+
for (const [operationId, args] of ALL_OPERATIONS) {
|
|
651
|
+
if (!LOCKABLE_OPERATIONS.has(operationId)) continue;
|
|
652
|
+
await failsWith(operationId, args, "RCRD_HAS_BEEN_CHANGED");
|
|
653
|
+
}
|
|
654
|
+
assert.equal((await get(`${REC}/invoice`)).json.totalResults, 11, "no invoice was written");
|
|
655
|
+
await problem("PATCH", `${REC}/customer/1001`, 409, "RCRD_HAS_BEEN_CHANGED", { body: { phone: "x" } });
|
|
656
|
+
await api("POST", `${REC}/customer`, { status: 204, body: { companyName: "Still Writable" } });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** The transform commits but its response is lost: the caller sees 500 while the record exists. */
|
|
660
|
+
async function transformLost() {
|
|
661
|
+
await failsWith("sales-order.transform", { recordId: "5004", target: "invoice", transformMarker: "!transform", body: {} }, "UNEXPECTED_ERROR");
|
|
662
|
+
assert.equal((await get(`${REC}/invoice`)).json.totalResults, 12, "the invoice was committed anyway");
|
|
663
|
+
assert.equal((await get(`${REC}/salesOrder/5004`)).json.status.refName, "Billed");
|
|
664
|
+
await failsWith("invoice.transform", { recordId: "5033", target: "customerPayment", transformMarker: "!transform", body: {} }, "UNEXPECTED_ERROR");
|
|
665
|
+
assert.equal((await get(`${REC}/customerPayment`)).json.totalResults, 6, "the payment was committed anyway");
|
|
666
|
+
await problem("POST", `${REC}/salesOrder/5004/${encodeURIComponent("!transform")}/invoice`, 400, "USER_ERROR", { body: {} });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Tight scan and page limits fail every scanning operation rather than truncating a result. */
|
|
670
|
+
async function tightLimits() {
|
|
671
|
+
for (const [operationId, args] of ALL_OPERATIONS) {
|
|
672
|
+
if (SCANNING_OPERATIONS.has(operationId)) await failsWith(operationId, args, "RESULT_SET_TOO_LARGE");
|
|
673
|
+
else await ok(operationId, args);
|
|
674
|
+
}
|
|
675
|
+
await problem("GET", `${REC}/customer`, 400, "RESULT_SET_TOO_LARGE");
|
|
676
|
+
assert.equal((await get(`${REC}/inventoryItem/2001`)).json.itemId, "HX-2", "a read that never scans still works");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ---------------------------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
const flows = {
|
|
682
|
+
records,
|
|
683
|
+
"order-to-cash": orderToCash,
|
|
684
|
+
analytics,
|
|
685
|
+
"role-ar-clerk": roleArClerk,
|
|
686
|
+
"role-sales-rep": roleSalesRep,
|
|
687
|
+
"subsidiary-scope": subsidiaryScope,
|
|
688
|
+
"default-identity": defaultIdentity,
|
|
689
|
+
denied,
|
|
690
|
+
"invalid-login": invalidLogin,
|
|
691
|
+
"insufficient-permission": insufficientPermission,
|
|
692
|
+
concurrency,
|
|
693
|
+
"write-outage": writeOutage,
|
|
694
|
+
"record-locked": recordLocked,
|
|
695
|
+
"transform-lost": transformLost,
|
|
696
|
+
"tight-limits": tightLimits,
|
|
697
|
+
};
|
|
698
|
+
const selected = Object.keys(flows).find((name) => instruction.includes(`the ${name} conformance flow`));
|
|
699
|
+
if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
|
|
700
|
+
await flows[selected]();
|
|
701
|
+
process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
|