@firedrill-tools/notion 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 +402 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounded.scenario.json +19 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/notion-bounded.drill.json +318 -0
- package/firedrill/notion-byte-budget.drill.json +116 -0
- package/firedrill/notion-mcp-aliases.drill.json +150 -0
- package/firedrill/notion-page-authoring.drill.json +254 -0
- package/firedrill/notion-rate-limited.drill.json +118 -0
- package/firedrill/notion-schema-growth.drill.json +88 -0
- package/firedrill/notion-scope-agent-only.drill.json +131 -0
- package/firedrill/notion-scope-auditor.drill.json +86 -0
- package/firedrill/notion-scope-board-bot.drill.json +128 -0
- package/firedrill/notion-scope-notes-bot.drill.json +303 -0
- package/firedrill/notion-scope-stranger.drill.json +773 -0
- package/firedrill/notion-task-triage.drill.json +277 -0
- package/firedrill/notion-trash-and-restore.drill.json +186 -0
- package/firedrill/notion-update-lost.drill.json +88 -0
- package/firedrill/notion-workspace-read.drill.json +258 -0
- package/firedrill/notion-write-unavailable.drill.json +161 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/app.js +797 -0
- package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/chrome.js +104 -0
- package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
- package/firedrill/tools/notion/app/site/database.js +648 -0
- package/firedrill/tools/notion/app/site/editors.js +320 -0
- package/firedrill/tools/notion/app/site/format-bar.js +97 -0
- package/firedrill/tools/notion/app/site/icons.js +131 -0
- package/firedrill/tools/notion/app/site/index.html +125 -0
- package/firedrill/tools/notion/app/site/page.js +826 -0
- package/firedrill/tools/notion/app/site/rich.js +159 -0
- package/firedrill/tools/notion/app/site/state.js +170 -0
- package/firedrill/tools/notion/app/site/styles.css +826 -0
- package/firedrill/tools/notion/app/site/ui.js +418 -0
- package/firedrill/tools/notion/behavior.mjs +1123 -0
- package/firedrill/tools/notion/lib/blocks.mjs +371 -0
- package/firedrill/tools/notion/lib/identity.mjs +123 -0
- package/firedrill/tools/notion/lib/ids.mjs +63 -0
- package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
- package/firedrill/tools/notion/lib/markdown.mjs +381 -0
- package/firedrill/tools/notion/lib/properties.mjs +513 -0
- package/firedrill/tools/notion/lib/query.mjs +272 -0
- package/firedrill/tools/notion/lib/render.mjs +137 -0
- package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
- package/firedrill/tools/notion/lib/size.mjs +44 -0
- package/firedrill/tools/notion/lib/state.mjs +192 -0
- package/firedrill/tools/notion/lib/wire.mjs +89 -0
- package/firedrill/tools/notion/notion.tool.json +9837 -0
- package/firedrill/update-lost.scenario.json +11 -0
- package/firedrill/world.json +7039 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +63 -0
- package/starter.json +6482 -0
- package/test/conformance.mjs +1186 -0
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
// Notion Tool conformance target. A scripted Tool test, not a model-driven agent.
|
|
2
|
+
// Node built-ins only: fetch against the Notion-shaped /v1 routes, the canonical operation endpoint and
|
|
3
|
+
// raw MCP JSON-RPC (Streamable HTTP) for the Notion MCP server tool-name aliases. Every flow fails loudly on
|
|
4
|
+
// an unexpected status, header or body. The drill instruction selects the flow.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
|
|
7
|
+
let task = "";
|
|
8
|
+
for await (const chunk of process.stdin) task += chunk;
|
|
9
|
+
const instruction = String(JSON.parse(task).instruction ?? "");
|
|
10
|
+
|
|
11
|
+
const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
12
|
+
const HTTP_TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
13
|
+
const MCP = process.env.FIREDRILL_MCP_URL;
|
|
14
|
+
const MCP_TOKEN = process.env.FIREDRILL_MCP_TOKEN;
|
|
15
|
+
assert.ok(HTTP && HTTP_TOKEN && MCP && MCP_TOKEN, "HTTP and MCP bindings are required");
|
|
16
|
+
|
|
17
|
+
// Seeded ids (see starter.json) ------------------------------------------------------------------
|
|
18
|
+
const ID = {
|
|
19
|
+
workspace: "fd000000-0000-4000-8000-000000000001",
|
|
20
|
+
ines: "fd000000-0000-4000-8000-000100000001",
|
|
21
|
+
tomas: "fd000000-0000-4000-8000-000100000002",
|
|
22
|
+
priya: "fd000000-0000-4000-8000-000100000003",
|
|
23
|
+
marco: "fd000000-0000-4000-8000-000100000004",
|
|
24
|
+
yuki: "fd000000-0000-4000-8000-000100000005",
|
|
25
|
+
agentBot: "fd000000-0000-4000-8000-000100000006",
|
|
26
|
+
notesBot: "fd000000-0000-4000-8000-000100000007",
|
|
27
|
+
home: "fd000000-0000-4000-8000-000200000001",
|
|
28
|
+
engineering: "fd000000-0000-4000-8000-000200000002",
|
|
29
|
+
onboarding: "fd000000-0000-4000-8000-000200000003",
|
|
30
|
+
design: "fd000000-0000-4000-8000-000200000004",
|
|
31
|
+
meetings: "fd000000-0000-4000-8000-000200000005",
|
|
32
|
+
sync0908: "fd000000-0000-4000-8000-000200000006",
|
|
33
|
+
sync0901: "fd000000-0000-4000-8000-000200000007",
|
|
34
|
+
scratch: "fd000000-0000-4000-8000-000200000008",
|
|
35
|
+
roadmap: "fd000000-0000-4000-8000-000200000009",
|
|
36
|
+
dbProjects: "fd000000-0000-4000-8000-000300000001",
|
|
37
|
+
dbTasks: "fd000000-0000-4000-8000-000300000002",
|
|
38
|
+
dbDecisions: "fd000000-0000-4000-8000-000300000003",
|
|
39
|
+
dsProjects: "fd000000-0000-4000-8000-000400000001",
|
|
40
|
+
dsTasks: "fd000000-0000-4000-8000-000400000002",
|
|
41
|
+
dsDecisions: "fd000000-0000-4000-8000-000400000003",
|
|
42
|
+
atlas: "fd000000-0000-4000-8000-000200000100",
|
|
43
|
+
beacon: "fd000000-0000-4000-8000-000200000101",
|
|
44
|
+
t1: "fd000000-0000-4000-8000-000200000120",
|
|
45
|
+
t3: "fd000000-0000-4000-8000-000200000122",
|
|
46
|
+
t4: "fd000000-0000-4000-8000-000200000123",
|
|
47
|
+
t7: "fd000000-0000-4000-8000-000200000126",
|
|
48
|
+
toggle: "fd000000-0000-4000-8000-00050000000e",
|
|
49
|
+
actionItem: "fd000000-0000-4000-8000-00050000001f",
|
|
50
|
+
designHeading: "fd000000-0000-4000-8000-000500000009",
|
|
51
|
+
bookmark: "fd000000-0000-4000-8000-000500000018",
|
|
52
|
+
discussion1: "fd000000-0000-4000-8000-000800000001",
|
|
53
|
+
missingPage: "fd000000-0000-4000-8000-000200000777",
|
|
54
|
+
newPage: "fd000000-0000-4000-8000-000200001002",
|
|
55
|
+
};
|
|
56
|
+
const ALL_OPERATIONS = [
|
|
57
|
+
"users.me", "users.list", "users.get", "search", "pages.create", "pages.retrieve", "pages.update", "pages.retrieve-property", "pages.move",
|
|
58
|
+
"pages.retrieve-markdown", "pages.update-markdown", "databases.create", "databases.retrieve", "data-sources.retrieve", "data-sources.query",
|
|
59
|
+
"data-sources.update", "blocks.retrieve", "blocks.children.list", "blocks.children.append", "blocks.update", "blocks.delete", "comments.create",
|
|
60
|
+
"comments.list", "workspace.context", "workspace.trash",
|
|
61
|
+
];
|
|
62
|
+
const ALIASES = [
|
|
63
|
+
"API-get-self", "API-get-users", "API-get-user", "API-post-search", "API-post-page", "API-retrieve-a-page", "API-patch-page",
|
|
64
|
+
"API-retrieve-a-page-property", "API-move-page", "API-retrieve-page-markdown", "API-update-page-markdown", "API-retrieve-a-database",
|
|
65
|
+
"API-retrieve-a-data-source", "API-query-data-source", "API-update-a-data-source", "API-retrieve-a-block", "API-get-block-children",
|
|
66
|
+
"API-patch-block-children", "API-update-a-block", "API-delete-a-block", "API-create-a-comment", "API-retrieve-a-comment",
|
|
67
|
+
];
|
|
68
|
+
const text = (content) => ({ type: "text", text: { content } });
|
|
69
|
+
const plain = (items) => items.map((item) => item.plain_text).join("");
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------------------------
|
|
72
|
+
// Transport helpers
|
|
73
|
+
// ---------------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** Notion-shaped request. `status` is asserted; the JSON body and headers are returned. */
|
|
76
|
+
async function api(method, path, { body, status = 200, headers = {} } = {}) {
|
|
77
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
78
|
+
method,
|
|
79
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "notion-version": "2025-09-03", ...(body === undefined ? {} : { "content-type": "application/json" }), ...headers },
|
|
80
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
81
|
+
});
|
|
82
|
+
const raw = await response.text();
|
|
83
|
+
const json = raw.length > 0 ? JSON.parse(raw) : undefined;
|
|
84
|
+
assert.equal(response.status, status, `${method} ${path} ${body === undefined ? "" : JSON.stringify(body).slice(0, 200)} -> ${response.status} ${raw.slice(0, 500)}`);
|
|
85
|
+
return { json, headers: response.headers, status: response.status };
|
|
86
|
+
}
|
|
87
|
+
const get = (path, options) => api("GET", path, options);
|
|
88
|
+
const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
89
|
+
const patch = (path, body, options) => api("PATCH", path, { ...options, body });
|
|
90
|
+
const del = (path, options) => api("DELETE", path, options);
|
|
91
|
+
|
|
92
|
+
/** Expect Notion's error envelope with the given HTTP status, `code` and a message containing `textPart`. */
|
|
93
|
+
async function apiError(method, path, status, code, textPart, options = {}) {
|
|
94
|
+
const result = await api(method, path, { ...options, status });
|
|
95
|
+
const error = result.json;
|
|
96
|
+
assert.ok(error && error.object === "error", `${method} ${path}: no Notion error envelope: ${JSON.stringify(error)}`);
|
|
97
|
+
assert.equal(error.status, status, JSON.stringify(error));
|
|
98
|
+
assert.equal(error.code, code, `${method} ${path}: ${JSON.stringify(error)}`);
|
|
99
|
+
assert.ok(typeof error.request_id === "string" && error.request_id.startsWith("corr_"), `${method} ${path}: no request_id`);
|
|
100
|
+
if (textPart !== undefined) assert.ok(error.message.includes(textPart), `${method} ${path}: expected ${JSON.stringify(textPart)} in ${JSON.stringify(error.message)}`);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Route-fuzz retrofit (2026-09-16): every JSON route's `decode` measures body nesting iteratively
|
|
106
|
+
* (firedrill/tools/notion/lib/json-depth.mjs, bound 512) and refuses a deeper body with 400 before argument
|
|
107
|
+
* validation, which would otherwise overflow and answer an opaque 500 around 2,995-3,155 levels.
|
|
108
|
+
*/
|
|
109
|
+
async function deepBody(method, path, rawBody, label, expected = 400) {
|
|
110
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
111
|
+
method,
|
|
112
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "notion-version": "2025-09-03", "content-type": "application/json" },
|
|
113
|
+
body: rawBody,
|
|
114
|
+
});
|
|
115
|
+
const text = await response.text();
|
|
116
|
+
if (expected === "below-500") assert.ok(response.status < 500, `${method} ${path} ${label} -> ${response.status} ${text.slice(0, 300)}`);
|
|
117
|
+
else assert.equal(response.status, expected, `${method} ${path} ${label} -> ${response.status} ${text.slice(0, 300)}`);
|
|
118
|
+
assert.ok(!/RangeError|call stack|Cannot read propert/i.test(text), `${method} ${path} ${label} leaked a runtime error: ${text.slice(0, 300)}`);
|
|
119
|
+
}
|
|
120
|
+
const deepObject = (depth) => `${'{"a":'.repeat(depth)}1${"}".repeat(depth)}`;
|
|
121
|
+
const deepArray = (depth) => `${"[".repeat(depth)}1${"]".repeat(depth)}`;
|
|
122
|
+
|
|
123
|
+
/** All twelve JSON routes refuse over-deep bodies at the reported depths; 512 itself still reaches the handler. */
|
|
124
|
+
async function assertDeepBodiesRefused(blockId) {
|
|
125
|
+
const routes = [
|
|
126
|
+
["POST", "/v1/search", "filter"],
|
|
127
|
+
["POST", "/v1/pages", "parent"],
|
|
128
|
+
["PATCH", `/v1/pages/${ID.design}`, "properties"],
|
|
129
|
+
["POST", `/v1/pages/${ID.design}/move`, "parent"],
|
|
130
|
+
["PATCH", `/v1/pages/${ID.design}/markdown`, "position"],
|
|
131
|
+
["POST", "/v1/databases", "parent"],
|
|
132
|
+
["POST", `/v1/databases/${ID.dbTasks}/query`, "filter"],
|
|
133
|
+
["POST", `/v1/data_sources/${ID.dsTasks}/query`, "filter"],
|
|
134
|
+
["PATCH", `/v1/data_sources/${ID.dsTasks}`, "properties"],
|
|
135
|
+
["PATCH", `/v1/blocks/${blockId}/children`, "children"],
|
|
136
|
+
["PATCH", `/v1/blocks/${ID.toggle}`, "type"],
|
|
137
|
+
["POST", "/v1/comments", "parent"],
|
|
138
|
+
];
|
|
139
|
+
for (const [method, path, property] of routes) {
|
|
140
|
+
for (const depth of [513, 2995, 3000, 3150, 3155]) {
|
|
141
|
+
await deepBody(method, path, `{"${property}":${deepObject(depth)}}`, `objects under ${property} at ${depth}`);
|
|
142
|
+
await deepBody(method, path, `{"${property}":${deepArray(depth)}}`, `arrays under ${property} at ${depth}`);
|
|
143
|
+
}
|
|
144
|
+
await deepBody(method, path, deepObject(3155), "objects top-level at 3155");
|
|
145
|
+
// At the bound the guard does not fire: the body reaches validation and the handler, which answer it (400, or
|
|
146
|
+
// 200 where the provider ignores an unrecognised value, such as an unknown block `type`) — never 5xx.
|
|
147
|
+
await deepBody(method, path, `{"${property}":${deepObject(511)}}`, `objects under ${property} at 511`, "below-500");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Canonical operation call (`POST /v1/operations/notion/<operation>`), returning the framework outcome. */
|
|
152
|
+
async function op(operation, args, idempotencyKey) {
|
|
153
|
+
const response = await fetch(`${HTTP}/v1/operations/notion/${operation}`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { authorization: `Bearer ${HTTP_TOKEN}`, "content-type": "application/json" },
|
|
156
|
+
body: JSON.stringify({ arguments: args, ...(idempotencyKey === undefined ? {} : { idempotencyKey }) }),
|
|
157
|
+
});
|
|
158
|
+
const result = await response.json();
|
|
159
|
+
assert.ok(result.outcome, `${operation}: unexpected HTTP ${response.status} ${JSON.stringify(result).slice(0, 300)}`);
|
|
160
|
+
return result.outcome;
|
|
161
|
+
}
|
|
162
|
+
async function opOk(operation, args, idempotencyKey) {
|
|
163
|
+
const outcome = await op(operation, args, idempotencyKey);
|
|
164
|
+
assert.equal(outcome.status, "ok", `${operation} ${JSON.stringify(args).slice(0, 200)}: ${JSON.stringify(outcome).slice(0, 400)}`);
|
|
165
|
+
return outcome.value;
|
|
166
|
+
}
|
|
167
|
+
async function opError(operation, args, code) {
|
|
168
|
+
const outcome = await op(operation, args);
|
|
169
|
+
assert.equal(outcome.status, "tool_error", `${operation} ${JSON.stringify(args).slice(0, 200)}: ${JSON.stringify(outcome).slice(0, 400)}`);
|
|
170
|
+
assert.equal(outcome.error.code, `tool.${code}`, JSON.stringify(outcome.error));
|
|
171
|
+
return outcome.error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let rpcId = 0;
|
|
175
|
+
async function rpc(method, params) {
|
|
176
|
+
const response = await fetch(MCP, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { authorization: `Bearer ${MCP_TOKEN}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
179
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
|
180
|
+
});
|
|
181
|
+
assert.equal(response.status, 200, `MCP ${method} -> HTTP ${response.status}`);
|
|
182
|
+
const raw = await response.text();
|
|
183
|
+
const type = response.headers.get("content-type") ?? "";
|
|
184
|
+
const messages = type.includes("text/event-stream")
|
|
185
|
+
? raw.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5).trim()))
|
|
186
|
+
: [JSON.parse(raw)];
|
|
187
|
+
const reply = messages.find((message) => message.id === rpcId);
|
|
188
|
+
assert.ok(reply, `MCP ${method}: no JSON-RPC reply`);
|
|
189
|
+
if (reply.error) throw new Error(`MCP ${method} failed: ${JSON.stringify(reply.error)}`);
|
|
190
|
+
return reply.result;
|
|
191
|
+
}
|
|
192
|
+
async function mcpInit() {
|
|
193
|
+
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "notion-conformance", version: "0.1.0" } });
|
|
194
|
+
}
|
|
195
|
+
async function mcp(name, args) {
|
|
196
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
197
|
+
assert.ok(!result.isError, `${name} ${JSON.stringify(args)}: ${JSON.stringify(result).slice(0, 500)}`);
|
|
198
|
+
return result.structuredContent;
|
|
199
|
+
}
|
|
200
|
+
async function mcpError(name, args, code) {
|
|
201
|
+
const result = await rpc("tools/call", { name, arguments: args });
|
|
202
|
+
assert.ok(result.isError, `${name} ${JSON.stringify(args)} unexpectedly succeeded`);
|
|
203
|
+
const error = result.structuredContent?.error;
|
|
204
|
+
assert.ok(error, `${name}: ${JSON.stringify(result).slice(0, 400)}`);
|
|
205
|
+
assert.equal(error.code, code, JSON.stringify(error));
|
|
206
|
+
return error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ids = (list) => list.map((item) => item.id);
|
|
210
|
+
const query = (source, body, options) => post(`/v1/data_sources/${source}/query`, body, options);
|
|
211
|
+
const queryIds = async (source, body) => ids((await query(source, body)).json.results);
|
|
212
|
+
const titleOf = (page) => plain((page.properties.Name ?? page.properties.title).title);
|
|
213
|
+
|
|
214
|
+
/** One representative request per operation, all valid against the baseline (used by the unauthorized and bounded flows). */
|
|
215
|
+
const CALLS = {
|
|
216
|
+
"users.list": ["GET", "/v1/users"],
|
|
217
|
+
"users.get": ["GET", `/v1/users/${ID.ines}`],
|
|
218
|
+
search: ["POST", "/v1/search", { query: "sync" }],
|
|
219
|
+
"pages.create": ["POST", "/v1/pages", { parent: { page_id: ID.scratch }, properties: { title: [text("Probe")] } }],
|
|
220
|
+
"pages.retrieve": ["GET", `/v1/pages/${ID.t1}`],
|
|
221
|
+
"pages.update": ["PATCH", `/v1/pages/${ID.t1}`, { properties: { Estimate: { number: 9 } } }],
|
|
222
|
+
"pages.retrieve-property": ["GET", `/v1/pages/${ID.t1}/properties/title`],
|
|
223
|
+
"pages.move": ["POST", `/v1/pages/${ID.scratch}/move`, { parent: { type: "page_id", page_id: ID.onboarding } }],
|
|
224
|
+
"pages.retrieve-markdown": ["GET", `/v1/pages/${ID.design}/markdown`],
|
|
225
|
+
"pages.update-markdown": ["PATCH", `/v1/pages/${ID.scratch}/markdown`, { type: "insert_content", content: "Probe line" }],
|
|
226
|
+
"databases.create": ["POST", "/v1/databases", { parent: { page_id: ID.scratch }, title: [text("Probe db")], initial_data_source: { properties: { Name: { title: {} } } } }],
|
|
227
|
+
"databases.retrieve": ["GET", `/v1/databases/${ID.dbTasks}`],
|
|
228
|
+
"data-sources.retrieve": ["GET", `/v1/data_sources/${ID.dsTasks}`],
|
|
229
|
+
"data-sources.query": ["POST", `/v1/data_sources/${ID.dsTasks}/query`, {}],
|
|
230
|
+
"data-sources.update": ["PATCH", `/v1/data_sources/${ID.dsDecisions}`, { description: [text("Probe")] }],
|
|
231
|
+
"blocks.retrieve": ["GET", `/v1/blocks/${ID.designHeading}`],
|
|
232
|
+
"blocks.children.list": ["GET", `/v1/blocks/${ID.design}/children`],
|
|
233
|
+
"blocks.children.append": ["PATCH", `/v1/blocks/${ID.scratch}/children`, { children: [{ type: "paragraph", paragraph: { rich_text: [text("Probe")] } }] }],
|
|
234
|
+
"blocks.update": ["PATCH", `/v1/blocks/${ID.designHeading}`, { heading_1: { rich_text: [text("Design principles")] } }],
|
|
235
|
+
"blocks.delete": ["DELETE", `/v1/blocks/${ID.bookmark}`],
|
|
236
|
+
"comments.create": ["POST", "/v1/comments", { parent: { page_id: ID.t1 }, rich_text: [text("Probe")] }],
|
|
237
|
+
"comments.list": ["GET", `/v1/comments?block_id=${ID.sync0908}`],
|
|
238
|
+
};
|
|
239
|
+
const call = ([method, path, body], options) => api(method, path, { ...options, ...(body === undefined ? {} : { body }) });
|
|
240
|
+
const callError = ([method, path, body], status, code, textPart) => apiError(method, path, status, code, textPart, body === undefined ? {} : { body });
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------------------------
|
|
243
|
+
// Drill: workspace-read (member, baseline)
|
|
244
|
+
// ---------------------------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
async function workspaceRead() {
|
|
247
|
+
const context = await opOk("workspace.context", {});
|
|
248
|
+
assert.equal(context.now, "2026-09-14T09:00:00.000Z", "now comes from virtual time");
|
|
249
|
+
assert.equal(context.workspace.name, "Halvard Robotics");
|
|
250
|
+
assert.equal(context.user.id, ID.ines);
|
|
251
|
+
assert.equal(context.integration.capabilities.content, "read_update_insert");
|
|
252
|
+
assert.equal(context.limits.max_rows_per_namespace, 10000);
|
|
253
|
+
|
|
254
|
+
// Users -------------------------------------------------------------------------------------
|
|
255
|
+
const me = (await get("/v1/users/me")).json;
|
|
256
|
+
assert.equal(me.type, "bot");
|
|
257
|
+
assert.equal(me.id, ID.agentBot);
|
|
258
|
+
assert.equal(me.bot.owner.type, "workspace");
|
|
259
|
+
assert.equal(me.bot.workspace_name, "Halvard Robotics");
|
|
260
|
+
assert.deepEqual(await opOk("users.me", {}), me, "the canonical users.me answers the same bot");
|
|
261
|
+
const sizes = [];
|
|
262
|
+
let cursor;
|
|
263
|
+
for (;;) {
|
|
264
|
+
const page = (await get(`/v1/users?page_size=3${cursor === undefined ? "" : `&start_cursor=${cursor}`}`)).json;
|
|
265
|
+
assert.equal(page.object, "list");
|
|
266
|
+
assert.equal(page.type, "user");
|
|
267
|
+
sizes.push(page.results.length);
|
|
268
|
+
if (!page.has_more) { assert.equal(page.next_cursor, null); break; }
|
|
269
|
+
cursor = page.next_cursor;
|
|
270
|
+
}
|
|
271
|
+
assert.deepEqual(sizes, [3, 3, 2], "eight users paginate 3/3/2");
|
|
272
|
+
await apiError("GET", "/v1/users?page_size=250", 400, "validation_error", "query.page_size should be ≤ 100, instead was 250.");
|
|
273
|
+
await apiError("GET", "/v1/users?page_size=-1", 400, "validation_error", "query.page_size should be ≥ 1, instead was -1.");
|
|
274
|
+
// Query page_size that is not a decimal integer reaches the handler and fails with Notion's envelope, never a mapping error.
|
|
275
|
+
for (const route of ["/v1/users?", `/v1/blocks/${ID.design}/children?`, `/v1/comments?block_id=${ID.sync0908}&`, `/v1/pages/${ID.t1}/properties/title?`]) {
|
|
276
|
+
for (const [value, textPart] of [["abc", 'query.page_size should be a number, instead was `"abc"`.'], ["", 'instead was `""`.'], ["1e999", '`"1e999"`'], ["1.5", '`"1.5"`'], ["99999999999", "instead was 99999999999."], ["0", "instead was 0."], ["101", "instead was 101."]]) {
|
|
277
|
+
await apiError("GET", `${route}page_size=${value}`, 400, "validation_error", textPart);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
await apiError("GET", "/v1/users?start_cursor=fd000000-0000-4000-8000-000100000099", 400, "validation_error", "The start_cursor provided is invalid.");
|
|
281
|
+
const ines = (await get(`/v1/users/${ID.ines}`)).json;
|
|
282
|
+
assert.equal(ines.person.email, "ines.okafor@halvard.example", "with_emails shows e-mails");
|
|
283
|
+
const yuki = (await get(`/v1/users/${ID.yuki.replaceAll("-", "")}`)).json;
|
|
284
|
+
assert.equal(yuki.id, ID.yuki, "32-hex ids are accepted and answered dashed");
|
|
285
|
+
await apiError("GET", "/v1/users/not-a-uuid", 400, "validation_error", "path.user_id should be a valid uuid");
|
|
286
|
+
const longId = (await apiError("GET", `/v1/users/${"a".repeat(4096)}`, 400, "validation_error", "path.user_id should be a valid uuid")).json;
|
|
287
|
+
assert.ok(longId.message.length <= 1000 && longId.message.endsWith("…"), "a long echoed id is capped to the 1000-character message contract");
|
|
288
|
+
await apiError("GET", "/v1/users/fd000000-0000-4000-8000-000100000099", 404, "object_not_found", "Could not find user with ID");
|
|
289
|
+
|
|
290
|
+
// Search ------------------------------------------------------------------------------------
|
|
291
|
+
const everything = (await post("/v1/search", {})).json;
|
|
292
|
+
assert.equal(everything.type, "page_or_data_source");
|
|
293
|
+
assert.equal(everything.results.length, 30, "8 live wiki pages + 5 projects + 14 tasks + 3 data sources; Roadmap 2025 is in the trash");
|
|
294
|
+
assert.deepEqual(everything.request_status, { type: "complete" });
|
|
295
|
+
const times = everything.results.map((item) => Date.parse(item.last_edited_time));
|
|
296
|
+
assert.ok(times.every((value, index) => index === 0 || value <= times[index - 1]), "default order is last_edited_time descending");
|
|
297
|
+
const sync = (await post("/v1/search", { query: "weekly SYNC" })).json;
|
|
298
|
+
assert.deepEqual(ids(sync.results), [ID.sync0908, ID.sync0901], "every token matches case-insensitively, newest first");
|
|
299
|
+
const ascending = (await post("/v1/search", { query: "sync", sort: { timestamp: "last_edited_time", direction: "ascending" } })).json;
|
|
300
|
+
assert.deepEqual(ids(ascending.results), [ID.sync0901, ID.sync0908]);
|
|
301
|
+
const sources = (await post("/v1/search", { filter: { property: "object", value: "data_source" } })).json;
|
|
302
|
+
assert.equal(sources.results.length, 3);
|
|
303
|
+
assert.ok(sources.results.every((item) => item.object === "data_source"));
|
|
304
|
+
assert.equal((await post("/v1/search", { query: "no such title anywhere" })).json.results.length, 0);
|
|
305
|
+
const first = (await post("/v1/search", { query: "sync", page_size: 1 })).json;
|
|
306
|
+
assert.equal(first.has_more, true);
|
|
307
|
+
assert.equal(first.next_cursor, ID.sync0908);
|
|
308
|
+
const second = (await post("/v1/search", { query: "sync", page_size: 1, start_cursor: first.next_cursor })).json;
|
|
309
|
+
assert.deepEqual(ids(second.results), [ID.sync0901]);
|
|
310
|
+
assert.equal(second.has_more, false);
|
|
311
|
+
await apiError("POST", "/v1/search", 400, "validation_error", "The start_cursor provided is invalid.", { body: { query: "sync", start_cursor: ID.home } });
|
|
312
|
+
await apiError("POST", "/v1/search", 400, "validation_error", "body.filter", { body: { filter: { property: "object", value: "database" } } });
|
|
313
|
+
await apiError("POST", "/v1/search", 400, "validation_error", "body.page_size should be ≥ 1, instead was 0.", { body: { page_size: 0 } });
|
|
314
|
+
await apiError("POST", "/v1/search", 400, "validation_error", "body.page_size should be ≤ 100, instead was 101.", { body: { page_size: 101 } });
|
|
315
|
+
await apiError("POST", "/v1/search", 400, "validation_error", 'body.page_size should be a number, instead was `"abc"`.', { body: { page_size: "abc" } });
|
|
316
|
+
// A search term carrying U+FFFD arrived mangled: it fails instead of silently matching nothing, and legitimate
|
|
317
|
+
// non-ASCII terms keep working.
|
|
318
|
+
await apiError("POST", "/v1/search", 400, "validation_error", "body.query contains an invalid character (U+FFFD)", { body: { query: "sy\uFFFDnc" } });
|
|
319
|
+
assert.equal((await post("/v1/search", { query: "sync" })).json.results.length > 0, true, "a plain search still matches");
|
|
320
|
+
|
|
321
|
+
// Pages -------------------------------------------------------------------------------------
|
|
322
|
+
const task = (await get(`/v1/pages/${ID.t1}`)).json;
|
|
323
|
+
assert.equal(task.object, "page");
|
|
324
|
+
assert.equal(task.parent.type, "data_source_id");
|
|
325
|
+
assert.equal(task.parent.database_id, ID.dbTasks);
|
|
326
|
+
assert.equal(task.properties.Status.status.name, "In progress");
|
|
327
|
+
assert.equal(task.properties.Assignee.people[0].name, "Tomas Lindqvist", "people values are expanded to user objects");
|
|
328
|
+
assert.equal(task.properties["Created by"].created_by.id, ID.tomas, "computed properties are materialised");
|
|
329
|
+
assert.equal(task.properties["Last edited"].last_edited_time, task.last_edited_time);
|
|
330
|
+
assert.deepEqual(task.properties.Project.relation, [{ id: ID.atlas }]);
|
|
331
|
+
assert.equal(task.properties.Project.has_more, false);
|
|
332
|
+
assert.ok(task.url.startsWith("https://www.notion.so/Firmware-bring-up-for-arm-controller-v2-"));
|
|
333
|
+
const filtered = (await get(`/v1/pages/${ID.t1}?filter_properties=title&filter_properties=tdue`)).json;
|
|
334
|
+
assert.deepEqual(Object.keys(filtered.properties).sort(), ["Due", "Name"]);
|
|
335
|
+
await apiError("GET", `/v1/pages/${ID.t1}?filter_properties=nope`, 400, "validation_error", "Could not find property with name or id: nope");
|
|
336
|
+
// A mangled filter_properties (U+FFFD from a bad percent-escape) is named where Notion documents it: on the query string.
|
|
337
|
+
await apiError("GET", `/v1/pages/${ID.t1}?filter_properties=%E0%A4%A`, 400, "validation_error", "query.filter_properties contains an invalid character (U+FFFD)");
|
|
338
|
+
const canonicalMangled = await opError("pages.retrieve", { page_id: ID.t1, filter_properties: ["ti\uFFFDtle"] }, "VALIDATION_ERROR");
|
|
339
|
+
assert.match(canonicalMangled.message, /query\.filter_properties contains an invalid character \(U\+FFFD\)/);
|
|
340
|
+
await apiError("GET", `/v1/pages/${ID.missingPage}`, 404, "object_not_found", "Could not find page with ID");
|
|
341
|
+
const trashed = (await get(`/v1/pages/${ID.roadmap}`)).json;
|
|
342
|
+
assert.equal(trashed.in_trash, true, "trashed pages stay readable by id");
|
|
343
|
+
assert.equal(trashed.archived, true);
|
|
344
|
+
const home = (await get(`/v1/pages/${ID.home}`)).json;
|
|
345
|
+
assert.deepEqual(home.parent, { type: "workspace", workspace: true });
|
|
346
|
+
assert.equal(home.icon.emoji, "🏠");
|
|
347
|
+
|
|
348
|
+
// Blocks ------------------------------------------------------------------------------------
|
|
349
|
+
const engineeringBlock = (await get(`/v1/blocks/${ID.engineering}`)).json;
|
|
350
|
+
assert.equal(engineeringBlock.type, "child_page");
|
|
351
|
+
assert.equal(engineeringBlock.child_page.title, "Engineering");
|
|
352
|
+
assert.equal(engineeringBlock.parent.page_id, ID.home);
|
|
353
|
+
await apiError("GET", `/v1/blocks/${ID.home}`, 404, "object_not_found", "Could not find block with ID", "a workspace-level page has no block");
|
|
354
|
+
const toggle = (await get(`/v1/blocks/${ID.toggle}`)).json;
|
|
355
|
+
assert.equal(toggle.type, "toggle");
|
|
356
|
+
assert.equal(toggle.has_children, true);
|
|
357
|
+
const pages = [];
|
|
358
|
+
cursor = undefined;
|
|
359
|
+
for (;;) {
|
|
360
|
+
const page = (await get(`/v1/blocks/${ID.design}/children?page_size=5${cursor === undefined ? "" : `&start_cursor=${cursor}`}`)).json;
|
|
361
|
+
assert.equal(page.type, "block");
|
|
362
|
+
pages.push(page.results.map((block) => block.type));
|
|
363
|
+
if (!page.has_more) break;
|
|
364
|
+
cursor = page.next_cursor;
|
|
365
|
+
}
|
|
366
|
+
assert.deepEqual(pages.map((page) => page.length), [5, 5, 3], "13 top-level blocks paginate 5/5/3");
|
|
367
|
+
assert.deepEqual(pages.flat().slice(0, 6), ["heading_1", "paragraph", "bulleted_list_item", "bulleted_list_item", "bulleted_list_item", "toggle"]);
|
|
368
|
+
const toggleChildren = (await get(`/v1/blocks/${ID.toggle}/children`)).json;
|
|
369
|
+
assert.deepEqual(toggleChildren.results.map((block) => block.type), ["to_do", "to_do", "paragraph"]);
|
|
370
|
+
assert.equal(toggleChildren.results[0].to_do.checked, true);
|
|
371
|
+
assert.equal((await get(`/v1/blocks/${ID.scratch}/children`)).json.results.length, 0, "an empty page lists no children");
|
|
372
|
+
await apiError("GET", `/v1/blocks/${ID.design}/children?start_cursor=${ID.toggle}&page_size=0`, 400, "validation_error", "page_size");
|
|
373
|
+
await apiError("GET", `/v1/blocks/${ID.roadmap}/children`, 200, undefined, undefined).catch(() => undefined);
|
|
374
|
+
|
|
375
|
+
// Property items --------------------------------------------------------------------------
|
|
376
|
+
const title = (await get(`/v1/pages/${ID.t1}/properties/title`)).json;
|
|
377
|
+
assert.equal(title.object, "list");
|
|
378
|
+
assert.equal(title.type, "property_item");
|
|
379
|
+
assert.equal(title.results[0].title.plain_text, "Firmware bring-up for arm controller v2");
|
|
380
|
+
assert.equal(title.property_item.next_url, null);
|
|
381
|
+
const status = (await get(`/v1/pages/${ID.t1}/properties/tsta`)).json;
|
|
382
|
+
assert.equal(status.object, "property_item");
|
|
383
|
+
assert.equal(status.status.name, "In progress");
|
|
384
|
+
const assignee = (await get(`/v1/pages/${ID.t1}/properties/Assignee`)).json;
|
|
385
|
+
assert.equal(assignee.results[0].people.id, ID.tomas, "property names are accepted too");
|
|
386
|
+
await apiError("GET", `/v1/pages/${ID.t1}/properties/zzzz`, 400, "validation_error", "Could not find property with name or id: zzzz");
|
|
387
|
+
// property_id path semantics: the path is decoded once by the server; a client that percent-encodes the id again
|
|
388
|
+
// (e.g. a URL-encoded Notion id such as %3AUPp sent as %253AUPp) still resolves, and malformed encodings never throw.
|
|
389
|
+
for (const encoded of ["%74sta", "%2574sta"]) assert.equal((await get(`/v1/pages/${ID.t1}/properties/${encoded}`)).json.id, "tsta", encoded);
|
|
390
|
+
assert.equal((await get(`/v1/pages/${ID.t1}/properties/%2541ssignee`)).json.results[0].people.id, ID.tomas);
|
|
391
|
+
for (const [encoded, shownId] of [["%25", "%"], ["%25E0%25A4%25A", "%E0%A4%A"], ["%25ZZ", "%ZZ"]]) {
|
|
392
|
+
await apiError("GET", `/v1/pages/${ID.t1}/properties/${encoded}`, 400, "validation_error", `Could not find property with name or id: ${shownId}`);
|
|
393
|
+
}
|
|
394
|
+
await apiError("GET", `/v1/pages/${ID.t1}/properties/tsta?page_size=abc`, 400, "validation_error", "query.page_size should be a number");
|
|
395
|
+
|
|
396
|
+
// Markdown ----------------------------------------------------------------------------------
|
|
397
|
+
const markdown = (await get(`/v1/pages/${ID.design}/markdown`)).json;
|
|
398
|
+
assert.equal(markdown.object, "page_markdown");
|
|
399
|
+
assert.ok(markdown.markdown.startsWith("# Design principles\n\nWe build robots that are **safe by default**, *boring to operate*"));
|
|
400
|
+
assert.ok(markdown.markdown.includes("<details>\n<summary>Review checklist</summary>"));
|
|
401
|
+
assert.ok(markdown.markdown.includes("```c\nvoid estop()"));
|
|
402
|
+
assert.ok(markdown.markdown.includes(`<unknown type="bookmark" id="${ID.bookmark}"`));
|
|
403
|
+
assert.deepEqual(markdown.unknown_block_ids, [ID.bookmark]);
|
|
404
|
+
assert.equal((await get(`/v1/pages/${ID.scratch}/markdown`)).json.markdown, "");
|
|
405
|
+
|
|
406
|
+
// Databases and data sources ----------------------------------------------------------------
|
|
407
|
+
const database = (await get(`/v1/databases/${ID.dbTasks}`)).json;
|
|
408
|
+
assert.equal(database.object, "database");
|
|
409
|
+
assert.equal(database.properties, undefined, "2025-09-03 containers carry no schema");
|
|
410
|
+
assert.deepEqual(database.data_sources, [{ id: ID.dsTasks, name: "Tasks" }]);
|
|
411
|
+
assert.equal(database.is_inline, false);
|
|
412
|
+
const source = (await get(`/v1/data_sources/${ID.dsTasks}`)).json;
|
|
413
|
+
assert.equal(source.object, "data_source");
|
|
414
|
+
assert.equal(source.parent.database_id, ID.dbTasks);
|
|
415
|
+
assert.equal(source.database_parent.page_id, ID.home);
|
|
416
|
+
assert.deepEqual(Object.keys(source.properties).sort(), ["Assignee", "Created by", "Done", "Due", "Estimate", "Last edited", "Name", "Notes", "Priority", "Project", "Status", "Tags", "Ticket"]);
|
|
417
|
+
assert.equal(source.properties.Status.status.options.length, 5);
|
|
418
|
+
assert.equal(source.properties.Project.relation.data_source_id, ID.dsProjects);
|
|
419
|
+
assert.equal((await get(`/v1/databases/${ID.dbDecisions}`)).json.is_inline, true);
|
|
420
|
+
await apiError("GET", `/v1/databases/${ID.dsTasks}`, 404, "object_not_found", "Could not find database with ID");
|
|
421
|
+
await apiError("GET", "/v1/data_sources/abc", 400, "validation_error", "path.data_source_id should be a valid uuid");
|
|
422
|
+
const legacy = (await post(`/v1/databases/${ID.dbTasks}/query`, { page_size: 3 })).json;
|
|
423
|
+
assert.equal(legacy.results.length, 3, "the legacy database query resolves the first data source");
|
|
424
|
+
assert.equal(legacy.has_more, true);
|
|
425
|
+
assert.equal(legacy.results[0].id, ID.t1, "default order is creation (row-id) order");
|
|
426
|
+
|
|
427
|
+
// Missing objects and malformed ids on every remaining route -----------------------------------
|
|
428
|
+
await apiError("GET", "/v1/blocks/zzz", 400, "validation_error", "path.block_id should be a valid uuid");
|
|
429
|
+
await apiError("PATCH", `/v1/blocks/${ID.missingPage}`, 404, "object_not_found", "Could not find block with ID", { body: { paragraph: { rich_text: [] } } });
|
|
430
|
+
await apiError("DELETE", `/v1/blocks/${ID.missingPage}`, 404, "object_not_found", "Could not find block with ID");
|
|
431
|
+
await apiError("GET", `/v1/data_sources/${ID.missingPage}`, 404, "object_not_found", "Could not find data source with ID");
|
|
432
|
+
await apiError("PATCH", `/v1/data_sources/${ID.missingPage}`, 404, "object_not_found", "Could not find data source with ID", { body: { description: [] } });
|
|
433
|
+
await apiError("GET", "/v1/databases/zzz", 400, "validation_error", "path.database_id should be a valid uuid");
|
|
434
|
+
await apiError("POST", `/v1/pages/${ID.missingPage}/move`, 404, "object_not_found", "Could not find page with ID", { body: { parent: { type: "page_id", page_id: ID.home } } });
|
|
435
|
+
await apiError("GET", "/v1/pages/zzz/markdown", 400, "validation_error", "path.page_id should be a valid uuid");
|
|
436
|
+
await apiError("GET", `/v1/pages/${ID.missingPage}/properties/title`, 404, "object_not_found", "Could not find page with ID");
|
|
437
|
+
await apiError("PATCH", `/v1/pages/${ID.missingPage}`, 404, "object_not_found", "Could not find page with ID", { body: { in_trash: true } });
|
|
438
|
+
await apiError("PATCH", `/v1/pages/${ID.missingPage}/markdown`, 404, "object_not_found", "Could not find page with ID", { body: { type: "insert_content", content: "x" } });
|
|
439
|
+
|
|
440
|
+
// Not part of the surface --------------------------------------------------------------------
|
|
441
|
+
for (const [method, path] of [["POST", "/v1/oauth/token"], ["GET", "/v1/file_uploads"], ["POST", "/v1/data_sources"]]) {
|
|
442
|
+
const response = await fetch(`${HTTP}${path}`, { method, headers: { authorization: `Bearer ${HTTP_TOKEN}` } });
|
|
443
|
+
assert.ok([404, 405].includes(response.status), `${method} ${path} -> ${response.status}`);
|
|
444
|
+
await response.text();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------------------------
|
|
449
|
+
// Drill: task-triage (member, baseline)
|
|
450
|
+
// ---------------------------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
async function taskTriage() {
|
|
453
|
+
const S = ID.dsTasks;
|
|
454
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Status", status: { equals: "In progress" } } }), [ID.t1, "fd000000-0000-4000-8000-000200000125"]);
|
|
455
|
+
assert.deepEqual(await queryIds(S, { filter: { and: [{ property: "Priority", select: { equals: "High" } }, { property: "Done", checkbox: { equals: false } }] } }), [ID.t1, ID.t3]);
|
|
456
|
+
const nested = await queryIds(S, {
|
|
457
|
+
filter: { or: [{ property: "Priority", select: { equals: "Urgent" } }, { and: [{ property: "Status", status: { equals: "Backlog" } }, { property: "Assignee", people: { is_empty: true } }] }] },
|
|
458
|
+
});
|
|
459
|
+
assert.deepEqual(nested, [ID.t4, "fd000000-0000-4000-8000-00020000012c"], "two-level nesting works");
|
|
460
|
+
assert.deepEqual(await queryIds(S, { filter: { and: [{ property: "Due", date: { on_or_before: "2026-09-14" } }, { property: "Done", checkbox: { equals: false } }] } }), [ID.t3, ID.t4], "overdue + due today, open only");
|
|
461
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Due", date: { equals: "2026-09-14" } } }), [ID.t3]);
|
|
462
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Due", date: { next_week: {} } } }), [ID.t1, ID.t3], "today through today+7 by calendar day");
|
|
463
|
+
assert.deepEqual(await queryIds(S, { filter: { timestamp: "created_time", created_time: { past_week: {} } } }), ["fd000000-0000-4000-8000-000200000127", "fd000000-0000-4000-8000-00020000012c", "fd000000-0000-4000-8000-00020000012d"]);
|
|
464
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Assignee", people: { contains: ID.priya } } }), [ID.t3, "fd000000-0000-4000-8000-000200000124", "fd000000-0000-4000-8000-000200000127", "fd000000-0000-4000-8000-00020000012b"]);
|
|
465
|
+
assert.equal((await queryIds(S, { filter: { property: "Project", relation: { contains: ID.beacon } } })).length, 4);
|
|
466
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Tags", multi_select: { contains: "infra" } } }), ["fd000000-0000-4000-8000-000200000126", "fd000000-0000-4000-8000-00020000012b", "fd000000-0000-4000-8000-00020000012d"]);
|
|
467
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Due", date: { is_empty: true } } }), ["fd000000-0000-4000-8000-000200000127", "fd000000-0000-4000-8000-000200000129", "fd000000-0000-4000-8000-00020000012c"]);
|
|
468
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Name", title: { contains: "FIRMWARE" } } }), [ID.t1, ID.t4, "fd000000-0000-4000-8000-00020000012d"], "title contains is case-insensitive");
|
|
469
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Estimate", number: { greater_than_or_equal_to: 8 } } }), [ID.t1, ID.t4, "fd000000-0000-4000-8000-000200000126", "fd000000-0000-4000-8000-00020000012a"]);
|
|
470
|
+
assert.deepEqual(await queryIds(S, { filter: { property: "Ticket", url: { is_not_empty: true } } }), [ID.t1, ID.t4]);
|
|
471
|
+
const sorted = (await query(S, { sorts: [{ property: "Due", direction: "ascending" }, { property: "Priority", direction: "descending" }], filter_properties: ["title", "tdue"] })).json;
|
|
472
|
+
assert.deepEqual(Object.keys(sorted.results[0].properties).sort(), ["Due", "Name"]);
|
|
473
|
+
const dues = sorted.results.map((page) => page.properties.Due.date?.start ?? null);
|
|
474
|
+
const defined = dues.filter((value) => value !== null);
|
|
475
|
+
assert.deepEqual(defined, [...defined].sort(), "ascending by Due");
|
|
476
|
+
assert.deepEqual(dues.slice(-3), [null, null, null], "empties sort last");
|
|
477
|
+
const byPriority = (await query(S, { sorts: [{ property: "Priority", direction: "descending" }], page_size: 2 })).json;
|
|
478
|
+
assert.deepEqual(byPriority.results.map((page) => page.properties.Priority.select.name), ["Urgent", "High"], "select sorts by option order");
|
|
479
|
+
const sizes = [];
|
|
480
|
+
let cursor;
|
|
481
|
+
for (;;) {
|
|
482
|
+
const page = (await query(S, { page_size: 5, ...(cursor === undefined ? {} : { start_cursor: cursor }) })).json;
|
|
483
|
+
sizes.push(page.results.length);
|
|
484
|
+
if (!page.has_more) break;
|
|
485
|
+
cursor = page.next_cursor;
|
|
486
|
+
}
|
|
487
|
+
assert.deepEqual(sizes, [5, 5, 4], "14 tasks paginate 5/5/4");
|
|
488
|
+
assert.equal((await query(ID.dsDecisions, {})).json.results.length, 0, "the Decisions log is empty");
|
|
489
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "nests compound filters deeper than 2 levels", {
|
|
490
|
+
body: { filter: { and: [{ or: [{ and: [{ property: "Done", checkbox: { equals: true } }] }] }] } },
|
|
491
|
+
});
|
|
492
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "Could not find property with name or id: Nope", { body: { filter: { property: "Nope", checkbox: { equals: true } } } });
|
|
493
|
+
// Mangled percent-encoding (U+FFFD) in a filter or sort is corruption, not a value to match.
|
|
494
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "body.filter.title.contains contains an invalid character (U+FFFD)", { body: { filter: { property: "Name", title: { contains: "Ro\uFFFDad" } } } });
|
|
495
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "body.filter.property contains an invalid character (U+FFFD)", { body: { filter: { property: "Na\uFFFDme", title: { contains: "a" } } } });
|
|
496
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "body.sorts[0].property contains an invalid character (U+FFFD)", { body: { sorts: [{ property: "Na\uFFFDme", direction: "ascending" }] } });
|
|
497
|
+
// filter_properties is a query-string parameter of this route too (the body copy maps onto the same argument), so
|
|
498
|
+
// the label is query.filter_properties whichever way it arrived.
|
|
499
|
+
await apiError("POST", `/v1/data_sources/${S}/query?filter_properties=%E0%A4%A`, 400, "validation_error", "query.filter_properties contains an invalid character (U+FFFD)", { body: {} });
|
|
500
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "query.filter_properties contains an invalid character (U+FFFD)", { body: { filter_properties: ["ti\uFFFDtle"] } });
|
|
501
|
+
const canonicalQueryMangled = await opError("data-sources.query", { data_source_id: S, filter_properties: ["ti\uFFFDtle"] }, "VALIDATION_ERROR");
|
|
502
|
+
assert.match(canonicalQueryMangled.message, /query\.filter_properties contains an invalid character \(U\+FFFD\)/);
|
|
503
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "not a supported condition for checkbox", { body: { filter: { property: "Done", checkbox: { contains: "x" } } } });
|
|
504
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "body.filter.status should be defined", { body: { filter: { property: "Status", select: { equals: "Done" } } } });
|
|
505
|
+
await apiError("POST", `/v1/data_sources/${S}/query`, 400, "validation_error", "The start_cursor provided is invalid.", { body: { start_cursor: ID.home } });
|
|
506
|
+
await apiError("POST", `/v1/data_sources/${ID.missingPage}/query`, 404, "object_not_found", "Could not find data source with ID", { body: {} });
|
|
507
|
+
const trashedOnly = (await query(S, { in_trash: true })).json;
|
|
508
|
+
assert.equal(trashedOnly.results.length, 0);
|
|
509
|
+
|
|
510
|
+
// Create a task -------------------------------------------------------------------------------
|
|
511
|
+
const created = (
|
|
512
|
+
await post("/v1/pages", {
|
|
513
|
+
parent: { data_source_id: S },
|
|
514
|
+
properties: {
|
|
515
|
+
Name: { title: [text("Bench-test the OTA rollback")] },
|
|
516
|
+
Status: { status: { name: "Todo" } },
|
|
517
|
+
Assignee: { people: [{ object: "user", id: ID.tomas }] },
|
|
518
|
+
Due: { date: { start: "2026-09-16" } },
|
|
519
|
+
Priority: { select: { name: "Critical" } },
|
|
520
|
+
Tags: { multi_select: [{ name: "firmware" }, { name: "release" }] },
|
|
521
|
+
Estimate: { number: 3 },
|
|
522
|
+
Project: { relation: [{ id: ID.atlas }] },
|
|
523
|
+
},
|
|
524
|
+
})
|
|
525
|
+
).json;
|
|
526
|
+
assert.equal(created.id, ID.newPage, "ids come from the counter row (0x1000 and 0x1001 went to the two new select options)");
|
|
527
|
+
assert.equal(created.created_by.id, ID.ines, "writes are attributed to the actor's userId");
|
|
528
|
+
assert.equal(created.properties.Priority.select.name, "Critical", "unknown select options are created");
|
|
529
|
+
assert.deepEqual(created.properties.Tags.multi_select.map((option) => option.name), ["firmware", "release"]);
|
|
530
|
+
assert.equal(created.properties.Done.checkbox, false, "omitted properties get empty values");
|
|
531
|
+
assert.equal(created.properties.Notes.rich_text.length, 0);
|
|
532
|
+
const schema = (await get(`/v1/data_sources/${S}`)).json.properties;
|
|
533
|
+
assert.ok(schema.Priority.select.options.some((option) => option.name === "Critical"), "the schema gained the option");
|
|
534
|
+
assert.ok(schema.Tags.multi_select.options.some((option) => option.name === "release"));
|
|
535
|
+
await apiError("POST", "/v1/pages", 400, "validation_error", "Bogus is not a property that exists.", { body: { parent: { data_source_id: S }, properties: { Bogus: { checkbox: true } } } });
|
|
536
|
+
await apiError("POST", "/v1/pages", 400, "validation_error", "Status is expected to be status.", { body: { parent: { data_source_id: S }, properties: { Status: { select: { name: "Todo" } } } } });
|
|
537
|
+
await apiError("POST", "/v1/pages", 400, "validation_error", "Status option", { body: { parent: { data_source_id: S }, properties: { Status: { status: { name: "Nope" } } } } });
|
|
538
|
+
await apiError("POST", "/v1/pages", 400, "validation_error", "reference pages of the related data source", { body: { parent: { data_source_id: S }, properties: { Project: { relation: [{ id: ID.t1 }] } } } });
|
|
539
|
+
await apiError("POST", "/v1/pages", 400, "validation_error", "integrations cannot create pages at the workspace root", { body: { parent: { type: "workspace", workspace: true }, properties: {} } });
|
|
540
|
+
await apiError("POST", "/v1/pages", 404, "object_not_found", "Could not find page with ID", { body: { parent: { page_id: ID.missingPage }, properties: {} } });
|
|
541
|
+
|
|
542
|
+
// Update it --------------------------------------------------------------------------------
|
|
543
|
+
const updated = (await patch(`/v1/pages/${created.id}`, { properties: { Status: { status: { name: "Done" } }, Done: { checkbox: true }, Due: null } })).json;
|
|
544
|
+
assert.equal(updated.properties.Status.status.name, "Done");
|
|
545
|
+
assert.equal(updated.properties.Done.checkbox, true);
|
|
546
|
+
assert.equal(updated.properties.Due.date, null, "null clears a value");
|
|
547
|
+
assert.equal(updated.properties.Estimate.number, 3, "untouched properties stay");
|
|
548
|
+
assert.equal((await get(`/v1/pages/${created.id}`)).json.properties.Status.status.name, "Done", "reads reflect the write");
|
|
549
|
+
await apiError("PATCH", `/v1/pages/${created.id}`, 400, "validation_error", "Cannot update property", { body: { properties: { "Created by": { created_by: { id: ID.marco } } } } });
|
|
550
|
+
assert.equal((await queryIds(S, { filter: { property: "Status", status: { equals: "Done" } } })).length, 5);
|
|
551
|
+
|
|
552
|
+
// Move it out and back ------------------------------------------------------------------------
|
|
553
|
+
const moved = (await post(`/v1/pages/${created.id}/move`, { parent: { type: "page_id", page_id: ID.engineering } })).json;
|
|
554
|
+
assert.deepEqual(moved.parent, { type: "page_id", page_id: ID.engineering });
|
|
555
|
+
assert.deepEqual(Object.keys(moved.properties), ["title"], "moving into a page keeps only the title");
|
|
556
|
+
assert.equal(plain(moved.properties.title.title), "Bench-test the OTA rollback");
|
|
557
|
+
const engineeringChildren = (await get(`/v1/blocks/${ID.engineering}/children`)).json.results;
|
|
558
|
+
assert.equal(engineeringChildren.at(-1).id, created.id, "a child_page block appeared at the end of Engineering");
|
|
559
|
+
assert.equal(engineeringChildren.at(-1).child_page.title, "Bench-test the OTA rollback");
|
|
560
|
+
assert.equal((await queryIds(S, { filter: { property: "Name", title: { contains: "Bench-test" } } })).length, 0, "it left the data source");
|
|
561
|
+
await apiError("POST", `/v1/pages/${ID.engineering}/move`, 400, "validation_error", "should not be the page itself or one of its descendants", { body: { parent: { type: "page_id", page_id: created.id } } });
|
|
562
|
+
const back = (await post(`/v1/pages/${created.id}/move`, { parent: { type: "data_source_id", data_source_id: S } })).json;
|
|
563
|
+
assert.equal(back.parent.data_source_id, S);
|
|
564
|
+
assert.equal(back.properties.Status.status, null, "re-keyed to the schema with empty values");
|
|
565
|
+
assert.equal(plain(back.properties.Name.title), "Bench-test the OTA rollback");
|
|
566
|
+
assert.ok(!(await get(`/v1/blocks/${ID.engineering}/children`)).json.results.some((block) => block.id === created.id), "the child_page block is gone again");
|
|
567
|
+
|
|
568
|
+
// Comments ---------------------------------------------------------------------------------
|
|
569
|
+
const comment = (await post("/v1/comments", { parent: { page_id: created.id }, rich_text: [text("Scheduled for the Thursday bench slot.")] })).json;
|
|
570
|
+
assert.equal(comment.object, "comment");
|
|
571
|
+
assert.equal(comment.created_by.id, ID.ines);
|
|
572
|
+
assert.deepEqual(comment.display_name, { type: "user", resolved_name: "Ines Okafor" });
|
|
573
|
+
const reply = (await post("/v1/comments", { discussion_id: comment.discussion_id, rich_text: [text("Ack, "), { type: "mention", mention: { user: { id: ID.tomas } } }] })).json;
|
|
574
|
+
assert.equal(reply.discussion_id, comment.discussion_id);
|
|
575
|
+
assert.equal(reply.parent.page_id, created.id);
|
|
576
|
+
assert.equal(reply.rich_text[1].plain_text, "@Tomas Lindqvist");
|
|
577
|
+
await apiError("POST", "/v1/comments", 400, "validation_error", "exactly one of body.parent or body.discussion_id", { body: { rich_text: [text("x")] } });
|
|
578
|
+
await apiError("POST", "/v1/comments", 400, "validation_error", "non-empty", { body: { parent: { page_id: created.id }, rich_text: [] } });
|
|
579
|
+
await apiError("POST", "/v1/comments", 404, "object_not_found", "Could not find discussion with ID", { body: { discussion_id: "fd000000-0000-4000-8000-000800000099", rich_text: [text("x")] } });
|
|
580
|
+
const firstComment = (await get(`/v1/comments?block_id=${created.id}&page_size=1`)).json;
|
|
581
|
+
assert.equal(firstComment.results.length, 1);
|
|
582
|
+
assert.equal(firstComment.next_cursor, comment.id);
|
|
583
|
+
const secondComment = (await get(`/v1/comments?block_id=${created.id}&page_size=1&start_cursor=${firstComment.next_cursor}`)).json;
|
|
584
|
+
assert.deepEqual(ids(secondComment.results), [reply.id]);
|
|
585
|
+
assert.equal(secondComment.has_more, false);
|
|
586
|
+
await apiError("GET", "/v1/comments", 400, "validation_error", "arguments do not match notion.comments.list");
|
|
587
|
+
await apiError("GET", `/v1/comments?block_id=${ID.missingPage}`, 404, "object_not_found", "Could not find block with ID");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------------------------
|
|
591
|
+
// Drill: page-authoring (member, baseline)
|
|
592
|
+
// ---------------------------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
async function pageAuthoring() {
|
|
595
|
+
const created = (
|
|
596
|
+
await post("/v1/pages", {
|
|
597
|
+
parent: { page_id: ID.onboarding },
|
|
598
|
+
properties: { title: [text("Lab safety refresher")] },
|
|
599
|
+
icon: { type: "emoji", emoji: "🧪" },
|
|
600
|
+
children: [
|
|
601
|
+
{ object: "block", type: "heading_2", heading_2: { rich_text: [text("Before you start")] } },
|
|
602
|
+
{ type: "paragraph", paragraph: { rich_text: [text("Read this "), { type: "text", text: { content: "carefully" }, annotations: { bold: true } }, text(".")] } },
|
|
603
|
+
{
|
|
604
|
+
type: "bulleted_list_item",
|
|
605
|
+
bulleted_list_item: {
|
|
606
|
+
rich_text: [text("Wear goggles")],
|
|
607
|
+
children: [{ type: "bulleted_list_item", bulleted_list_item: { rich_text: [text("Even for demos")], children: [{ type: "paragraph", paragraph: { rich_text: [text("No exceptions.")] } }] } }],
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
{ type: "to_do", to_do: { rich_text: [text("Sign the attendance sheet")], checked: false } },
|
|
611
|
+
],
|
|
612
|
+
})
|
|
613
|
+
).json;
|
|
614
|
+
assert.equal(created.icon.emoji, "🧪");
|
|
615
|
+
const onboardingChildren = (await get(`/v1/blocks/${ID.onboarding}/children`)).json.results;
|
|
616
|
+
assert.equal(onboardingChildren.at(-1).type, "child_page");
|
|
617
|
+
assert.equal(onboardingChildren.at(-1).id, created.id);
|
|
618
|
+
const children = (await get(`/v1/blocks/${created.id}/children`)).json.results;
|
|
619
|
+
assert.deepEqual(children.map((block) => block.type), ["heading_2", "paragraph", "bulleted_list_item", "to_do"]);
|
|
620
|
+
assert.equal(children[2].has_children, true);
|
|
621
|
+
const grandchildren = (await get(`/v1/blocks/${children[2].id}/children`)).json.results;
|
|
622
|
+
assert.equal(grandchildren.length, 1);
|
|
623
|
+
assert.equal((await get(`/v1/blocks/${grandchildren[0].id}/children`)).json.results[0].paragraph.rich_text[0].plain_text, "No exceptions.");
|
|
624
|
+
assert.equal((await get(`/v1/pages/${ID.onboarding}`)).json.last_edited_time, "2026-09-14T09:00:00.000Z", "the parent page was touched");
|
|
625
|
+
|
|
626
|
+
// Append after a sibling ----------------------------------------------------------------------
|
|
627
|
+
const appended = (
|
|
628
|
+
await patch(`/v1/blocks/${created.id}/children`, {
|
|
629
|
+
children: [{ type: "callout", callout: { rich_text: [text("Emergency stop is the red button.")], icon: { type: "emoji", emoji: "🛑" } } }, { type: "divider", divider: {} }],
|
|
630
|
+
after: children[0].id,
|
|
631
|
+
})
|
|
632
|
+
).json;
|
|
633
|
+
assert.equal(appended.object, "list");
|
|
634
|
+
assert.deepEqual(appended.results.map((block) => block.type), ["callout", "divider"]);
|
|
635
|
+
assert.equal(appended.has_more, false);
|
|
636
|
+
const reordered = (await get(`/v1/blocks/${created.id}/children`)).json.results;
|
|
637
|
+
assert.deepEqual(reordered.map((block) => block.type), ["heading_2", "callout", "divider", "paragraph", "bulleted_list_item", "to_do"]);
|
|
638
|
+
await apiError("PATCH", `/v1/blocks/${created.id}/children`, 400, "validation_error", "body.children.length should be ≤ 100", {
|
|
639
|
+
body: { children: Array.from({ length: 101 }, () => ({ type: "paragraph", paragraph: { rich_text: [text("x")] } })) },
|
|
640
|
+
});
|
|
641
|
+
const deep = { type: "bulleted_list_item", bulleted_list_item: { rich_text: [text("1")], children: [{ type: "bulleted_list_item", bulleted_list_item: { rich_text: [text("2")], children: [{ type: "bulleted_list_item", bulleted_list_item: { rich_text: [text("3")], children: [{ type: "paragraph", paragraph: { rich_text: [text("4")] } }] } }] } }] } };
|
|
642
|
+
await apiError("PATCH", `/v1/blocks/${created.id}/children`, 400, "validation_error", "deeper than 2 levels", { body: { children: [deep] } });
|
|
643
|
+
await apiError("PATCH", `/v1/blocks/${created.id}/children`, 400, "validation_error", "body.children[0].type should be one of", { body: { children: [{ type: "table", table: { table_width: 2 } }] } });
|
|
644
|
+
await apiError("PATCH", `/v1/blocks/${created.id}/children`, 400, "validation_error", 'should be "text" or "mention"', { body: { children: [{ type: "paragraph", paragraph: { rich_text: [{ type: "equation", equation: { expression: "e=mc^2" } }] } }] } });
|
|
645
|
+
await apiError("PATCH", `/v1/blocks/${created.id}/children`, 400, "validation_error", "body.after should be the id of a child block", { body: { children: [{ type: "paragraph", paragraph: { rich_text: [text("x")] } }], after: ID.toggle } });
|
|
646
|
+
await apiError("PATCH", `/v1/blocks/${reordered[2].id}/children`, 400, "validation_error", "do not support children", { body: { children: [{ type: "paragraph", paragraph: { rich_text: [text("x")] } }] } });
|
|
647
|
+
await apiError("PATCH", `/v1/blocks/${ID.missingPage}/children`, 404, "object_not_found", "Could not find block with ID", { body: { children: [{ type: "paragraph", paragraph: { rich_text: [text("x")] } }] } });
|
|
648
|
+
|
|
649
|
+
// Update blocks -----------------------------------------------------------------------------
|
|
650
|
+
const paragraph = reordered[3];
|
|
651
|
+
const edited = (await patch(`/v1/blocks/${paragraph.id}`, { paragraph: { rich_text: [text("Read this twice.")], color: "blue_background" } })).json;
|
|
652
|
+
assert.equal(edited.paragraph.rich_text[0].plain_text, "Read this twice.");
|
|
653
|
+
assert.equal(edited.paragraph.color, "blue_background");
|
|
654
|
+
const todo = reordered[5];
|
|
655
|
+
const checked = (await patch(`/v1/blocks/${todo.id}`, { to_do: { checked: true } })).json;
|
|
656
|
+
assert.equal(checked.to_do.checked, true);
|
|
657
|
+
assert.equal(checked.to_do.rich_text[0].plain_text, "Sign the attendance sheet", "partial updates keep the text");
|
|
658
|
+
const wrapped = (await patch(`/v1/blocks/${reordered[0].id}`, { type: { heading_2: { rich_text: [text("Before you begin")] } } })).json;
|
|
659
|
+
assert.equal(wrapped.heading_2.rich_text[0].plain_text, "Before you begin", "the MCP wrapper form is accepted");
|
|
660
|
+
await apiError("PATCH", `/v1/blocks/${paragraph.id}`, 400, "validation_error", "a block's type cannot be changed", { body: { heading_1: { rich_text: [text("x")] } } });
|
|
661
|
+
await apiError("PATCH", `/v1/blocks/${created.id}`, 400, "validation_error", "child_page blocks are updated through", { body: { paragraph: { rich_text: [] } } });
|
|
662
|
+
|
|
663
|
+
// Delete and restore ----------------------------------------------------------------------------
|
|
664
|
+
const deleted = (await del(`/v1/blocks/${reordered[4].id}`)).json;
|
|
665
|
+
assert.equal(deleted.archived, true);
|
|
666
|
+
assert.equal(deleted.in_trash, true);
|
|
667
|
+
const afterDelete = (await get(`/v1/blocks/${created.id}/children`)).json.results;
|
|
668
|
+
assert.deepEqual(afterDelete.map((block) => block.type), ["heading_2", "callout", "divider", "paragraph", "to_do"]);
|
|
669
|
+
assert.equal((await get(`/v1/blocks/${reordered[4].id}`)).json.archived, true, "trashed blocks stay readable");
|
|
670
|
+
assert.equal((await get(`/v1/blocks/${grandchildren[0].id}`)).json.in_trash, true, "descendants are trashed too");
|
|
671
|
+
const restored = (await patch(`/v1/blocks/${reordered[4].id}`, { archived: false })).json;
|
|
672
|
+
assert.equal(restored.in_trash, false);
|
|
673
|
+
assert.equal((await get(`/v1/blocks/${created.id}/children`)).json.results.length, 6);
|
|
674
|
+
assert.equal((await get(`/v1/blocks/${grandchildren[0].id}`)).json.in_trash, false);
|
|
675
|
+
await apiError("DELETE", `/v1/blocks/${created.id}`, 400, "validation_error", "child_page blocks cannot be deleted here");
|
|
676
|
+
await apiError("DELETE", "/v1/blocks/zzz", 400, "validation_error", "path.block_id should be a valid uuid");
|
|
677
|
+
|
|
678
|
+
// Markdown --------------------------------------------------------------------------------------
|
|
679
|
+
const before = (await get(`/v1/pages/${created.id}/markdown`)).json;
|
|
680
|
+
assert.ok(before.markdown.startsWith("## Before you begin\n\n> 🛑 Emergency stop is the red button.\n\n---\n\nRead this twice."));
|
|
681
|
+
await apiError("PATCH", `/v1/pages/${created.id}/markdown`, 400, "validation_error", "Set allow_deleting_content to true", { body: { type: "replace_content", new_str: "# Fresh" } });
|
|
682
|
+
await apiError("PATCH", `/v1/pages/${created.id}/markdown`, 400, "validation_error", "was not found in the page content", { body: { type: "update_content", content_updates: [{ old_str: "never there", new_str: "x" }] } });
|
|
683
|
+
await apiError("PATCH", `/v1/pages/${created.id}/markdown`, 400, "validation_error", "allow_async", { body: { type: "insert_content", content: "x", allow_async: true } });
|
|
684
|
+
await apiError("PATCH", `/v1/pages/${created.id}/markdown`, 400, "validation_error", "body.type should be one of", { body: { type: "replace_content_range", new_str: "x" } });
|
|
685
|
+
const inserted = (await patch(`/v1/pages/${created.id}/markdown`, { type: "insert_content", content: "### Checklist\n\n- [ ] Badge visible\n- [x] Goggles on", position: { type: "end" } })).json;
|
|
686
|
+
assert.ok(inserted.markdown.endsWith("### Checklist\n\n- [ ] Badge visible\n- [x] Goggles on"));
|
|
687
|
+
const updatedMarkdown = (await patch(`/v1/pages/${created.id}/markdown`, { type: "update_content", content_updates: [{ old_str: "Read this twice.", new_str: "Read this **three** times." }] })).json;
|
|
688
|
+
assert.ok(updatedMarkdown.markdown.includes("Read this **three** times."));
|
|
689
|
+
assert.equal((await get(`/v1/blocks/${paragraph.id}`)).json.paragraph.rich_text[1].annotations.bold, true, "the paragraph was updated in place (same block id)");
|
|
690
|
+
const fresh = "# Fresh start\n\nA paragraph with *italics* and a [link](https://docs.example/safety).\n\n- one\n- two\n\n1. first\n2. second\n\n> A quote\n\n> 💡 A callout\n\n```python\nprint(1)\n```\n\n---\n\n<details>\n<summary>More</summary>\n\nHidden text.\n\n</details>";
|
|
691
|
+
const replaced = (await patch(`/v1/pages/${created.id}/markdown`, { type: "replace_content", new_str: fresh, allow_deleting_content: true })).json;
|
|
692
|
+
assert.equal(replaced.markdown, fresh, "the dialect round-trips");
|
|
693
|
+
assert.equal((await get(`/v1/pages/${created.id}/markdown`)).json.markdown, fresh);
|
|
694
|
+
const replacedBlocks = (await get(`/v1/blocks/${created.id}/children`)).json.results;
|
|
695
|
+
assert.deepEqual(replacedBlocks.map((block) => block.type), ["heading_1", "paragraph", "bulleted_list_item", "bulleted_list_item", "numbered_list_item", "numbered_list_item", "quote", "callout", "code", "divider", "toggle"]);
|
|
696
|
+
assert.equal(replacedBlocks[7].callout.icon.emoji, "💡");
|
|
697
|
+
assert.equal(replacedBlocks[8].code.language, "python");
|
|
698
|
+
await apiError("GET", `/v1/pages/${ID.missingPage}/markdown`, 404, "object_not_found", "Could not find page with ID");
|
|
699
|
+
|
|
700
|
+
// Databases --------------------------------------------------------------------------------------
|
|
701
|
+
const database = (
|
|
702
|
+
await post("/v1/databases", {
|
|
703
|
+
parent: { page_id: created.id },
|
|
704
|
+
title: [text("Sprint retro")],
|
|
705
|
+
is_inline: true,
|
|
706
|
+
initial_data_source: { properties: { Name: { title: {} }, "Went well": { rich_text: {} }, Owner: { people: {} }, Mood: { select: { options: [{ name: "😀", color: "green" }, { name: "😐" }] } } } },
|
|
707
|
+
})
|
|
708
|
+
).json;
|
|
709
|
+
assert.equal(database.object, "database");
|
|
710
|
+
assert.equal(database.is_inline, true);
|
|
711
|
+
assert.equal(database.data_sources.length, 1);
|
|
712
|
+
const sourceId = database.data_sources[0].id;
|
|
713
|
+
assert.equal((await get(`/v1/blocks/${created.id}/children`)).json.results.at(-1).type, "child_database");
|
|
714
|
+
const source = (await get(`/v1/data_sources/${sourceId}`)).json;
|
|
715
|
+
assert.deepEqual(Object.keys(source.properties).sort(), ["Mood", "Name", "Owner", "Went well"]);
|
|
716
|
+
assert.equal(source.properties.Mood.select.options[1].color, "default");
|
|
717
|
+
await apiError("POST", "/v1/databases", 400, "validation_error", "exactly one property of type title", { body: { parent: { page_id: created.id }, initial_data_source: { properties: { A: { rich_text: {} } } } } });
|
|
718
|
+
await apiError("POST", "/v1/databases", 400, "validation_error", "supported property type", { body: { parent: { page_id: created.id }, initial_data_source: { properties: { Name: { title: {} }, F: { formula: { expression: "1" } } } } } });
|
|
719
|
+
await apiError("POST", "/v1/databases", 404, "object_not_found", "Could not find page with ID", { body: { parent: { page_id: ID.missingPage }, initial_data_source: { properties: { Name: { title: {} } } } } });
|
|
720
|
+
const row = (await post("/v1/pages", { parent: { database_id: database.id }, properties: { Name: { title: [text("Retro 1")] }, "Went well": { rich_text: [text("Shipped on time")] }, Owner: { people: [{ id: ID.priya }] } } })).json;
|
|
721
|
+
assert.equal(row.parent.data_source_id, sourceId, "the legacy database_id parent resolves to the first data source");
|
|
722
|
+
const reshaped = (await patch(`/v1/data_sources/${sourceId}`, { properties: { Score: { number: { format: "percent" } }, "Went well": { name: "Highlights" }, Owner: null }, description: [text("One row per sprint")] })).json;
|
|
723
|
+
assert.deepEqual(Object.keys(reshaped.properties).sort(), ["Highlights", "Mood", "Name", "Score"]);
|
|
724
|
+
assert.equal(reshaped.properties.Score.number.format, "percent");
|
|
725
|
+
assert.equal(reshaped.description[0].plain_text, "One row per sprint");
|
|
726
|
+
const rowAfter = (await get(`/v1/pages/${row.id}`)).json;
|
|
727
|
+
assert.equal(plain(rowAfter.properties.Highlights.rich_text), "Shipped on time", "values follow the rename");
|
|
728
|
+
assert.equal(rowAfter.properties.Owner, undefined, "removed properties disappear from pages");
|
|
729
|
+
assert.equal(rowAfter.properties.Score.number, null, "added properties are materialised empty");
|
|
730
|
+
await apiError("PATCH", `/v1/data_sources/${sourceId}`, 400, "validation_error", "The title property cannot be removed.", { body: { properties: { Name: null } } });
|
|
731
|
+
await apiError("PATCH", `/v1/data_sources/${sourceId}`, 400, "validation_error", "is not a property that exists", { body: { properties: { Ghost: null } } });
|
|
732
|
+
// A property cannot be named __proto__ (the key would vanish from plain-object schemas).
|
|
733
|
+
await apiError("PATCH", `/v1/data_sources/${sourceId}`, 400, "validation_error", "uses the reserved property name __proto__", { body: { properties: { Highlights: { name: "__proto__" } } } });
|
|
734
|
+
const retyped = (await patch(`/v1/data_sources/${sourceId}`, { properties: { Highlights: { checkbox: {} } } })).json;
|
|
735
|
+
assert.equal(retyped.properties.Highlights.type, "checkbox");
|
|
736
|
+
assert.equal((await get(`/v1/pages/${row.id}`)).json.properties.Highlights.checkbox, false, "retyping clears values");
|
|
737
|
+
const queried = (await query(sourceId, { filter: { property: "Name", title: { starts_with: "retro" } } })).json;
|
|
738
|
+
assert.deepEqual(ids(queried.results), [row.id]);
|
|
739
|
+
const trashedSource = (await patch(`/v1/data_sources/${sourceId}`, { in_trash: true })).json;
|
|
740
|
+
assert.equal(trashedSource.in_trash, true);
|
|
741
|
+
assert.equal((await get(`/v1/pages/${row.id}`)).json.in_trash, true, "trashing a source trashes its pages");
|
|
742
|
+
assert.equal((await query(sourceId, {})).json.results.length, 0);
|
|
743
|
+
assert.equal((await query(sourceId, { in_trash: true })).json.results.length, 1);
|
|
744
|
+
|
|
745
|
+
// Bounded markdown parsing: adversarial markers and nesting answer declared errors quickly ---------
|
|
746
|
+
// Request bodies stay below 64 KiB here: larger drill arguments stop `firedrill tool test` itself (framework limit, see
|
|
747
|
+
// specs/notion/VERIFICATION.md); the 100 000-character cases are reproduced there against `firedrill serve`.
|
|
748
|
+
const MD = `/v1/pages/${created.id}/markdown`;
|
|
749
|
+
const legit = "- one\n - two\n - three\n- **bold**, *italic*, ~~gone~~, `code` and [link](https://docs.example/n)\n\n<details>\n<summary>Outer</summary>\n\n- inner\n - deeper\n\n</details>\n\nanchor-line";
|
|
750
|
+
assert.ok((await patch(MD, { type: "insert_content", content: legit })).json.markdown.endsWith(`\n\n${legit}`), "two-level lists, inline marks and a details block round-trip");
|
|
751
|
+
const quick = async (label, run) => {
|
|
752
|
+
const started = performance.now();
|
|
753
|
+
const result = await run();
|
|
754
|
+
const elapsed = performance.now() - started;
|
|
755
|
+
assert.ok(elapsed < 3000, `${label} took ${Math.round(elapsed)} ms`);
|
|
756
|
+
return result;
|
|
757
|
+
};
|
|
758
|
+
const tooLong = "text.content.length should be ≤ 2000";
|
|
759
|
+
const nestedTooDeep = "body.content[0].children[0].children[0].children nests blocks deeper than 2 levels in one request.";
|
|
760
|
+
// replace_content checks that the page's inline database survives before validating rich text, so for the rich-text
|
|
761
|
+
// cases that guard answers (after a parse that must still be fast); parser errors (nesting, block count) come first.
|
|
762
|
+
const keepsDatabase = "child pages and databases cannot be removed through markdown";
|
|
763
|
+
for (const [label, content, textPart, replaceTextPart = textPart] of [
|
|
764
|
+
["[ x60000", "[".repeat(60000), tooLong, keepsDatabase],
|
|
765
|
+
["[a]( x15000", "[a](".repeat(15000), tooLong, keepsDatabase],
|
|
766
|
+
["` x60000", "`".repeat(60000), "code.language should be a supported language", keepsDatabase],
|
|
767
|
+
["<details> x3", "<details>\n".repeat(3) + "x", nestedTooDeep],
|
|
768
|
+
["<details> x3000", "<details>\n".repeat(3000) + "x", nestedTooDeep],
|
|
769
|
+
["<details> x6000", "<details>\n".repeat(6000) + "x", nestedTooDeep],
|
|
770
|
+
["indented list x200", Array.from({ length: 200 }, (_, i) => `${" ".repeat(i)}- x`).join("\n"), nestedTooDeep],
|
|
771
|
+
["1001 paragraphs", Array.from({ length: 1001 }, (_, i) => `p${i}`).join("\n\n"), "should contain ≤ 1000 block elements"],
|
|
772
|
+
]) {
|
|
773
|
+
await quick(`insert ${label}`, () => apiError("PATCH", MD, 400, "validation_error", textPart, { body: { type: "insert_content", content } }));
|
|
774
|
+
await quick(`replace ${label}`, () => apiError("PATCH", MD, 400, "validation_error", replaceTextPart, { body: { type: "replace_content", new_str: content, allow_deleting_content: true } }));
|
|
775
|
+
}
|
|
776
|
+
const canonicalError = await quick("canonical <details> x6000", () => opError("pages.update-markdown", { page_id: created.id, type: "insert_content", content: "<details>\n".repeat(6000) + "x" }, "VALIDATION_ERROR"));
|
|
777
|
+
assert.ok(canonicalError.message.includes(nestedTooDeep), canonicalError.message);
|
|
778
|
+
await quick("canonical [ x60000", () => opError("pages.update-markdown", { page_id: created.id, type: "replace_content", new_str: "[".repeat(60000), allow_deleting_content: true }, "VALIDATION_ERROR"));
|
|
779
|
+
await quick("update_content <details> x6000", () =>
|
|
780
|
+
apiError("PATCH", MD, 400, "validation_error", "the markdown nests blocks deeper than 64 levels", { body: { type: "update_content", content_updates: [{ old_str: "anchor-line", new_str: "<details>\n".repeat(6000) + "x" }] } }));
|
|
781
|
+
// update_content: the size of every step is checked before it is built, the entry count is capped, replacements are literal.
|
|
782
|
+
const doubling = Array.from({ length: 25 }, () => ({ old_str: "e", new_str: "ee", replace_all_matches: true }));
|
|
783
|
+
await quick("update_content e->ee x25", () => apiError("PATCH", MD, 400, "validation_error", "the resulting markdown exceeds 102400 bytes (at body.content_updates[", { body: { type: "update_content", content_updates: doubling } }));
|
|
784
|
+
await quick("update_content 101 entries", () =>
|
|
785
|
+
apiError("PATCH", MD, 400, "validation_error", "body.content_updates.length should be ≤ 100, instead was 101.", { body: { type: "update_content", content_updates: Array.from({ length: 101 }, () => ({ old_str: "anchor-line", new_str: "anchor-line" })) } }));
|
|
786
|
+
await apiError("PATCH", MD, 400, "validation_error", "body.content_updates[0].old_str matched more than one place; make it unique or set replace_all_matches to true.", { body: { type: "update_content", content_updates: [{ old_str: "e", new_str: "x" }] } });
|
|
787
|
+
const literal = (await patch(MD, { type: "update_content", content_updates: [{ old_str: "anchor-line", new_str: "anchor-$&-line" }] })).json.markdown;
|
|
788
|
+
assert.ok(literal.endsWith("anchor-$&-line"), "new_str is inserted literally ($& is not a pattern)");
|
|
789
|
+
await patch(MD, { type: "update_content", content_updates: [{ old_str: "anchor-$&-line", new_str: "anchor-line" }] });
|
|
790
|
+
// Replacing a page's blocks again and again stays fast and exact: trashed blocks are never renumbered or rescanned per
|
|
791
|
+
// entry. (300 short paragraphs keep the drill evidence small; the 1000-block x8 case is timed in VERIFICATION.md.)
|
|
792
|
+
const bulk = (await post("/v1/pages", { parent: { page_id: created.id }, properties: { title: [text("Bulk")] } })).json;
|
|
793
|
+
for (let round = 0; round < 4; round += 1) {
|
|
794
|
+
const doc = Array.from({ length: 300 }, (_, i) => `${round}.${i}`).join("\n\n");
|
|
795
|
+
const replaced = (await quick(`replace 1000 blocks #${round}`, () => patch(`/v1/pages/${bulk.id}/markdown`, { type: "replace_content", new_str: doc, allow_deleting_content: true }))).json;
|
|
796
|
+
assert.equal(replaced.markdown, doc, `replace #${round} renders exactly the new document`);
|
|
797
|
+
}
|
|
798
|
+
const bulkChildren = (await get(`/v1/blocks/${bulk.id}/children?page_size=2`)).json.results;
|
|
799
|
+
assert.deepEqual(bulkChildren.map((block) => plain(block.paragraph.rich_text)), ["3.0", "3.1"]);
|
|
800
|
+
for (const marker of ["**", "~~"]) {
|
|
801
|
+
const markdown = (await quick(`${marker} x30000`, () => patch(MD, { type: "insert_content", content: marker.repeat(30000) }))).json.markdown;
|
|
802
|
+
assert.ok(markdown.includes(legit), "paired markers only toggle formatting");
|
|
803
|
+
}
|
|
804
|
+
// 150 toggle levels is about 450 JSON levels, below the codec's 512 bound, so the handler's own bound answers.
|
|
805
|
+
let deepChild = { type: "paragraph", paragraph: { rich_text: [] } };
|
|
806
|
+
for (let level = 0; level < 150; level += 1) deepChild = { type: "toggle", toggle: { rich_text: [] }, children: [deepChild] };
|
|
807
|
+
await quick("append nested 150 deep", () => apiError("PATCH", `/v1/blocks/${created.id}/children`, 400, "validation_error", "body.children[0].children[0].children[0].children nests blocks deeper than 2 levels", { body: { children: [deepChild] } }));
|
|
808
|
+
await assertDeepBodiesRefused(created.id);
|
|
809
|
+
// Stored trees stay within 64 levels below the page: 21 appends of three levels reach 63, one more level is allowed.
|
|
810
|
+
const toggleChain = (label) => ({ type: "toggle", toggle: { rich_text: [text(`${label}.1`)] }, children: [{ type: "toggle", toggle: { rich_text: [text(`${label}.2`)] }, children: [{ type: "toggle", toggle: { rich_text: [text(`${label}.3`)] } }] }] });
|
|
811
|
+
let chainParent = created.id;
|
|
812
|
+
for (let round = 0; round < 21; round += 1) {
|
|
813
|
+
let id = (await patch(`/v1/blocks/${chainParent}/children`, { children: [toggleChain(`r${round}`)] })).json.results[0].id;
|
|
814
|
+
for (let step = 0; step < 2; step += 1) id = (await get(`/v1/blocks/${id}/children`)).json.results[0].id;
|
|
815
|
+
chainParent = id;
|
|
816
|
+
}
|
|
817
|
+
await apiError("PATCH", `/v1/blocks/${chainParent}/children`, 400, "validation_error", "would nest blocks 66 levels below the page; the limit is 64", { body: { children: [toggleChain("over")] } });
|
|
818
|
+
await patch(`/v1/blocks/${chainParent}/children`, { children: [{ type: "paragraph", paragraph: { rich_text: [text("level 64")] } }] });
|
|
819
|
+
const deepMarkdown = (await quick("retrieve 64-level page", () => get(MD))).json.markdown;
|
|
820
|
+
assert.ok(deepMarkdown.includes("<summary>r20.3</summary>") && deepMarkdown.includes("level 64"), "the 64-level tree renders");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------------------------
|
|
824
|
+
// Drill: trash-and-restore (member, baseline)
|
|
825
|
+
// ---------------------------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
async function trashAndRestore() {
|
|
828
|
+
const seededTrash = await opOk("workspace.trash", {});
|
|
829
|
+
assert.equal(seededTrash.type, "page_or_database");
|
|
830
|
+
assert.deepEqual(ids(seededTrash.results), [ID.roadmap], "only the seeded Roadmap 2025 page starts in the trash");
|
|
831
|
+
await opError("workspace.trash", { page_size: 0 }, "VALIDATION_ERROR");
|
|
832
|
+
const trashed = (await patch(`/v1/pages/${ID.meetings}`, { in_trash: true })).json;
|
|
833
|
+
assert.equal(trashed.in_trash, true);
|
|
834
|
+
assert.equal(trashed.archived, true);
|
|
835
|
+
const afterTrash = await opOk("workspace.trash", { page_size: 1 });
|
|
836
|
+
assert.deepEqual(ids(afterTrash.results), [ID.meetings], "the newest deletion comes first and children of a trashed page are not listed as roots");
|
|
837
|
+
assert.equal(afterTrash.has_more, true);
|
|
838
|
+
assert.equal(afterTrash.next_cursor, ID.meetings);
|
|
839
|
+
const child = (await get(`/v1/pages/${ID.sync0908}`)).json;
|
|
840
|
+
assert.equal(child.in_trash, true, "child pages are trashed with their parent");
|
|
841
|
+
assert.equal((await get(`/v1/blocks/${ID.actionItem}`)).json.in_trash, true, "blocks are trashed with their page");
|
|
842
|
+
assert.equal((await post("/v1/search", { query: "sync" })).json.results.length, 0, "trashed pages leave search");
|
|
843
|
+
assert.equal((await post("/v1/search", { query: "meeting" })).json.results.length, 0);
|
|
844
|
+
assert.ok(!(await get(`/v1/blocks/${ID.home}/children`)).json.results.some((block) => block.id === ID.meetings), "the child_page block left the listing");
|
|
845
|
+
await apiError("PATCH", `/v1/pages/${ID.sync0908}`, 400, "validation_error", "Can't edit block that is archived", { body: { properties: { title: [text("x")] } } });
|
|
846
|
+
await apiError("POST", `/v1/pages/${ID.scratch}/move`, 400, "validation_error", "in the trash", { body: { parent: { type: "page_id", page_id: ID.meetings } } });
|
|
847
|
+
const restored = (await patch(`/v1/pages/${ID.meetings}`, { archived: false })).json;
|
|
848
|
+
assert.equal(restored.in_trash, false);
|
|
849
|
+
assert.equal((await get(`/v1/pages/${ID.sync0908}`)).json.in_trash, false, "the subtree came back");
|
|
850
|
+
assert.equal((await get(`/v1/blocks/${ID.actionItem}`)).json.in_trash, false);
|
|
851
|
+
assert.deepEqual(ids((await post("/v1/search", { query: "sync" })).json.results), [ID.sync0908, ID.sync0901]);
|
|
852
|
+
assert.equal((await post("/v1/search", { query: "roadmap" })).json.results.length, 0, "the seeded trashed page is hidden");
|
|
853
|
+
const roadmap = (await patch(`/v1/pages/${ID.roadmap}`, { in_trash: false })).json;
|
|
854
|
+
assert.equal(roadmap.in_trash, false);
|
|
855
|
+
assert.deepEqual(ids((await post("/v1/search", { query: "roadmap" })).json.results), [ID.roadmap]);
|
|
856
|
+
assert.equal((await get(`/v1/blocks/${ID.roadmap}/children`)).json.results.length, 1, "its paragraph is back");
|
|
857
|
+
const emptied = await opOk("workspace.trash", {});
|
|
858
|
+
assert.deepEqual(emptied.results, [], "the trash is empty once everything is restored");
|
|
859
|
+
assert.equal(emptied.has_more, false);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ---------------------------------------------------------------------------------------------
|
|
863
|
+
// Drill: mcp-aliases (member, baseline)
|
|
864
|
+
// ---------------------------------------------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
async function mcpAliases() {
|
|
867
|
+
await mcpInit();
|
|
868
|
+
const tools = (await rpc("tools/list", {})).tools.map((tool) => tool.name);
|
|
869
|
+
for (const alias of ALIASES) assert.ok(tools.includes(alias), `alias ${alias} missing`);
|
|
870
|
+
for (const operationId of ALL_OPERATIONS) assert.ok(tools.includes(`notion.${operationId}`), `canonical notion.${operationId} missing`);
|
|
871
|
+
assert.equal(tools.length, ALIASES.length + ALL_OPERATIONS.length);
|
|
872
|
+
const me = await mcp("API-get-self", {});
|
|
873
|
+
assert.equal(me.id, ID.agentBot);
|
|
874
|
+
const search = await mcp("API-post-search", { query: "design", filter: { property: "object", value: "page" } });
|
|
875
|
+
assert.deepEqual(ids(search.results), [ID.design]);
|
|
876
|
+
const page = await mcp("API-retrieve-a-page", { page_id: ID.t3 });
|
|
877
|
+
assert.equal(page.properties.Status.status.name, "Todo");
|
|
878
|
+
const queried = await mcp("API-query-data-source", { data_source_id: ID.dsTasks, filter: { property: "Assignee", people: { contains: ID.priya } }, page_size: 2 });
|
|
879
|
+
assert.equal(queried.results.length, 2);
|
|
880
|
+
assert.equal(queried.has_more, true);
|
|
881
|
+
const children = await mcp("API-get-block-children", { block_id: ID.sync0908, page_size: 3 });
|
|
882
|
+
assert.equal(children.results.length, 3);
|
|
883
|
+
const updated = await mcp("API-patch-page", { page_id: ID.t3, properties: { Status: { status: { name: "In progress" } } } });
|
|
884
|
+
assert.equal(updated.properties.Status.status.name, "In progress");
|
|
885
|
+
const block = await mcp("API-update-a-block", { block_id: ID.actionItem, type: { to_do: { checked: true } } });
|
|
886
|
+
assert.equal(block.to_do.checked, true);
|
|
887
|
+
const comments = await mcp("API-retrieve-a-comment", { block_id: ID.sync0908, page_size: 2 });
|
|
888
|
+
assert.equal(comments.results.length, 2);
|
|
889
|
+
assert.equal(comments.has_more, true);
|
|
890
|
+
const missing = await mcpError("API-retrieve-a-page", { page_id: ID.missingPage }, "tool.OBJECT_NOT_FOUND");
|
|
891
|
+
assert.ok(missing.message.includes("Could not find page with ID"));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ---------------------------------------------------------------------------------------------
|
|
895
|
+
// Drill: scope-notes-bot (notes-bot, baseline)
|
|
896
|
+
// ---------------------------------------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
async function scopeNotesBot() {
|
|
899
|
+
const me = await opOk("users.me", {});
|
|
900
|
+
assert.equal((await get("/v1/users/me")).json.id, ID.notesBot, "GET /v1/users/me is served by users.get");
|
|
901
|
+
assert.equal(me.id, ID.notesBot);
|
|
902
|
+
assert.equal(me.name, "Notes Sync");
|
|
903
|
+
const page = (await get(`/v1/pages/${ID.sync0908}`)).json;
|
|
904
|
+
assert.equal(page.id, ID.sync0908);
|
|
905
|
+
assert.deepEqual(page.created_by, { object: "user", id: ID.priya });
|
|
906
|
+
const children = (await get(`/v1/blocks/${ID.sync0908}/children`)).json.results;
|
|
907
|
+
assert.equal(children.length, 8);
|
|
908
|
+
await apiError("GET", `/v1/pages/${ID.engineering}`, 404, "object_not_found", "shared with your integration");
|
|
909
|
+
await apiError("GET", `/v1/pages/${ID.t1}`, 404, "object_not_found", "Could not find page with ID");
|
|
910
|
+
await apiError("GET", `/v1/blocks/${ID.design}/children`, 404, "object_not_found");
|
|
911
|
+
await apiError("GET", `/v1/databases/${ID.dbTasks}`, 404, "object_not_found");
|
|
912
|
+
const search = (await post("/v1/search", {})).json;
|
|
913
|
+
assert.deepEqual(ids(search.results).sort(), [ID.meetings, ID.sync0908, ID.sync0901].sort(), "only the shared subtree is searchable");
|
|
914
|
+
const comments = (await get(`/v1/comments?block_id=${ID.sync0908}`)).json;
|
|
915
|
+
assert.equal(comments.results.length, 3);
|
|
916
|
+
assert.deepEqual(comments.results[0].created_by, { object: "user", id: ID.marco }, "user_information: none hides names and e-mails");
|
|
917
|
+
await apiError("POST", "/v1/pages", 403, "restricted_resource", "insert content capabilities", { body: { parent: { page_id: ID.meetings }, properties: { title: [text("x")] } } });
|
|
918
|
+
await apiError("PATCH", `/v1/pages/${ID.sync0908}`, 403, "restricted_resource", "update content capabilities", { body: { properties: { title: [text("x")] } } });
|
|
919
|
+
await apiError("POST", `/v1/pages/${ID.sync0901}/move`, 403, "restricted_resource", "update content capabilities", { body: { parent: { type: "page_id", page_id: ID.meetings } } });
|
|
920
|
+
await apiError("PATCH", `/v1/pages/${ID.sync0908}/markdown`, 403, "restricted_resource", "update content capabilities", { body: { type: "insert_content", content: "x" } });
|
|
921
|
+
await apiError("POST", "/v1/databases", 403, "restricted_resource", "insert content capabilities", { body: { parent: { page_id: ID.meetings }, initial_data_source: { properties: { Name: { title: {} } } } } });
|
|
922
|
+
await apiError("PATCH", `/v1/data_sources/${ID.dsTasks}`, 403, "restricted_resource", "update content capabilities", { body: { description: [] } });
|
|
923
|
+
await apiError("PATCH", `/v1/blocks/${ID.sync0908}/children`, 403, "restricted_resource", "insert content capabilities", { body: { children: [{ type: "paragraph", paragraph: { rich_text: [text("x")] } }] } });
|
|
924
|
+
await apiError("PATCH", `/v1/blocks/${ID.actionItem}`, 403, "restricted_resource", "update content capabilities", { body: { to_do: { checked: true } } });
|
|
925
|
+
await apiError("DELETE", `/v1/blocks/${ID.actionItem}`, 403, "restricted_resource", "update content capabilities");
|
|
926
|
+
await apiError("POST", "/v1/comments", 403, "restricted_resource", "insert comment capabilities", { body: { parent: { page_id: ID.sync0908 }, rich_text: [text("x")] } });
|
|
927
|
+
await apiError("GET", "/v1/users", 403, "restricted_resource", "user information capabilities");
|
|
928
|
+
await apiError("GET", `/v1/users/${ID.ines}`, 403, "restricted_resource", "user information capabilities");
|
|
929
|
+
assert.equal((await get(`/v1/pages/${ID.sync0908}`)).json.last_edited_time, "2026-09-08T10:05:00.000Z", "nothing was written");
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ---------------------------------------------------------------------------------------------
|
|
933
|
+
// Drill: scope-board-bot (board-bot, baseline)
|
|
934
|
+
// ---------------------------------------------------------------------------------------------
|
|
935
|
+
|
|
936
|
+
async function scopeBoardBot() {
|
|
937
|
+
const me = await opOk("users.me", {});
|
|
938
|
+
assert.equal(me.name, "Status Board");
|
|
939
|
+
const users = (await get("/v1/users")).json;
|
|
940
|
+
assert.equal(users.results.length, 8);
|
|
941
|
+
assert.deepEqual(users.results[0].person, {}, "without_emails hides e-mail addresses");
|
|
942
|
+
assert.equal(users.results[0].name, "Ines Okafor", "but names stay visible");
|
|
943
|
+
assert.deepEqual((await get(`/v1/users/${ID.yuki}`)).json.person, {});
|
|
944
|
+
const task = (await get(`/v1/pages/${ID.t1}`)).json;
|
|
945
|
+
assert.equal(task.properties.Assignee.people[0].name, "Tomas Lindqvist");
|
|
946
|
+
assert.equal(task.properties.Assignee.people[0].person.email, undefined);
|
|
947
|
+
assert.equal((await post("/v1/search", { query: "engineering" })).json.results.length, 1, "workspace-wide access");
|
|
948
|
+
await apiError("GET", `/v1/comments?block_id=${ID.sync0908}`, 403, "restricted_resource", "read comment capabilities");
|
|
949
|
+
await apiError("POST", "/v1/comments", 403, "restricted_resource", "insert comment capabilities", { body: { parent: { page_id: ID.sync0908 }, rich_text: [text("x")] } });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ---------------------------------------------------------------------------------------------
|
|
953
|
+
// Drill: scope-stranger (stranger, baseline)
|
|
954
|
+
// ---------------------------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
async function scopeStranger() {
|
|
957
|
+
for (const operationId of ALL_OPERATIONS) {
|
|
958
|
+
if (operationId === "workspace.context" || operationId === "workspace.trash" || operationId === "users.me") {
|
|
959
|
+
await opError(operationId, {}, "UNAUTHORIZED");
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
await callError(CALLS[operationId], 401, "unauthorized", "The bearer token is not valid.");
|
|
963
|
+
}
|
|
964
|
+
await mcpInit();
|
|
965
|
+
await mcpError("API-get-self", {}, "tool.UNAUTHORIZED");
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ---------------------------------------------------------------------------------------------
|
|
969
|
+
// Drill: scope-auditor (auditor, baseline)
|
|
970
|
+
// ---------------------------------------------------------------------------------------------
|
|
971
|
+
|
|
972
|
+
async function scopeAuditor() {
|
|
973
|
+
for (const operationId of ["pages.retrieve", "search", "pages.create"]) {
|
|
974
|
+
const result = await callError(CALLS[operationId], 403, "restricted_resource", "not granted to the calling actor");
|
|
975
|
+
assert.equal(result.json.object, "error");
|
|
976
|
+
}
|
|
977
|
+
await mcpInit();
|
|
978
|
+
const result = await rpc("tools/call", { name: "API-get-self", arguments: {} });
|
|
979
|
+
assert.ok(result.isError);
|
|
980
|
+
assert.equal(result.structuredContent?.error?.code, "world.OPERATION_DENIED", JSON.stringify(result).slice(0, 300));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ---------------------------------------------------------------------------------------------
|
|
984
|
+
// Drill: scope-agent-only (agent-only, baseline)
|
|
985
|
+
// ---------------------------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
async function scopeAgentOnly() {
|
|
988
|
+
const me = await opOk("users.me", {});
|
|
989
|
+
assert.equal(me.id, ID.agentBot, "no attributes → the first integration's bot");
|
|
990
|
+
const context = await opOk("workspace.context", {});
|
|
991
|
+
assert.equal(context.user.id, ID.agentBot);
|
|
992
|
+
assert.equal(context.user.type, "bot");
|
|
993
|
+
assert.equal(context.integration.access.type, "workspace");
|
|
994
|
+
assert.equal((await post("/v1/search", { query: "engineering" })).json.results.length, 1, "the whole workspace is visible");
|
|
995
|
+
const comment = (await post("/v1/comments", { parent: { page_id: ID.t1 }, rich_text: [text("Bring-up suite is green again.")] })).json;
|
|
996
|
+
assert.equal(comment.id, "fd000000-0000-4000-8000-000600001000", "the comment takes counter 0x1000, its discussion 0x1001");
|
|
997
|
+
assert.equal(comment.created_by.id, ID.agentBot, "writes are attributed to the bot");
|
|
998
|
+
assert.deepEqual(comment.display_name, { type: "integration", resolved_name: "Firedrill Agent" });
|
|
999
|
+
const page = (await post("/v1/pages", { parent: { page_id: ID.scratch }, properties: { title: { title: [text("Bot notes")] } } })).json;
|
|
1000
|
+
assert.equal(page.id, "fd000000-0000-4000-8000-000200001002");
|
|
1001
|
+
assert.equal(page.created_by.id, ID.agentBot);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ---------------------------------------------------------------------------------------------
|
|
1005
|
+
// Fault drills
|
|
1006
|
+
// ---------------------------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
async function rateLimited() {
|
|
1009
|
+
for (const operationId of ["search", "data-sources.query", "blocks.children.list", "pages.retrieve"]) {
|
|
1010
|
+
const result = await callError(CALLS[operationId], 429, "rate_limited", "You have been rate limited.");
|
|
1011
|
+
assert.equal(result.headers.get("retry-after"), "1", `${operationId}: Retry-After header`);
|
|
1012
|
+
}
|
|
1013
|
+
assert.equal((await opOk("users.me", {})).id, ID.agentBot, "other reads keep working");
|
|
1014
|
+
assert.equal((await get(`/v1/databases/${ID.dbTasks}`)).json.id, ID.dbTasks);
|
|
1015
|
+
await mcpInit();
|
|
1016
|
+
const error = await mcpError("API-post-search", { query: "sync" }, "tool.RATE_LIMITED");
|
|
1017
|
+
assert.equal(error.retryable, true);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function writeUnavailable() {
|
|
1021
|
+
for (const operationId of ["pages.create", "pages.update", "blocks.children.append", "comments.create"]) {
|
|
1022
|
+
await callError(CALLS[operationId], 503, "service_unavailable", "Notion is unavailable");
|
|
1023
|
+
}
|
|
1024
|
+
assert.equal((await get(`/v1/pages/${ID.t1}`)).json.properties.Estimate.number, 8, "nothing was written");
|
|
1025
|
+
assert.equal((await get(`/v1/blocks/${ID.scratch}/children`)).json.results.length, 0);
|
|
1026
|
+
const block = (await patch(`/v1/blocks/${ID.actionItem}`, { to_do: { checked: true } })).json;
|
|
1027
|
+
assert.equal(block.to_do.checked, true, "blocks.update is not covered by the fault");
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function updateLost() {
|
|
1031
|
+
await apiError("PATCH", `/v1/pages/${ID.t7}`, 409, "conflict_error", "Conflict occurred while saving.", { body: { properties: { Status: { status: { name: "In progress" } } } } });
|
|
1032
|
+
const page = (await get(`/v1/pages/${ID.t7}`)).json;
|
|
1033
|
+
assert.equal(page.properties.Status.status.name, "In progress", "the update committed although the caller saw 409");
|
|
1034
|
+
assert.equal(page.last_edited_time, "2026-09-14T09:00:00.000Z");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function bounded() {
|
|
1038
|
+
const BOUNDED = ["users.list", "search", "pages.create", "pages.update", "pages.move", "pages.retrieve-markdown", "pages.update-markdown", "databases.create", "data-sources.query", "data-sources.update", "blocks.children.list", "blocks.children.append", "blocks.update", "blocks.delete", "comments.create", "comments.list"];
|
|
1039
|
+
for (const operationId of BOUNDED) {
|
|
1040
|
+
const request = operationId === "comments.create" ? ["POST", "/v1/comments", { discussion_id: ID.discussion1, rich_text: [text("Probe")] }] : CALLS[operationId];
|
|
1041
|
+
await callError(request, 400, "validation_error", "state exceeds the supported bound of 4 rows");
|
|
1042
|
+
}
|
|
1043
|
+
await opError("workspace.trash", {}, "FAILED_PRECONDITION");
|
|
1044
|
+
const context = await opOk("workspace.context", {});
|
|
1045
|
+
assert.equal(context.limits.max_rows_per_namespace, 4);
|
|
1046
|
+
assert.equal((await opOk("users.me", {})).id, ID.agentBot);
|
|
1047
|
+
assert.equal((await get(`/v1/pages/${ID.t1}`)).json.id, ID.t1, "point reads do not scan");
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ---------------------------------------------------------------------------------------------
|
|
1051
|
+
// Drill: byte-budget (member, baseline) — responses stay under 1 MiB; lists page by encoded bytes
|
|
1052
|
+
// ---------------------------------------------------------------------------------------------
|
|
1053
|
+
|
|
1054
|
+
const encodedBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
1055
|
+
const cjkItems = (count) => Array.from({ length: count }, () => text("界".repeat(2000)));
|
|
1056
|
+
|
|
1057
|
+
/** Walk a Notion list to the end, asserting every page is a real, bounded page; returns ids and page count. */
|
|
1058
|
+
async function walkList(first, next) {
|
|
1059
|
+
const seen = [];
|
|
1060
|
+
let pages = 0;
|
|
1061
|
+
let result = await first();
|
|
1062
|
+
for (;;) {
|
|
1063
|
+
pages += 1;
|
|
1064
|
+
assert.ok(encodedBytes(result.json) < 1_000_000, `list page of ${encodedBytes(result.json)} bytes`);
|
|
1065
|
+
assert.ok(result.json.results.length > 0 || !result.json.has_more, "an empty page claims has_more");
|
|
1066
|
+
seen.push(...ids(result.json.results));
|
|
1067
|
+
if (!result.json.has_more) break;
|
|
1068
|
+
assert.equal(result.json.next_cursor, seen[seen.length - 1], "next_cursor names the last returned item");
|
|
1069
|
+
result = await next(result.json.next_cursor);
|
|
1070
|
+
}
|
|
1071
|
+
assert.equal(new Set(seen).size, seen.length, "every item appears exactly once");
|
|
1072
|
+
return { seen, pages };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function byteBudget() {
|
|
1076
|
+
// One paragraph whose rendering (text.content + plain_text) passes the object budget is refused; nothing is stored.
|
|
1077
|
+
const before = (await get(`/v1/blocks/${ID.scratch}/children`)).json.results.length;
|
|
1078
|
+
await apiError("PATCH", `/v1/blocks/${ID.scratch}/children`, 400, "validation_error", "once rendered", { body: { children: [{ type: "paragraph", paragraph: { rich_text: cjkItems(100) } }] } });
|
|
1079
|
+
await apiError("PATCH", `/v1/blocks/${ID.scratch}/children`, 400, "validation_error", "text.link.url.length should be ≤ 2000", { body: { children: [{ paragraph: { rich_text: [{ text: { content: "x", link: { url: `https://example.com/${"a".repeat(2000)}` } } }] } }] } });
|
|
1080
|
+
assert.equal((await get(`/v1/blocks/${ID.scratch}/children`)).json.results.length, before);
|
|
1081
|
+
// Near-limit blocks are stored and read back one page at a time with a real cursor.
|
|
1082
|
+
const page = (await post("/v1/pages", { parent: { page_id: ID.scratch }, properties: { title: [text("Byte budget")] } })).json;
|
|
1083
|
+
const appended = [];
|
|
1084
|
+
for (let index = 0; index < 3; index += 1) {
|
|
1085
|
+
const result = await patch(`/v1/blocks/${page.id}/children`, { children: [{ type: "paragraph", paragraph: { rich_text: cjkItems(65) } }] });
|
|
1086
|
+
appended.push(...ids(result.json.results));
|
|
1087
|
+
}
|
|
1088
|
+
appended.push(...ids((await patch(`/v1/blocks/${page.id}/children`, { children: [{ type: "paragraph", paragraph: { rich_text: [text("small")] } }] })).json.results));
|
|
1089
|
+
const children = await walkList(() => get(`/v1/blocks/${page.id}/children?page_size=100`), (cursor) => get(`/v1/blocks/${page.id}/children?page_size=100&start_cursor=${cursor}`));
|
|
1090
|
+
assert.deepEqual(children.seen, appended);
|
|
1091
|
+
assert.ok(children.pages >= 3, `children in ${children.pages} pages`);
|
|
1092
|
+
await apiError("GET", `/v1/pages/${page.id}/markdown`, 400, "validation_error", "The response is too large");
|
|
1093
|
+
// Comments, search and a data source query fill pages by bytes as well.
|
|
1094
|
+
for (let index = 0; index < 2; index += 1) await post("/v1/comments", { parent: { page_id: page.id }, rich_text: cjkItems(64) });
|
|
1095
|
+
const comments = await walkList(() => get(`/v1/comments?block_id=${page.id}&page_size=100`), (cursor) => get(`/v1/comments?block_id=${page.id}&page_size=100&start_cursor=${cursor}`));
|
|
1096
|
+
assert.equal(comments.seen.length, 2);
|
|
1097
|
+
assert.equal(comments.pages, 2);
|
|
1098
|
+
const created = [];
|
|
1099
|
+
for (let index = 0; index < 2; index += 1) {
|
|
1100
|
+
created.push((await post("/v1/pages", { parent: { page_id: page.id }, properties: { title: [text("zqbudget "), ...cjkItems(64)] } })).json.id);
|
|
1101
|
+
created.push((await post("/v1/pages", { parent: { data_source_id: ID.dsTasks }, properties: { Name: { title: [text("zqbudget "), ...cjkItems(64)] } } })).json.id);
|
|
1102
|
+
}
|
|
1103
|
+
const found = await walkList(() => post("/v1/search", { query: "zqbudget", page_size: 100 }), (cursor) => post("/v1/search", { query: "zqbudget", page_size: 100, start_cursor: cursor }));
|
|
1104
|
+
assert.deepEqual([...found.seen].sort(), [...created].sort());
|
|
1105
|
+
assert.equal(found.pages, 4);
|
|
1106
|
+
const rows = await walkList(() => query(ID.dsTasks, { page_size: 100 }), (cursor) => query(ID.dsTasks, { page_size: 100, start_cursor: cursor }));
|
|
1107
|
+
for (const id of created.filter((_, index) => index % 2 === 1)) assert.ok(rows.seen.includes(id), `query is missing ${id}`);
|
|
1108
|
+
const title = await get(`/v1/pages/${created[0]}/properties/title?page_size=100`);
|
|
1109
|
+
assert.equal(title.json.results.length, 65);
|
|
1110
|
+
// A database whose title and description together render past the object budget is refused.
|
|
1111
|
+
await apiError("POST", "/v1/databases", 400, "validation_error", "bytes once rendered", { body: { parent: { page_id: page.id }, title: cjkItems(64), description: cjkItems(64), initial_data_source: { properties: { Name: { title: {} } } } } });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Raw PATCH that returns whatever status the Tool answers (used where a refusal point is being searched for). */
|
|
1115
|
+
async function patchAny(path, body) {
|
|
1116
|
+
const response = await fetch(`${HTTP}${path}`, { method: "PATCH", headers: { authorization: `Bearer ${HTTP_TOKEN}`, "notion-version": "2025-09-03", "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1117
|
+
const raw = await response.text();
|
|
1118
|
+
return { status: response.status, json: raw.length > 0 ? JSON.parse(raw) : undefined };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* A page near the object budget stays readable while its data source schema grows: the schema is bounded by what it
|
|
1123
|
+
* adds to every page (created_by renders an expanded user per page), refused at exactly limit + 1, accepted at limit.
|
|
1124
|
+
*/
|
|
1125
|
+
async function schemaGrowth() {
|
|
1126
|
+
const LIMIT = 150_000;
|
|
1127
|
+
const parentId = (await post("/v1/pages", { parent: { page_id: ID.scratch }, properties: { title: [text("Schema growth")] } })).json.id;
|
|
1128
|
+
const database = (await post("/v1/databases", { parent: { page_id: parentId }, title: [text("Schema growth")], initial_data_source: { properties: { Name: { title: {} } } } })).json;
|
|
1129
|
+
const source = database.data_sources[0].id;
|
|
1130
|
+
const path = `/v1/data_sources/${source}`;
|
|
1131
|
+
// A small row keeps the drill's evidence small; the 64-item near-limit row is proven by the live probe in VERIFICATION.md.
|
|
1132
|
+
const row = (await post("/v1/pages", { parent: { data_source_id: source }, properties: { Name: { title: cjkItems(4) } } })).json.id;
|
|
1133
|
+
const added = (message) => Number(/would add (\d+) bytes to the rendering of each of its pages/.exec(message)?.[1]);
|
|
1134
|
+
// Grow in batches of 100 created_by properties until the page-cost bound refuses a batch (nothing of it is stored).
|
|
1135
|
+
let batches = 0;
|
|
1136
|
+
for (;;) {
|
|
1137
|
+
const properties = Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`c${batches}.${index}`, { created_by: {} }]));
|
|
1138
|
+
const result = await patchAny(path, { properties });
|
|
1139
|
+
if (result.status === 400) {
|
|
1140
|
+
assert.equal(result.json.code, "validation_error");
|
|
1141
|
+
assert.ok(added(result.json.message) > LIMIT, result.json.message);
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
assert.equal(result.status, 200, JSON.stringify(result.json).slice(0, 300));
|
|
1145
|
+
batches += 1;
|
|
1146
|
+
assert.ok(batches < 100, "the schema page-cost bound never refused");
|
|
1147
|
+
}
|
|
1148
|
+
assert.equal(Object.keys((await get(path)).json.properties).length, 1 + batches * 100);
|
|
1149
|
+
// Size one more property's name so the total is exactly limit + 1 (refused), then limit (accepted).
|
|
1150
|
+
const probeLength = 60_000;
|
|
1151
|
+
const probe = await apiError("PATCH", path, 400, "validation_error", "to the rendering of each of its pages", { body: { properties: { ["p".repeat(probeLength)]: { created_by: {} } } } });
|
|
1152
|
+
const exactLength = probeLength - (added(probe.json.message) - (LIMIT + 1));
|
|
1153
|
+
assert.ok(exactLength > 1, `probe reported ${probe.json.message}`);
|
|
1154
|
+
await apiError("PATCH", path, 400, "validation_error", `would add ${LIMIT + 1} bytes`, { body: { properties: { ["p".repeat(exactLength)]: { created_by: {} } } } });
|
|
1155
|
+
await patch(path, { properties: { ["p".repeat(exactLength - 1)]: { created_by: {} } } });
|
|
1156
|
+
// The row and its whole data source stay readable, under 1 MiB.
|
|
1157
|
+
const retrieved = await get(`/v1/pages/${row}`);
|
|
1158
|
+
assert.ok(encodedBytes(retrieved.json) < 1_000_000, `page of ${encodedBytes(retrieved.json)} bytes`);
|
|
1159
|
+
const rows = await walkList(() => query(source, { page_size: 1 }), (cursor) => query(source, { page_size: 1, start_cursor: cursor }));
|
|
1160
|
+
assert.deepEqual(rows.seen, [row]);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ---------------------------------------------------------------------------------------------
|
|
1164
|
+
|
|
1165
|
+
const flows = {
|
|
1166
|
+
"workspace-read": workspaceRead,
|
|
1167
|
+
"task-triage": taskTriage,
|
|
1168
|
+
"page-authoring": pageAuthoring,
|
|
1169
|
+
"trash-and-restore": trashAndRestore,
|
|
1170
|
+
"mcp-aliases": mcpAliases,
|
|
1171
|
+
"scope-notes-bot": scopeNotesBot,
|
|
1172
|
+
"scope-board-bot": scopeBoardBot,
|
|
1173
|
+
"scope-stranger": scopeStranger,
|
|
1174
|
+
"scope-auditor": scopeAuditor,
|
|
1175
|
+
"scope-agent-only": scopeAgentOnly,
|
|
1176
|
+
"rate-limited": rateLimited,
|
|
1177
|
+
"write-unavailable": writeUnavailable,
|
|
1178
|
+
"update-lost": updateLost,
|
|
1179
|
+
bounded,
|
|
1180
|
+
"byte-budget": byteBudget,
|
|
1181
|
+
"schema-growth": schemaGrowth,
|
|
1182
|
+
};
|
|
1183
|
+
const selected = Object.keys(flows).find((name) => instruction.includes(`the ${name} conformance flow`));
|
|
1184
|
+
if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
|
|
1185
|
+
await flows[selected]();
|
|
1186
|
+
process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
|