@gravitykit/block-mcp 2.0.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Tool tests: edit_block_tree (9 ops)
3
+ *
4
+ * Ops covered: update-attrs, update-html, replace-block, remove-block,
5
+ * wrap-in-group, unwrap-group, insert-child, duplicate, move.
6
+ *
7
+ * Each op has dedicated describe blocks covering:
8
+ * - Validation (per-op required fields)
9
+ * - Forwarding to client.mutateBlockTree
10
+ * - Common: path/ref XOR, op enum, post_id required, integer-path shape
11
+ * - Warning formatting (static_markup_stale_risk + preference warning)
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from 'vitest';
15
+ import { handleMutateTool } from '../../../tools/mutate.js';
16
+ import { makeMockClient } from '../../helpers/mock-client.js';
17
+ import {
18
+ mutationUpdateAttrsResponse, mutationWithStaticWarning,
19
+ } from '../../fixtures/rest-responses.js';
20
+ import { assertMutationResponse } from '../../helpers/schema-asserts.js';
21
+
22
+ // ── Common validation ─────────────────────────────────────────────────────────
23
+
24
+ describe('edit_block_tree — common validation', () => {
25
+ let client: ReturnType<typeof makeMockClient>;
26
+ beforeEach(() => {
27
+ client = makeMockClient();
28
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
29
+ });
30
+
31
+ it('rejects unknown top-level tool name', async () => {
32
+ await expect(
33
+ handleMutateTool('not_a_real_tool', { post_id: 1, op: 'update-attrs', path: [0] }, client as any)
34
+ ).rejects.toThrow(/Unknown mutate tool/);
35
+ });
36
+
37
+ it('requires post_id', async () => {
38
+ await expect(
39
+ handleMutateTool('edit_block_tree', { op: 'update-attrs', path: [0], attributes: {} }, client as any)
40
+ ).rejects.toThrow(/post_id is required/);
41
+ });
42
+
43
+ it('rejects unknown op', async () => {
44
+ await expect(
45
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'frobnicate', path: [0] }, client as any)
46
+ ).rejects.toThrow(/op must be one of/);
47
+ });
48
+
49
+ it('rejects missing op', async () => {
50
+ await expect(
51
+ handleMutateTool('edit_block_tree', { post_id: 1, path: [0] }, client as any)
52
+ ).rejects.toThrow(/op must be one of/);
53
+ });
54
+
55
+ it('rejects both path and ref together', async () => {
56
+ await expect(
57
+ handleMutateTool('edit_block_tree', {
58
+ post_id: 1, op: 'remove-block', path: [0], ref: 'blk_a',
59
+ }, client as any)
60
+ ).rejects.toThrow(/path.*OR.*ref/i);
61
+ });
62
+
63
+ it('rejects when neither path nor ref is given', async () => {
64
+ await expect(
65
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'remove-block' }, client as any)
66
+ ).rejects.toThrow(/path.*or.*ref/i);
67
+ });
68
+
69
+ it('rejects non-array path', async () => {
70
+ await expect(
71
+ handleMutateTool('edit_block_tree', {
72
+ post_id: 1, op: 'remove-block', path: 'not-an-array',
73
+ }, client as any)
74
+ ).rejects.toThrow(/path must be an array of integers/);
75
+ });
76
+
77
+ it('rejects path with non-integer elements', async () => {
78
+ await expect(
79
+ handleMutateTool('edit_block_tree', {
80
+ post_id: 1, op: 'remove-block', path: [0, 1.5],
81
+ }, client as any)
82
+ ).rejects.toThrow(/path must contain only integers/);
83
+ });
84
+
85
+ it('rejects empty path', async () => {
86
+ await expect(
87
+ handleMutateTool('edit_block_tree', {
88
+ post_id: 1, op: 'remove-block', path: [],
89
+ }, client as any)
90
+ ).rejects.toThrow(/path must not be empty/);
91
+ });
92
+
93
+ it('forwards path verbatim to client.mutateBlockTree', async () => {
94
+ await handleMutateTool('edit_block_tree', {
95
+ post_id: 1, op: 'remove-block', path: [0, 2, 1],
96
+ }, client as any);
97
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
98
+ op: 'remove-block', path: [0, 2, 1],
99
+ }));
100
+ });
101
+
102
+ it('forwards ref verbatim to client.mutateBlockTree', async () => {
103
+ await handleMutateTool('edit_block_tree', {
104
+ post_id: 1, op: 'remove-block', ref: 'blk_xyz',
105
+ }, client as any);
106
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
107
+ op: 'remove-block', ref: 'blk_xyz',
108
+ }));
109
+ });
110
+ });
111
+
112
+ // ── op: update-attrs ──────────────────────────────────────────────────────────
113
+
114
+ describe('edit_block_tree — update-attrs', () => {
115
+ let client: ReturnType<typeof makeMockClient>;
116
+ beforeEach(() => {
117
+ client = makeMockClient();
118
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
119
+ });
120
+
121
+ it('requires attributes object', async () => {
122
+ await expect(
123
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'update-attrs', path: [0] }, client as any)
124
+ ).rejects.toThrow(/attributes/);
125
+ });
126
+
127
+ it('forwards attributes to client', async () => {
128
+ await handleMutateTool('edit_block_tree', {
129
+ post_id: 1, op: 'update-attrs', path: [0], attributes: { level: 3 },
130
+ }, client as any);
131
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
132
+ op: 'update-attrs', attributes: { level: 3 },
133
+ }));
134
+ });
135
+ });
136
+
137
+ // ── op: update-html ───────────────────────────────────────────────────────────
138
+
139
+ describe('edit_block_tree — update-html', () => {
140
+ let client: ReturnType<typeof makeMockClient>;
141
+ beforeEach(() => {
142
+ client = makeMockClient();
143
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
144
+ });
145
+
146
+ it('requires innerHTML string', async () => {
147
+ await expect(
148
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'update-html', path: [0] }, client as any)
149
+ ).rejects.toThrow(/innerHTML/);
150
+ });
151
+
152
+ it('forwards innerHTML to client', async () => {
153
+ await handleMutateTool('edit_block_tree', {
154
+ post_id: 1, op: 'update-html', path: [0], innerHTML: '<p>New</p>',
155
+ }, client as any);
156
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
157
+ op: 'update-html', innerHTML: '<p>New</p>',
158
+ }));
159
+ });
160
+
161
+ it('empty string innerHTML is forwarded (allowed)', async () => {
162
+ await handleMutateTool('edit_block_tree', {
163
+ post_id: 1, op: 'update-html', path: [0], innerHTML: '',
164
+ }, client as any);
165
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
166
+ innerHTML: '',
167
+ }));
168
+ });
169
+ });
170
+
171
+ // ── op: replace-block ─────────────────────────────────────────────────────────
172
+
173
+ describe('edit_block_tree — replace-block', () => {
174
+ let client: ReturnType<typeof makeMockClient>;
175
+ beforeEach(() => {
176
+ client = makeMockClient();
177
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
178
+ });
179
+
180
+ it('requires a block object with name', async () => {
181
+ await expect(
182
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'replace-block', path: [0] }, client as any)
183
+ ).rejects.toThrow(/"block" object.*"name"/);
184
+ });
185
+
186
+ it('rejects block missing name property', async () => {
187
+ await expect(
188
+ handleMutateTool('edit_block_tree', {
189
+ post_id: 1, op: 'replace-block', path: [0], block: { attributes: {} },
190
+ }, client as any)
191
+ ).rejects.toThrow(/name/);
192
+ });
193
+
194
+ it('forwards the block to client', async () => {
195
+ await handleMutateTool('edit_block_tree', {
196
+ post_id: 1, op: 'replace-block', path: [0],
197
+ block: { name: 'core/paragraph', attributes: { content: 'Hi' } },
198
+ }, client as any);
199
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
200
+ op: 'replace-block', block: { name: 'core/paragraph', attributes: { content: 'Hi' } },
201
+ }));
202
+ });
203
+ });
204
+
205
+ // ── op: insert-child ──────────────────────────────────────────────────────────
206
+
207
+ describe('edit_block_tree — insert-child', () => {
208
+ let client: ReturnType<typeof makeMockClient>;
209
+ beforeEach(() => {
210
+ client = makeMockClient();
211
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
212
+ });
213
+
214
+ it('requires a block object with name', async () => {
215
+ await expect(
216
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'insert-child', path: [0] }, client as any)
217
+ ).rejects.toThrow(/"block" object.*"name"/);
218
+ });
219
+
220
+ it('forwards integer position', async () => {
221
+ await handleMutateTool('edit_block_tree', {
222
+ post_id: 1, op: 'insert-child', path: [0],
223
+ block: { name: 'core/paragraph' }, position: 2,
224
+ }, client as any);
225
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
226
+ op: 'insert-child', position: 2,
227
+ }));
228
+ });
229
+
230
+ it('forwards "start" position', async () => {
231
+ await handleMutateTool('edit_block_tree', {
232
+ post_id: 1, op: 'insert-child', path: [0],
233
+ block: { name: 'core/paragraph' }, position: 'start',
234
+ }, client as any);
235
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
236
+ position: 'start',
237
+ }));
238
+ });
239
+
240
+ it('forwards "end" position', async () => {
241
+ await handleMutateTool('edit_block_tree', {
242
+ post_id: 1, op: 'insert-child', path: [0],
243
+ block: { name: 'core/paragraph' }, position: 'end',
244
+ }, client as any);
245
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
246
+ position: 'end',
247
+ }));
248
+ });
249
+
250
+ it('rejects invalid position string', async () => {
251
+ await expect(
252
+ handleMutateTool('edit_block_tree', {
253
+ post_id: 1, op: 'insert-child', path: [0],
254
+ block: { name: 'core/paragraph' }, position: 'middle',
255
+ }, client as any)
256
+ ).rejects.toThrow(/position must be/);
257
+ });
258
+
259
+ it('rejects non-integer numeric position', async () => {
260
+ await expect(
261
+ handleMutateTool('edit_block_tree', {
262
+ post_id: 1, op: 'insert-child', path: [0],
263
+ block: { name: 'core/paragraph' }, position: 1.5,
264
+ }, client as any)
265
+ ).rejects.toThrow(/position must be/);
266
+ });
267
+
268
+ it('omits position when not provided', async () => {
269
+ await handleMutateTool('edit_block_tree', {
270
+ post_id: 1, op: 'insert-child', path: [0], block: { name: 'core/paragraph' },
271
+ }, client as any);
272
+ const arg = client.mutateBlockTree.mock.calls[0]![1] as Record<string, unknown>;
273
+ expect('position' in arg).toBe(false);
274
+ });
275
+ });
276
+
277
+ // ── op: wrap-in-group ─────────────────────────────────────────────────────────
278
+
279
+ describe('edit_block_tree — wrap-in-group', () => {
280
+ let client: ReturnType<typeof makeMockClient>;
281
+ beforeEach(() => {
282
+ client = makeMockClient();
283
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
284
+ });
285
+
286
+ it('works without a wrapper (defaults applied server-side)', async () => {
287
+ await handleMutateTool('edit_block_tree', { post_id: 1, op: 'wrap-in-group', path: [0] }, client as any);
288
+ expect(client.mutateBlockTree).toHaveBeenCalled();
289
+ const arg = client.mutateBlockTree.mock.calls[0]![1] as Record<string, unknown>;
290
+ expect('wrapper' in arg).toBe(false);
291
+ });
292
+
293
+ it('forwards custom wrapper when provided', async () => {
294
+ await handleMutateTool('edit_block_tree', {
295
+ post_id: 1, op: 'wrap-in-group', path: [0],
296
+ wrapper: { name: 'core/cover', attributes: { url: 'x.jpg' } },
297
+ }, client as any);
298
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
299
+ wrapper: { name: 'core/cover', attributes: { url: 'x.jpg' } },
300
+ }));
301
+ });
302
+ });
303
+
304
+ // ── ops with no payload: remove-block, unwrap-group, duplicate ────────────────
305
+
306
+ describe('edit_block_tree — payload-less ops', () => {
307
+ let client: ReturnType<typeof makeMockClient>;
308
+ beforeEach(() => {
309
+ client = makeMockClient();
310
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
311
+ });
312
+
313
+ it.each(['remove-block', 'unwrap-group', 'duplicate'] as const)(
314
+ 'forwards %s with no extra payload',
315
+ async (op) => {
316
+ await handleMutateTool('edit_block_tree', { post_id: 1, op, path: [0] }, client as any);
317
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
318
+ op, path: [0],
319
+ }));
320
+ }
321
+ );
322
+ });
323
+
324
+ // ── op: move ──────────────────────────────────────────────────────────────────
325
+
326
+ describe('edit_block_tree — move', () => {
327
+ let client: ReturnType<typeof makeMockClient>;
328
+ beforeEach(() => {
329
+ client = makeMockClient();
330
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
331
+ });
332
+
333
+ it('requires destination or destination_ref', async () => {
334
+ await expect(
335
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'move', path: [0] }, client as any)
336
+ ).rejects.toThrow(/destination/);
337
+ });
338
+
339
+ it('rejects both destination and destination_ref together', async () => {
340
+ await expect(
341
+ handleMutateTool('edit_block_tree', {
342
+ post_id: 1, op: 'move', path: [0],
343
+ destination: [1], destination_ref: 'blk_b',
344
+ }, client as any)
345
+ ).rejects.toThrow(/destination.*OR.*destination_ref/i);
346
+ });
347
+
348
+ it('forwards destination path', async () => {
349
+ await handleMutateTool('edit_block_tree', {
350
+ post_id: 1, op: 'move', path: [0], destination: [2],
351
+ }, client as any);
352
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
353
+ op: 'move', destination: [2],
354
+ }));
355
+ });
356
+
357
+ it('forwards destination_ref', async () => {
358
+ await handleMutateTool('edit_block_tree', {
359
+ post_id: 1, op: 'move', path: [0], destination_ref: 'blk_target',
360
+ }, client as any);
361
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
362
+ op: 'move', destination_ref: 'blk_target',
363
+ }));
364
+ });
365
+
366
+ it('rejects non-integer destination', async () => {
367
+ await expect(
368
+ handleMutateTool('edit_block_tree', {
369
+ post_id: 1, op: 'move', path: [0], destination: [1.5],
370
+ }, client as any)
371
+ ).rejects.toThrow(/integers/);
372
+ });
373
+
374
+ it('forwards integer count', async () => {
375
+ await handleMutateTool('edit_block_tree', {
376
+ post_id: 1, op: 'move', path: [0], destination: [2], count: 3,
377
+ }, client as any);
378
+ expect(client.mutateBlockTree).toHaveBeenCalledWith(1, expect.objectContaining({
379
+ count: 3,
380
+ }));
381
+ });
382
+
383
+ it('rejects count < 1', async () => {
384
+ await expect(
385
+ handleMutateTool('edit_block_tree', {
386
+ post_id: 1, op: 'move', path: [0], destination: [2], count: 0,
387
+ }, client as any)
388
+ ).rejects.toThrow(/positive integer/);
389
+ });
390
+
391
+ it('rejects non-integer count', async () => {
392
+ await expect(
393
+ handleMutateTool('edit_block_tree', {
394
+ post_id: 1, op: 'move', path: [0], destination: [2], count: 1.5,
395
+ }, client as any)
396
+ ).rejects.toThrow(/positive integer/);
397
+ });
398
+ });
399
+
400
+ // ── Response shape ────────────────────────────────────────────────────────────
401
+
402
+ describe('edit_block_tree — response shape', () => {
403
+ let client: ReturnType<typeof makeMockClient>;
404
+ beforeEach(() => {
405
+ client = makeMockClient();
406
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
407
+ });
408
+
409
+ it('returns a valid MutationResponse', async () => {
410
+ const result = await handleMutateTool('edit_block_tree', {
411
+ post_id: 1, op: 'update-attrs', path: [0], attributes: { level: 3 },
412
+ }, client as any);
413
+ assertMutationResponse(result);
414
+ });
415
+ });
416
+
417
+ // ── Warning formatting ────────────────────────────────────────────────────────
418
+
419
+ describe('edit_block_tree — warning formatting', () => {
420
+ let client: ReturnType<typeof makeMockClient>;
421
+ beforeEach(() => { client = makeMockClient(); });
422
+
423
+ it('formats static_markup_stale_risk warnings', async () => {
424
+ client.mutateBlockTree.mockResolvedValueOnce(mutationWithStaticWarning as any);
425
+ const result = await handleMutateTool('edit_block_tree', {
426
+ post_id: 1, op: 'update-attrs', path: [0], attributes: { url: 'x' },
427
+ }, client as any) as { formatted_warnings: string[] };
428
+ expect(Array.isArray(result.formatted_warnings)).toBe(true);
429
+ expect(result.formatted_warnings.length).toBeGreaterThan(0);
430
+ expect(result.formatted_warnings[0]).toMatch(/WARNING|static/i);
431
+ });
432
+
433
+ it('omits formatted_warnings when response has no warnings', async () => {
434
+ const result = await handleMutateTool('edit_block_tree', {
435
+ post_id: 1, op: 'update-attrs', path: [0], attributes: { level: 3 },
436
+ }, client as any) as Record<string, unknown>;
437
+ expect('formatted_warnings' in result).toBe(false);
438
+ });
439
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tool tests: edit_block_tree — ref vs path addressing
3
+ *
4
+ * Covers:
5
+ * - ref forwarded when ref is provided (path absent)
6
+ * - path forwarded when path is provided (ref absent)
7
+ * - both path and ref rejected
8
+ * - neither path nor ref rejected
9
+ * - empty path array rejected
10
+ * - path integer-array validation still applies with ref
11
+ * - move: destination_ref accepted instead of destination path
12
+ * - move: both destination + destination_ref rejected
13
+ * - move: neither destination nor destination_ref rejected
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import { handleMutateTool } from '../../../tools/mutate.js';
18
+ import { makeMockClient } from '../../helpers/mock-client.js';
19
+ import { mutationUpdateAttrsResponse } from '../../fixtures/rest-responses.js';
20
+
21
+ describe('edit_block_tree — ref vs path', () => {
22
+ let client: ReturnType<typeof makeMockClient>;
23
+ beforeEach(() => {
24
+ client = makeMockClient();
25
+ client.mutateBlockTree.mockResolvedValue(mutationUpdateAttrsResponse);
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it('forwards path when only path is provided', async () => {
30
+ await handleMutateTool('edit_block_tree', {
31
+ post_id: 1, op: 'update-attrs', path: [0, 1], attributes: { level: 2 },
32
+ }, client as any);
33
+ const call = client.mutateBlockTree.mock.calls[0]![1] as Record<string, unknown>;
34
+ expect(call.path).toEqual([0, 1]);
35
+ expect(call.ref).toBeUndefined();
36
+ });
37
+
38
+ it('forwards ref when only ref is provided', async () => {
39
+ await handleMutateTool('edit_block_tree', {
40
+ post_id: 1, op: 'update-attrs', ref: 'blk_target', attributes: { level: 2 },
41
+ }, client as any);
42
+ const call = client.mutateBlockTree.mock.calls[0]![1] as Record<string, unknown>;
43
+ expect(call.ref).toBe('blk_target');
44
+ expect(call.path).toBeUndefined();
45
+ });
46
+
47
+ it('rejects when both path and ref are provided', async () => {
48
+ await expect(
49
+ handleMutateTool('edit_block_tree', {
50
+ post_id: 1, op: 'update-attrs', path: [0], ref: 'blk_x', attributes: {},
51
+ }, client as any)
52
+ ).rejects.toThrow(/path.*OR.*ref.*not both/i);
53
+ });
54
+
55
+ it('rejects when neither path nor ref is provided', async () => {
56
+ await expect(
57
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'update-attrs', attributes: {} }, client as any)
58
+ ).rejects.toThrow(/Provide either "path" or "ref"/);
59
+ });
60
+
61
+ it('rejects empty path array', async () => {
62
+ await expect(
63
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'update-attrs', path: [], attributes: {} }, client as any)
64
+ ).rejects.toThrow(/path must not be empty/);
65
+ });
66
+
67
+ it('still validates path is an array of integers', async () => {
68
+ await expect(
69
+ handleMutateTool('edit_block_tree', {
70
+ post_id: 1, op: 'update-attrs', path: 'not-array', attributes: {},
71
+ }, client as any)
72
+ ).rejects.toThrow(/must be an array of integers/);
73
+ });
74
+ });
75
+
76
+ describe('edit_block_tree — move: destination_ref', () => {
77
+ let client: ReturnType<typeof makeMockClient>;
78
+ beforeEach(() => {
79
+ client = makeMockClient();
80
+ client.mutateBlockTree.mockResolvedValue({ ...mutationUpdateAttrsResponse, op: 'move' as any });
81
+ vi.clearAllMocks();
82
+ });
83
+
84
+ it('accepts destination_ref instead of destination path', async () => {
85
+ await handleMutateTool('edit_block_tree', {
86
+ post_id: 1, op: 'move', ref: 'blk_source', destination_ref: 'blk_dest',
87
+ }, client as any);
88
+ const call = client.mutateBlockTree.mock.calls[0]![1] as Record<string, unknown>;
89
+ expect(call.destination_ref).toBe('blk_dest');
90
+ });
91
+
92
+ it('rejects when no destination or destination_ref provided', async () => {
93
+ await expect(
94
+ handleMutateTool('edit_block_tree', { post_id: 1, op: 'move', ref: 'blk_x' }, client as any)
95
+ ).rejects.toThrow(/move requires/);
96
+ });
97
+
98
+ it('rejects when both destination path AND destination_ref are provided', async () => {
99
+ await expect(
100
+ handleMutateTool('edit_block_tree', {
101
+ post_id: 1, op: 'move', ref: 'blk_src', destination: [3], destination_ref: 'blk_dest',
102
+ }, client as any)
103
+ ).rejects.toThrow(/destination.*OR.*destination_ref.*not both/i);
104
+ });
105
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tool tests: insert_pattern
3
+ *
4
+ * Covers:
5
+ * - Validation: post_id required, pattern_id required
6
+ * - Default synced=true
7
+ * - synced=false forwarded
8
+ * - after_top_level → after
9
+ * - before_top_level → before
10
+ * - String pattern_id accepted
11
+ * - Response note for synced insertion
12
+ * - Response note for non-synced insertion
13
+ * - Unknown tool throws
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import { handlePatternTool } from '../../../tools/patterns.js';
18
+ import { makeMockClient } from '../../helpers/mock-client.js';
19
+ import { patternInsertResponse } from '../../fixtures/rest-responses.js';
20
+
21
+ describe('insert_pattern — validation', () => {
22
+ let client: ReturnType<typeof makeMockClient>;
23
+ beforeEach(() => { client = makeMockClient(); vi.clearAllMocks(); });
24
+
25
+ it('requires post_id', async () => {
26
+ await expect(handlePatternTool('insert_pattern', { pattern_id: 1 }, client as any))
27
+ .rejects.toThrow('post_id');
28
+ });
29
+
30
+ it('requires pattern_id', async () => {
31
+ await expect(handlePatternTool('insert_pattern', { post_id: 1 }, client as any))
32
+ .rejects.toThrow('pattern_id');
33
+ });
34
+
35
+ it('throws on unknown tool name', async () => {
36
+ await expect(handlePatternTool('unknown_tool', { post_id: 1, pattern_id: 1 }, client as any))
37
+ .rejects.toThrow('Unknown pattern tool');
38
+ });
39
+ });
40
+
41
+ describe('insert_pattern — request shape', () => {
42
+ let client: ReturnType<typeof makeMockClient>;
43
+ beforeEach(() => {
44
+ client = makeMockClient();
45
+ client.insertPattern.mockResolvedValue(patternInsertResponse);
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ it('calls client with default synced=true', async () => {
50
+ await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123 }, client as any);
51
+ expect(client.insertPattern).toHaveBeenCalledWith(1, expect.objectContaining({
52
+ pattern_id: 123, synced: true,
53
+ }));
54
+ });
55
+
56
+ it('passes synced=false through', async () => {
57
+ await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123, synced: false }, client as any);
58
+ expect(client.insertPattern).toHaveBeenCalledWith(1, expect.objectContaining({ synced: false }));
59
+ });
60
+
61
+ it('maps after_top_level → after in client call', async () => {
62
+ await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123, after_top_level: 3 }, client as any);
63
+ expect(client.insertPattern).toHaveBeenCalledWith(1, expect.objectContaining({ after: 3 }));
64
+ });
65
+
66
+ it('maps before_top_level → before in client call', async () => {
67
+ await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123, before_top_level: 2 }, client as any);
68
+ expect(client.insertPattern).toHaveBeenCalledWith(1, expect.objectContaining({ before: 2 }));
69
+ });
70
+
71
+ it('accepts a string pattern_id', async () => {
72
+ await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 'my-pattern-slug' }, client as any);
73
+ expect(client.insertPattern).toHaveBeenCalledWith(1, expect.objectContaining({
74
+ pattern_id: 'my-pattern-slug',
75
+ }));
76
+ });
77
+ });
78
+
79
+ describe('insert_pattern — response shape', () => {
80
+ let client: ReturnType<typeof makeMockClient>;
81
+ beforeEach(() => {
82
+ client = makeMockClient();
83
+ vi.clearAllMocks();
84
+ });
85
+
86
+ it('adds a synced-reference note when pattern was inserted as synced', async () => {
87
+ client.insertPattern.mockResolvedValue(patternInsertResponse); // synced: true
88
+ const result = await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123 }, client as any) as any;
89
+ expect(result.note).toContain('synced reference');
90
+ });
91
+
92
+ it('adds an inline/independent note when pattern was inserted non-synced', async () => {
93
+ client.insertPattern.mockResolvedValue({
94
+ ...patternInsertResponse,
95
+ synced: false,
96
+ inserted: [{ index: 5, name: 'core/heading' }],
97
+ });
98
+ const result = await handlePatternTool('insert_pattern', {
99
+ post_id: 1, pattern_id: 123, synced: false,
100
+ }, client as any) as any;
101
+ expect(result.note).toContain('inline');
102
+ expect(result.note).toContain('independent');
103
+ });
104
+
105
+ it('success flag is present', async () => {
106
+ client.insertPattern.mockResolvedValue(patternInsertResponse);
107
+ const result = await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123 }, client as any) as any;
108
+ expect(result.success).toBe(true);
109
+ });
110
+
111
+ it('revision IDs are present', async () => {
112
+ client.insertPattern.mockResolvedValue(patternInsertResponse);
113
+ const result = await handlePatternTool('insert_pattern', { post_id: 1, pattern_id: 123 }, client as any) as any;
114
+ expect(result.before_revision_id).toBe(100);
115
+ expect(result.revision_id).toBe(101);
116
+ });
117
+ });