@adsim/wordpress-mcp-server 3.0.0 → 4.4.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 (33) hide show
  1. package/README.md +543 -176
  2. package/dxt/build-mcpb.sh +7 -0
  3. package/dxt/manifest.json +189 -0
  4. package/index.js +3156 -36
  5. package/package.json +3 -2
  6. package/src/confirmationToken.js +64 -0
  7. package/src/contentAnalyzer.js +476 -0
  8. package/src/htmlParser.js +80 -0
  9. package/src/linkUtils.js +158 -0
  10. package/src/utils/contentCompressor.js +116 -0
  11. package/src/woocommerceClient.js +88 -0
  12. package/tests/unit/contentAnalyzer.test.js +397 -0
  13. package/tests/unit/dxt/manifest.test.js +78 -0
  14. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  15. package/tests/unit/tools/approval.test.js +251 -0
  16. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  17. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  18. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  19. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  20. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  21. package/tests/unit/tools/contentCompressor.test.js +320 -0
  22. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  23. package/tests/unit/tools/destructive.test.js +246 -0
  24. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  25. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  26. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  27. package/tests/unit/tools/findThinContent.test.js +145 -0
  28. package/tests/unit/tools/internalLinks.test.js +283 -0
  29. package/tests/unit/tools/perTargetControls.test.js +228 -0
  30. package/tests/unit/tools/site.test.js +6 -1
  31. package/tests/unit/tools/woocommerce.test.js +344 -0
  32. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  33. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,2168 @@
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 { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
15
+ afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
16
+
17
+ // =========================================================================
18
+ // Helpers
19
+ // =========================================================================
20
+
21
+ function makeWpPost(id, content = '<p>Content</p>', extras = {}) {
22
+ return {
23
+ id,
24
+ title: { rendered: `Post ${id}` },
25
+ content: { rendered: content },
26
+ excerpt: { rendered: `Excerpt ${id}` },
27
+ status: 'publish',
28
+ date: '2025-06-01',
29
+ modified: '2025-06-02',
30
+ link: `https://test.example.com/post-${id}`,
31
+ slug: `post-${id}`,
32
+ categories: [3, 5],
33
+ tags: [7, 9],
34
+ author: 1,
35
+ featured_media: 42,
36
+ comment_status: 'open',
37
+ meta: {
38
+ rank_math_title: 'SEO Title',
39
+ rank_math_description: 'SEO Desc',
40
+ rank_math_focus_keyword: 'wordpress seo',
41
+ rank_math_canonical_url: 'https://test.example.com/post-1'
42
+ },
43
+ ...extras
44
+ };
45
+ }
46
+
47
+ const CATEGORY_RESPONSE = [
48
+ { id: 3, name: 'Tutorials' },
49
+ { id: 5, name: 'SEO' }
50
+ ];
51
+
52
+ const TAG_RESPONSE = [
53
+ { id: 7, name: 'wordpress' },
54
+ { id: 9, name: 'guide' }
55
+ ];
56
+
57
+ // =========================================================================
58
+ // wp_get_content_brief
59
+ // =========================================================================
60
+
61
+ describe('wp_get_content_brief', () => {
62
+ it('NOMINAL — returns complete brief with all sections', async () => {
63
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
64
+ const html = '<p>Introduction text for the post with enough content.</p><h2>Section One</h2><p>Body content here. <a href="https://test.example.com/other/">internal</a> <a href="https://external.com/page">ext</a></p><ul><li>Item</li></ul>';
65
+ mockSuccess(makeWpPost(1, html)); // post
66
+ mockSuccess(CATEGORY_RESPONSE); // categories
67
+ mockSuccess(TAG_RESPONSE); // tags
68
+
69
+ const res = await call('wp_get_content_brief', { id: 1 });
70
+ const data = parseResult(res);
71
+
72
+ expect(data.id).toBe(1);
73
+ expect(data.title).toBe('Post 1');
74
+ expect(data.slug).toBe('post-1');
75
+ expect(data.word_count).toBeGreaterThan(0);
76
+ expect(data.readability).toHaveProperty('score');
77
+ expect(data.readability).toHaveProperty('level');
78
+ expect(data.seo).toHaveProperty('focus_keyword');
79
+ expect(data.structure).toHaveProperty('headings');
80
+ expect(data.structure).toHaveProperty('has_intro');
81
+ expect(data.categories).toHaveLength(2);
82
+ expect(data.tags).toHaveLength(2);
83
+ expect(data.internal_links).toHaveProperty('count');
84
+ expect(data.external_links).toHaveProperty('count');
85
+ expect(data.featured_media).toBe(42);
86
+ });
87
+
88
+ it('SEO — RankMath meta extracted correctly', async () => {
89
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
90
+ mockSuccess(makeWpPost(1));
91
+ mockSuccess(CATEGORY_RESPONSE);
92
+ mockSuccess(TAG_RESPONSE);
93
+
94
+ const res = await call('wp_get_content_brief', { id: 1 });
95
+ const data = parseResult(res);
96
+
97
+ expect(data.seo.title).toBe('SEO Title');
98
+ expect(data.seo.description).toBe('SEO Desc');
99
+ expect(data.seo.focus_keyword).toBe('wordpress seo');
100
+ expect(data.seo.canonical).toBe('https://test.example.com/post-1');
101
+ });
102
+
103
+ it('STRUCTURE — headings + lists + FAQ detected', async () => {
104
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
105
+ const html = '<p>Intro paragraph with enough text for detection.</p><h2>First</h2><p>Text.</p><h2>FAQ</h2><p>Questions.</p><ul><li>A</li></ul><ol><li>B</li></ol><table><tr><td>C</td></tr></table>';
106
+ mockSuccess(makeWpPost(1, html));
107
+ mockSuccess(CATEGORY_RESPONSE);
108
+ mockSuccess(TAG_RESPONSE);
109
+
110
+ const res = await call('wp_get_content_brief', { id: 1 });
111
+ const data = parseResult(res);
112
+
113
+ expect(data.structure.headings.length).toBeGreaterThan(0);
114
+ expect(data.structure.has_faq).toBe(true);
115
+ expect(data.structure.lists_count).toBe(2);
116
+ expect(data.structure.tables_count).toBe(1);
117
+ });
118
+
119
+ it('WORD COUNT and READABILITY calculated', async () => {
120
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
121
+ const html = '<p>Le chat mange la souris. Le chien dort. Il fait beau.</p>';
122
+ mockSuccess(makeWpPost(1, html));
123
+ mockSuccess(CATEGORY_RESPONSE);
124
+ mockSuccess(TAG_RESPONSE);
125
+
126
+ const res = await call('wp_get_content_brief', { id: 1 });
127
+ const data = parseResult(res);
128
+
129
+ expect(data.word_count).toBeGreaterThan(5);
130
+ expect(data.readability.score).toBeGreaterThan(0);
131
+ expect(typeof data.readability.avg_words_per_sentence).toBe('number');
132
+ });
133
+
134
+ it('CATEGORIES and TAGS resolved to names', async () => {
135
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
136
+ mockSuccess(makeWpPost(1));
137
+ mockSuccess(CATEGORY_RESPONSE);
138
+ mockSuccess(TAG_RESPONSE);
139
+
140
+ const res = await call('wp_get_content_brief', { id: 1 });
141
+ const data = parseResult(res);
142
+
143
+ expect(data.categories).toEqual([{ id: 3, name: 'Tutorials' }, { id: 5, name: 'SEO' }]);
144
+ expect(data.tags).toEqual([{ id: 7, name: 'wordpress' }, { id: 9, name: 'guide' }]);
145
+ });
146
+
147
+ it('AUDIT — logs success entry', async () => {
148
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
149
+ mockSuccess(makeWpPost(1));
150
+ mockSuccess(CATEGORY_RESPONSE);
151
+ mockSuccess(TAG_RESPONSE);
152
+
153
+ await call('wp_get_content_brief', { id: 1 });
154
+
155
+ const logs = getAuditLogs();
156
+ const entry = logs.find(l => l.tool === 'wp_get_content_brief');
157
+ expect(entry).toBeDefined();
158
+ expect(entry.status).toBe('success');
159
+ expect(entry.action).toBe('content_brief');
160
+ });
161
+
162
+ it('ERROR — 404 returns isError', async () => {
163
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
164
+ mockError(404, '{"code":"rest_post_invalid_id","message":"Invalid post ID."}');
165
+
166
+ const res = await call('wp_get_content_brief', { id: 99999 });
167
+ expect(res.isError).toBe(true);
168
+ });
169
+
170
+ it('VALIDATION — missing id returns error', async () => {
171
+ const res = await call('wp_get_content_brief', {});
172
+ expect(res.isError).toBe(true);
173
+ });
174
+
175
+ it('PAGE — works with post_type page', async () => {
176
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
177
+ const pagePost = makeWpPost(10, '<p>Page content.</p>', { categories: [], tags: [] });
178
+ mockSuccess(pagePost);
179
+
180
+ const res = await call('wp_get_content_brief', { id: 10, post_type: 'page' });
181
+ const data = parseResult(res);
182
+
183
+ expect(data.id).toBe(10);
184
+ // Verify API was called with /pages/ endpoint
185
+ const fetchCalls = fetch.mock.calls;
186
+ expect(fetchCalls[0][0]).toContain('/pages/');
187
+ });
188
+ });
189
+
190
+ // =========================================================================
191
+ // wp_extract_post_outline
192
+ // =========================================================================
193
+
194
+ describe('wp_extract_post_outline', () => {
195
+ function makeOutlinePost(id, headingsHtml, wordCount = 200) {
196
+ const words = Array(wordCount).fill('mot').join(' ');
197
+ return {
198
+ id,
199
+ title: { rendered: `Post ${id}` },
200
+ content: { rendered: `${headingsHtml}<p>${words}</p>` },
201
+ slug: `post-${id}`,
202
+ link: `https://test.example.com/post-${id}`,
203
+ meta: {}
204
+ };
205
+ }
206
+
207
+ it('NOMINAL — outlines + aggregated stats for 3 posts', async () => {
208
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
209
+ mockSuccess([
210
+ makeOutlinePost(1, '<h2>Intro</h2><h3>Sub A</h3><h2>Body</h2>', 300),
211
+ makeOutlinePost(2, '<h2>Intro</h2><h2>Details</h2><h2>Body</h2>', 400),
212
+ makeOutlinePost(3, '<h2>Overview</h2><h2>Body</h2>', 200)
213
+ ]);
214
+
215
+ const res = await call('wp_extract_post_outline', { category_id: 5 });
216
+ const data = parseResult(res);
217
+
218
+ expect(data.category_id).toBe(5);
219
+ expect(data.posts_analyzed).toBe(3);
220
+ expect(data.avg_word_count).toBeGreaterThan(0);
221
+ expect(data.avg_headings_count).toBeGreaterThan(0);
222
+ expect(data.outlines).toHaveLength(3);
223
+ expect(data.outlines[0].headings.length).toBeGreaterThan(0);
224
+ });
225
+
226
+ it('COMMON H2 — recurring H2 patterns detected and ranked', async () => {
227
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
228
+ mockSuccess([
229
+ makeOutlinePost(1, '<h2>Introduction</h2><h2>Conclusion</h2>'),
230
+ makeOutlinePost(2, '<h2>Introduction</h2><h2>Méthode</h2>'),
231
+ makeOutlinePost(3, '<h2>Introduction</h2><h2>Résultats</h2>')
232
+ ]);
233
+
234
+ const res = await call('wp_extract_post_outline', { category_id: 5 });
235
+ const data = parseResult(res);
236
+
237
+ expect(data.common_h2_patterns[0].text).toBe('introduction');
238
+ expect(data.common_h2_patterns[0].frequency).toBe(3);
239
+ });
240
+
241
+ it('AVG WORD COUNT calculated correctly', async () => {
242
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
243
+ mockSuccess([
244
+ makeOutlinePost(1, '<h2>A</h2>', 100),
245
+ makeOutlinePost(2, '<h2>B</h2>', 300)
246
+ ]);
247
+
248
+ const res = await call('wp_extract_post_outline', { category_id: 5 });
249
+ const data = parseResult(res);
250
+
251
+ // Word count includes heading text + words, so approximately 100-300 range per post
252
+ expect(data.avg_word_count).toBeGreaterThan(0);
253
+ });
254
+
255
+ it('POST WITHOUT HEADINGS — headings array is empty', async () => {
256
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
257
+ mockSuccess([makeOutlinePost(1, '', 100)]);
258
+
259
+ const res = await call('wp_extract_post_outline', { category_id: 5 });
260
+ const data = parseResult(res);
261
+
262
+ expect(data.outlines[0].headings).toEqual([]);
263
+ expect(data.outlines[0].headings_count).toBe(0);
264
+ });
265
+
266
+ it('EMPTY CATEGORY — 0 posts analyzed', async () => {
267
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
268
+ mockSuccess([]);
269
+
270
+ const res = await call('wp_extract_post_outline', { category_id: 999 });
271
+ const data = parseResult(res);
272
+
273
+ expect(data.posts_analyzed).toBe(0);
274
+ expect(data.outlines).toEqual([]);
275
+ expect(data.avg_word_count).toBe(0);
276
+ expect(data.common_h2_patterns).toEqual([]);
277
+ });
278
+
279
+ it('AUDIT — logs success entry', async () => {
280
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
281
+ mockSuccess([makeOutlinePost(1, '<h2>A</h2>')]);
282
+
283
+ await call('wp_extract_post_outline', { category_id: 5 });
284
+
285
+ const logs = getAuditLogs();
286
+ const entry = logs.find(l => l.tool === 'wp_extract_post_outline');
287
+ expect(entry).toBeDefined();
288
+ expect(entry.status).toBe('success');
289
+ expect(entry.action).toBe('extract_outline');
290
+ });
291
+
292
+ it('ERROR — 403 returns isError', async () => {
293
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
294
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
295
+
296
+ const res = await call('wp_extract_post_outline', { category_id: 5 });
297
+ expect(res.isError).toBe(true);
298
+ });
299
+
300
+ it('VALIDATION — missing category_id returns error', async () => {
301
+ const res = await call('wp_extract_post_outline', {});
302
+ expect(res.isError).toBe(true);
303
+ });
304
+ });
305
+
306
+ // =========================================================================
307
+ // Shared mock data for Week 2 tools
308
+ // =========================================================================
309
+
310
+ const SIMPLE_TEXT = '<p>Le chat mange la souris. Le chien dort dans le jardin. Il fait beau aujourd\'hui.</p>';
311
+ const COMPLEX_TEXT = '<p>Nonobstant les considérations susmentionnées, l\'implémentation des mécanismes algorithmiques nécessite une compréhension approfondie des paradigmes computationnels contemporains utilisés dans les sciences et les humanités numériques modernes.</p>';
312
+ const TEXT_WITH_TRANSITIONS = '<p>Le produit est bon. Cependant, il est cher. De plus, la livraison est lente. En effet, le délai est trop long. Toutefois, le service client est excellent.</p>';
313
+ const PASSIVE_TEXT = '<p>Le rapport est rédigé par le comité. La décision a été prise hier. Le produit est vendu en ligne. Le contrat est signé par les parties.</p>';
314
+
315
+ function makeReadabilityPost(id, content, extras = {}) {
316
+ return {
317
+ id,
318
+ title: { rendered: `Post ${id}` },
319
+ content: { rendered: content },
320
+ slug: `post-${id}`,
321
+ link: `https://test.example.com/post-${id}`,
322
+ categories: [3],
323
+ meta: {},
324
+ ...extras
325
+ };
326
+ }
327
+
328
+ function makeUpdateFreqPost(id, modified, meta = {}, content = '<p>' + Array(100).fill('mot').join(' ') + '</p>') {
329
+ return {
330
+ id,
331
+ title: { rendered: `Post ${id}` },
332
+ slug: `post-${id}`,
333
+ link: `https://test.example.com/post-${id}`,
334
+ date: '2024-01-01T00:00:00',
335
+ modified,
336
+ content: { rendered: content },
337
+ meta,
338
+ categories: [3]
339
+ };
340
+ }
341
+
342
+ function makeLinkMapPost(id, content) {
343
+ return {
344
+ id,
345
+ title: { rendered: `Post ${id}` },
346
+ slug: `post-${id}`,
347
+ link: `https://test.example.com/post-${id}`,
348
+ content: { rendered: content },
349
+ categories: [3]
350
+ };
351
+ }
352
+
353
+ // =========================================================================
354
+ // wp_audit_readability
355
+ // =========================================================================
356
+
357
+ describe('wp_audit_readability', () => {
358
+ it('NOMINAL — 3 posts scored and sorted ASC', async () => {
359
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
360
+ const pad = ' ' + Array(120).fill('mot').join(' ');
361
+ mockSuccess([
362
+ makeReadabilityPost(1, SIMPLE_TEXT + pad),
363
+ makeReadabilityPost(2, COMPLEX_TEXT + pad),
364
+ makeReadabilityPost(3, TEXT_WITH_TRANSITIONS + pad)
365
+ ]);
366
+
367
+ const res = await call('wp_audit_readability');
368
+ const data = parseResult(res);
369
+
370
+ expect(data.total_analyzed).toBeGreaterThan(0);
371
+ expect(data.avg_readability_score).toBeGreaterThan(0);
372
+ // Should be sorted ASC (worst first)
373
+ if (data.posts.length >= 2) {
374
+ expect(data.posts[0].readability.score).toBeLessThanOrEqual(data.posts[data.posts.length - 1].readability.score);
375
+ }
376
+ });
377
+
378
+ it('DISTRIBUTION — level buckets populated', async () => {
379
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
380
+ mockSuccess([
381
+ makeReadabilityPost(1, SIMPLE_TEXT),
382
+ makeReadabilityPost(2, COMPLEX_TEXT)
383
+ ]);
384
+
385
+ const res = await call('wp_audit_readability');
386
+ const data = parseResult(res);
387
+
388
+ expect(data.distribution).toBeDefined();
389
+ const totalDist = Object.values(data.distribution).reduce((s, n) => s + n, 0);
390
+ expect(totalDist).toBe(data.total_analyzed);
391
+ });
392
+
393
+ it('ISSUES — low_readability detected for score < 40', async () => {
394
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
395
+ mockSuccess([makeReadabilityPost(1, COMPLEX_TEXT)]);
396
+
397
+ const res = await call('wp_audit_readability');
398
+ const data = parseResult(res);
399
+
400
+ if (data.posts.length > 0) {
401
+ const post = data.posts.find(p => p.readability.score < 40);
402
+ if (post) {
403
+ expect(post.issues).toEqual(expect.arrayContaining([expect.stringMatching(/low_readability/)]));
404
+ }
405
+ }
406
+ });
407
+
408
+ it('ISSUES — high_passive_ratio detected', async () => {
409
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
410
+ mockSuccess([makeReadabilityPost(1, PASSIVE_TEXT)]);
411
+
412
+ const res = await call('wp_audit_readability');
413
+ const data = parseResult(res);
414
+
415
+ if (data.posts.length > 0 && data.posts[0].passive_ratio > 0.3) {
416
+ expect(data.posts[0].issues).toContain('high_passive_ratio');
417
+ }
418
+ });
419
+
420
+ it('ISSUES — low_transition_density detected', async () => {
421
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
422
+ const noTransitions = '<p>' + Array(50).fill('Le chat dort.').join(' ') + '</p>';
423
+ mockSuccess([makeReadabilityPost(1, noTransitions)]);
424
+
425
+ const res = await call('wp_audit_readability');
426
+ const data = parseResult(res);
427
+
428
+ if (data.posts.length > 0) {
429
+ expect(data.posts[0].issues).toEqual(expect.arrayContaining([expect.stringMatching(/transition/)]));
430
+ }
431
+ });
432
+
433
+ it('FILTER — min_words excludes short posts', async () => {
434
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
435
+ mockSuccess([
436
+ makeReadabilityPost(1, '<p>Court.</p>'),
437
+ makeReadabilityPost(2, '<p>' + Array(200).fill('mot').join(' ') + '</p>')
438
+ ]);
439
+
440
+ const res = await call('wp_audit_readability', { min_words: 100 });
441
+ const data = parseResult(res);
442
+
443
+ // Short post should be filtered out
444
+ expect(data.total_analyzed).toBe(1);
445
+ });
446
+
447
+ it('FILTER — category_id passed to API', async () => {
448
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
449
+ mockSuccess([makeReadabilityPost(1, '<p>' + Array(150).fill('mot').join(' ') + '</p>')]);
450
+
451
+ await call('wp_audit_readability', { category_id: 7 });
452
+
453
+ const fetchCalls = fetch.mock.calls;
454
+ expect(fetchCalls[0][0]).toContain('categories=7');
455
+ });
456
+
457
+ it('EMPTY — no posts matching → total_analyzed: 0', async () => {
458
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
459
+ mockSuccess([makeReadabilityPost(1, '<p>Court.</p>')]);
460
+
461
+ const res = await call('wp_audit_readability', { min_words: 9999 });
462
+ const data = parseResult(res);
463
+
464
+ expect(data.total_analyzed).toBe(0);
465
+ expect(data.posts).toEqual([]);
466
+ });
467
+
468
+ it('AUDIT — logs success entry', async () => {
469
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
470
+ mockSuccess([makeReadabilityPost(1, '<p>' + Array(150).fill('mot').join(' ') + '</p>')]);
471
+
472
+ await call('wp_audit_readability');
473
+
474
+ const logs = getAuditLogs();
475
+ const entry = logs.find(l => l.tool === 'wp_audit_readability');
476
+ expect(entry).toBeDefined();
477
+ expect(entry.status).toBe('success');
478
+ expect(entry.action).toBe('audit_readability');
479
+ });
480
+
481
+ it('ERROR — 403 returns isError', async () => {
482
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
483
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
484
+
485
+ const res = await call('wp_audit_readability');
486
+ expect(res.isError).toBe(true);
487
+ });
488
+ });
489
+
490
+ // =========================================================================
491
+ // wp_audit_update_frequency
492
+ // =========================================================================
493
+
494
+ describe('wp_audit_update_frequency', () => {
495
+ const recentDate = new Date().toISOString();
496
+ const oldDate200 = new Date(Date.now() - 200 * 86400000).toISOString();
497
+ const oldDate400 = new Date(Date.now() - 400 * 86400000).toISOString();
498
+
499
+ it('NOMINAL — filters and sorts by priority', async () => {
500
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
501
+ mockSuccess([
502
+ makeUpdateFreqPost(1, recentDate),
503
+ makeUpdateFreqPost(2, oldDate200, {}),
504
+ makeUpdateFreqPost(3, oldDate400, {})
505
+ ]);
506
+
507
+ const res = await call('wp_audit_update_frequency');
508
+ const data = parseResult(res);
509
+
510
+ expect(data.total_published).toBe(3);
511
+ expect(data.outdated_count).toBe(2);
512
+ // Post 3 (400d) should have higher priority than post 2 (200d)
513
+ expect(data.posts[0].days_since_modified).toBeGreaterThan(data.posts[1].days_since_modified);
514
+ });
515
+
516
+ it('PRIORITY — old post + bad SEO → high priority', async () => {
517
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
518
+ mockSuccess([
519
+ makeUpdateFreqPost(1, oldDate200, { rank_math_title: 'Title', rank_math_description: 'Desc', rank_math_focus_keyword: 'kw' }),
520
+ makeUpdateFreqPost(2, oldDate200, {})
521
+ ]);
522
+
523
+ const res = await call('wp_audit_update_frequency');
524
+ const data = parseResult(res);
525
+
526
+ // Post 2 (no SEO meta) should have higher priority than post 1 (good SEO)
527
+ const p1 = data.posts.find(p => p.id === 1);
528
+ const p2 = data.posts.find(p => p.id === 2);
529
+ expect(p2.priority_score).toBeGreaterThan(p1.priority_score);
530
+ });
531
+
532
+ it('ISSUES — outdated_365d for posts > 1 year', async () => {
533
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
534
+ mockSuccess([makeUpdateFreqPost(1, oldDate400, {})]);
535
+
536
+ const res = await call('wp_audit_update_frequency');
537
+ const data = parseResult(res);
538
+
539
+ expect(data.posts[0].issues).toContain('outdated_365d');
540
+ });
541
+
542
+ it('ISSUES — thin_content for word_count < 300', async () => {
543
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
544
+ mockSuccess([makeUpdateFreqPost(1, oldDate200, {}, '<p>Short content only.</p>')]);
545
+
546
+ const res = await call('wp_audit_update_frequency');
547
+ const data = parseResult(res);
548
+
549
+ expect(data.posts[0].issues).toContain('thin_content');
550
+ });
551
+
552
+ it('ISSUES — missing_seo_title detected', async () => {
553
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
554
+ mockSuccess([makeUpdateFreqPost(1, oldDate200, {})]);
555
+
556
+ const res = await call('wp_audit_update_frequency');
557
+ const data = parseResult(res);
558
+
559
+ expect(data.posts[0].issues).toContain('missing_seo_title');
560
+ expect(data.posts[0].issues).toContain('missing_meta_description');
561
+ });
562
+
563
+ it('SEO DISABLED — include_seo_score=false → seo_score null', async () => {
564
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
565
+ mockSuccess([makeUpdateFreqPost(1, oldDate200, {})]);
566
+
567
+ const res = await call('wp_audit_update_frequency', { include_seo_score: false });
568
+ const data = parseResult(res);
569
+
570
+ expect(data.posts[0].seo_score).toBeNull();
571
+ expect(data.posts[0].issues).not.toContain('missing_seo_title');
572
+ });
573
+
574
+ it('CUSTOM THRESHOLD — days_threshold=90 filters correctly', async () => {
575
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
576
+ const oldDate100 = new Date(Date.now() - 100 * 86400000).toISOString();
577
+ mockSuccess([
578
+ makeUpdateFreqPost(1, recentDate),
579
+ makeUpdateFreqPost(2, oldDate100)
580
+ ]);
581
+
582
+ const res = await call('wp_audit_update_frequency', { days_threshold: 90 });
583
+ const data = parseResult(res);
584
+
585
+ expect(data.outdated_count).toBe(1);
586
+ expect(data.posts[0].id).toBe(2);
587
+ });
588
+
589
+ it('NONE OUTDATED — outdated_count: 0', async () => {
590
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
591
+ mockSuccess([makeUpdateFreqPost(1, recentDate)]);
592
+
593
+ const res = await call('wp_audit_update_frequency');
594
+ const data = parseResult(res);
595
+
596
+ expect(data.outdated_count).toBe(0);
597
+ expect(data.posts).toEqual([]);
598
+ });
599
+
600
+ it('AUDIT — logs success entry', async () => {
601
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
602
+ mockSuccess([makeUpdateFreqPost(1, oldDate200)]);
603
+
604
+ await call('wp_audit_update_frequency');
605
+
606
+ const logs = getAuditLogs();
607
+ const entry = logs.find(l => l.tool === 'wp_audit_update_frequency');
608
+ expect(entry).toBeDefined();
609
+ expect(entry.status).toBe('success');
610
+ expect(entry.action).toBe('audit_update_frequency');
611
+ });
612
+
613
+ it('ERROR — 403 returns isError', async () => {
614
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
615
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
616
+
617
+ const res = await call('wp_audit_update_frequency');
618
+ expect(res.isError).toBe(true);
619
+ });
620
+ });
621
+
622
+ // =========================================================================
623
+ // wp_build_link_map
624
+ // =========================================================================
625
+
626
+ describe('wp_build_link_map', () => {
627
+ // Post A links to B and C, Post B links to A, Post C has no outbound links
628
+ const postA = makeLinkMapPost(1, '<p>Text <a href="https://test.example.com/post-2">link to B</a> and <a href="https://test.example.com/post-3">link to C</a></p>');
629
+ const postB = makeLinkMapPost(2, '<p>Text <a href="https://test.example.com/post-1">link to A</a></p>');
630
+ const postC = makeLinkMapPost(3, '<p>Text with no internal links.</p>');
631
+
632
+ it('NOMINAL — 3 posts with cross-links → matrix + PageRank', async () => {
633
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
634
+ mockSuccess([postA, postB, postC]);
635
+
636
+ const res = await call('wp_build_link_map');
637
+ const data = parseResult(res);
638
+
639
+ expect(data.total_analyzed).toBe(3);
640
+ expect(data.total_internal_links).toBeGreaterThan(0);
641
+ expect(data.avg_outbound_per_post).toBeGreaterThan(0);
642
+ expect(data.posts).toHaveLength(3);
643
+ expect(data.link_matrix).toBeDefined();
644
+ });
645
+
646
+ it('ORPHAN — post C has no outbound, is not orphan (linked from A)', async () => {
647
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
648
+ mockSuccess([postA, postB, postC]);
649
+
650
+ const res = await call('wp_build_link_map');
651
+ const data = parseResult(res);
652
+
653
+ const pC = data.posts.find(p => p.id === 3);
654
+ expect(pC.inbound_count).toBeGreaterThan(0);
655
+ expect(pC.is_orphan).toBe(false);
656
+ });
657
+
658
+ it('PAGERANK — most-linked post has highest score', async () => {
659
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
660
+ // Post A is linked from B, Post B is linked from A, Post C is linked from A
661
+ mockSuccess([postA, postB, postC]);
662
+
663
+ const res = await call('wp_build_link_map');
664
+ const data = parseResult(res);
665
+
666
+ // Posts are sorted by pagerank DESC
667
+ expect(data.posts[0].pagerank_score).toBeGreaterThanOrEqual(data.posts[data.posts.length - 1].pagerank_score);
668
+ });
669
+
670
+ it('OUTBOUND — resolved links have target_id and target_title', async () => {
671
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
672
+ mockSuccess([postA, postB, postC]);
673
+
674
+ const res = await call('wp_build_link_map');
675
+ const data = parseResult(res);
676
+
677
+ const pA = data.posts.find(p => p.id === 1);
678
+ expect(pA.outbound_count).toBe(2);
679
+ expect(pA.outbound_links[0].target_id).toBeDefined();
680
+ expect(pA.outbound_links[0].target_title).toBeDefined();
681
+ });
682
+
683
+ it('UNRESOLVED — links to posts outside corpus counted', async () => {
684
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
685
+ const postWithExtCorpus = makeLinkMapPost(1, '<p><a href="https://test.example.com/unknown-post">link</a></p>');
686
+ mockSuccess([postWithExtCorpus]);
687
+
688
+ const res = await call('wp_build_link_map');
689
+ const data = parseResult(res);
690
+
691
+ expect(data.posts[0].unresolved_links).toBeGreaterThan(0);
692
+ });
693
+
694
+ it('BOTH — post_type=both triggers 2 API calls', async () => {
695
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
696
+ mockSuccess([postA]); // posts
697
+ mockSuccess([postC]); // pages
698
+
699
+ const res = await call('wp_build_link_map', { post_type: 'both' });
700
+ const data = parseResult(res);
701
+
702
+ expect(data.total_analyzed).toBe(2);
703
+ // Verify 2 API calls were made
704
+ expect(fetch.mock.calls.length).toBe(2);
705
+ expect(fetch.mock.calls[0][0]).toContain('/posts');
706
+ expect(fetch.mock.calls[1][0]).toContain('/pages');
707
+ });
708
+
709
+ it('CATEGORY — category_id passed in query string', async () => {
710
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
711
+ mockSuccess([postA]);
712
+
713
+ await call('wp_build_link_map', { category_id: 5 });
714
+
715
+ expect(fetch.mock.calls[0][0]).toContain('categories=5');
716
+ });
717
+
718
+ it('MATRIX — link_matrix sparse format correct', async () => {
719
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
720
+ mockSuccess([postA, postB, postC]);
721
+
722
+ const res = await call('wp_build_link_map');
723
+ const data = parseResult(res);
724
+
725
+ // Post A (id:1) links to 2 and 3
726
+ expect(data.link_matrix['1']).toEqual(expect.arrayContaining([2, 3]));
727
+ // Post B (id:2) links to 1
728
+ expect(data.link_matrix['2']).toEqual([1]);
729
+ // Post C should not be in matrix (no outbound)
730
+ expect(data.link_matrix['3']).toBeUndefined();
731
+ });
732
+
733
+ it('AUDIT — logs success entry', async () => {
734
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
735
+ mockSuccess([postA, postB]);
736
+
737
+ await call('wp_build_link_map');
738
+
739
+ const logs = getAuditLogs();
740
+ const entry = logs.find(l => l.tool === 'wp_build_link_map');
741
+ expect(entry).toBeDefined();
742
+ expect(entry.status).toBe('success');
743
+ expect(entry.action).toBe('build_link_map');
744
+ });
745
+
746
+ it('ERROR — 403 returns isError', async () => {
747
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
748
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
749
+
750
+ const res = await call('wp_build_link_map');
751
+ expect(res.isError).toBe(true);
752
+ });
753
+ });
754
+
755
+ // =========================================================================
756
+ // Shared mock data for Week 3 tools
757
+ // =========================================================================
758
+
759
+ function makeAnchorPost(id, content) {
760
+ return {
761
+ id,
762
+ title: { rendered: `Post ${id}` },
763
+ slug: `post-${id}`,
764
+ link: `https://test.example.com/post-${id}`,
765
+ content: { rendered: content },
766
+ categories: [3]
767
+ };
768
+ }
769
+
770
+ function makeSchemaPost(id, content) {
771
+ return {
772
+ id,
773
+ title: { rendered: `Post ${id}` },
774
+ slug: `post-${id}`,
775
+ link: `https://test.example.com/post-${id}`,
776
+ content: { rendered: content },
777
+ categories: [3]
778
+ };
779
+ }
780
+
781
+ function makeStructurePost(id, content, extras = {}) {
782
+ return {
783
+ id,
784
+ title: { rendered: `Post ${id}` },
785
+ slug: `post-${id}`,
786
+ link: `https://test.example.com/post-${id}`,
787
+ content: { rendered: content },
788
+ categories: [3],
789
+ ...extras
790
+ };
791
+ }
792
+
793
+ // =========================================================================
794
+ // wp_audit_anchor_texts
795
+ // =========================================================================
796
+
797
+ describe('wp_audit_anchor_texts', () => {
798
+ const postA = makeAnchorPost(1, '<p>Text <a href="https://test.example.com/post-2">guide SEO complet</a> and <a href="https://test.example.com/post-3">cliquez ici</a></p>');
799
+ const postB = makeAnchorPost(2, '<p>Text <a href="https://test.example.com/post-1">guide SEO complet</a> and <a href="https://test.example.com/post-3">autre ancre</a></p>');
800
+ const postC = makeAnchorPost(3, '<p>Text with no internal links.</p>');
801
+
802
+ it('NOMINAL — 3 posts with internal links → anchor health calculated', async () => {
803
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
804
+ mockSuccess([postA, postB, postC]);
805
+
806
+ const res = await call('wp_audit_anchor_texts');
807
+ const data = parseResult(res);
808
+
809
+ expect(data.total_analyzed).toBe(3);
810
+ expect(data.total_internal_links).toBeGreaterThan(0);
811
+ expect(data.total_unique_anchors).toBeGreaterThan(0);
812
+ expect(data.anchor_health).toBeDefined();
813
+ expect(data.posts).toHaveLength(3);
814
+ });
815
+
816
+ it('GENERIC — "cliquez ici" detected as generic', async () => {
817
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
818
+ mockSuccess([postA]);
819
+
820
+ const res = await call('wp_audit_anchor_texts');
821
+ const data = parseResult(res);
822
+
823
+ expect(data.anchor_health.generic).toBeGreaterThan(0);
824
+ expect(data.generic_anchors.some(a => a.text === 'cliquez ici')).toBe(true);
825
+ const pA = data.posts.find(p => p.id === 1);
826
+ expect(pA.issues).toContain('has_generic_anchors');
827
+ });
828
+
829
+ it('OVER-OPTIMIZED — same anchor > 3 times to different targets', async () => {
830
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
831
+ // 4+ occurrences of "mot clé" to different targets
832
+ const overOpt = makeAnchorPost(4, '<p>' +
833
+ '<a href="https://test.example.com/post-1">mot clé</a> ' +
834
+ '<a href="https://test.example.com/post-2">mot clé</a> ' +
835
+ '<a href="https://test.example.com/post-3">mot clé</a> ' +
836
+ '<a href="https://test.example.com/post-5">mot clé</a>' +
837
+ '</p>');
838
+ const target5 = makeAnchorPost(5, '<p>Target.</p>');
839
+ mockSuccess([overOpt, postA, postB, postC, target5]);
840
+
841
+ const res = await call('wp_audit_anchor_texts');
842
+ const data = parseResult(res);
843
+
844
+ expect(data.anchor_health.over_optimized).toBeGreaterThan(0);
845
+ expect(data.over_optimized_anchors.length).toBeGreaterThan(0);
846
+ });
847
+
848
+ it('IMAGE LINK — empty anchor detected', async () => {
849
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
850
+ const imagePost = makeAnchorPost(10, '<p><a href="https://test.example.com/post-2"><img src="img.jpg"/></a></p>');
851
+ mockSuccess([imagePost, makeAnchorPost(2, '<p>No links.</p>')]);
852
+
853
+ const res = await call('wp_audit_anchor_texts');
854
+ const data = parseResult(res);
855
+
856
+ expect(data.anchor_health.image_link).toBeGreaterThan(0);
857
+ const pImg = data.posts.find(p => p.id === 10);
858
+ expect(pImg.issues).toContain('has_image_links');
859
+ });
860
+
861
+ it('DIVERSITY — score calculated correctly (unique/total)', async () => {
862
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
863
+ // Post with 4 links, 2 unique anchor texts → diversity = 0.5
864
+ const dupPost = makeAnchorPost(20, '<p>' +
865
+ '<a href="https://test.example.com/post-1">ancre A</a> ' +
866
+ '<a href="https://test.example.com/post-2">ancre A</a> ' +
867
+ '<a href="https://test.example.com/post-3">ancre B</a> ' +
868
+ '<a href="https://test.example.com/post-4">ancre B</a>' +
869
+ '</p>');
870
+ mockSuccess([dupPost, makeAnchorPost(1, ''), makeAnchorPost(2, ''), makeAnchorPost(3, ''), makeAnchorPost(4, '')]);
871
+
872
+ const res = await call('wp_audit_anchor_texts');
873
+ const data = parseResult(res);
874
+
875
+ const p20 = data.posts.find(p => p.id === 20);
876
+ expect(p20.diversity_score).toBe(0.5);
877
+ });
878
+
879
+ it('LOW DIVERSITY — diversity_score < 0.5 → issue flagged', async () => {
880
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
881
+ // 4 links, all same anchor → diversity = 0.25
882
+ const lowDiv = makeAnchorPost(30, '<p>' +
883
+ '<a href="https://test.example.com/post-1">same</a> ' +
884
+ '<a href="https://test.example.com/post-2">same</a> ' +
885
+ '<a href="https://test.example.com/post-3">same</a> ' +
886
+ '<a href="https://test.example.com/post-4">same</a>' +
887
+ '</p>');
888
+ mockSuccess([lowDiv, makeAnchorPost(1, ''), makeAnchorPost(2, ''), makeAnchorPost(3, ''), makeAnchorPost(4, '')]);
889
+
890
+ const res = await call('wp_audit_anchor_texts');
891
+ const data = parseResult(res);
892
+
893
+ const p30 = data.posts.find(p => p.id === 30);
894
+ expect(p30.diversity_score).toBeLessThan(0.5);
895
+ expect(p30.issues).toContain('low_anchor_diversity');
896
+ });
897
+
898
+ it('BOTH — post_type=both → 2 API calls', async () => {
899
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
900
+ mockSuccess([postA]); // posts
901
+ mockSuccess([postC]); // pages
902
+
903
+ await call('wp_audit_anchor_texts', { post_type: 'both' });
904
+
905
+ const urls = fetch.mock.calls.map(c => c[0]);
906
+ expect(urls.some(u => u.includes('/posts'))).toBe(true);
907
+ expect(urls.some(u => u.includes('/pages'))).toBe(true);
908
+ });
909
+
910
+ it('NO LINKS — corpus without internal links → total: 0', async () => {
911
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
912
+ mockSuccess([makeAnchorPost(1, '<p>No links here.</p>')]);
913
+
914
+ const res = await call('wp_audit_anchor_texts');
915
+ const data = parseResult(res);
916
+
917
+ expect(data.total_internal_links).toBe(0);
918
+ });
919
+
920
+ it('AUDIT — logs success entry', async () => {
921
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
922
+ mockSuccess([postA]);
923
+
924
+ await call('wp_audit_anchor_texts');
925
+
926
+ const logs = getAuditLogs();
927
+ const entry = logs.find(l => l.tool === 'wp_audit_anchor_texts');
928
+ expect(entry).toBeDefined();
929
+ expect(entry.status).toBe('success');
930
+ expect(entry.action).toBe('audit_anchor_texts');
931
+ });
932
+
933
+ it('ERROR — 403 returns isError', async () => {
934
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
935
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
936
+
937
+ const res = await call('wp_audit_anchor_texts');
938
+ expect(res.isError).toBe(true);
939
+ });
940
+ });
941
+
942
+ // =========================================================================
943
+ // wp_audit_schema_markup
944
+ // =========================================================================
945
+
946
+ describe('wp_audit_schema_markup', () => {
947
+ const validArticle = '<script type="application/ld+json">{"@type":"Article","headline":"Test","datePublished":"2024-01-01","author":{"@type":"Person","name":"Georges"}}</script>';
948
+ const validFaq = '<script type="application/ld+json">{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Q1","acceptedAnswer":{"@type":"Answer","text":"A1"}}]}</script>';
949
+ const invalidFaq = '<script type="application/ld+json">{"@type":"FAQPage"}</script>';
950
+ const brokenJson = '<script type="application/ld+json">{invalid json here</script>';
951
+
952
+ it('NOMINAL — Article with valid JSON-LD → valid: true', async () => {
953
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
954
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${validArticle}`)]);
955
+
956
+ const res = await call('wp_audit_schema_markup');
957
+ const data = parseResult(res);
958
+
959
+ expect(data.total_analyzed).toBe(1);
960
+ expect(data.posts_with_schema).toBe(1);
961
+ expect(data.posts[0].schemas[0].type).toBe('Article');
962
+ expect(data.posts[0].schemas[0].valid).toBe(true);
963
+ expect(data.total_valid).toBe(1);
964
+ });
965
+
966
+ it('FAQPAGE — valid FAQPage schema detected', async () => {
967
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
968
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${validFaq}`)]);
969
+
970
+ const res = await call('wp_audit_schema_markup');
971
+ const data = parseResult(res);
972
+
973
+ expect(data.posts[0].schemas[0].type).toBe('FAQPage');
974
+ expect(data.posts[0].schemas[0].valid).toBe(true);
975
+ expect(data.schema_type_distribution['FAQPage']).toBe(1);
976
+ });
977
+
978
+ it('FAQPAGE INVALID — missing mainEntity → missing_required_fields', async () => {
979
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
980
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${invalidFaq}`)]);
981
+
982
+ const res = await call('wp_audit_schema_markup');
983
+ const data = parseResult(res);
984
+
985
+ expect(data.posts[0].schemas[0].valid).toBe(false);
986
+ expect(data.posts[0].schemas[0].missing_fields).toContain('mainEntity');
987
+ expect(data.posts[0].issues).toContain('missing_required_fields');
988
+ });
989
+
990
+ it('INVALID JSON — malformed JSON-LD → invalid_json issue', async () => {
991
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
992
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${brokenJson}`)]);
993
+
994
+ const res = await call('wp_audit_schema_markup');
995
+ const data = parseResult(res);
996
+
997
+ expect(data.posts[0].issues).toContain('invalid_json');
998
+ expect(data.total_invalid).toBeGreaterThan(0);
999
+ });
1000
+
1001
+ it('NO SCHEMA — post without JSON-LD → no_schema issue', async () => {
1002
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1003
+ mockSuccess([makeSchemaPost(1, '<p>Just plain content.</p>')]);
1004
+
1005
+ const res = await call('wp_audit_schema_markup');
1006
+ const data = parseResult(res);
1007
+
1008
+ expect(data.posts_without_schema).toBe(1);
1009
+ expect(data.posts[0].issues).toContain('no_schema');
1010
+ });
1011
+
1012
+ it('MULTIPLE — post with 2 schemas → both listed', async () => {
1013
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1014
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${validArticle}${validFaq}`)]);
1015
+
1016
+ const res = await call('wp_audit_schema_markup');
1017
+ const data = parseResult(res);
1018
+
1019
+ expect(data.posts[0].schemas_found).toBe(2);
1020
+ expect(data.total_schemas_found).toBe(2);
1021
+ });
1022
+
1023
+ it('NO ARTICLE — post with schema but no Article type → no_article_schema', async () => {
1024
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1025
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${validFaq}`)]);
1026
+
1027
+ const res = await call('wp_audit_schema_markup');
1028
+ const data = parseResult(res);
1029
+
1030
+ expect(data.posts[0].issues).toContain('no_article_schema');
1031
+ });
1032
+
1033
+ it('OTHER TYPE — unrecognized @type → valid: true, classified as other', async () => {
1034
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1035
+ const otherSchema = '<script type="application/ld+json">{"@type":"Product","name":"Widget"}</script>';
1036
+ mockSuccess([makeSchemaPost(1, `<p>Content.</p>${otherSchema}`)]);
1037
+
1038
+ const res = await call('wp_audit_schema_markup');
1039
+ const data = parseResult(res);
1040
+
1041
+ expect(data.posts[0].schemas[0].valid).toBe(true);
1042
+ expect(data.schema_type_distribution['other']).toBe(1);
1043
+ });
1044
+
1045
+ it('AUDIT — logs success entry', async () => {
1046
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1047
+ mockSuccess([makeSchemaPost(1, '<p>Content.</p>')]);
1048
+
1049
+ await call('wp_audit_schema_markup');
1050
+
1051
+ const logs = getAuditLogs();
1052
+ const entry = logs.find(l => l.tool === 'wp_audit_schema_markup');
1053
+ expect(entry).toBeDefined();
1054
+ expect(entry.status).toBe('success');
1055
+ expect(entry.action).toBe('audit_schema_markup');
1056
+ });
1057
+
1058
+ it('ERROR — 403 returns isError', async () => {
1059
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1060
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1061
+
1062
+ const res = await call('wp_audit_schema_markup');
1063
+ expect(res.isError).toBe(true);
1064
+ });
1065
+ });
1066
+
1067
+ // =========================================================================
1068
+ // wp_audit_content_structure
1069
+ // =========================================================================
1070
+
1071
+ describe('wp_audit_content_structure', () => {
1072
+ const wellStructured = '<p>Introduction du guide avec assez de contenu pour être détecté comme introduction.</p>' +
1073
+ '<h2>Première partie</h2><p>Contenu de la première partie.</p>' +
1074
+ '<ul><li>Item 1</li><li>Item 2</li></ul>' +
1075
+ '<h2>Deuxième partie</h2><p>Contenu.</p><img src="test.jpg"/>' +
1076
+ '<h2>FAQ</h2><p>Questions fréquentes.</p>' +
1077
+ '<h2>Conclusion</h2><p>En conclusion, voici le résumé final de cet article.</p>';
1078
+
1079
+ const poorStructure = '<h2>Titre direct</h2><p>' + Array(200).fill('mot').join(' ') + '</p>';
1080
+
1081
+ it('NOMINAL — well-structured post → score >= 60', async () => {
1082
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1083
+ mockSuccess([makeStructurePost(1, wellStructured)]);
1084
+
1085
+ const res = await call('wp_audit_content_structure');
1086
+ const data = parseResult(res);
1087
+
1088
+ expect(data.total_analyzed).toBe(1);
1089
+ expect(data.posts[0].structure_score).toBeGreaterThanOrEqual(60);
1090
+ expect(data.posts[0].features).toBeDefined();
1091
+ });
1092
+
1093
+ it('HIGH SCORE — intro + conclusion + headings + images + lists → >= 80', async () => {
1094
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1095
+ const rich = '<p>Introduction longue avec assez de contenu pour être détecté.</p>' +
1096
+ '<h2>Section 1</h2><p>Texte.</p>' +
1097
+ '<h2>Section 2</h2><p>Texte.</p><ul><li>A</li></ul><img src="a.jpg"/>' +
1098
+ '<h2>Section 3</h2><p>Texte.</p><table><tr><td>X</td></tr></table>' +
1099
+ '<h2>FAQ</h2><p>Questions.</p>' +
1100
+ '<div class="toc"><a href="#s1">S1</a></div>' +
1101
+ '<blockquote>Citation</blockquote>' +
1102
+ '<h2>Conclusion</h2><p>Résumé final.</p>';
1103
+ mockSuccess([makeStructurePost(1, rich)]);
1104
+
1105
+ const res = await call('wp_audit_content_structure');
1106
+ const data = parseResult(res);
1107
+
1108
+ expect(data.posts[0].structure_score).toBeGreaterThanOrEqual(80);
1109
+ });
1110
+
1111
+ it('FEATURE COVERAGE — percentages calculated correctly', async () => {
1112
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1113
+ // 1 post with intro + conclusion, 1 post without
1114
+ mockSuccess([
1115
+ makeStructurePost(1, wellStructured),
1116
+ makeStructurePost(2, poorStructure)
1117
+ ]);
1118
+
1119
+ const res = await call('wp_audit_content_structure');
1120
+ const data = parseResult(res);
1121
+
1122
+ expect(data.feature_coverage).toBeDefined();
1123
+ expect(typeof data.feature_coverage.intro).toBe('number');
1124
+ expect(data.feature_coverage.intro).toBeGreaterThanOrEqual(0);
1125
+ expect(data.feature_coverage.intro).toBeLessThanOrEqual(100);
1126
+ });
1127
+
1128
+ it('NO INTRO — content starting with H2 → no_intro issue', async () => {
1129
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1130
+ mockSuccess([makeStructurePost(1, poorStructure)]);
1131
+
1132
+ const res = await call('wp_audit_content_structure');
1133
+ const data = parseResult(res);
1134
+
1135
+ expect(data.posts[0].features.has_intro).toBe(false);
1136
+ expect(data.posts[0].issues).toContain('no_intro');
1137
+ });
1138
+
1139
+ it('NO IMAGES — post without img tag → no_images issue', async () => {
1140
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1141
+ mockSuccess([makeStructurePost(1, '<p>Intro text with enough words.</p><h2>Title</h2><p>Body content.</p>')]);
1142
+
1143
+ const res = await call('wp_audit_content_structure');
1144
+ const data = parseResult(res);
1145
+
1146
+ expect(data.posts[0].features.images_count).toBe(0);
1147
+ expect(data.posts[0].issues).toContain('no_images');
1148
+ });
1149
+
1150
+ it('LONG PARAGRAPHS — avg > 150 → long_paragraphs issue', async () => {
1151
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1152
+ // Single paragraph with 300+ words
1153
+ const longPara = '<p>' + Array(300).fill('mot').join(' ') + '</p>';
1154
+ mockSuccess([makeStructurePost(1, longPara)]);
1155
+
1156
+ const res = await call('wp_audit_content_structure');
1157
+ const data = parseResult(res);
1158
+
1159
+ expect(data.posts[0].features.avg_paragraph_length).toBeGreaterThan(150);
1160
+ expect(data.posts[0].issues).toContain('long_paragraphs');
1161
+ });
1162
+
1163
+ it('LOW HEADING DENSITY — long post without headings → low_heading_density', async () => {
1164
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1165
+ // 500+ words, 0 headings
1166
+ const longNoHeadings = '<p>' + Array(500).fill('mot').join(' ') + '</p>';
1167
+ mockSuccess([makeStructurePost(1, longNoHeadings)]);
1168
+
1169
+ const res = await call('wp_audit_content_structure');
1170
+ const data = parseResult(res);
1171
+
1172
+ expect(data.posts[0].features.heading_density).toBeLessThan(0.3);
1173
+ expect(data.posts[0].issues).toContain('low_heading_density');
1174
+ });
1175
+
1176
+ it('TOC DETECTION — bloc with class "toc" detected', async () => {
1177
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1178
+ const withToc = '<p>Intro.</p><div class="toc"><a href="#s1">Section 1</a></div><h2>Section 1</h2><p>Text.</p>';
1179
+ mockSuccess([makeStructurePost(1, withToc)]);
1180
+
1181
+ const res = await call('wp_audit_content_structure');
1182
+ const data = parseResult(res);
1183
+
1184
+ expect(data.posts[0].features.has_toc).toBe(true);
1185
+ });
1186
+
1187
+ it('CATEGORY FILTER — category_id passed in query string', async () => {
1188
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1189
+ mockSuccess([makeStructurePost(1, wellStructured)]);
1190
+
1191
+ await call('wp_audit_content_structure', { category_id: 7 });
1192
+
1193
+ const fetchCalls = fetch.mock.calls;
1194
+ expect(fetchCalls[0][0]).toContain('categories=7');
1195
+ });
1196
+
1197
+ it('DISTRIBUTION — buckets populated correctly', async () => {
1198
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1199
+ mockSuccess([
1200
+ makeStructurePost(1, wellStructured),
1201
+ makeStructurePost(2, poorStructure)
1202
+ ]);
1203
+
1204
+ const res = await call('wp_audit_content_structure');
1205
+ const data = parseResult(res);
1206
+
1207
+ expect(data.distribution).toBeDefined();
1208
+ const totalDist = data.distribution.excellent + data.distribution.good + data.distribution.average + data.distribution.poor;
1209
+ expect(totalDist).toBe(data.total_analyzed);
1210
+ });
1211
+
1212
+ it('AUDIT — logs success entry', async () => {
1213
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1214
+ mockSuccess([makeStructurePost(1, wellStructured)]);
1215
+
1216
+ await call('wp_audit_content_structure');
1217
+
1218
+ const logs = getAuditLogs();
1219
+ const entry = logs.find(l => l.tool === 'wp_audit_content_structure');
1220
+ expect(entry).toBeDefined();
1221
+ expect(entry.status).toBe('success');
1222
+ expect(entry.action).toBe('audit_content_structure');
1223
+ });
1224
+
1225
+ it('ERROR — 403 returns isError', async () => {
1226
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1227
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1228
+
1229
+ const res = await call('wp_audit_content_structure');
1230
+ expect(res.isError).toBe(true);
1231
+ });
1232
+ });
1233
+
1234
+ // =========================================================================
1235
+ // Helpers — Batch 4A
1236
+ // =========================================================================
1237
+
1238
+ const dupContent1 = '<p>Le marketing digital est essentiel pour les entreprises modernes dans le monde actuel. La transformation numérique permet aux entreprises de développer leur présence en ligne et atteindre de nouveaux clients potentiels via les canaux numériques comme les moteurs de recherche, les réseaux sociaux et les campagnes publicitaires. Une stratégie marketing efficace combine le référencement naturel, le marketing de contenu et les publicités payantes pour maximiser la visibilité en ligne et générer des conversions significatives pour améliorer le retour sur investissement global.</p>';
1239
+ const dupContent2 = '<p>Le marketing digital est essentiel pour les sociétés modernes dans le monde actuel. La transformation numérique permet aux sociétés de développer leur présence sur internet et atteindre de nouveaux clients potentiels via les canaux numériques comme les moteurs de recherche, les réseaux sociaux et les campagnes publicitaires. Une stratégie marketing efficace combine le référencement naturel, le marketing de contenu et les publicités payantes pour maximiser la visibilité sur internet et générer des résultats significatifs pour améliorer le retour sur investissement global.</p>';
1240
+ const dupContent3 = '<p>La cuisine française est réputée dans le monde entier pour sa créativité et son raffinement exceptionnel. Les chefs français utilisent des techniques sophistiquées héritées de traditions centenaires pour préparer des plats exceptionnels avec des ingrédients frais et locaux au cœur de chaque recette depuis les entrées légères jusqu aux desserts élaborés. Chaque région possède ses spécialités culinaires qui reflètent le terroir et la culture locale avec une attention particulière aux saveurs authentiques et au patrimoine gastronomique régional.</p>';
1241
+
1242
+ function makeDupPost(id, content) {
1243
+ return { id, title: { rendered: `Post ${id}` }, slug: `post-${id}`, link: `https://test.example.com/post-${id}`, content: { rendered: content }, categories: [3] };
1244
+ }
1245
+
1246
+ function makeFaqPost(id, content) {
1247
+ return { id, title: { rendered: `Post ${id}` }, slug: `post-${id}`, link: `https://test.example.com/post-${id}`, content: { rendered: content }, categories: [3] };
1248
+ }
1249
+
1250
+ function makeCtaPost(id, content) {
1251
+ return { id, title: { rendered: `Post ${id}` }, slug: `post-${id}`, link: `https://test.example.com/post-${id}`, content: { rendered: content }, categories: [3] };
1252
+ }
1253
+
1254
+ // =========================================================================
1255
+ // wp_find_duplicate_content
1256
+ // =========================================================================
1257
+
1258
+ describe('wp_find_duplicate_content', () => {
1259
+ it('NOMINAL — detects near-duplicate pair', async () => {
1260
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1261
+ mockSuccess([makeDupPost(1, dupContent1), makeDupPost(2, dupContent2), makeDupPost(3, dupContent3)]);
1262
+
1263
+ const res = await call('wp_find_duplicate_content', { similarity_threshold: 0.6 });
1264
+ const data = parseResult(res);
1265
+
1266
+ expect(data.total_analyzed).toBe(3);
1267
+ expect(data.duplicate_pairs_found).toBeGreaterThanOrEqual(1);
1268
+ const pair12 = data.pairs.find(p => (p.post1.id === 1 && p.post2.id === 2) || (p.post1.id === 2 && p.post2.id === 1));
1269
+ expect(pair12).toBeDefined();
1270
+ expect(pair12.similarity).toBeGreaterThan(0.5);
1271
+ });
1272
+
1273
+ it('SEVERITY — high similarity → critical', async () => {
1274
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1275
+ // Same content = similarity ~1.0
1276
+ mockSuccess([makeDupPost(1, dupContent1), makeDupPost(2, dupContent1)]);
1277
+
1278
+ const res = await call('wp_find_duplicate_content');
1279
+ const data = parseResult(res);
1280
+
1281
+ expect(data.pairs[0].severity).toBe('critical');
1282
+ expect(data.pairs[0].similarity).toBeGreaterThanOrEqual(0.9);
1283
+ });
1284
+
1285
+ it('CLUSTERING — A~B and B~C → cluster {A,B,C}', async () => {
1286
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1287
+ // 3 identical posts → all in one cluster
1288
+ mockSuccess([makeDupPost(1, dupContent1), makeDupPost(2, dupContent1), makeDupPost(3, dupContent1)]);
1289
+
1290
+ const res = await call('wp_find_duplicate_content');
1291
+ const data = parseResult(res);
1292
+
1293
+ expect(data.duplicate_clusters).toBe(1);
1294
+ expect(data.clusters[0].posts).toHaveLength(3);
1295
+ });
1296
+
1297
+ it('THRESHOLD — custom 0.95 → fewer pairs', async () => {
1298
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1299
+ mockSuccess([makeDupPost(1, dupContent1), makeDupPost(2, dupContent2)]);
1300
+
1301
+ const res = await call('wp_find_duplicate_content', { similarity_threshold: 0.95 });
1302
+ const data = parseResult(res);
1303
+
1304
+ // Not identical enough for 0.95 threshold
1305
+ expect(data.duplicate_pairs_found).toBe(0);
1306
+ });
1307
+
1308
+ it('SHORT POSTS — < 50 words filtered out', async () => {
1309
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1310
+ const short = '<p>Très court.</p>';
1311
+ mockSuccess([makeDupPost(1, short), makeDupPost(2, short)]);
1312
+
1313
+ const res = await call('wp_find_duplicate_content');
1314
+ const data = parseResult(res);
1315
+
1316
+ expect(data.duplicate_pairs_found).toBe(0);
1317
+ });
1318
+
1319
+ it('NO DUPLICATES — different content → 0 pairs', async () => {
1320
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1321
+ mockSuccess([makeDupPost(1, dupContent1), makeDupPost(2, dupContent3)]);
1322
+
1323
+ const res = await call('wp_find_duplicate_content');
1324
+ const data = parseResult(res);
1325
+
1326
+ expect(data.duplicate_pairs_found).toBe(0);
1327
+ });
1328
+
1329
+ it('AUDIT — logs success entry', async () => {
1330
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1331
+ mockSuccess([makeDupPost(1, dupContent1)]);
1332
+
1333
+ await call('wp_find_duplicate_content');
1334
+
1335
+ const logs = getAuditLogs();
1336
+ const entry = logs.find(l => l.tool === 'wp_find_duplicate_content');
1337
+ expect(entry).toBeDefined();
1338
+ expect(entry.status).toBe('success');
1339
+ expect(entry.action).toBe('find_duplicate_content');
1340
+ });
1341
+
1342
+ it('ERROR — 403 returns isError', async () => {
1343
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1344
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1345
+
1346
+ const res = await call('wp_find_duplicate_content');
1347
+ expect(res.isError).toBe(true);
1348
+ });
1349
+ });
1350
+
1351
+ // =========================================================================
1352
+ // wp_find_content_gaps
1353
+ // =========================================================================
1354
+
1355
+ const gapCategories = [
1356
+ { id: 1, name: 'Tech', slug: 'tech', count: 10, description: '', parent: 0 },
1357
+ { id: 2, name: 'SEO', slug: 'seo', count: 1, description: '', parent: 0 },
1358
+ { id: 3, name: 'Empty Cat', slug: 'empty-cat', count: 0, description: '', parent: 1 }
1359
+ ];
1360
+
1361
+ const gapTags = [
1362
+ { id: 10, name: 'wordpress', slug: 'wordpress', count: 8 },
1363
+ { id: 11, name: 'seo-tips', slug: 'seo-tips', count: 2 },
1364
+ { id: 12, name: 'unused', slug: 'unused', count: 0 }
1365
+ ];
1366
+
1367
+ describe('wp_find_content_gaps', () => {
1368
+ it('NOMINAL — category with 1 post flagged as gap', async () => {
1369
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1370
+ mockSuccess(gapCategories); // categories
1371
+ mockSuccess(gapTags); // tags
1372
+
1373
+ const res = await call('wp_find_content_gaps');
1374
+ const data = parseResult(res);
1375
+
1376
+ expect(data.gaps_found).toBeGreaterThan(0);
1377
+ const seoGap = data.gaps.find(g => g.name === 'SEO');
1378
+ expect(seoGap).toBeDefined();
1379
+ expect(seoGap.severity).toBe('underrepresented');
1380
+ expect(seoGap.deficit).toBe(2); // min_posts=3 - count=1
1381
+ });
1382
+
1383
+ it('EMPTY — category with 0 posts → severity empty', async () => {
1384
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1385
+ mockSuccess(gapCategories);
1386
+ mockSuccess(gapTags);
1387
+
1388
+ const res = await call('wp_find_content_gaps');
1389
+ const data = parseResult(res);
1390
+
1391
+ const emptyGap = data.gaps.find(g => g.name === 'Empty Cat');
1392
+ expect(emptyGap).toBeDefined();
1393
+ expect(emptyGap.severity).toBe('empty');
1394
+ expect(emptyGap.current_count).toBe(0);
1395
+ });
1396
+
1397
+ it('EXCLUDE EMPTY — exclude_empty=true filters zero-count terms', async () => {
1398
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1399
+ mockSuccess(gapCategories);
1400
+ mockSuccess(gapTags);
1401
+
1402
+ const res = await call('wp_find_content_gaps', { exclude_empty: true });
1403
+ const data = parseResult(res);
1404
+
1405
+ const emptyNames = data.gaps.map(g => g.name);
1406
+ expect(emptyNames).not.toContain('Empty Cat');
1407
+ expect(emptyNames).not.toContain('unused');
1408
+ });
1409
+
1410
+ it('TAGS — under-represented tags detected', async () => {
1411
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1412
+ mockSuccess(gapCategories);
1413
+ mockSuccess(gapTags);
1414
+
1415
+ const res = await call('wp_find_content_gaps');
1416
+ const data = parseResult(res);
1417
+
1418
+ expect(data.gaps_by_taxonomy.tags).toBeGreaterThan(0);
1419
+ const tagGap = data.gaps.find(g => g.taxonomy === 'post_tag' && g.name === 'seo-tips');
1420
+ expect(tagGap).toBeDefined();
1421
+ });
1422
+
1423
+ it('TAXONOMY FILTER — taxonomy=category → only categories', async () => {
1424
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1425
+ mockSuccess(gapCategories);
1426
+
1427
+ const res = await call('wp_find_content_gaps', { taxonomy: 'category' });
1428
+ const data = parseResult(res);
1429
+
1430
+ expect(data.gaps_by_taxonomy.tags).toBe(0);
1431
+ expect(data.gaps.every(g => g.taxonomy === 'category')).toBe(true);
1432
+ });
1433
+
1434
+ it('WELL COVERED — high-count term in well_covered', async () => {
1435
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1436
+ mockSuccess(gapCategories);
1437
+ mockSuccess(gapTags);
1438
+
1439
+ const res = await call('wp_find_content_gaps');
1440
+ const data = parseResult(res);
1441
+
1442
+ const techCovered = data.well_covered.find(w => w.name === 'Tech');
1443
+ expect(techCovered).toBeDefined();
1444
+ expect(techCovered.count).toBe(10);
1445
+ });
1446
+
1447
+ it('AUDIT — logs success entry', async () => {
1448
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1449
+ mockSuccess(gapCategories);
1450
+ mockSuccess(gapTags);
1451
+
1452
+ await call('wp_find_content_gaps');
1453
+
1454
+ const logs = getAuditLogs();
1455
+ const entry = logs.find(l => l.tool === 'wp_find_content_gaps');
1456
+ expect(entry).toBeDefined();
1457
+ expect(entry.status).toBe('success');
1458
+ expect(entry.action).toBe('find_content_gaps');
1459
+ });
1460
+
1461
+ it('ERROR — 403 returns isError', async () => {
1462
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1463
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1464
+
1465
+ const res = await call('wp_find_content_gaps');
1466
+ expect(res.isError).toBe(true);
1467
+ });
1468
+ });
1469
+
1470
+ // =========================================================================
1471
+ // wp_extract_faq_blocks
1472
+ // =========================================================================
1473
+
1474
+ const faqJsonLd = '<script type="application/ld+json">{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is SEO?","acceptedAnswer":{"@type":"Answer","text":"SEO stands for Search Engine Optimization."}},{"@type":"Question","name":"Why is SEO important?","acceptedAnswer":{"@type":"Answer","text":"SEO helps websites rank higher."}}]}</script>';
1475
+
1476
+ const faqGutenbergYoast = '<!-- wp:yoast/faq-block --><div class="schema-faq"><div class="schema-faq-section"><strong class="schema-faq-question">How to install?</strong><p class="schema-faq-answer">Download and run the installer.</p></div></div><!-- /wp:yoast/faq-block -->';
1477
+
1478
+ const faqHtmlPattern = '<h2>FAQ</h2><h3>Is it free?</h3><p>Yes, it is completely free.</p><h3>Where to download?</h3><p>Visit our website to download.</p>';
1479
+
1480
+ describe('wp_extract_faq_blocks', () => {
1481
+ it('JSON-LD — FAQPage questions extracted correctly', async () => {
1482
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1483
+ mockSuccess([makeFaqPost(1, faqJsonLd)]);
1484
+
1485
+ const res = await call('wp_extract_faq_blocks');
1486
+ const data = parseResult(res);
1487
+
1488
+ expect(data.posts_with_faq).toBe(1);
1489
+ expect(data.total_questions).toBe(2);
1490
+ expect(data.faq_by_source['json-ld']).toBe(2);
1491
+ const post = data.posts.find(p => p.id === 1);
1492
+ expect(post.faq_blocks[0].source).toBe('json-ld');
1493
+ expect(post.faq_blocks[0].questions).toHaveLength(2);
1494
+ expect(post.faq_blocks[0].questions[0].question).toBe('What is SEO?');
1495
+ });
1496
+
1497
+ it('GUTENBERG — Yoast FAQ block detected', async () => {
1498
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1499
+ mockSuccess([makeFaqPost(1, faqGutenbergYoast)]);
1500
+
1501
+ const res = await call('wp_extract_faq_blocks');
1502
+ const data = parseResult(res);
1503
+
1504
+ expect(data.posts_with_faq).toBe(1);
1505
+ expect(data.faq_by_source['gutenberg-block']).toBeGreaterThan(0);
1506
+ const block = data.posts[0].faq_blocks.find(b => b.source === 'gutenberg-block');
1507
+ expect(block).toBeDefined();
1508
+ expect(block.plugin).toBe('yoast');
1509
+ });
1510
+
1511
+ it('HTML PATTERN — FAQ heading + H3/P questions extracted', async () => {
1512
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1513
+ mockSuccess([makeFaqPost(1, faqHtmlPattern)]);
1514
+
1515
+ const res = await call('wp_extract_faq_blocks');
1516
+ const data = parseResult(res);
1517
+
1518
+ expect(data.posts_with_faq).toBe(1);
1519
+ expect(data.faq_by_source['html-pattern']).toBe(2);
1520
+ const block = data.posts[0].faq_blocks.find(b => b.source === 'html-pattern');
1521
+ expect(block.questions[0].question).toBe('Is it free?');
1522
+ });
1523
+
1524
+ it('NO FAQ — post without FAQ → has_faq false', async () => {
1525
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1526
+ mockSuccess([makeFaqPost(1, '<p>Just some regular content.</p>')]);
1527
+
1528
+ const res = await call('wp_extract_faq_blocks');
1529
+ const data = parseResult(res);
1530
+
1531
+ expect(data.posts_with_faq).toBe(0);
1532
+ // With <= 10 posts, all are included
1533
+ expect(data.posts[0].has_faq).toBe(false);
1534
+ });
1535
+
1536
+ it('MULTIPLE SOURCES — all listed in one post', async () => {
1537
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1538
+ const combined = faqJsonLd + faqHtmlPattern;
1539
+ mockSuccess([makeFaqPost(1, combined)]);
1540
+
1541
+ const res = await call('wp_extract_faq_blocks');
1542
+ const data = parseResult(res);
1543
+
1544
+ const sources = data.posts[0].faq_blocks.map(b => b.source);
1545
+ expect(sources).toContain('json-ld');
1546
+ expect(sources).toContain('html-pattern');
1547
+ });
1548
+
1549
+ it('ANSWER TRUNCATED — answer > 200 chars truncated', async () => {
1550
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1551
+ const longAnswer = 'A'.repeat(300);
1552
+ const jsonLd = `<script type="application/ld+json">{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Q?","acceptedAnswer":{"@type":"Answer","text":"${longAnswer}"}}]}</script>`;
1553
+ mockSuccess([makeFaqPost(1, jsonLd)]);
1554
+
1555
+ const res = await call('wp_extract_faq_blocks');
1556
+ const data = parseResult(res);
1557
+
1558
+ expect(data.posts[0].faq_blocks[0].questions[0].answer.length).toBeLessThanOrEqual(200);
1559
+ });
1560
+
1561
+ it('AUDIT — logs success entry', async () => {
1562
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1563
+ mockSuccess([makeFaqPost(1, '<p>Content</p>')]);
1564
+
1565
+ await call('wp_extract_faq_blocks');
1566
+
1567
+ const logs = getAuditLogs();
1568
+ const entry = logs.find(l => l.tool === 'wp_extract_faq_blocks');
1569
+ expect(entry).toBeDefined();
1570
+ expect(entry.status).toBe('success');
1571
+ expect(entry.action).toBe('extract_faq_blocks');
1572
+ });
1573
+
1574
+ it('ERROR — 403 returns isError', async () => {
1575
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1576
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1577
+
1578
+ const res = await call('wp_extract_faq_blocks');
1579
+ expect(res.isError).toBe(true);
1580
+ });
1581
+ });
1582
+
1583
+ // =========================================================================
1584
+ // wp_audit_cta_presence
1585
+ // =========================================================================
1586
+
1587
+ describe('wp_audit_cta_presence', () => {
1588
+ it('NOMINAL — contact link + form → cta_score 70', async () => {
1589
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1590
+ const html = '<p>Text <a href="/contact">Contactez-nous</a></p><div class="wpforms-container">form</div>';
1591
+ mockSuccess([makeCtaPost(1, html)]);
1592
+
1593
+ const res = await call('wp_audit_cta_presence');
1594
+ const data = parseResult(res);
1595
+
1596
+ expect(data.posts_with_cta).toBe(1);
1597
+ expect(data.posts[0].cta_score).toBe(70);
1598
+ expect(data.posts[0].cta_types).toContain('contact_link');
1599
+ expect(data.posts[0].cta_types).toContain('form');
1600
+ });
1601
+
1602
+ it('NO CTA — post without CTA → score 0, no_cta issue', async () => {
1603
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1604
+ mockSuccess([makeCtaPost(1, '<p>Just regular text without any call to action.</p>')]);
1605
+
1606
+ const res = await call('wp_audit_cta_presence');
1607
+ const data = parseResult(res);
1608
+
1609
+ expect(data.posts_without_cta).toBe(1);
1610
+ expect(data.posts[0].cta_score).toBe(0);
1611
+ expect(data.posts[0].issues).toContain('no_cta');
1612
+ });
1613
+
1614
+ it('BUTTON CTA — button with "devis" text detected', async () => {
1615
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1616
+ const html = '<p>Info</p><button>Demander un devis</button>';
1617
+ mockSuccess([makeCtaPost(1, html)]);
1618
+
1619
+ const res = await call('wp_audit_cta_presence');
1620
+ const data = parseResult(res);
1621
+
1622
+ expect(data.posts[0].cta_types).toContain('button_cta');
1623
+ expect(data.cta_type_distribution.button_cta).toBe(1);
1624
+ });
1625
+
1626
+ it('PHONE LINK — href="tel:" detected', async () => {
1627
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1628
+ const html = '<p>Call us: <a href="tel:+33123456789">+33 1 23 45 67 89</a></p>';
1629
+ mockSuccess([makeCtaPost(1, html)]);
1630
+
1631
+ const res = await call('wp_audit_cta_presence');
1632
+ const data = parseResult(res);
1633
+
1634
+ expect(data.posts[0].cta_types).toContain('phone_link');
1635
+ expect(data.cta_type_distribution.phone_link).toBe(1);
1636
+ });
1637
+
1638
+ it('FORM DETECTION — wpforms class detected', async () => {
1639
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1640
+ const html = '<p>Fill the form:</p><div class="wpforms-form">...</div>';
1641
+ mockSuccess([makeCtaPost(1, html)]);
1642
+
1643
+ const res = await call('wp_audit_cta_presence');
1644
+ const data = parseResult(res);
1645
+
1646
+ expect(data.posts[0].cta_types).toContain('form');
1647
+ expect(data.posts[0].ctas.find(c => c.type === 'form').element).toBe('wpforms');
1648
+ });
1649
+
1650
+ it('SINGLE CTA TYPE — 1 type → single_cta_type issue', async () => {
1651
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1652
+ const html = '<p><a href="/contact">Contact</a></p>';
1653
+ mockSuccess([makeCtaPost(1, html)]);
1654
+
1655
+ const res = await call('wp_audit_cta_presence');
1656
+ const data = parseResult(res);
1657
+
1658
+ expect(data.posts[0].cta_score).toBe(40);
1659
+ expect(data.posts[0].issues).toContain('single_cta_type');
1660
+ });
1661
+
1662
+ it('AUDIT — logs success entry', async () => {
1663
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1664
+ mockSuccess([makeCtaPost(1, '<p>Content</p>')]);
1665
+
1666
+ await call('wp_audit_cta_presence');
1667
+
1668
+ const logs = getAuditLogs();
1669
+ const entry = logs.find(l => l.tool === 'wp_audit_cta_presence');
1670
+ expect(entry).toBeDefined();
1671
+ expect(entry.status).toBe('success');
1672
+ expect(entry.action).toBe('audit_cta_presence');
1673
+ });
1674
+
1675
+ it('ERROR — 403 returns isError', async () => {
1676
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1677
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1678
+
1679
+ const res = await call('wp_audit_cta_presence');
1680
+ expect(res.isError).toBe(true);
1681
+ });
1682
+ });
1683
+
1684
+ // =========================================================================
1685
+ // wp_extract_entities (tool #76)
1686
+ // =========================================================================
1687
+
1688
+ function makeEntityPost(id, content) {
1689
+ return {
1690
+ id, title: { rendered: `Post ${id}` }, content: { rendered: content },
1691
+ slug: `post-${id}`, status: 'publish'
1692
+ };
1693
+ }
1694
+
1695
+ const entityContent1 = '<p>Google Analytics est un outil puissant pour le marketing digital. À Bruxelles, Marie Martin utilise Google Analytics pour ses clients. L\'agence AdSim est basée en Belgique.</p>';
1696
+ const entityContent2 = '<p>WordPress et WooCommerce sont des plateformes très populaires. Jean Dupont travaille chez Microsoft à Paris depuis cinq ans.</p>';
1697
+
1698
+ describe('wp_extract_entities', () => {
1699
+ it('NOMINAL — extracts entities with correct types', async () => {
1700
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1701
+ mockSuccess([makeEntityPost(1, entityContent1), makeEntityPost(2, entityContent2)]);
1702
+
1703
+ const res = await call('wp_extract_entities', { min_occurrences: 1 });
1704
+ const data = parseResult(res);
1705
+
1706
+ expect(data.total_analyzed).toBe(2);
1707
+ expect(data.total_entities_found).toBeGreaterThan(0);
1708
+ expect(data.entities_by_type).toBeDefined();
1709
+ expect(data.top_entities.length).toBeGreaterThan(0);
1710
+ expect(data.posts).toHaveLength(2);
1711
+ });
1712
+
1713
+ it('BRAND — detects known brand entities', async () => {
1714
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1715
+ mockSuccess([makeEntityPost(1, entityContent2)]);
1716
+
1717
+ const res = await call('wp_extract_entities', { min_occurrences: 1 });
1718
+ const data = parseResult(res);
1719
+
1720
+ const brands = data.top_entities.filter(e => e.type === 'brand');
1721
+ const brandNames = brands.map(e => e.name);
1722
+ expect(brandNames.some(n => n.includes('WordPress') || n.includes('WooCommerce') || n.includes('Microsoft'))).toBe(true);
1723
+ });
1724
+
1725
+ it('LOCATION — detects known location entities', async () => {
1726
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1727
+ mockSuccess([makeEntityPost(1, entityContent1)]);
1728
+
1729
+ const res = await call('wp_extract_entities', { min_occurrences: 1 });
1730
+ const data = parseResult(res);
1731
+
1732
+ const locs = data.top_entities.filter(e => e.type === 'location');
1733
+ expect(locs.some(e => e.name === 'Bruxelles' || e.name === 'Belgique')).toBe(true);
1734
+ });
1735
+
1736
+ it('PERSON — detects person entities', async () => {
1737
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1738
+ mockSuccess([makeEntityPost(1, entityContent2)]);
1739
+
1740
+ const res = await call('wp_extract_entities', { min_occurrences: 1 });
1741
+ const data = parseResult(res);
1742
+
1743
+ const persons = data.top_entities.filter(e => e.type === 'person');
1744
+ expect(persons.some(e => e.name === 'Jean Dupont')).toBe(true);
1745
+ });
1746
+
1747
+ it('MIN_OCCURRENCES — filters entities below threshold', async () => {
1748
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1749
+ mockSuccess([makeEntityPost(1, '<p>Le site utilise Shopify pour vendre en ligne.</p>')]);
1750
+
1751
+ const res = await call('wp_extract_entities', { min_occurrences: 5 });
1752
+ const data = parseResult(res);
1753
+
1754
+ expect(data.total_entities_found).toBe(0);
1755
+ });
1756
+
1757
+ it('DEDUPLICATION — same entity counted multiple times', async () => {
1758
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1759
+ mockSuccess([makeEntityPost(1, entityContent1)]);
1760
+
1761
+ const res = await call('wp_extract_entities', { min_occurrences: 1 });
1762
+ const data = parseResult(res);
1763
+
1764
+ const ga = data.top_entities.find(e => e.name === 'Google Analytics');
1765
+ if (ga) {
1766
+ expect(ga.total_count).toBeGreaterThanOrEqual(2);
1767
+ }
1768
+ });
1769
+
1770
+ it('AUDIT — logs success entry', async () => {
1771
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1772
+ mockSuccess([makeEntityPost(1, entityContent1)]);
1773
+
1774
+ await call('wp_extract_entities', { min_occurrences: 1 });
1775
+
1776
+ const logs = getAuditLogs();
1777
+ const entry = logs.find(l => l.tool === 'wp_extract_entities');
1778
+ expect(entry).toBeDefined();
1779
+ expect(entry.status).toBe('success');
1780
+ expect(entry.action).toBe('extract_entities');
1781
+ });
1782
+
1783
+ it('ERROR — 403 returns isError', async () => {
1784
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1785
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1786
+
1787
+ const res = await call('wp_extract_entities');
1788
+ expect(res.isError).toBe(true);
1789
+ });
1790
+ });
1791
+
1792
+ // =========================================================================
1793
+ // wp_get_publishing_velocity (tool #77)
1794
+ // =========================================================================
1795
+
1796
+ function makeDatedPost(id, daysAgo, author = 1, categories = [1]) {
1797
+ const d = new Date(Date.now() - daysAgo * 86400000);
1798
+ return {
1799
+ id, title: { rendered: `Post ${id}` },
1800
+ date: d.toISOString(),
1801
+ author,
1802
+ categories
1803
+ };
1804
+ }
1805
+
1806
+ const pvAuthors = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
1807
+ const pvCategories = [{ id: 1, name: 'Tech' }, { id: 2, name: 'Marketing' }];
1808
+
1809
+ describe('wp_get_publishing_velocity', () => {
1810
+ it('NOMINAL — calculates velocity by period', async () => {
1811
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1812
+ // posts, authors, categories
1813
+ mockSuccess([
1814
+ makeDatedPost(1, 5, 1, [1]), makeDatedPost(2, 10, 1, [1]),
1815
+ makeDatedPost(3, 20, 2, [2]), makeDatedPost(4, 60, 1, [1]),
1816
+ makeDatedPost(5, 100, 2, [2]), makeDatedPost(6, 150, 1, [2])
1817
+ ]);
1818
+ mockSuccess(pvAuthors);
1819
+ mockSuccess(pvCategories);
1820
+
1821
+ const res = await call('wp_get_publishing_velocity');
1822
+ const data = parseResult(res);
1823
+
1824
+ expect(data.total_posts_fetched).toBe(6);
1825
+ expect(data.periods).toHaveLength(3); // default 30,90,180
1826
+ expect(data.periods[0].days).toBe(30);
1827
+ expect(data.periods[0].velocity_per_month).toBeGreaterThan(0);
1828
+ expect(data.trend).toBeDefined();
1829
+ expect(['accelerating', 'stable', 'decelerating']).toContain(data.trend.direction);
1830
+ });
1831
+
1832
+ it('BY AUTHOR — authors have different velocities', async () => {
1833
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1834
+ mockSuccess([
1835
+ makeDatedPost(1, 5, 1, [1]), makeDatedPost(2, 10, 1, [1]),
1836
+ makeDatedPost(3, 20, 1, [1]), makeDatedPost(4, 25, 2, [2])
1837
+ ]);
1838
+ mockSuccess(pvAuthors);
1839
+ mockSuccess(pvCategories);
1840
+
1841
+ const res = await call('wp_get_publishing_velocity', { periods: '30' });
1842
+ const data = parseResult(res);
1843
+
1844
+ const period = data.periods[0];
1845
+ expect(period.by_author.length).toBeGreaterThan(0);
1846
+ // Alice (author 1) should have more posts
1847
+ const alice = period.by_author.find(a => a.name === 'Alice');
1848
+ expect(alice).toBeDefined();
1849
+ expect(alice.posts_count).toBe(3);
1850
+ });
1851
+
1852
+ it('BY CATEGORY — categories counted correctly', async () => {
1853
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1854
+ mockSuccess([
1855
+ makeDatedPost(1, 5, 1, [1]), makeDatedPost(2, 10, 1, [2]),
1856
+ makeDatedPost(3, 20, 2, [1])
1857
+ ]);
1858
+ mockSuccess(pvAuthors);
1859
+ mockSuccess(pvCategories);
1860
+
1861
+ const res = await call('wp_get_publishing_velocity', { periods: '30' });
1862
+ const data = parseResult(res);
1863
+
1864
+ const period = data.periods[0];
1865
+ const tech = period.by_category.find(c => c.name === 'Tech');
1866
+ expect(tech).toBeDefined();
1867
+ expect(tech.posts_count).toBe(2);
1868
+ });
1869
+
1870
+ it('TREND ACCELERATING — more recent posts', async () => {
1871
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1872
+ // 5 posts in last 30 days, only 5 total in 180 days → short velocity much higher
1873
+ mockSuccess([
1874
+ makeDatedPost(1, 2), makeDatedPost(2, 5), makeDatedPost(3, 10),
1875
+ makeDatedPost(4, 15), makeDatedPost(5, 25)
1876
+ ]);
1877
+ mockSuccess(pvAuthors);
1878
+ mockSuccess(pvCategories);
1879
+
1880
+ const res = await call('wp_get_publishing_velocity', { periods: '30,180' });
1881
+ const data = parseResult(res);
1882
+
1883
+ // All 5 posts are within 30 days, so short and long velocity are the same
1884
+ // For accelerating, we need more in short period vs long period avg
1885
+ expect(data.trend).toBeDefined();
1886
+ });
1887
+
1888
+ it('CUSTOM PERIODS — parses correctly', async () => {
1889
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1890
+ mockSuccess([makeDatedPost(1, 3)]);
1891
+ mockSuccess(pvAuthors);
1892
+ mockSuccess(pvCategories);
1893
+
1894
+ const res = await call('wp_get_publishing_velocity', { periods: '7,30,60' });
1895
+ const data = parseResult(res);
1896
+
1897
+ expect(data.periods).toHaveLength(3);
1898
+ expect(data.periods[0].days).toBe(7);
1899
+ expect(data.periods[1].days).toBe(30);
1900
+ expect(data.periods[2].days).toBe(60);
1901
+ });
1902
+
1903
+ it('AUDIT — logs success entry', async () => {
1904
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1905
+ mockSuccess([makeDatedPost(1, 5)]);
1906
+ mockSuccess(pvAuthors);
1907
+ mockSuccess(pvCategories);
1908
+
1909
+ await call('wp_get_publishing_velocity');
1910
+
1911
+ const logs = getAuditLogs();
1912
+ const entry = logs.find(l => l.tool === 'wp_get_publishing_velocity');
1913
+ expect(entry).toBeDefined();
1914
+ expect(entry.status).toBe('success');
1915
+ expect(entry.action).toBe('publishing_velocity');
1916
+ });
1917
+
1918
+ it('ERROR — 403 returns isError', async () => {
1919
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1920
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
1921
+
1922
+ const res = await call('wp_get_publishing_velocity');
1923
+ expect(res.isError).toBe(true);
1924
+ });
1925
+ });
1926
+
1927
+ // =========================================================================
1928
+ // wp_compare_revisions_diff (tool #78)
1929
+ // =========================================================================
1930
+
1931
+ const revisionFrom = {
1932
+ id: 101, date: '2025-05-01T10:00:00',
1933
+ content: { rendered: 'Paragraphe original.\nTitre original\nContenu original.\nLigne quatre.\nLigne cinq.\nLigne six.\nLigne sept.\nLigne huit.\nLigne neuf.\nLigne dix.' },
1934
+ title: { rendered: 'Mon Article' }
1935
+ };
1936
+
1937
+ const revisionTo = {
1938
+ id: 102, date: '2025-06-01T10:00:00',
1939
+ content: { rendered: 'Paragraphe modifié.\nTitre original\n<h2>Nouveau titre</h2>\nContenu original.\nNouveau paragraphe ajouté.\nLigne quatre.\nLigne cinq.\nLigne six modifiée.\nLigne sept.\nLigne huit.\nLigne neuf.\nLigne dix.' },
1940
+ title: { rendered: 'Mon Article Mis à Jour' }
1941
+ };
1942
+
1943
+ const revisionMinor = {
1944
+ id: 103, date: '2025-06-01T10:00:00',
1945
+ content: { rendered: 'Paragraphe original.\nTitre original\nContenu original.\nLigne quatre.\nLigne cinq.\nLigne six.\nLigne sept.\nLigne huit.\nLigne neuf modifiée.\nLigne dix.' },
1946
+ title: { rendered: 'Mon Article' }
1947
+ };
1948
+
1949
+ describe('wp_compare_revisions_diff', () => {
1950
+ it('NOMINAL — diff between two revisions', async () => {
1951
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1952
+ mockSuccess(revisionFrom);
1953
+ mockSuccess(revisionTo);
1954
+
1955
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101, revision_id_to: 102 });
1956
+ const data = parseResult(res);
1957
+
1958
+ expect(data.post_id).toBe(1);
1959
+ expect(data.from.revision_id).toBe(101);
1960
+ expect(data.to.revision_id).toBe(102);
1961
+ expect(data.diff.lines_added).toBeGreaterThan(0);
1962
+ expect(data.diff.change_ratio).toBeGreaterThan(0);
1963
+ expect(data.diff.amplitude).toBeDefined();
1964
+ });
1965
+
1966
+ it('AMPLITUDE MAJOR — large changes', async () => {
1967
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1968
+ const bigFrom = { id: 101, date: '2025-05-01', content: { rendered: '<p>Ancien contenu complet.</p>' }, title: { rendered: 'Old' } };
1969
+ const bigTo = { id: 102, date: '2025-06-01', content: { rendered: '<p>Tout nouveau contenu entièrement réécrit avec beaucoup plus de texte.</p><p>Deuxième paragraphe ajouté.</p><p>Troisième paragraphe.</p>' }, title: { rendered: 'New' } };
1970
+ mockSuccess(bigFrom);
1971
+ mockSuccess(bigTo);
1972
+
1973
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101, revision_id_to: 102 });
1974
+ const data = parseResult(res);
1975
+
1976
+ expect(data.diff.amplitude).toBe('major');
1977
+ });
1978
+
1979
+ it('AMPLITUDE MINOR — small changes', async () => {
1980
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1981
+ mockSuccess(revisionFrom);
1982
+ mockSuccess(revisionMinor);
1983
+
1984
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101, revision_id_to: 103 });
1985
+ const data = parseResult(res);
1986
+
1987
+ expect(data.diff.amplitude).toBe('minor');
1988
+ });
1989
+
1990
+ it('HEADINGS DIFF — detects added headings', async () => {
1991
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
1992
+ mockSuccess(revisionFrom);
1993
+ mockSuccess(revisionTo);
1994
+
1995
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101, revision_id_to: 102 });
1996
+ const data = parseResult(res);
1997
+
1998
+ expect(data.headings_diff.added.length).toBeGreaterThan(0);
1999
+ expect(data.headings_diff.added.some(h => h.text === 'Nouveau titre')).toBe(true);
2000
+ });
2001
+
2002
+ it('CURRENT POST — omitting revision_id_to compares with current', async () => {
2003
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2004
+ mockSuccess(revisionFrom);
2005
+ mockSuccess({ id: 1, content: { rendered: '<p>Contenu actuel du post.</p>' }, title: { rendered: 'Current' }, modified: '2025-06-15T10:00:00' });
2006
+
2007
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101 });
2008
+ const data = parseResult(res);
2009
+
2010
+ expect(data.to.revision_id).toBe('current');
2011
+ expect(data.to.date).toBe('2025-06-15T10:00:00');
2012
+ });
2013
+
2014
+ it('WORD COUNT — change calculated correctly', async () => {
2015
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2016
+ mockSuccess(revisionFrom);
2017
+ mockSuccess(revisionTo);
2018
+
2019
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101, revision_id_to: 102 });
2020
+ const data = parseResult(res);
2021
+
2022
+ expect(typeof data.from.word_count).toBe('number');
2023
+ expect(typeof data.to.word_count).toBe('number');
2024
+ expect(data.diff.word_count_change).toBe(data.to.word_count - data.from.word_count);
2025
+ });
2026
+
2027
+ it('AUDIT — logs success entry', async () => {
2028
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2029
+ mockSuccess(revisionFrom);
2030
+ mockSuccess(revisionTo);
2031
+
2032
+ await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 101, revision_id_to: 102 });
2033
+
2034
+ const logs = getAuditLogs();
2035
+ const entry = logs.find(l => l.tool === 'wp_compare_revisions_diff');
2036
+ expect(entry).toBeDefined();
2037
+ expect(entry.status).toBe('success');
2038
+ expect(entry.action).toBe('compare_revisions_diff');
2039
+ });
2040
+
2041
+ it('ERROR — 404 returns isError', async () => {
2042
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2043
+ mockError(404, '{"code":"rest_post_invalid_id","message":"Invalid revision ID"}');
2044
+
2045
+ const res = await call('wp_compare_revisions_diff', { post_id: 1, revision_id_from: 999 });
2046
+ expect(res.isError).toBe(true);
2047
+ });
2048
+ });
2049
+
2050
+ // =========================================================================
2051
+ // wp_list_posts_by_word_count (tool #79)
2052
+ // =========================================================================
2053
+
2054
+ function makeWordCountPost(id, wordCount) {
2055
+ // Generate content with approximately wordCount words
2056
+ const words = Array(wordCount).fill('mot').join(' ');
2057
+ return {
2058
+ id, title: { rendered: `Post ${id}` },
2059
+ content: { rendered: `<p>${words}</p>` },
2060
+ slug: `post-${id}`, link: `https://test.example.com/post-${id}`,
2061
+ date: '2025-06-01', modified: '2025-06-02',
2062
+ categories: [1]
2063
+ };
2064
+ }
2065
+
2066
+ describe('wp_list_posts_by_word_count', () => {
2067
+ it('NOMINAL — posts sorted by word count DESC', async () => {
2068
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2069
+ mockSuccess([
2070
+ makeWordCountPost(1, 100), makeWordCountPost(2, 450),
2071
+ makeWordCountPost(3, 800), makeWordCountPost(4, 1500), makeWordCountPost(5, 3500)
2072
+ ]);
2073
+
2074
+ const res = await call('wp_list_posts_by_word_count');
2075
+ const data = parseResult(res);
2076
+
2077
+ expect(data.total_analyzed).toBe(5);
2078
+ expect(data.posts[0].word_count).toBeGreaterThanOrEqual(data.posts[1].word_count);
2079
+ expect(data.posts[1].word_count).toBeGreaterThanOrEqual(data.posts[2].word_count);
2080
+ });
2081
+
2082
+ it('ORDER ASC — posts sorted ascending', async () => {
2083
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2084
+ mockSuccess([makeWordCountPost(1, 500), makeWordCountPost(2, 100), makeWordCountPost(3, 2000)]);
2085
+
2086
+ const res = await call('wp_list_posts_by_word_count', { order: 'asc' });
2087
+ const data = parseResult(res);
2088
+
2089
+ expect(data.posts[0].word_count).toBeLessThanOrEqual(data.posts[1].word_count);
2090
+ expect(data.posts[1].word_count).toBeLessThanOrEqual(data.posts[2].word_count);
2091
+ });
2092
+
2093
+ it('DISTRIBUTION — segments calculated correctly', async () => {
2094
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2095
+ mockSuccess([
2096
+ makeWordCountPost(1, 100), // very_short
2097
+ makeWordCountPost(2, 450), // short
2098
+ makeWordCountPost(3, 800), // medium
2099
+ makeWordCountPost(4, 1500), // standard
2100
+ makeWordCountPost(5, 3500) // very_long
2101
+ ]);
2102
+
2103
+ const res = await call('wp_list_posts_by_word_count');
2104
+ const data = parseResult(res);
2105
+
2106
+ expect(data.distribution.very_short.count).toBe(1);
2107
+ expect(data.distribution.short.count).toBe(1);
2108
+ expect(data.distribution.medium.count).toBe(1);
2109
+ expect(data.distribution.standard.count).toBe(1);
2110
+ expect(data.distribution.very_long.count).toBe(1);
2111
+ });
2112
+
2113
+ it('STATS — avg, median, min, max correct', async () => {
2114
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2115
+ mockSuccess([
2116
+ makeWordCountPost(1, 100), makeWordCountPost(2, 200), makeWordCountPost(3, 300)
2117
+ ]);
2118
+
2119
+ const res = await call('wp_list_posts_by_word_count');
2120
+ const data = parseResult(res);
2121
+
2122
+ expect(data.min_word_count).toBeLessThanOrEqual(data.max_word_count);
2123
+ expect(data.avg_word_count).toBeGreaterThan(0);
2124
+ expect(data.median_word_count).toBeGreaterThan(0);
2125
+ });
2126
+
2127
+ it('POST_TYPE BOTH — fetches posts and pages', async () => {
2128
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2129
+ mockSuccess([makeWordCountPost(1, 500), makeWordCountPost(2, 800)]); // posts
2130
+ mockSuccess([makeWordCountPost(3, 300)]); // pages
2131
+
2132
+ const res = await call('wp_list_posts_by_word_count', { post_type: 'both' });
2133
+ const data = parseResult(res);
2134
+
2135
+ expect(data.total_analyzed).toBe(3);
2136
+ });
2137
+
2138
+ it('CATEGORY — filter passed to query', async () => {
2139
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2140
+ mockSuccess([makeWordCountPost(1, 500)]);
2141
+
2142
+ const res = await call('wp_list_posts_by_word_count', { category_id: 5 });
2143
+ const data = parseResult(res);
2144
+
2145
+ expect(data.total_analyzed).toBe(1);
2146
+ });
2147
+
2148
+ it('AUDIT — logs success entry', async () => {
2149
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2150
+ mockSuccess([makeWordCountPost(1, 500)]);
2151
+
2152
+ await call('wp_list_posts_by_word_count');
2153
+
2154
+ const logs = getAuditLogs();
2155
+ const entry = logs.find(l => l.tool === 'wp_list_posts_by_word_count');
2156
+ expect(entry).toBeDefined();
2157
+ expect(entry.status).toBe('success');
2158
+ expect(entry.action).toBe('list_by_word_count');
2159
+ });
2160
+
2161
+ it('ERROR — 403 returns isError', async () => {
2162
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
2163
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
2164
+
2165
+ const res = await call('wp_list_posts_by_word_count');
2166
+ expect(res.isError).toBe(true);
2167
+ });
2168
+ });