@adsim/wordpress-mcp-server 4.4.0 → 4.5.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.
@@ -0,0 +1,864 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+ vi.mock('../../../src/pluginDetector.js', () => ({
5
+ detectSeoPlugin: vi.fn(),
6
+ getRenderedHead: vi.fn(),
7
+ parseRenderedHead: vi.fn(),
8
+ _clearPluginCache: vi.fn()
9
+ }));
10
+
11
+ import fetch from 'node-fetch';
12
+ import { handleToolCall, _testSetTarget } from '../../../index.js';
13
+ import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
14
+ import { detectSeoPlugin, getRenderedHead, parseRenderedHead } from '../../../src/pluginDetector.js';
15
+
16
+ function call(name, args = {}) {
17
+ return handleToolCall(makeRequest(name, args));
18
+ }
19
+
20
+ let consoleSpy;
21
+ beforeEach(() => {
22
+ fetch.mockReset();
23
+ detectSeoPlugin.mockReset();
24
+ getRenderedHead.mockReset();
25
+ parseRenderedHead.mockReset();
26
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
27
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
28
+ });
29
+ afterEach(() => {
30
+ consoleSpy.mockRestore();
31
+ _testSetTarget(null);
32
+ });
33
+
34
+ // =========================================================================
35
+ // Mock data
36
+ // =========================================================================
37
+
38
+ const RENDERED_HEAD_HTML = [
39
+ '<title>Mon Article Test | MonSite</title>',
40
+ '<meta name="description" content="Description rendue par RankMath" />',
41
+ '<link rel="canonical" href="https://test.example.com/mon-article-test" />',
42
+ '<meta name="robots" content="index, follow" />',
43
+ '<meta property="og:title" content="Mon Article Test" />',
44
+ '<meta property="og:description" content="OG Description" />',
45
+ '<meta property="og:image" content="https://test.example.com/image.jpg" />',
46
+ '<meta name="twitter:card" content="summary_large_image" />',
47
+ '<script type="application/ld+json">{"@type":"Article","headline":"Mon Article Test"}</script>'
48
+ ].join('\n');
49
+
50
+ const PARSED_HEAD = {
51
+ title: 'Mon Article Test | MonSite',
52
+ meta_description: 'Description rendue par RankMath',
53
+ canonical: 'https://test.example.com/mon-article-test',
54
+ robots: 'index, follow',
55
+ og_title: 'Mon Article Test',
56
+ og_description: 'OG Description',
57
+ og_image: 'https://test.example.com/image.jpg',
58
+ og_type: null,
59
+ twitter_card: 'summary_large_image',
60
+ twitter_title: null,
61
+ twitter_description: null,
62
+ twitter_image: null,
63
+ schema_json_ld: [{ '@type': 'Article', headline: 'Mon Article Test' }]
64
+ };
65
+
66
+ function makePost(id, extras = {}) {
67
+ return {
68
+ id,
69
+ title: { rendered: `Post ${id}` },
70
+ link: `https://test.example.com/post-${id}`,
71
+ slug: `post-${id}`,
72
+ meta: {
73
+ rank_math_title: `SEO Title ${id}`,
74
+ rank_math_description: `SEO Desc ${id}`,
75
+ rank_math_focus_keyword: 'wordpress seo',
76
+ rank_math_canonical_url: `https://test.example.com/post-${id}`,
77
+ rank_math_robots: [],
78
+ rank_math_pillar_content: ''
79
+ },
80
+ ...extras
81
+ };
82
+ }
83
+
84
+ // =========================================================================
85
+ // wp_get_rendered_head
86
+ // =========================================================================
87
+
88
+ describe('wp_get_rendered_head', () => {
89
+ it('NOMINAL RankMath — returns rendered + stored meta', async () => {
90
+ detectSeoPlugin.mockResolvedValue('rankmath');
91
+ mockSuccess(makePost(1));
92
+ getRenderedHead.mockResolvedValue({ success: true, head: RENDERED_HEAD_HTML, plugin: 'rankmath' });
93
+ parseRenderedHead.mockReturnValue(PARSED_HEAD);
94
+
95
+ const res = await call('wp_get_rendered_head', { post_id: 1 });
96
+ const data = parseResult(res);
97
+
98
+ expect(data.post_id).toBe(1);
99
+ expect(data.seo_plugin).toBe('rankmath');
100
+ expect(data.rendered.title).toBe('Mon Article Test | MonSite');
101
+ expect(data.rendered.meta_description).toBe('Description rendue par RankMath');
102
+ expect(data.stored.title).toBe('SEO Title 1');
103
+ expect(data.stored.description).toBe('SEO Desc 1');
104
+ expect(data.stored.focus_keyword).toBe('wordpress seo');
105
+ expect(data.raw_head_length).toBeGreaterThan(0);
106
+ expect(data.schemas_count).toBe(1);
107
+ });
108
+
109
+ it('NOMINAL Yoast — returns rendered meta', async () => {
110
+ detectSeoPlugin.mockResolvedValue('yoast');
111
+ const yoastPost = makePost(2, {
112
+ meta: {
113
+ _yoast_wpseo_title: 'Yoast Title',
114
+ _yoast_wpseo_metadesc: 'Yoast Desc',
115
+ _yoast_wpseo_focuskw: 'yoast keyword',
116
+ _yoast_wpseo_canonical: '',
117
+ _yoast_wpseo_meta_robots_noindex: ''
118
+ }
119
+ });
120
+ mockSuccess(yoastPost);
121
+ getRenderedHead.mockResolvedValue({ success: true, head: '<title>Yoast</title>', plugin: 'yoast' });
122
+ parseRenderedHead.mockReturnValue({ ...PARSED_HEAD, title: 'Yoast' });
123
+
124
+ const res = await call('wp_get_rendered_head', { post_id: 2 });
125
+ const data = parseResult(res);
126
+
127
+ expect(data.seo_plugin).toBe('yoast');
128
+ expect(data.stored.title).toBe('Yoast Title');
129
+ expect(data.stored.description).toBe('Yoast Desc');
130
+ expect(data.stored.focus_keyword).toBe('yoast keyword');
131
+ });
132
+
133
+ it('ERROR — unsupported plugin (seopress)', async () => {
134
+ detectSeoPlugin.mockResolvedValue('seopress');
135
+
136
+ const res = await call('wp_get_rendered_head', { post_id: 1 });
137
+ expect(res.isError).toBe(true);
138
+ expect(res.content[0].text).toContain('requires RankMath or Yoast');
139
+ });
140
+
141
+ it('ERROR — no SEO plugin detected', async () => {
142
+ detectSeoPlugin.mockResolvedValue(null);
143
+
144
+ const res = await call('wp_get_rendered_head', { post_id: 1 });
145
+ expect(res.isError).toBe(true);
146
+ expect(res.content[0].text).toContain('No supported SEO plugin');
147
+ });
148
+
149
+ it('ERROR — rendered head failure', async () => {
150
+ detectSeoPlugin.mockResolvedValue('rankmath');
151
+ mockSuccess(makePost(1));
152
+ getRenderedHead.mockResolvedValue({ success: false, error: 'RankMath API: 500' });
153
+
154
+ const res = await call('wp_get_rendered_head', { post_id: 1 });
155
+ expect(res.isError).toBe(true);
156
+ expect(res.content[0].text).toContain('RankMath API: 500');
157
+ });
158
+
159
+ it('STORED — compares stored vs rendered in response', async () => {
160
+ detectSeoPlugin.mockResolvedValue('rankmath');
161
+ mockSuccess(makePost(1));
162
+ getRenderedHead.mockResolvedValue({ success: true, head: 'x', plugin: 'rankmath' });
163
+ parseRenderedHead.mockReturnValue(PARSED_HEAD);
164
+
165
+ const res = await call('wp_get_rendered_head', { post_id: 1 });
166
+ const data = parseResult(res);
167
+
168
+ expect(data).toHaveProperty('rendered');
169
+ expect(data).toHaveProperty('stored');
170
+ expect(data.stored.canonical).toBe('https://test.example.com/post-1');
171
+ });
172
+
173
+ it('AUDIT — logs get_rendered_head with plugin param', async () => {
174
+ detectSeoPlugin.mockResolvedValue('rankmath');
175
+ mockSuccess(makePost(1));
176
+ getRenderedHead.mockResolvedValue({ success: true, head: 'x', plugin: 'rankmath' });
177
+ parseRenderedHead.mockReturnValue(PARSED_HEAD);
178
+
179
+ await call('wp_get_rendered_head', { post_id: 1 });
180
+ const logs = getAuditLogs();
181
+ const entry = logs.find(l => l.tool === 'wp_get_rendered_head');
182
+ expect(entry).toBeDefined();
183
+ expect(entry.action).toBe('get_rendered_head');
184
+ expect(entry.params.plugin).toBe('rankmath');
185
+ });
186
+
187
+ it('ERROR — post not found (404)', async () => {
188
+ detectSeoPlugin.mockResolvedValue('rankmath');
189
+ mockError(404, 'Not Found');
190
+
191
+ const res = await call('wp_get_rendered_head', { post_id: 9999 });
192
+ expect(res.isError).toBe(true);
193
+ });
194
+ });
195
+
196
+ // =========================================================================
197
+ // wp_audit_rendered_seo
198
+ // =========================================================================
199
+
200
+ describe('wp_audit_rendered_seo', () => {
201
+ function makeAuditPost(id, meta = {}) {
202
+ return {
203
+ id,
204
+ title: { rendered: `Post ${id}` },
205
+ link: `https://test.example.com/post-${id}`,
206
+ slug: `post-${id}`,
207
+ meta: {
208
+ rank_math_title: `SEO Title ${id}`,
209
+ rank_math_description: `SEO Desc ${id}`,
210
+ rank_math_canonical_url: `https://test.example.com/post-${id}`,
211
+ rank_math_robots: [],
212
+ ...meta
213
+ }
214
+ };
215
+ }
216
+
217
+ it('NOMINAL — audits posts and returns scores', async () => {
218
+ detectSeoPlugin.mockResolvedValue('rankmath');
219
+ mockSuccess([makeAuditPost(1), makeAuditPost(2), makeAuditPost(3)]);
220
+
221
+ // Post 1: perfect
222
+ getRenderedHead.mockResolvedValueOnce({ success: true, head: 'h1', plugin: 'rankmath' });
223
+ parseRenderedHead.mockReturnValueOnce({
224
+ title: 'SEO Title 1 | Site', meta_description: 'SEO Desc 1',
225
+ canonical: 'https://test.example.com/post-1', robots: 'index, follow',
226
+ schema_json_ld: [{ '@type': 'Article' }]
227
+ });
228
+ // Post 2: title mismatch
229
+ getRenderedHead.mockResolvedValueOnce({ success: true, head: 'h2', plugin: 'rankmath' });
230
+ parseRenderedHead.mockReturnValueOnce({
231
+ title: 'Completely Different Title', meta_description: 'SEO Desc 2',
232
+ canonical: 'https://test.example.com/post-2', robots: 'index, follow',
233
+ schema_json_ld: [{ '@type': 'Article' }]
234
+ });
235
+ // Post 3: missing description + schema
236
+ getRenderedHead.mockResolvedValueOnce({ success: true, head: 'h3', plugin: 'rankmath' });
237
+ parseRenderedHead.mockReturnValueOnce({
238
+ title: 'SEO Title 3 | Site', meta_description: null,
239
+ canonical: 'https://test.example.com/post-3', robots: 'index',
240
+ schema_json_ld: []
241
+ });
242
+
243
+ const res = await call('wp_audit_rendered_seo', { limit: 3 });
244
+ const data = parseResult(res);
245
+
246
+ expect(data.seo_plugin).toBe('rankmath');
247
+ expect(data.total_audited).toBe(3);
248
+ expect(data.avg_score).toBeGreaterThan(0);
249
+ expect(data.issues_summary).toHaveProperty('title_mismatch');
250
+ expect(data.posts).toHaveLength(3);
251
+ });
252
+
253
+ it('TITLE MISMATCH — detected when stored title not in rendered', async () => {
254
+ detectSeoPlugin.mockResolvedValue('rankmath');
255
+ mockSuccess([makeAuditPost(1)]);
256
+ getRenderedHead.mockResolvedValue({ success: true, head: 'h', plugin: 'rankmath' });
257
+ parseRenderedHead.mockReturnValue({
258
+ title: 'Something Else Entirely', meta_description: 'SEO Desc 1',
259
+ canonical: 'https://test.example.com/post-1', robots: 'index',
260
+ schema_json_ld: [{ '@type': 'Article' }]
261
+ });
262
+
263
+ const res = await call('wp_audit_rendered_seo', { limit: 1 });
264
+ const data = parseResult(res);
265
+
266
+ expect(data.issues_summary.title_mismatch).toBe(1);
267
+ expect(data.posts[0].issues).toContain('title_mismatch');
268
+ });
269
+
270
+ it('DESCRIPTION MISMATCH — detected when rendered !== stored', async () => {
271
+ detectSeoPlugin.mockResolvedValue('rankmath');
272
+ mockSuccess([makeAuditPost(1)]);
273
+ getRenderedHead.mockResolvedValue({ success: true, head: 'h', plugin: 'rankmath' });
274
+ parseRenderedHead.mockReturnValue({
275
+ title: 'SEO Title 1 | Site', meta_description: 'Different description',
276
+ canonical: 'https://test.example.com/post-1', robots: 'index',
277
+ schema_json_ld: [{ '@type': 'Article' }]
278
+ });
279
+
280
+ const res = await call('wp_audit_rendered_seo', { limit: 1 });
281
+ const data = parseResult(res);
282
+
283
+ expect(data.issues_summary.description_mismatch).toBe(1);
284
+ });
285
+
286
+ it('SCHEMA MISSING — detected when no JSON-LD', async () => {
287
+ detectSeoPlugin.mockResolvedValue('rankmath');
288
+ mockSuccess([makeAuditPost(1)]);
289
+ getRenderedHead.mockResolvedValue({ success: true, head: 'h', plugin: 'rankmath' });
290
+ parseRenderedHead.mockReturnValue({
291
+ title: 'SEO Title 1 | Site', meta_description: 'SEO Desc 1',
292
+ canonical: 'https://test.example.com/post-1', robots: 'index',
293
+ schema_json_ld: []
294
+ });
295
+
296
+ const res = await call('wp_audit_rendered_seo', { limit: 1 });
297
+ const data = parseResult(res);
298
+
299
+ expect(data.issues_summary.schema_missing).toBe(1);
300
+ });
301
+
302
+ it('PERFECT SCORE — 100 when no divergences', async () => {
303
+ detectSeoPlugin.mockResolvedValue('rankmath');
304
+ mockSuccess([makeAuditPost(1)]);
305
+ getRenderedHead.mockResolvedValue({ success: true, head: 'h', plugin: 'rankmath' });
306
+ parseRenderedHead.mockReturnValue({
307
+ title: 'SEO Title 1 | Site', meta_description: 'SEO Desc 1',
308
+ canonical: 'https://test.example.com/post-1', robots: 'index',
309
+ schema_json_ld: [{ '@type': 'Article' }]
310
+ });
311
+
312
+ const res = await call('wp_audit_rendered_seo', { limit: 1 });
313
+ const data = parseResult(res);
314
+
315
+ expect(data.posts[0].score).toBe(100);
316
+ expect(data.posts[0].issues).toHaveLength(0);
317
+ });
318
+
319
+ it('DEGRADED SCORE — multiple issues reduce score', async () => {
320
+ detectSeoPlugin.mockResolvedValue('rankmath');
321
+ mockSuccess([makeAuditPost(1)]);
322
+ getRenderedHead.mockResolvedValue({ success: true, head: 'h', plugin: 'rankmath' });
323
+ parseRenderedHead.mockReturnValue({
324
+ title: null, meta_description: null,
325
+ canonical: 'https://test.example.com/post-1', robots: 'noindex',
326
+ schema_json_ld: []
327
+ });
328
+
329
+ const res = await call('wp_audit_rendered_seo', { limit: 1 });
330
+ const data = parseResult(res);
331
+
332
+ // missing_rendered_title + missing_rendered_description + robots_mismatch + schema_missing = 4 issues
333
+ expect(data.posts[0].issues.length).toBeGreaterThanOrEqual(3);
334
+ expect(data.posts[0].score).toBeLessThan(70);
335
+ });
336
+
337
+ it('AUDIT — logs audit_rendered_seo action', async () => {
338
+ detectSeoPlugin.mockResolvedValue('rankmath');
339
+ mockSuccess([makeAuditPost(1)]);
340
+ getRenderedHead.mockResolvedValue({ success: true, head: 'h', plugin: 'rankmath' });
341
+ parseRenderedHead.mockReturnValue({
342
+ title: 'SEO Title 1', meta_description: 'SEO Desc 1',
343
+ canonical: 'https://test.example.com/post-1', robots: 'index',
344
+ schema_json_ld: [{}]
345
+ });
346
+
347
+ await call('wp_audit_rendered_seo', { limit: 1 });
348
+ const logs = getAuditLogs();
349
+ const entry = logs.find(l => l.tool === 'wp_audit_rendered_seo');
350
+ expect(entry).toBeDefined();
351
+ expect(entry.action).toBe('audit_rendered_seo');
352
+ expect(entry.params.plugin).toBe('rankmath');
353
+ });
354
+
355
+ it('ERROR — unsupported plugin', async () => {
356
+ detectSeoPlugin.mockResolvedValue('seopress');
357
+
358
+ const res = await call('wp_audit_rendered_seo', { limit: 5 });
359
+ expect(res.isError).toBe(true);
360
+ expect(res.content[0].text).toContain('requires RankMath or Yoast');
361
+ });
362
+ });
363
+
364
+ // =========================================================================
365
+ // wp_get_pillar_content
366
+ // =========================================================================
367
+
368
+ describe('wp_get_pillar_content', () => {
369
+ function makePillarPost(id, isPillar) {
370
+ return {
371
+ id,
372
+ title: { rendered: `Post ${id}` },
373
+ link: `https://test.example.com/post-${id}`,
374
+ slug: `post-${id}`,
375
+ meta: {
376
+ rank_math_pillar_content: isPillar ? 'on' : ''
377
+ }
378
+ };
379
+ }
380
+
381
+ it('LIST — returns pillar posts', async () => {
382
+ detectSeoPlugin.mockResolvedValue('rankmath');
383
+ mockSuccess([makePillarPost(1, true), makePillarPost(2, false), makePillarPost(3, true)]);
384
+
385
+ const res = await call('wp_get_pillar_content', { list_pillars: true });
386
+ const data = parseResult(res);
387
+
388
+ expect(data.mode).toBe('list_pillars');
389
+ expect(data.seo_plugin).toBe('rankmath');
390
+ expect(data.pillar_count).toBe(2);
391
+ expect(data.pillars).toHaveLength(2);
392
+ expect(data.pillars[0].id).toBe(1);
393
+ expect(data.pillars[1].id).toBe(3);
394
+ });
395
+
396
+ it('LIST — no pillars returns empty', async () => {
397
+ detectSeoPlugin.mockResolvedValue('rankmath');
398
+ mockSuccess([makePillarPost(1, false), makePillarPost(2, false)]);
399
+
400
+ const res = await call('wp_get_pillar_content', { list_pillars: true });
401
+ const data = parseResult(res);
402
+
403
+ expect(data.pillar_count).toBe(0);
404
+ expect(data.pillars).toHaveLength(0);
405
+ });
406
+
407
+ it('READ — post with pillar flag', async () => {
408
+ detectSeoPlugin.mockResolvedValue('rankmath');
409
+ mockSuccess(makePillarPost(1, true));
410
+
411
+ const res = await call('wp_get_pillar_content', { post_id: 1 });
412
+ const data = parseResult(res);
413
+
414
+ expect(data.mode).toBe('read');
415
+ expect(data.post_id).toBe(1);
416
+ expect(data.is_pillar).toBe(true);
417
+ expect(data.seo_plugin).toBe('rankmath');
418
+ });
419
+
420
+ it('READ — post without pillar flag', async () => {
421
+ detectSeoPlugin.mockResolvedValue('rankmath');
422
+ mockSuccess(makePillarPost(5, false));
423
+
424
+ const res = await call('wp_get_pillar_content', { post_id: 5 });
425
+ const data = parseResult(res);
426
+
427
+ expect(data.is_pillar).toBe(false);
428
+ });
429
+
430
+ it('WRITE — set_pillar=true marks post as pillar', async () => {
431
+ detectSeoPlugin.mockResolvedValue('rankmath');
432
+ mockSuccess(makePillarPost(1, false)); // GET post
433
+ mockSuccess({ id: 1 }); // PUT update
434
+
435
+ const res = await call('wp_get_pillar_content', { post_id: 1, set_pillar: true });
436
+ const data = parseResult(res);
437
+
438
+ expect(data.mode).toBe('write');
439
+ expect(data.is_pillar).toBe(true);
440
+ expect(data.action).toBe('marked_as_pillar');
441
+ });
442
+
443
+ it('WRITE — WP_READ_ONLY=true blocks write', async () => {
444
+ const original = process.env.WP_READ_ONLY;
445
+ process.env.WP_READ_ONLY = 'true';
446
+
447
+ try {
448
+ detectSeoPlugin.mockResolvedValue('rankmath');
449
+
450
+ const res = await call('wp_get_pillar_content', { post_id: 1, set_pillar: true });
451
+ expect(res.isError).toBe(true);
452
+ expect(res.content[0].text).toContain('READ-ONLY');
453
+ } finally {
454
+ if (original === undefined) delete process.env.WP_READ_ONLY;
455
+ else process.env.WP_READ_ONLY = original;
456
+ }
457
+ });
458
+
459
+ it('ERROR — plugin not RankMath', async () => {
460
+ detectSeoPlugin.mockResolvedValue('yoast');
461
+
462
+ const res = await call('wp_get_pillar_content', { list_pillars: true });
463
+ expect(res.isError).toBe(true);
464
+ expect(res.content[0].text).toContain('requires RankMath');
465
+ });
466
+
467
+ it('AUDIT — logs correctly for read vs write', async () => {
468
+ // Read mode
469
+ detectSeoPlugin.mockResolvedValue('rankmath');
470
+ mockSuccess(makePillarPost(1, true));
471
+
472
+ await call('wp_get_pillar_content', { post_id: 1 });
473
+ let logs = getAuditLogs();
474
+ let entry = logs.find(l => l.tool === 'wp_get_pillar_content');
475
+ expect(entry).toBeDefined();
476
+ expect(entry.action).toBe('read_pillar_content');
477
+
478
+ // Write mode
479
+ consoleSpy.mockClear();
480
+ detectSeoPlugin.mockResolvedValue('rankmath');
481
+ mockSuccess(makePillarPost(2, false));
482
+ mockSuccess({ id: 2 });
483
+
484
+ await call('wp_get_pillar_content', { post_id: 2, set_pillar: true });
485
+ logs = getAuditLogs();
486
+ const writeEntry = logs.find(l => l.tool === 'wp_get_pillar_content' && l.action === 'update_pillar_content');
487
+ expect(writeEntry).toBeDefined();
488
+ expect(writeEntry.target).toBe(2);
489
+ });
490
+ });
491
+
492
+ // =========================================================================
493
+ // wp_audit_schema_plugins
494
+ // =========================================================================
495
+
496
+ describe('wp_audit_schema_plugins', () => {
497
+ function makeSchemaPost(id, schema) {
498
+ return {
499
+ id,
500
+ title: { rendered: `Post ${id}` },
501
+ link: `https://test.example.com/post-${id}`,
502
+ slug: `post-${id}`,
503
+ meta: { rank_math_schema: schema }
504
+ };
505
+ }
506
+
507
+ it('NOMINAL RankMath — parses and validates schemas', async () => {
508
+ detectSeoPlugin.mockResolvedValue('rankmath');
509
+ mockSuccess([
510
+ makeSchemaPost(1, '{"@type":"Article","headline":"Title","datePublished":"2025-01-01","author":{"@type":"Person","name":"A"}}'),
511
+ makeSchemaPost(2, '{"@type":"FAQPage","mainEntity":[{"@type":"Question"}]}')
512
+ ]);
513
+
514
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
515
+ const data = parseResult(res);
516
+
517
+ expect(data.seo_plugin).toBe('rankmath');
518
+ expect(data.total_audited).toBe(2);
519
+ expect(data.schema_coverage.posts_with_schema).toBe(2);
520
+ expect(data.schema_types_found).toHaveProperty('Article');
521
+ expect(data.posts).toHaveLength(2);
522
+ });
523
+
524
+ it('NOMINAL Yoast — reads schemas via getRenderedHead', async () => {
525
+ detectSeoPlugin.mockResolvedValue('yoast');
526
+ mockSuccess([makePost(1), makePost(2)]);
527
+ getRenderedHead.mockResolvedValue({ success: true, head: '<script type="application/ld+json">{"@type":"Article"}</script>', plugin: 'yoast' });
528
+ parseRenderedHead.mockReturnValue({ ...PARSED_HEAD, schema_json_ld: [{ '@type': 'Article', headline: 'T', datePublished: '2025-01-01', author: { '@type': 'Person' } }] });
529
+
530
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
531
+ const data = parseResult(res);
532
+
533
+ expect(data.seo_plugin).toBe('yoast');
534
+ expect(data.schema_coverage.posts_with_schema).toBe(2);
535
+ });
536
+
537
+ it('NO SCHEMA — flags no_plugin_schema issue', async () => {
538
+ detectSeoPlugin.mockResolvedValue('rankmath');
539
+ mockSuccess([makeSchemaPost(1, '')]);
540
+
541
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
542
+ const data = parseResult(res);
543
+
544
+ expect(data.issues_summary.no_plugin_schema).toBe(1);
545
+ expect(data.posts[0].issues).toContain('no_plugin_schema');
546
+ });
547
+
548
+ it('INVALID JSON — flags invalid_schema_json', async () => {
549
+ detectSeoPlugin.mockResolvedValue('rankmath');
550
+ mockSuccess([makeSchemaPost(1, '{invalid json}')]);
551
+
552
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
553
+ const data = parseResult(res);
554
+
555
+ expect(data.issues_summary.invalid_schema_json).toBe(1);
556
+ expect(data.posts[0].issues).toContain('invalid_schema_json');
557
+ });
558
+
559
+ it('MISSING FIELDS — Article without headline', async () => {
560
+ detectSeoPlugin.mockResolvedValue('rankmath');
561
+ mockSuccess([makeSchemaPost(1, '{"@type":"Article","datePublished":"2025-01-01"}')]);
562
+
563
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
564
+ const data = parseResult(res);
565
+
566
+ expect(data.issues_summary.missing_required_fields).toBe(1);
567
+ expect(data.posts[0].schemas[0].valid).toBe(false);
568
+ expect(data.posts[0].schemas[0].missing_fields).toContain('headline');
569
+ });
570
+
571
+ it('COVERAGE — calculated correctly', async () => {
572
+ detectSeoPlugin.mockResolvedValue('rankmath');
573
+ mockSuccess([
574
+ makeSchemaPost(1, '{"@type":"Article","headline":"T","datePublished":"2025","author":"A"}'),
575
+ makeSchemaPost(2, ''),
576
+ makeSchemaPost(3, '{"@type":"WebSite","name":"S","url":"https://s.com"}')
577
+ ]);
578
+
579
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
580
+ const data = parseResult(res);
581
+
582
+ expect(data.schema_coverage.posts_with_schema).toBe(2);
583
+ expect(data.schema_coverage.posts_without_schema).toBe(1);
584
+ expect(data.schema_coverage.coverage_percent).toBe(67);
585
+ });
586
+
587
+ it('AUDIT — logs audit_schema_plugins action', async () => {
588
+ detectSeoPlugin.mockResolvedValue('rankmath');
589
+ mockSuccess([makeSchemaPost(1, '{"@type":"Article","headline":"T","datePublished":"2025","author":"A"}')]);
590
+
591
+ await call('wp_audit_schema_plugins', { limit: 5 });
592
+ const logs = getAuditLogs();
593
+ const entry = logs.find(l => l.tool === 'wp_audit_schema_plugins');
594
+ expect(entry).toBeDefined();
595
+ expect(entry.action).toBe('audit_schema_plugins');
596
+ expect(entry.params.plugin).toBe('rankmath');
597
+ });
598
+
599
+ it('ERROR — unsupported plugin', async () => {
600
+ detectSeoPlugin.mockResolvedValue('seopress');
601
+
602
+ const res = await call('wp_audit_schema_plugins', { limit: 5 });
603
+ expect(res.isError).toBe(true);
604
+ expect(res.content[0].text).toContain('requires RankMath or Yoast');
605
+ });
606
+ });
607
+
608
+ // =========================================================================
609
+ // wp_get_seo_score
610
+ // =========================================================================
611
+
612
+ describe('wp_get_seo_score', () => {
613
+ function makeScorePost(id, score, keyword) {
614
+ return {
615
+ id,
616
+ title: { rendered: `Post ${id}` },
617
+ link: `https://test.example.com/post-${id}`,
618
+ slug: `post-${id}`,
619
+ meta: {
620
+ rank_math_seo_score: score,
621
+ rank_math_focus_keyword: keyword || null
622
+ }
623
+ };
624
+ }
625
+
626
+ it('SINGLE — score 85 returns excellent rating', async () => {
627
+ detectSeoPlugin.mockResolvedValue('rankmath');
628
+ mockSuccess(makeScorePost(1, '85', 'wordpress seo'));
629
+
630
+ const res = await call('wp_get_seo_score', { post_id: 1 });
631
+ const data = parseResult(res);
632
+
633
+ expect(data.mode).toBe('single');
634
+ expect(data.post_id).toBe(1);
635
+ expect(data.seo_score).toBe(85);
636
+ expect(data.rating).toBe('excellent');
637
+ expect(data.focus_keyword).toBe('wordpress seo');
638
+ });
639
+
640
+ it('SINGLE — no score returns null and no_score', async () => {
641
+ detectSeoPlugin.mockResolvedValue('rankmath');
642
+ mockSuccess(makeScorePost(1, undefined));
643
+
644
+ const res = await call('wp_get_seo_score', { post_id: 1 });
645
+ const data = parseResult(res);
646
+
647
+ expect(data.seo_score).toBeNull();
648
+ expect(data.rating).toBe('no_score');
649
+ });
650
+
651
+ it('BULK — sorted by score DESC', async () => {
652
+ detectSeoPlugin.mockResolvedValue('rankmath');
653
+ mockSuccess([
654
+ makeScorePost(1, '92'), makeScorePost(2, '75'), makeScorePost(3, '65'),
655
+ makeScorePost(4, '55'), makeScorePost(5, '30')
656
+ ]);
657
+
658
+ const res = await call('wp_get_seo_score', { limit: 5 });
659
+ const data = parseResult(res);
660
+
661
+ expect(data.mode).toBe('bulk');
662
+ expect(data.total_analyzed).toBe(5);
663
+ expect(data.posts[0].seo_score).toBe(92);
664
+ expect(data.posts[4].seo_score).toBe(30);
665
+ });
666
+
667
+ it('BULK — order=asc sorts ascending', async () => {
668
+ detectSeoPlugin.mockResolvedValue('rankmath');
669
+ mockSuccess([
670
+ makeScorePost(1, '92'), makeScorePost(2, '30'), makeScorePost(3, '65')
671
+ ]);
672
+
673
+ const res = await call('wp_get_seo_score', { limit: 5, order: 'asc' });
674
+ const data = parseResult(res);
675
+
676
+ expect(data.posts[0].seo_score).toBe(30);
677
+ expect(data.posts[2].seo_score).toBe(92);
678
+ });
679
+
680
+ it('DISTRIBUTION — correct counts', async () => {
681
+ detectSeoPlugin.mockResolvedValue('rankmath');
682
+ mockSuccess([
683
+ makeScorePost(1, '92'), makeScorePost(2, '75'), makeScorePost(3, '65'),
684
+ makeScorePost(4, '55'), makeScorePost(5, '30')
685
+ ]);
686
+
687
+ const res = await call('wp_get_seo_score', { limit: 5 });
688
+ const data = parseResult(res);
689
+
690
+ expect(data.distribution.excellent.count).toBe(1);
691
+ expect(data.distribution.good.count).toBe(2);
692
+ expect(data.distribution.average.count).toBe(1);
693
+ expect(data.distribution.poor.count).toBe(1);
694
+ });
695
+
696
+ it('STATS — avg and median calculated', async () => {
697
+ detectSeoPlugin.mockResolvedValue('rankmath');
698
+ mockSuccess([
699
+ makeScorePost(1, '92'), makeScorePost(2, '75'), makeScorePost(3, '65'),
700
+ makeScorePost(4, '55'), makeScorePost(5, '30')
701
+ ]);
702
+
703
+ const res = await call('wp_get_seo_score', { limit: 5 });
704
+ const data = parseResult(res);
705
+
706
+ expect(data.avg_score).toBe(63);
707
+ expect(data.median_score).toBe(65);
708
+ });
709
+
710
+ it('ERROR — plugin not RankMath', async () => {
711
+ detectSeoPlugin.mockResolvedValue('yoast');
712
+
713
+ const res = await call('wp_get_seo_score', { post_id: 1 });
714
+ expect(res.isError).toBe(true);
715
+ expect(res.content[0].text).toContain('requires RankMath');
716
+ });
717
+
718
+ it('AUDIT — logs get_seo_score action', async () => {
719
+ detectSeoPlugin.mockResolvedValue('rankmath');
720
+ mockSuccess(makeScorePost(1, '85'));
721
+
722
+ await call('wp_get_seo_score', { post_id: 1 });
723
+ const logs = getAuditLogs();
724
+ const entry = logs.find(l => l.tool === 'wp_get_seo_score');
725
+ expect(entry).toBeDefined();
726
+ expect(entry.action).toBe('get_seo_score');
727
+ });
728
+ });
729
+
730
+ // =========================================================================
731
+ // wp_get_twitter_meta
732
+ // =========================================================================
733
+
734
+ describe('wp_get_twitter_meta', () => {
735
+ function makeTwitterPost(id, twitterMeta) {
736
+ return {
737
+ id,
738
+ title: { rendered: `Post ${id}` },
739
+ link: `https://test.example.com/post-${id}`,
740
+ slug: `post-${id}`,
741
+ meta: twitterMeta || {}
742
+ };
743
+ }
744
+
745
+ it('READ RankMath — returns twitter meta values', async () => {
746
+ detectSeoPlugin.mockResolvedValue('rankmath');
747
+ mockSuccess(makeTwitterPost(1, {
748
+ rank_math_twitter_title: 'TW Title',
749
+ rank_math_twitter_description: 'TW Desc',
750
+ rank_math_twitter_image: 'https://img.com/tw.jpg',
751
+ rank_math_twitter_card_type: 'summary_large_image'
752
+ }));
753
+
754
+ const res = await call('wp_get_twitter_meta', { post_id: 1 });
755
+ const data = parseResult(res);
756
+
757
+ expect(data.mode).toBe('read');
758
+ expect(data.seo_plugin).toBe('rankmath');
759
+ expect(data.twitter.title).toBe('TW Title');
760
+ expect(data.twitter.description).toBe('TW Desc');
761
+ expect(data.twitter.image).toBe('https://img.com/tw.jpg');
762
+ expect(data.twitter.card_type).toBe('summary_large_image');
763
+ });
764
+
765
+ it('READ Yoast — returns twitter meta values', async () => {
766
+ detectSeoPlugin.mockResolvedValue('yoast');
767
+ mockSuccess(makeTwitterPost(1, {
768
+ '_yoast_wpseo_twitter-title': 'Yoast TW',
769
+ '_yoast_wpseo_twitter-description': 'Yoast TW Desc',
770
+ '_yoast_wpseo_twitter-image': 'https://img.com/yoast.jpg'
771
+ }));
772
+
773
+ const res = await call('wp_get_twitter_meta', { post_id: 1 });
774
+ const data = parseResult(res);
775
+
776
+ expect(data.seo_plugin).toBe('yoast');
777
+ expect(data.twitter.title).toBe('Yoast TW');
778
+ expect(data.twitter.description).toBe('Yoast TW Desc');
779
+ expect(data.twitter.image).toBe('https://img.com/yoast.jpg');
780
+ });
781
+
782
+ it('READ — no twitter meta returns all null', async () => {
783
+ detectSeoPlugin.mockResolvedValue('rankmath');
784
+ mockSuccess(makeTwitterPost(1, {}));
785
+
786
+ const res = await call('wp_get_twitter_meta', { post_id: 1 });
787
+ const data = parseResult(res);
788
+
789
+ expect(data.twitter.title).toBeNull();
790
+ expect(data.twitter.description).toBeNull();
791
+ expect(data.twitter.image).toBeNull();
792
+ });
793
+
794
+ it('WRITE RankMath — updates twitter_title', async () => {
795
+ detectSeoPlugin.mockResolvedValue('rankmath');
796
+ mockSuccess(makeTwitterPost(1, {}));
797
+ mockSuccess({ id: 1 });
798
+
799
+ const res = await call('wp_get_twitter_meta', { post_id: 1, twitter_title: 'New TW Title' });
800
+ const data = parseResult(res);
801
+
802
+ expect(data.mode).toBe('write');
803
+ expect(data.updated_fields).toContain('twitter_title');
804
+ expect(data.twitter.title).toBe('New TW Title');
805
+ });
806
+
807
+ it('WRITE Yoast — updates twitter_description', async () => {
808
+ detectSeoPlugin.mockResolvedValue('yoast');
809
+ mockSuccess(makeTwitterPost(1, {}));
810
+ mockSuccess({ id: 1 });
811
+
812
+ const res = await call('wp_get_twitter_meta', { post_id: 1, twitter_description: 'New Desc' });
813
+ const data = parseResult(res);
814
+
815
+ expect(data.mode).toBe('write');
816
+ expect(data.seo_plugin).toBe('yoast');
817
+ expect(data.updated_fields).toContain('twitter_description');
818
+ });
819
+
820
+ it('WRITE — WP_READ_ONLY=true blocks update', async () => {
821
+ const original = process.env.WP_READ_ONLY;
822
+ process.env.WP_READ_ONLY = 'true';
823
+
824
+ try {
825
+ detectSeoPlugin.mockResolvedValue('rankmath');
826
+
827
+ const res = await call('wp_get_twitter_meta', { post_id: 1, twitter_title: 'X' });
828
+ expect(res.isError).toBe(true);
829
+ expect(res.content[0].text).toContain('READ-ONLY');
830
+ } finally {
831
+ if (original === undefined) delete process.env.WP_READ_ONLY;
832
+ else process.env.WP_READ_ONLY = original;
833
+ }
834
+ });
835
+
836
+ it('WRITE — unsupported plugin (seopress) errors', async () => {
837
+ detectSeoPlugin.mockResolvedValue('seopress');
838
+
839
+ const res = await call('wp_get_twitter_meta', { post_id: 1, twitter_title: 'X' });
840
+ expect(res.isError).toBe(true);
841
+ expect(res.content[0].text).toContain('requires RankMath or Yoast');
842
+ });
843
+
844
+ it('AUDIT — different action for read vs write', async () => {
845
+ detectSeoPlugin.mockResolvedValue('rankmath');
846
+ mockSuccess(makeTwitterPost(1, {}));
847
+
848
+ await call('wp_get_twitter_meta', { post_id: 1 });
849
+ let logs = getAuditLogs();
850
+ let entry = logs.find(l => l.tool === 'wp_get_twitter_meta');
851
+ expect(entry).toBeDefined();
852
+ expect(entry.action).toBe('read_twitter_meta');
853
+
854
+ detectSeoPlugin.mockResolvedValue('rankmath');
855
+ mockSuccess(makeTwitterPost(2, {}));
856
+ mockSuccess({ id: 2 });
857
+
858
+ await call('wp_get_twitter_meta', { post_id: 2, twitter_title: 'X' });
859
+ logs = getAuditLogs();
860
+ const writeEntry = logs.find(l => l.tool === 'wp_get_twitter_meta' && l.action === 'update_twitter_meta');
861
+ expect(writeEntry).toBeDefined();
862
+ expect(writeEntry.target).toBe(2);
863
+ });
864
+ });