@adsim/wordpress-mcp-server 4.5.1 → 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 (61) hide show
  1. package/.env.example +18 -0
  2. package/README.md +857 -447
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +718 -90
  5. package/index.js +188 -4747
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/plugins/IPluginAdapter.js +95 -0
  9. package/src/plugins/adapters/acf/acfAdapter.js +181 -0
  10. package/src/plugins/adapters/elementor/elementorAdapter.js +176 -0
  11. package/src/plugins/contextGuard.js +57 -0
  12. package/src/plugins/registry.js +94 -0
  13. package/src/shared/api.js +79 -0
  14. package/src/shared/audit.js +39 -0
  15. package/src/shared/context.js +15 -0
  16. package/src/shared/governance.js +98 -0
  17. package/src/shared/utils.js +148 -0
  18. package/src/tools/comments.js +50 -0
  19. package/src/tools/content.js +353 -0
  20. package/src/tools/core.js +114 -0
  21. package/src/tools/editorial.js +634 -0
  22. package/src/tools/fse.js +370 -0
  23. package/src/tools/health.js +160 -0
  24. package/src/tools/index.js +96 -0
  25. package/src/tools/intelligence.js +2082 -0
  26. package/src/tools/links.js +118 -0
  27. package/src/tools/media.js +71 -0
  28. package/src/tools/performance.js +219 -0
  29. package/src/tools/plugins.js +368 -0
  30. package/src/tools/schema.js +417 -0
  31. package/src/tools/security.js +590 -0
  32. package/src/tools/seo.js +1633 -0
  33. package/src/tools/taxonomy.js +115 -0
  34. package/src/tools/users.js +188 -0
  35. package/src/tools/woocommerce.js +1008 -0
  36. package/src/tools/workflow.js +409 -0
  37. package/src/transport/http.js +39 -0
  38. package/tests/unit/helpers/pagination.test.js +43 -0
  39. package/tests/unit/pluginLayer.test.js +151 -0
  40. package/tests/unit/plugins/acf/acfAdapter.test.js +205 -0
  41. package/tests/unit/plugins/acf/acfAdapter.write.test.js +157 -0
  42. package/tests/unit/plugins/contextGuard.test.js +51 -0
  43. package/tests/unit/plugins/elementor/elementorAdapter.test.js +206 -0
  44. package/tests/unit/plugins/iPluginAdapter.test.js +34 -0
  45. package/tests/unit/plugins/registry.test.js +84 -0
  46. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  47. package/tests/unit/tools/diagnostics.test.js +397 -0
  48. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  49. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  50. package/tests/unit/tools/fse.test.js +548 -0
  51. package/tests/unit/tools/multilingual.test.js +653 -0
  52. package/tests/unit/tools/performance.test.js +351 -0
  53. package/tests/unit/tools/runWorkflow.test.js +150 -0
  54. package/tests/unit/tools/schema.test.js +477 -0
  55. package/tests/unit/tools/security.test.js +695 -0
  56. package/tests/unit/tools/site.test.js +1 -1
  57. package/tests/unit/tools/siteOptions.test.js +101 -0
  58. package/tests/unit/tools/users.crud.test.js +399 -0
  59. package/tests/unit/tools/validateBlocks.test.js +186 -0
  60. package/tests/unit/tools/visualStaging.test.js +271 -0
  61. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,477 @@
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
+ function mockFetchJson(data, status = 200) {
14
+ return Promise.resolve({
15
+ ok: status >= 200 && status < 400,
16
+ status,
17
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
18
+ json: () => Promise.resolve(data),
19
+ text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data))
20
+ });
21
+ }
22
+
23
+ function mockFetchText(text, status = 200) {
24
+ return Promise.resolve({
25
+ ok: status >= 200 && status < 400,
26
+ status,
27
+ headers: { get: (h) => h === 'content-type' ? 'text/html' : null },
28
+ json: () => Promise.reject(new Error('not json')),
29
+ text: () => Promise.resolve(text)
30
+ });
31
+ }
32
+
33
+ let consoleSpy;
34
+ const envBackup = {};
35
+
36
+ beforeEach(() => {
37
+ fetch.mockReset();
38
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
39
+ envBackup.WP_READ_ONLY = process.env.WP_READ_ONLY;
40
+ delete process.env.WP_READ_ONLY;
41
+ });
42
+ afterEach(() => {
43
+ consoleSpy.mockRestore();
44
+ if (envBackup.WP_READ_ONLY === undefined) delete process.env.WP_READ_ONLY;
45
+ else process.env.WP_READ_ONLY = envBackup.WP_READ_ONLY;
46
+ });
47
+
48
+ // ════════════════════════════════════════════════════════════
49
+ // wp_generate_schema_article
50
+ // ════════════════════════════════════════════════════════════
51
+
52
+ describe('wp_generate_schema_article', () => {
53
+ it('generates Article schema with all fields', async () => {
54
+ fetch.mockImplementation((url) => {
55
+ if (url.toString().includes('/posts/42')) {
56
+ return mockFetchJson({
57
+ id: 42, title: { rendered: 'My Post' }, excerpt: { rendered: '<p>Excerpt here</p>' },
58
+ date: '2024-01-01T00:00:00', modified: '2024-06-01T00:00:00', link: 'https://example.com/my-post',
59
+ _embedded: {
60
+ author: [{ name: 'John Doe', link: 'https://example.com/author/john' }],
61
+ 'wp:featuredmedia': [{ source_url: 'https://example.com/img.jpg', media_details: { width: 1200, height: 630 } }]
62
+ }
63
+ });
64
+ }
65
+ if (url.toString().includes('/settings')) {
66
+ return mockFetchJson({ title: 'My Site', site_icon_url: 'https://example.com/icon.png' });
67
+ }
68
+ return mockFetchJson({});
69
+ });
70
+ const res = await call('wp_generate_schema_article', { post_id: 42 });
71
+ const data = parseResult(res);
72
+ expect(data.jsonld['@type']).toBe('Article');
73
+ expect(data.jsonld.headline).toBe('My Post');
74
+ expect(data.jsonld.author.name).toBe('John Doe');
75
+ expect(data.jsonld.image.url).toBe('https://example.com/img.jpg');
76
+ expect(data.jsonld.publisher.name).toBe('My Site');
77
+ expect(data.fields_missing).toHaveLength(0);
78
+ });
79
+
80
+ it('reports missing fields gracefully', async () => {
81
+ fetch.mockImplementation((url) => {
82
+ if (url.toString().includes('/posts/99')) {
83
+ return mockFetchJson({
84
+ id: 99, title: { rendered: 'Minimal' }, excerpt: { rendered: '' },
85
+ date: '2024-01-01T00:00:00', _embedded: {}
86
+ });
87
+ }
88
+ return mockFetchJson({}, 403);
89
+ });
90
+ const res = await call('wp_generate_schema_article', { post_id: 99 });
91
+ const data = parseResult(res);
92
+ expect(data.jsonld['@type']).toBe('Article');
93
+ expect(data.fields_missing).toContain('description');
94
+ expect(data.fields_missing).toContain('author');
95
+ expect(data.fields_missing).toContain('image');
96
+ });
97
+
98
+ it('handles 404 post not found', async () => {
99
+ fetch.mockImplementation(() => mockFetchJson({ code: 'rest_post_invalid_id', message: 'Invalid post ID.' }, 404));
100
+ const res = await call('wp_generate_schema_article', { post_id: 999 });
101
+ expect(res.isError).toBe(true);
102
+ });
103
+ });
104
+
105
+ // ════════════════════════════════════════════════════════════
106
+ // wp_generate_schema_faq
107
+ // ════════════════════════════════════════════════════════════
108
+
109
+ describe('wp_generate_schema_faq', () => {
110
+ const faqPost = (content) => ({ id: 10, title: { rendered: 'FAQ' }, content: { rendered: content }, excerpt: { rendered: '' } });
111
+
112
+ it('detects Gutenberg FAQ blocks', async () => {
113
+ fetch.mockImplementation(() => mockFetchJson(faqPost(
114
+ '<div class="wp-block-faq"><div class="faq-question">Q1?</div><div class="faq-answer">A1.</div></div>' +
115
+ '<div class="wp-block-faq"><div class="faq-question">Q2?</div><div class="faq-answer">A2.</div></div>'
116
+ )));
117
+ const res = await call('wp_generate_schema_faq', { post_id: 10 });
118
+ const data = parseResult(res);
119
+ expect(data.detection_method).toBe('gutenberg_faq_block');
120
+ expect(data.qa_count).toBe(2);
121
+ expect(data.jsonld['@type']).toBe('FAQPage');
122
+ expect(data.jsonld.mainEntity[0].name).toBe('Q1?');
123
+ });
124
+
125
+ it('detects RankMath FAQ blocks', async () => {
126
+ fetch.mockImplementation(() => mockFetchJson(faqPost(
127
+ '<div class="rank-math-faq-block"><div class="faq-question">RQ?</div><div class="faq-answer">RA.</div></div></div>'
128
+ )));
129
+ const res = await call('wp_generate_schema_faq', { post_id: 10 });
130
+ const data = parseResult(res);
131
+ expect(data.detection_method).toBe('rankmath_faq');
132
+ expect(data.qa_count).toBe(1);
133
+ });
134
+
135
+ it('detects AIOSEO FAQ blocks', async () => {
136
+ fetch.mockImplementation(() => mockFetchJson(faqPost(
137
+ '<div class="aioseo-faq"><div class="faq-question">AQ?</div><div class="faq-answer">AA.</div></div></div>'
138
+ )));
139
+ const res = await call('wp_generate_schema_faq', { post_id: 10 });
140
+ const data = parseResult(res);
141
+ expect(data.detection_method).toBe('aioseo_faq');
142
+ });
143
+
144
+ it('detects <details><summary> pattern', async () => {
145
+ fetch.mockImplementation(() => mockFetchJson(faqPost(
146
+ '<details><summary>What is this?</summary><p>This is that.</p></details>' +
147
+ '<details><summary>How does it work?</summary><p>Like this.</p></details>'
148
+ )));
149
+ const res = await call('wp_generate_schema_faq', { post_id: 10 });
150
+ const data = parseResult(res);
151
+ expect(data.detection_method).toBe('details_summary');
152
+ expect(data.qa_count).toBe(2);
153
+ });
154
+
155
+ it('falls back to H3 + paragraph', async () => {
156
+ fetch.mockImplementation(() => mockFetchJson(faqPost(
157
+ '<h3>Why use this?</h3><p>Because it is useful.</p><h3>Is it free?</h3><p>Yes.</p>'
158
+ )));
159
+ const res = await call('wp_generate_schema_faq', { post_id: 10 });
160
+ const data = parseResult(res);
161
+ expect(data.detection_method).toBe('h3_paragraph');
162
+ expect(data.qa_count).toBe(2);
163
+ });
164
+
165
+ it('returns empty FAQ when no Q&A detected', async () => {
166
+ fetch.mockImplementation(() => mockFetchJson(faqPost('<p>Just a paragraph.</p>')));
167
+ const res = await call('wp_generate_schema_faq', { post_id: 10 });
168
+ const data = parseResult(res);
169
+ expect(data.detection_method).toBe('none');
170
+ expect(data.qa_count).toBe(0);
171
+ });
172
+ });
173
+
174
+ // ════════════════════════════════════════════════════════════
175
+ // wp_generate_schema_howto
176
+ // ════════════════════════════════════════════════════════════
177
+
178
+ describe('wp_generate_schema_howto', () => {
179
+ const htPost = (content) => ({ id: 20, title: { rendered: 'How to Cook' }, content: { rendered: content }, excerpt: { rendered: '' } });
180
+
181
+ it('detects ordered list steps', async () => {
182
+ fetch.mockImplementation(() => mockFetchJson(htPost(
183
+ '<ol class="wp-block-list"><li>Boil water</li><li>Add pasta</li><li>Drain and serve</li></ol>'
184
+ )));
185
+ const res = await call('wp_generate_schema_howto', { post_id: 20 });
186
+ const data = parseResult(res);
187
+ expect(data.detection_method).toBe('ordered_list_gutenberg');
188
+ expect(data.step_count).toBe(3);
189
+ expect(data.jsonld['@type']).toBe('HowTo');
190
+ expect(data.jsonld.step[0].name).toBe('Boil water');
191
+ expect(data.jsonld.step[0].position).toBe(1);
192
+ });
193
+
194
+ it('detects numbered headings', async () => {
195
+ fetch.mockImplementation(() => mockFetchJson(htPost(
196
+ '<h3>Step 1: Prepare</h3><p>Get ingredients ready.</p><h3>Step 2: Mix</h3><p>Combine all.</p>'
197
+ )));
198
+ const res = await call('wp_generate_schema_howto', { post_id: 20 });
199
+ const data = parseResult(res);
200
+ expect(data.detection_method).toBe('numbered_headings');
201
+ expect(data.step_count).toBe(2);
202
+ });
203
+
204
+ it('extracts totalTime if present', async () => {
205
+ fetch.mockImplementation(() => mockFetchJson(htPost(
206
+ '<p>Total time: PT30M</p><ol><li>Do this</li></ol>'
207
+ )));
208
+ const res = await call('wp_generate_schema_howto', { post_id: 20 });
209
+ const data = parseResult(res);
210
+ expect(data.has_total_time).toBe(true);
211
+ expect(data.jsonld.totalTime).toBe('PT30M');
212
+ });
213
+
214
+ it('extracts estimatedCost if present', async () => {
215
+ fetch.mockImplementation(() => mockFetchJson(htPost(
216
+ '<p>Cost: $25.00</p><ol><li>Buy materials</li></ol>'
217
+ )));
218
+ const res = await call('wp_generate_schema_howto', { post_id: 20 });
219
+ const data = parseResult(res);
220
+ expect(data.has_estimated_cost).toBe(true);
221
+ });
222
+ });
223
+
224
+ // ════════════════════════════════════════════════════════════
225
+ // wp_generate_schema_localbusiness
226
+ // ════════════════════════════════════════════════════════════
227
+
228
+ describe('wp_generate_schema_localbusiness', () => {
229
+ it('generates from WP options fallback', async () => {
230
+ fetch.mockImplementation((url) => {
231
+ if (url.toString().includes('/settings')) {
232
+ return mockFetchJson({ title: 'My Biz', email: 'info@biz.com', url: 'https://biz.com' });
233
+ }
234
+ return mockFetchJson({}, 404);
235
+ });
236
+ const res = await call('wp_generate_schema_localbusiness', {});
237
+ const data = parseResult(res);
238
+ expect(data.jsonld['@type']).toBe('LocalBusiness');
239
+ expect(data.jsonld.name).toBe('My Biz');
240
+ expect(data.sources).toContain('wp_options');
241
+ expect(data.fields_missing).toContain('address');
242
+ expect(data.fields_missing).toContain('telephone');
243
+ });
244
+
245
+ it('generates from post ACF fields', async () => {
246
+ fetch.mockImplementation((url) => {
247
+ if (url.toString().includes('/posts/55')) {
248
+ return mockFetchJson({
249
+ id: 55, acf: { business_name: 'ACF Biz', address: '123 Main St', phone: '+1234', email: 'acf@biz.com', opening_hours: 'Mo-Fr 9-17' }
250
+ });
251
+ }
252
+ if (url.toString().includes('/settings')) {
253
+ return mockFetchJson({ title: 'Site', url: 'https://site.com' });
254
+ }
255
+ return mockFetchJson({}, 404);
256
+ });
257
+ const res = await call('wp_generate_schema_localbusiness', { target: '55' });
258
+ const data = parseResult(res);
259
+ expect(data.jsonld.name).toBe('ACF Biz');
260
+ expect(data.jsonld.telephone).toBe('+1234');
261
+ expect(data.sources).toContain('acf');
262
+ });
263
+ });
264
+
265
+ // ════════════════════════════════════════════════════════════
266
+ // wp_generate_schema_breadcrumb
267
+ // ════════════════════════════════════════════════════════════
268
+
269
+ describe('wp_generate_schema_breadcrumb', () => {
270
+ it('builds breadcrumb for post with category', async () => {
271
+ fetch.mockImplementation((url) => {
272
+ const u = url.toString();
273
+ if (u.includes('/posts/30')) {
274
+ return mockFetchJson({ id: 30, title: { rendered: 'My Article' }, link: 'https://example.com/my-article', categories: [5], type: 'post' });
275
+ }
276
+ if (u.includes('/categories/5')) {
277
+ return mockFetchJson({ id: 5, name: 'Tech', link: 'https://example.com/category/tech', parent: 0 });
278
+ }
279
+ return mockFetchJson({}, 404);
280
+ });
281
+ const res = await call('wp_generate_schema_breadcrumb', { post_id: 30 });
282
+ const data = parseResult(res);
283
+ expect(data.jsonld['@type']).toBe('BreadcrumbList');
284
+ expect(data.item_count).toBe(3); // Home > Tech > My Article
285
+ expect(data.path).toBe('Home > Tech > My Article');
286
+ expect(data.jsonld.itemListElement[0].position).toBe(1);
287
+ expect(data.jsonld.itemListElement[0].name).toBe('Home');
288
+ });
289
+
290
+ it('builds breadcrumb for page with parent', async () => {
291
+ let callCount = 0;
292
+ fetch.mockImplementation((url) => {
293
+ const u = url.toString();
294
+ // First call to /posts/{id} should 404, second to /pages/{id} succeeds
295
+ if (u.includes('/posts/40') && !u.includes('pages')) {
296
+ return mockFetchJson({ code: 'rest_post_invalid_id' }, 404);
297
+ }
298
+ if (u.includes('/pages/40')) {
299
+ return mockFetchJson({ id: 40, title: { rendered: 'Child Page' }, link: 'https://example.com/parent/child', parent: 20, type: 'page' });
300
+ }
301
+ if (u.includes('/pages/20')) {
302
+ return mockFetchJson({ id: 20, title: { rendered: 'Parent Page' }, link: 'https://example.com/parent', parent: 0, type: 'page' });
303
+ }
304
+ return mockFetchJson({}, 404);
305
+ });
306
+ const res = await call('wp_generate_schema_breadcrumb', { post_id: 40 });
307
+ const data = parseResult(res);
308
+ expect(data.item_count).toBe(3); // Home > Parent Page > Child Page
309
+ expect(data.path).toContain('Parent Page');
310
+ expect(data.path).toContain('Child Page');
311
+ });
312
+
313
+ it('handles post not found', async () => {
314
+ fetch.mockImplementation(() => mockFetchJson({ code: 'rest_post_invalid_id' }, 404));
315
+ const res = await call('wp_generate_schema_breadcrumb', { post_id: 9999 });
316
+ expect(res.isError).toBe(true);
317
+ });
318
+ });
319
+
320
+ // ════════════════════════════════════════════════════════════
321
+ // wp_inject_schema
322
+ // ════════════════════════════════════════════════════════════
323
+
324
+ describe('wp_inject_schema', () => {
325
+ it('dry_run=true returns preview without injecting', async () => {
326
+ fetch.mockImplementation((url) => {
327
+ if (url.toString().includes('/posts/42')) {
328
+ return mockFetchJson({
329
+ id: 42, title: { rendered: 'Post' }, excerpt: { rendered: '<p>Ex</p>' },
330
+ date: '2024-01-01', _embedded: { author: [{ name: 'A' }] }
331
+ });
332
+ }
333
+ if (url.toString().includes('/settings')) {
334
+ return mockFetchJson({ title: 'Site' });
335
+ }
336
+ return mockFetchJson({});
337
+ });
338
+ const res = await call('wp_inject_schema', { post_id: 42, schema_type: 'article', dry_run: true });
339
+ const data = parseResult(res);
340
+ expect(data.dry_run).toBe(true);
341
+ expect(data.status).toBe('preview');
342
+ expect(data.jsonld['@type']).toBe('Article');
343
+ });
344
+
345
+ it('dry_run=true bypasses WP_READ_ONLY', async () => {
346
+ process.env.WP_READ_ONLY = 'true';
347
+ fetch.mockImplementation((url) => {
348
+ if (url.toString().includes('/posts/42')) {
349
+ return mockFetchJson({
350
+ id: 42, title: { rendered: 'Post' }, excerpt: { rendered: '' },
351
+ date: '2024-01-01', _embedded: {}
352
+ });
353
+ }
354
+ return mockFetchJson({}, 404);
355
+ });
356
+ const res = await call('wp_inject_schema', { post_id: 42, schema_type: 'article', dry_run: true });
357
+ const data = parseResult(res);
358
+ expect(data.dry_run).toBe(true);
359
+ expect(res.isError).toBeUndefined();
360
+ });
361
+
362
+ it('blocks when WP_READ_ONLY and dry_run=false', async () => {
363
+ process.env.WP_READ_ONLY = 'true';
364
+ const res = await call('wp_inject_schema', { post_id: 42, schema_type: 'article' });
365
+ expect(res.isError).toBe(true);
366
+ expect(res.content[0].text).toContain('READ-ONLY');
367
+ });
368
+
369
+ it('returns error if meta exists and override=false', async () => {
370
+ fetch.mockImplementation((url) => {
371
+ const u = url.toString();
372
+ // First: generate the article schema (wpApiCall for /posts/42)
373
+ if (u.includes('/posts/42') && !u.includes('schema')) {
374
+ return mockFetchJson({
375
+ id: 42, title: { rendered: 'Post' }, excerpt: { rendered: '' },
376
+ date: '2024-01-01', _embedded: {}
377
+ });
378
+ }
379
+ // Settings call
380
+ if (u.includes('/settings')) {
381
+ return mockFetchJson({}, 404);
382
+ }
383
+ // Schema endpoint check: existing meta
384
+ if (u.includes('/schema/42') && !u.includes('POST')) {
385
+ return mockFetchJson({ post_id: 42, schema: '{"@type":"Article"}' });
386
+ }
387
+ return mockFetchJson({});
388
+ });
389
+ const res = await call('wp_inject_schema', { post_id: 42, schema_type: 'article', override: false });
390
+ expect(res.isError).toBe(true);
391
+ expect(res.content[0].text).toContain('already exists');
392
+ });
393
+
394
+ it('injects successfully with override=true', async () => {
395
+ fetch.mockImplementation((url, opts) => {
396
+ const u = url.toString();
397
+ if (u.includes('/posts/42') && (!opts || opts.method !== 'POST')) {
398
+ return mockFetchJson({
399
+ id: 42, title: { rendered: 'Post' }, excerpt: { rendered: '<p>Ex</p>' },
400
+ date: '2024-01-01', _embedded: { author: [{ name: 'Auth' }] }
401
+ });
402
+ }
403
+ if (u.includes('/settings')) return mockFetchJson({ title: 'Site' });
404
+ if (u.includes('/schema/42') && opts?.method === 'POST') return mockFetchJson({ status: 'saved' });
405
+ if (u.includes('/schema/42')) return mockFetchJson({ post_id: 42, schema: '{"old":true}' });
406
+ return mockFetchJson({});
407
+ });
408
+ const res = await call('wp_inject_schema', { post_id: 42, schema_type: 'article', override: true });
409
+ const data = parseResult(res);
410
+ expect(data.status).toBe('injected');
411
+ expect(data.jsonld['@type']).toBe('Article');
412
+ });
413
+ });
414
+
415
+ // ════════════════════════════════════════════════════════════
416
+ // wp_validate_schema_live
417
+ // ════════════════════════════════════════════════════════════
418
+
419
+ describe('wp_validate_schema_live', () => {
420
+ it('validates a page with valid Article schema', async () => {
421
+ const html = `<html><head><script type="application/ld+json">{"@context":"https://schema.org","@type":"Article","headline":"Test","author":{"name":"A"}}</script></head><body></body></html>`;
422
+ fetch.mockImplementation(() => mockFetchText(html));
423
+ const res = await call('wp_validate_schema_live', { url: 'https://example.com/post' });
424
+ const data = parseResult(res);
425
+ expect(data.total_schemas).toBe(1);
426
+ expect(data.schemas_found[0].type).toBe('Article');
427
+ expect(data.schemas_found[0].valid).toBe(true);
428
+ expect(data.rich_results_eligible.article).toBe(true);
429
+ });
430
+
431
+ it('detects invalid FAQ (missing mainEntity)', async () => {
432
+ const html = `<html><head><script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage"}</script></head><body></body></html>`;
433
+ fetch.mockImplementation(() => mockFetchText(html));
434
+ const res = await call('wp_validate_schema_live', { url: 'https://example.com/faq' });
435
+ const data = parseResult(res);
436
+ expect(data.schemas_found[0].type).toBe('FAQPage');
437
+ expect(data.schemas_found[0].valid).toBe(false);
438
+ expect(data.schemas_found[0].errors).toContain('Missing required field: mainEntity');
439
+ });
440
+
441
+ it('handles page with no schema', async () => {
442
+ fetch.mockImplementation(() => mockFetchText('<html><head></head><body>Hello</body></html>'));
443
+ const res = await call('wp_validate_schema_live', { url: 'https://example.com/plain' });
444
+ const data = parseResult(res);
445
+ expect(data.total_schemas).toBe(0);
446
+ expect(data.recommendations).toContain('No JSON-LD schemas found. Add structured data to improve SEO.');
447
+ });
448
+
449
+ it('detects common errors: relative URL, bad date', async () => {
450
+ const html = `<html><head><script type="application/ld+json">{"@context":"https://schema.org","@type":"Article","headline":"T","author":{"name":"A"},"url":"/relative","datePublished":"not-a-date"}</script></head><body></body></html>`;
451
+ fetch.mockImplementation(() => mockFetchText(html));
452
+ const res = await call('wp_validate_schema_live', { url: 'https://example.com/bad' });
453
+ const data = parseResult(res);
454
+ expect(data.schemas_found[0].warnings).toEqual(
455
+ expect.arrayContaining([
456
+ expect.stringContaining('Relative URL'),
457
+ expect.stringContaining('Invalid datePublished'),
458
+ ])
459
+ );
460
+ });
461
+
462
+ it('handles @graph arrays', async () => {
463
+ const html = `<html><head><script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem"}]},{"@type":"Article","headline":"T","author":{"name":"A"}}]}</script></head><body></body></html>`;
464
+ fetch.mockImplementation(() => mockFetchText(html));
465
+ const res = await call('wp_validate_schema_live', { url: 'https://example.com/graph' });
466
+ const data = parseResult(res);
467
+ expect(data.schemas_found.length).toBe(2);
468
+ expect(data.rich_results_eligible.breadcrumb).toBe(true);
469
+ expect(data.rich_results_eligible.article).toBe(true);
470
+ });
471
+
472
+ it('handles fetch failure', async () => {
473
+ fetch.mockImplementation(() => mockFetchText('', 500));
474
+ const res = await call('wp_validate_schema_live', { url: 'https://example.com/err' });
475
+ expect(res.isError).toBe(true);
476
+ });
477
+ });