@adsim/wordpress-mcp-server 4.6.0 → 5.1.0

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 (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -80,7 +80,7 @@ describe('wp_site_info', () => {
80
80
 
81
81
  // Server info
82
82
  expect(data.server.mcp_version).toBeDefined();
83
- expect(data.server.tools_total).toBe(86);
83
+ expect(data.server.tools_total).toBe(175);
84
84
  expect(typeof data.server.tools_exposed).toBe('number');
85
85
  expect(Array.isArray(data.server.filtered_out)).toBe(true);
86
86
  });
@@ -0,0 +1,399 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall } from '../../../index.js';
7
+ import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ // Helper: mock two sequential fetch calls
14
+ function mockTwoSuccess(data1, data2) {
15
+ fetch
16
+ .mockImplementationOnce(() => Promise.resolve({
17
+ ok: true, status: 200,
18
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
19
+ json: () => Promise.resolve(data1),
20
+ text: () => Promise.resolve(JSON.stringify(data1))
21
+ }))
22
+ .mockImplementationOnce(() => Promise.resolve({
23
+ ok: true, status: 200,
24
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
25
+ json: () => Promise.resolve(data2),
26
+ text: () => Promise.resolve(JSON.stringify(data2))
27
+ }));
28
+ }
29
+
30
+ // ════════════════════════════════════════════════════════════
31
+ // wp_get_user
32
+ // ════════════════════════════════════════════════════════════
33
+
34
+ describe('wp_get_user', () => {
35
+ let consoleSpy;
36
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
37
+ afterEach(() => { consoleSpy.mockRestore(); });
38
+
39
+ it('returns full user profile', async () => {
40
+ fetch.mockResolvedValue(mockSuccess({
41
+ id: 1, username: 'admin', name: 'Admin', slug: 'admin', email: 'admin@example.com',
42
+ roles: ['administrator'], first_name: 'Site', last_name: 'Admin', url: 'https://example.com',
43
+ description: 'The admin', link: 'https://example.com/author/admin', registered_date: '2023-01-01T00:00:00',
44
+ locale: 'en_US', nickname: 'admin', avatar_urls: { '96': 'https://example.com/avatar.jpg' }, meta: {}
45
+ }));
46
+ const result = await call('wp_get_user', { id: 1 });
47
+ const data = parseResult(result);
48
+ expect(data.id).toBe(1);
49
+ expect(data.username).toBe('admin');
50
+ expect(data.email).toBe('admin@example.com');
51
+ expect(data.roles).toContain('administrator');
52
+ expect(data.first_name).toBe('Site');
53
+ expect(data.registered_date).toBe('2023-01-01T00:00:00');
54
+ });
55
+
56
+ it('returns error on 404', async () => {
57
+ fetch.mockResolvedValue(mockError(404));
58
+ const result = await call('wp_get_user', { id: 9999 });
59
+ expect(result.isError).toBe(true);
60
+ });
61
+
62
+ it('logs audit entry', async () => {
63
+ fetch.mockResolvedValue(mockSuccess({ id: 1, name: 'Admin', slug: 'admin', roles: [] }));
64
+ await call('wp_get_user', { id: 1 });
65
+ const logs = getAuditLogs();
66
+ const entry = logs.find(l => l.tool === 'wp_get_user');
67
+ expect(entry).toBeDefined();
68
+ expect(entry.target).toBe(1);
69
+ });
70
+ });
71
+
72
+ // ════════════════════════════════════════════════════════════
73
+ // wp_create_user
74
+ // ════════════════════════════════════════════════════════════
75
+
76
+ describe('wp_create_user', () => {
77
+ let consoleSpy;
78
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
79
+ afterEach(() => { consoleSpy.mockRestore(); });
80
+
81
+ it('creates a user when confirm=true', async () => {
82
+ fetch.mockResolvedValue(mockSuccess({
83
+ id: 5, username: 'newuser', slug: 'newuser', email: 'new@example.com',
84
+ name: 'New User', roles: ['subscriber']
85
+ }));
86
+ const result = await call('wp_create_user', {
87
+ username: 'newuser', email: 'new@example.com', password: 'Str0ngP@ss!', confirm: true
88
+ });
89
+ const data = parseResult(result);
90
+ expect(data.success).toBe(true);
91
+ expect(data.user.id).toBe(5);
92
+ expect(data.user.username).toBe('newuser');
93
+ });
94
+
95
+ it('rejects when confirm is not true', async () => {
96
+ const result = await call('wp_create_user', {
97
+ username: 'test', email: 'test@example.com', password: 'pass', confirm: false
98
+ });
99
+ expect(result.isError).toBe(true);
100
+ expect(result.content[0].text).toContain('confirm=true');
101
+ });
102
+
103
+ it('rejects when confirm is missing', async () => {
104
+ const result = await call('wp_create_user', {
105
+ username: 'test', email: 'test@example.com', password: 'pass'
106
+ });
107
+ expect(result.isError).toBe(true);
108
+ });
109
+
110
+ it('passes optional fields', async () => {
111
+ fetch.mockResolvedValue(mockSuccess({
112
+ id: 6, username: 'jane', slug: 'jane', email: 'jane@example.com',
113
+ name: 'Jane Doe', roles: ['editor']
114
+ }));
115
+ await call('wp_create_user', {
116
+ username: 'jane', email: 'jane@example.com', password: 'P@ss123!',
117
+ role: 'editor', first_name: 'Jane', last_name: 'Doe', confirm: true
118
+ });
119
+ const [, opts] = fetch.mock.calls[0];
120
+ const body = JSON.parse(opts.body);
121
+ expect(body.roles).toEqual(['editor']);
122
+ expect(body.first_name).toBe('Jane');
123
+ expect(body.last_name).toBe('Doe');
124
+ });
125
+
126
+ it('is blocked by WP_READ_ONLY', async () => {
127
+ process.env.WP_READ_ONLY = 'true';
128
+ try {
129
+ const result = await call('wp_create_user', {
130
+ username: 'test', email: 'test@example.com', password: 'pass', confirm: true
131
+ });
132
+ expect(result.isError).toBe(true);
133
+ expect(result.content[0].text).toContain('READ-ONLY');
134
+ } finally {
135
+ delete process.env.WP_READ_ONLY;
136
+ }
137
+ });
138
+ });
139
+
140
+ // ════════════════════════════════════════════════════════════
141
+ // wp_update_user
142
+ // ════════════════════════════════════════════════════════════
143
+
144
+ describe('wp_update_user', () => {
145
+ let consoleSpy;
146
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
147
+ afterEach(() => { consoleSpy.mockRestore(); });
148
+
149
+ it('updates user fields', async () => {
150
+ fetch.mockResolvedValue(mockSuccess({
151
+ id: 2, name: 'Updated Name', email: 'updated@example.com', roles: ['editor'], slug: 'updated'
152
+ }));
153
+ const result = await call('wp_update_user', { id: 2, display_name: 'Updated Name', role: 'editor' });
154
+ const data = parseResult(result);
155
+ expect(data.success).toBe(true);
156
+ expect(data.user.name).toBe('Updated Name');
157
+ });
158
+
159
+ it('sends correct payload', async () => {
160
+ fetch.mockResolvedValue(mockSuccess({ id: 2, name: 'X', email: 'x@x.com', roles: ['author'], slug: 'x' }));
161
+ await call('wp_update_user', { id: 2, email: 'new@example.com', description: 'A bio', meta: { custom: 'value' } });
162
+ const [, opts] = fetch.mock.calls[0];
163
+ const body = JSON.parse(opts.body);
164
+ expect(body.email).toBe('new@example.com');
165
+ expect(body.description).toBe('A bio');
166
+ expect(body.meta).toEqual({ custom: 'value' });
167
+ });
168
+
169
+ it('is blocked by WP_READ_ONLY', async () => {
170
+ process.env.WP_READ_ONLY = 'true';
171
+ try {
172
+ const result = await call('wp_update_user', { id: 2, display_name: 'X' });
173
+ expect(result.isError).toBe(true);
174
+ expect(result.content[0].text).toContain('READ-ONLY');
175
+ } finally {
176
+ delete process.env.WP_READ_ONLY;
177
+ }
178
+ });
179
+ });
180
+
181
+ // ════════════════════════════════════════════════════════════
182
+ // wp_delete_user
183
+ // ════════════════════════════════════════════════════════════
184
+
185
+ describe('wp_delete_user', () => {
186
+ let consoleSpy;
187
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
188
+ afterEach(() => { consoleSpy.mockRestore(); });
189
+
190
+ it('deletes user when confirm=true and reassign provided', async () => {
191
+ fetch.mockResolvedValue(mockSuccess({ deleted: true }));
192
+ const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
193
+ const data = parseResult(result);
194
+ expect(data.success).toBe(true);
195
+ expect(data.message).toContain('reassigned to user 1');
196
+ });
197
+
198
+ it('rejects when confirm is not true', async () => {
199
+ const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: false });
200
+ expect(result.isError).toBe(true);
201
+ expect(result.content[0].text).toContain('confirm=true');
202
+ });
203
+
204
+ it('passes force and reassign in URL', async () => {
205
+ fetch.mockResolvedValue(mockSuccess({ deleted: true }));
206
+ await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
207
+ const [url] = fetch.mock.calls[0];
208
+ expect(url).toContain('/users/5?force=true&reassign=1');
209
+ });
210
+
211
+ it('is blocked by WP_READ_ONLY', async () => {
212
+ process.env.WP_READ_ONLY = 'true';
213
+ try {
214
+ const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
215
+ expect(result.isError).toBe(true);
216
+ expect(result.content[0].text).toContain('READ-ONLY');
217
+ } finally {
218
+ delete process.env.WP_READ_ONLY;
219
+ }
220
+ });
221
+
222
+ it('is blocked by WP_DISABLE_DELETE', async () => {
223
+ process.env.WP_DISABLE_DELETE = 'true';
224
+ try {
225
+ const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
226
+ expect(result.isError).toBe(true);
227
+ expect(result.content[0].text).toContain('DISABLE_DELETE');
228
+ } finally {
229
+ delete process.env.WP_DISABLE_DELETE;
230
+ }
231
+ });
232
+ });
233
+
234
+ // ════════════════════════════════════════════════════════════
235
+ // wp_list_user_roles
236
+ // ════════════════════════════════════════════════════════════
237
+
238
+ describe('wp_list_user_roles', () => {
239
+ let consoleSpy;
240
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
241
+ afterEach(() => { consoleSpy.mockRestore(); });
242
+
243
+ it('returns roles as array', async () => {
244
+ fetch.mockResolvedValue(mockSuccess([
245
+ { slug: 'administrator', name: 'Administrator', capabilities: { manage_options: true, edit_posts: true } },
246
+ { slug: 'editor', name: 'Editor', capabilities: { edit_posts: true, edit_others_posts: true } }
247
+ ]));
248
+ const result = await call('wp_list_user_roles');
249
+ const data = parseResult(result);
250
+ expect(data.total).toBe(2);
251
+ expect(data.roles[0].slug).toBe('administrator');
252
+ expect(data.roles[0].capabilities.manage_options).toBe(true);
253
+ });
254
+
255
+ it('handles object format', async () => {
256
+ fetch.mockResolvedValue(mockSuccess({
257
+ administrator: { name: 'Administrator', capabilities: { manage_options: true } },
258
+ subscriber: { name: 'Subscriber', capabilities: { read: true } }
259
+ }));
260
+ const result = await call('wp_list_user_roles');
261
+ const data = parseResult(result);
262
+ expect(data.total).toBe(2);
263
+ expect(data.roles.find(r => r.slug === 'administrator')).toBeDefined();
264
+ });
265
+ });
266
+
267
+ // ════════════════════════════════════════════════════════════
268
+ // wp_get_user_capabilities
269
+ // ════════════════════════════════════════════════════════════
270
+
271
+ describe('wp_get_user_capabilities', () => {
272
+ let consoleSpy;
273
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
274
+ afterEach(() => { consoleSpy.mockRestore(); });
275
+
276
+ it('returns active capabilities', async () => {
277
+ fetch.mockResolvedValue(mockSuccess({
278
+ id: 1, name: 'Admin', roles: ['administrator'],
279
+ capabilities: { manage_options: true, edit_posts: true, read: true, delete_posts: false }
280
+ }));
281
+ const result = await call('wp_get_user_capabilities', { id: 1 });
282
+ const data = parseResult(result);
283
+ expect(data.id).toBe(1);
284
+ expect(data.capabilities).toContain('manage_options');
285
+ expect(data.capabilities).toContain('edit_posts');
286
+ expect(data.capabilities).not.toContain('delete_posts');
287
+ expect(data.capabilities_count).toBe(3);
288
+ });
289
+ });
290
+
291
+ // ════════════════════════════════════════════════════════════
292
+ // wp_reset_user_password
293
+ // ════════════════════════════════════════════════════════════
294
+
295
+ describe('wp_reset_user_password', () => {
296
+ let consoleSpy;
297
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
298
+ afterEach(() => { consoleSpy.mockRestore(); });
299
+
300
+ it('sends password reset email', async () => {
301
+ mockTwoSuccess(
302
+ { id: 3, name: 'User', email: 'user@example.com' },
303
+ { success: true, user_id: 3, message: 'Password reset email sent.' }
304
+ );
305
+ const result = await call('wp_reset_user_password', { id: 3 });
306
+ const data = parseResult(result);
307
+ expect(data.success).toBe(true);
308
+ expect(data.user.email).toContain('***'); // Masked email
309
+ });
310
+
311
+ it('is blocked by WP_READ_ONLY', async () => {
312
+ process.env.WP_READ_ONLY = 'true';
313
+ try {
314
+ const result = await call('wp_reset_user_password', { id: 3 });
315
+ expect(result.isError).toBe(true);
316
+ expect(result.content[0].text).toContain('READ-ONLY');
317
+ } finally {
318
+ delete process.env.WP_READ_ONLY;
319
+ }
320
+ });
321
+
322
+ it('calls mcp-diagnostics endpoint', async () => {
323
+ mockTwoSuccess(
324
+ { id: 3, name: 'User', email: 'user@example.com' },
325
+ { success: true }
326
+ );
327
+ await call('wp_reset_user_password', { id: 3 });
328
+ const secondUrl = fetch.mock.calls[1][0];
329
+ expect(secondUrl).toContain('/wp-json/mcp-diagnostics/v1/password-reset');
330
+ });
331
+ });
332
+
333
+ // ════════════════════════════════════════════════════════════
334
+ // wp_list_user_application_passwords
335
+ // ════════════════════════════════════════════════════════════
336
+
337
+ describe('wp_list_user_application_passwords', () => {
338
+ let consoleSpy;
339
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
340
+ afterEach(() => { consoleSpy.mockRestore(); });
341
+
342
+ it('returns application passwords', async () => {
343
+ fetch.mockResolvedValue(mockSuccess([
344
+ { uuid: 'abc-123', name: 'MCP Server', created: '2024-01-01', last_used: '2024-06-01', last_ip: '1.2.3.4' },
345
+ { uuid: 'def-456', name: 'Mobile App', created: '2024-03-01', last_used: null, last_ip: null }
346
+ ]));
347
+ const result = await call('wp_list_user_application_passwords', { id: 1 });
348
+ const data = parseResult(result);
349
+ expect(data.user_id).toBe(1);
350
+ expect(data.total).toBe(2);
351
+ expect(data.application_passwords[0].uuid).toBe('abc-123');
352
+ expect(data.application_passwords[0].name).toBe('MCP Server');
353
+ expect(data.application_passwords[1].last_used).toBeNull();
354
+ });
355
+
356
+ it('calls correct endpoint', async () => {
357
+ fetch.mockResolvedValue(mockSuccess([]));
358
+ await call('wp_list_user_application_passwords', { id: 1 });
359
+ const [url] = fetch.mock.calls[0];
360
+ expect(url).toContain('/users/1/application-passwords');
361
+ });
362
+ });
363
+
364
+ // ════════════════════════════════════════════════════════════
365
+ // wp_revoke_application_password
366
+ // ════════════════════════════════════════════════════════════
367
+
368
+ describe('wp_revoke_application_password', () => {
369
+ let consoleSpy;
370
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
371
+ afterEach(() => { consoleSpy.mockRestore(); });
372
+
373
+ it('revokes an application password', async () => {
374
+ fetch.mockResolvedValue(mockSuccess({ deleted: true }));
375
+ const result = await call('wp_revoke_application_password', { id: 1, uuid: 'abc-123' });
376
+ const data = parseResult(result);
377
+ expect(data.success).toBe(true);
378
+ expect(data.message).toContain('abc-123');
379
+ });
380
+
381
+ it('calls correct DELETE endpoint', async () => {
382
+ fetch.mockResolvedValue(mockSuccess({ deleted: true }));
383
+ await call('wp_revoke_application_password', { id: 1, uuid: 'abc-123' });
384
+ const [url, opts] = fetch.mock.calls[0];
385
+ expect(url).toContain('/users/1/application-passwords/abc-123');
386
+ expect(opts.method).toBe('DELETE');
387
+ });
388
+
389
+ it('is blocked by WP_READ_ONLY', async () => {
390
+ process.env.WP_READ_ONLY = 'true';
391
+ try {
392
+ const result = await call('wp_revoke_application_password', { id: 1, uuid: 'abc-123' });
393
+ expect(result.isError).toBe(true);
394
+ expect(result.content[0].text).toContain('READ-ONLY');
395
+ } finally {
396
+ delete process.env.WP_READ_ONLY;
397
+ }
398
+ });
399
+ });
@@ -0,0 +1,186 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, _testSetTarget } from '../../../index.js';
7
+ import { makeRequest, mockSuccess, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+
15
+ beforeEach(() => {
16
+ fetch.mockReset();
17
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
18
+ _testSetTarget('site1', { url: 'https://example.com', auth: 'Basic dGVzdDp0ZXN0' });
19
+ delete process.env.WP_VALIDATE_BLOCKS;
20
+ delete process.env.WP_READ_ONLY;
21
+ });
22
+ afterEach(() => {
23
+ consoleSpy.mockRestore();
24
+ delete process.env.WP_VALIDATE_BLOCKS;
25
+ delete process.env.WP_READ_ONLY;
26
+ });
27
+
28
+ // =========================================================================
29
+ // 1. Valid HTML → valid: true, errors: []
30
+ // =========================================================================
31
+ describe('wp_validate_block_structure', () => {
32
+ it('returns valid for well-formed Gutenberg blocks', async () => {
33
+ const content = [
34
+ '<!-- wp:paragraph -->',
35
+ '<p>Hello world</p>',
36
+ '<!-- /wp:paragraph -->',
37
+ '',
38
+ '<!-- wp:heading {"level":2} -->',
39
+ '<h2>Title</h2>',
40
+ '<!-- /wp:heading -->'
41
+ ].join('\n');
42
+
43
+ const res = await call('wp_validate_block_structure', { content });
44
+ const data = parseResult(res);
45
+ expect(data.valid).toBe(true);
46
+ expect(data.errors).toHaveLength(0);
47
+ expect(data.blocks_found).toEqual(
48
+ expect.arrayContaining([
49
+ { name: 'core/paragraph', count: 1 },
50
+ { name: 'core/heading', count: 1 }
51
+ ])
52
+ );
53
+ });
54
+
55
+ // =========================================================================
56
+ // 2. Unclosed block → error detected
57
+ // =========================================================================
58
+ it('detects unclosed block comment', async () => {
59
+ const content = [
60
+ '<!-- wp:paragraph -->',
61
+ '<p>No closing comment</p>'
62
+ ].join('\n');
63
+
64
+ const res = await call('wp_validate_block_structure', { content });
65
+ const data = parseResult(res);
66
+ expect(data.valid).toBe(false);
67
+ expect(data.errors.some(e => e.type === 'unclosed_block')).toBe(true);
68
+ });
69
+
70
+ // =========================================================================
71
+ // 3. Malformed JSON in comment → error detected
72
+ // =========================================================================
73
+ it('detects malformed JSON in block attributes', async () => {
74
+ const content = [
75
+ '<!-- wp:heading {level:2} -->',
76
+ '<h2>Bad JSON</h2>',
77
+ '<!-- /wp:heading -->'
78
+ ].join('\n');
79
+
80
+ const res = await call('wp_validate_block_structure', { content });
81
+ const data = parseResult(res);
82
+ expect(data.valid).toBe(false);
83
+ expect(data.errors.some(e => e.type === 'malformed_json')).toBe(true);
84
+ });
85
+
86
+ // =========================================================================
87
+ // 4. Invalid nesting → error detected
88
+ // =========================================================================
89
+ it('detects invalid nesting (paragraph inside paragraph)', async () => {
90
+ const content = [
91
+ '<!-- wp:paragraph -->',
92
+ '<p>Outer</p>',
93
+ '<!-- wp:paragraph -->',
94
+ '<p>Inner — invalid</p>',
95
+ '<!-- /wp:paragraph -->',
96
+ '<!-- /wp:paragraph -->'
97
+ ].join('\n');
98
+
99
+ const res = await call('wp_validate_block_structure', { content });
100
+ const data = parseResult(res);
101
+ expect(data.valid).toBe(false);
102
+ expect(data.errors.some(e => e.type === 'invalid_nesting')).toBe(true);
103
+ });
104
+
105
+ // =========================================================================
106
+ // 5. strict: false → no error on missing attributes
107
+ // =========================================================================
108
+ it('does not error on missing attributes when strict=false', async () => {
109
+ const content = [
110
+ '<!-- wp:heading -->',
111
+ '<h2>No attributes</h2>',
112
+ '<!-- /wp:heading -->'
113
+ ].join('\n');
114
+
115
+ const res = await call('wp_validate_block_structure', { content, strict: false });
116
+ const data = parseResult(res);
117
+ expect(data.valid).toBe(true);
118
+ expect(data.errors.filter(e => e.type === 'missing_attributes')).toHaveLength(0);
119
+ });
120
+
121
+ // =========================================================================
122
+ // 6. strict: true → error on missing attributes
123
+ // =========================================================================
124
+ it('errors on missing attributes when strict=true', async () => {
125
+ const content = [
126
+ '<!-- wp:heading -->',
127
+ '<h2>No attributes</h2>',
128
+ '<!-- /wp:heading -->'
129
+ ].join('\n');
130
+
131
+ const res = await call('wp_validate_block_structure', { content, strict: true });
132
+ const data = parseResult(res);
133
+ expect(data.valid).toBe(false);
134
+ expect(data.errors.some(e => e.type === 'missing_attributes')).toBe(true);
135
+ });
136
+ });
137
+
138
+ // =========================================================================
139
+ // 7-9. WP_VALIDATE_BLOCKS guard on wp_update_post
140
+ // =========================================================================
141
+ describe('WP_VALIDATE_BLOCKS guard', () => {
142
+ // 7. WP_VALIDATE_BLOCKS=true → blocks update if invalid content
143
+ it('blocks wp_update_post with invalid content when WP_VALIDATE_BLOCKS=true', async () => {
144
+ process.env.WP_VALIDATE_BLOCKS = 'true';
145
+ const invalidContent = '<!-- wp:paragraph -->\n<p>Unclosed block</p>';
146
+
147
+ const res = await call('wp_update_post', { id: 1, content: invalidContent });
148
+ expect(res.isError).toBe(true);
149
+ const data = JSON.parse(res.content[0].text);
150
+ expect(data.status).toBe('blocked');
151
+ expect(data.reason).toContain('WP_VALIDATE_BLOCKS');
152
+ expect(data.errors.length).toBeGreaterThan(0);
153
+ });
154
+
155
+ // 8. WP_VALIDATE_BLOCKS=true → allows update if valid content
156
+ it('allows wp_update_post with valid content when WP_VALIDATE_BLOCKS=true', async () => {
157
+ process.env.WP_VALIDATE_BLOCKS = 'true';
158
+ const validContent = '<!-- wp:paragraph -->\n<p>Valid</p>\n<!-- /wp:paragraph -->';
159
+
160
+ // POST /posts/1 → updated
161
+ mockSuccess({
162
+ id: 1, title: { rendered: 'Test' }, status: 'publish',
163
+ link: 'https://example.com/test/', modified: '2025-06-16'
164
+ });
165
+
166
+ const res = await call('wp_update_post', { id: 1, content: validContent });
167
+ const data = parseResult(res);
168
+ expect(data.success).toBe(true);
169
+ });
170
+
171
+ // 9. WP_VALIDATE_BLOCKS not set → no interception
172
+ it('does not intercept wp_update_post when WP_VALIDATE_BLOCKS is not set', async () => {
173
+ // No WP_VALIDATE_BLOCKS env var
174
+ const invalidContent = '<!-- wp:paragraph -->\n<p>Unclosed</p>';
175
+
176
+ // POST /posts/1 → updated normally despite invalid blocks
177
+ mockSuccess({
178
+ id: 1, title: { rendered: 'Test' }, status: 'publish',
179
+ link: 'https://example.com/test/', modified: '2025-06-16'
180
+ });
181
+
182
+ const res = await call('wp_update_post', { id: 1, content: invalidContent });
183
+ const data = parseResult(res);
184
+ expect(data.success).toBe(true);
185
+ });
186
+ });