@adsim/wordpress-mcp-server 4.6.0 → 5.3.1

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 (51) hide show
  1. package/.env.example +18 -0
  2. package/README.md +867 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/plugins/adapters/acf/acfAdapter.js +55 -3
  9. package/src/shared/api.js +79 -0
  10. package/src/shared/audit.js +39 -0
  11. package/src/shared/context.js +15 -0
  12. package/src/shared/governance.js +98 -0
  13. package/src/shared/utils.js +148 -0
  14. package/src/tools/comments.js +50 -0
  15. package/src/tools/content.js +395 -0
  16. package/src/tools/core.js +114 -0
  17. package/src/tools/editorial.js +634 -0
  18. package/src/tools/fse.js +370 -0
  19. package/src/tools/health.js +160 -0
  20. package/src/tools/index.js +96 -0
  21. package/src/tools/intelligence.js +2082 -0
  22. package/src/tools/links.js +118 -0
  23. package/src/tools/media.js +71 -0
  24. package/src/tools/performance.js +219 -0
  25. package/src/tools/plugins.js +368 -0
  26. package/src/tools/schema.js +417 -0
  27. package/src/tools/security.js +590 -0
  28. package/src/tools/seo.js +1633 -0
  29. package/src/tools/taxonomy.js +115 -0
  30. package/src/tools/users.js +188 -0
  31. package/src/tools/woocommerce.js +1008 -0
  32. package/src/tools/workflow.js +409 -0
  33. package/src/transport/http.js +39 -0
  34. package/tests/unit/helpers/pagination.test.js +43 -0
  35. package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
  36. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  37. package/tests/unit/tools/diagnostics.test.js +397 -0
  38. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  39. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  40. package/tests/unit/tools/fse.test.js +548 -0
  41. package/tests/unit/tools/multilingual.test.js +653 -0
  42. package/tests/unit/tools/performance.test.js +351 -0
  43. package/tests/unit/tools/postMeta.test.js +105 -0
  44. package/tests/unit/tools/runWorkflow.test.js +150 -0
  45. package/tests/unit/tools/schema.test.js +477 -0
  46. package/tests/unit/tools/security.test.js +695 -0
  47. package/tests/unit/tools/site.test.js +1 -1
  48. package/tests/unit/tools/users.crud.test.js +399 -0
  49. package/tests/unit/tools/validateBlocks.test.js +186 -0
  50. package/tests/unit/tools/visualStaging.test.js +271 -0
  51. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,817 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, _testSetTarget } from '../../../index.js';
7
+ import { makeRequest, mockSuccess, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+
15
+ beforeEach(() => {
16
+ fetch.mockReset();
17
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
18
+ _testSetTarget('site1', { url: 'https://example.com', auth: 'Basic dGVzdDp0ZXN0' });
19
+ });
20
+ afterEach(() => {
21
+ consoleSpy.mockRestore();
22
+ });
23
+
24
+ // Helper: create mock post — title, content, excerpt are extracted
25
+ // so that ...rest doesn't overwrite the { rendered: ... } wrappers.
26
+ function mockPost(overrides = {}) {
27
+ const { title, content, excerpt, ...rest } = overrides;
28
+ return {
29
+ id: 1,
30
+ title: { rendered: title || 'Test Post' },
31
+ content: { rendered: content || '<p>Some default content here for testing purposes with enough words to be meaningful in our analysis pipeline.</p>' },
32
+ excerpt: { rendered: excerpt || '<p>Test excerpt</p>' },
33
+ slug: 'test-post',
34
+ link: 'https://example.com/test-post/',
35
+ date: '2025-06-15T10:00:00',
36
+ modified: '2025-06-15T10:00:00',
37
+ status: 'publish',
38
+ author: 1,
39
+ categories: [1],
40
+ tags: [],
41
+ ...rest
42
+ };
43
+ }
44
+
45
+ // =========================================================================
46
+ // wp_suggest_content_updates
47
+ // =========================================================================
48
+
49
+ describe('wp_suggest_content_updates', () => {
50
+ it('identifies stale posts older than threshold', async () => {
51
+ const oldDate = '2024-01-15T10:00:00';
52
+ fetch.mockImplementation(() => Promise.resolve({
53
+ ok: true, status: 200,
54
+ headers: { get: () => 'application/json' },
55
+ json: () => Promise.resolve([
56
+ mockPost({ id: 1, title: 'Old Post', modified: oldDate, date: oldDate, content: '<p>' + 'word '.repeat(200) + '</p>' })
57
+ ]),
58
+ text: () => Promise.resolve('[]')
59
+ }));
60
+
61
+ const res = await call('wp_suggest_content_updates', { months: 6 });
62
+ const data = parseResult(res);
63
+ expect(data.suggestions.length).toBeGreaterThan(0);
64
+ expect(data.suggestions[0].days_since_update).toBeGreaterThan(180);
65
+ });
66
+
67
+ it('detects outdated year references in content', async () => {
68
+ const oldDate = '2024-03-01T10:00:00';
69
+ fetch.mockImplementation(() => Promise.resolve({
70
+ ok: true, status: 200,
71
+ headers: { get: () => 'application/json' },
72
+ json: () => Promise.resolve([
73
+ mockPost({ id: 2, title: 'Guide 2022', modified: oldDate, date: oldDate, content: '<p>En 2022, les meilleures pratiques étaient différentes. Mise à jour en 2023 pour refléter les changements. ' + 'word '.repeat(150) + '</p>' })
74
+ ]),
75
+ text: () => Promise.resolve('[]')
76
+ }));
77
+
78
+ const res = await call('wp_suggest_content_updates', { months: 3 });
79
+ const data = parseResult(res);
80
+ expect(data.suggestions.length).toBe(1);
81
+ const reasons = data.suggestions[0].reasons.join(' ');
82
+ expect(reasons).toMatch(/outdated date/i);
83
+ });
84
+
85
+ it('flags thin content with bonus priority', async () => {
86
+ const oldDate = '2024-02-01T10:00:00';
87
+ fetch.mockImplementation(() => Promise.resolve({
88
+ ok: true, status: 200,
89
+ headers: { get: () => 'application/json' },
90
+ json: () => Promise.resolve([
91
+ mockPost({ id: 3, modified: oldDate, date: oldDate, content: '<p>' + 'word '.repeat(150) + '</p>' }),
92
+ mockPost({ id: 4, modified: oldDate, date: oldDate, content: '<p>' + 'word '.repeat(500) + '</p>' })
93
+ ]),
94
+ text: () => Promise.resolve('[]')
95
+ }));
96
+
97
+ const res = await call('wp_suggest_content_updates', { months: 3 });
98
+ const data = parseResult(res);
99
+ const thin = data.suggestions.find(s => s.post_id === 3);
100
+ const thick = data.suggestions.find(s => s.post_id === 4);
101
+ expect(thin).toBeDefined();
102
+ expect(thick).toBeDefined();
103
+ // Thin content should have higher priority (includes thin bonus)
104
+ expect(thin.reasons.join(' ')).toMatch(/thin content/i);
105
+ });
106
+
107
+ it('excludes posts below min_word_count', async () => {
108
+ const oldDate = '2024-01-01T10:00:00';
109
+ fetch.mockImplementation(() => Promise.resolve({
110
+ ok: true, status: 200,
111
+ headers: { get: () => 'application/json' },
112
+ json: () => Promise.resolve([
113
+ mockPost({ id: 5, modified: oldDate, date: oldDate, content: '<p>Short</p>' })
114
+ ]),
115
+ text: () => Promise.resolve('[]')
116
+ }));
117
+
118
+ const res = await call('wp_suggest_content_updates', { months: 3, min_word_count: 100 });
119
+ const data = parseResult(res);
120
+ expect(data.suggestions).toHaveLength(0);
121
+ });
122
+
123
+ it('returns priority_score sorted descending', async () => {
124
+ const oldDate1 = '2023-01-01T10:00:00';
125
+ const oldDate2 = '2024-06-01T10:00:00';
126
+ fetch.mockImplementation(() => Promise.resolve({
127
+ ok: true, status: 200,
128
+ headers: { get: () => 'application/json' },
129
+ json: () => Promise.resolve([
130
+ mockPost({ id: 10, modified: oldDate2, date: oldDate2, content: '<p>' + 'word '.repeat(200) + '</p>' }),
131
+ mockPost({ id: 11, modified: oldDate1, date: oldDate1, content: '<p>' + 'word '.repeat(200) + '</p>' })
132
+ ]),
133
+ text: () => Promise.resolve('[]')
134
+ }));
135
+
136
+ const res = await call('wp_suggest_content_updates', { months: 3 });
137
+ const data = parseResult(res);
138
+ expect(data.suggestions.length).toBe(2);
139
+ expect(data.suggestions[0].priority_score).toBeGreaterThanOrEqual(data.suggestions[1].priority_score);
140
+ });
141
+
142
+ it('handles multiple post_types', async () => {
143
+ let callCount = 0;
144
+ fetch.mockImplementation(() => {
145
+ callCount++;
146
+ return Promise.resolve({
147
+ ok: true, status: 200,
148
+ headers: { get: () => 'application/json' },
149
+ json: () => Promise.resolve([mockPost({ id: callCount, modified: '2024-01-01T10:00:00', date: '2024-01-01T10:00:00', content: '<p>' + 'word '.repeat(200) + '</p>' })]),
150
+ text: () => Promise.resolve('[]')
151
+ });
152
+ });
153
+
154
+ const res = await call('wp_suggest_content_updates', { months: 3, post_types: ['post', 'page'] });
155
+ const data = parseResult(res);
156
+ expect(data.total_analyzed).toBeGreaterThanOrEqual(2);
157
+ });
158
+ });
159
+
160
+ // =========================================================================
161
+ // wp_audit_author_consistency
162
+ // =========================================================================
163
+
164
+ describe('wp_audit_author_consistency', () => {
165
+ function mockAuthorFlow(users, postsByAuthor) {
166
+ fetch.mockImplementation((url) => {
167
+ const u = typeof url === 'string' ? url : url.toString();
168
+ if (u.includes('/users')) {
169
+ return Promise.resolve({
170
+ ok: true, status: 200,
171
+ headers: { get: () => 'application/json' },
172
+ json: () => Promise.resolve(users),
173
+ text: () => Promise.resolve(JSON.stringify(users))
174
+ });
175
+ }
176
+ // Match author= parameter
177
+ const authorMatch = u.match(/author=(\d+)/);
178
+ const authorId = authorMatch ? parseInt(authorMatch[1]) : null;
179
+ const posts = postsByAuthor[authorId] || [];
180
+ return Promise.resolve({
181
+ ok: true, status: 200,
182
+ headers: { get: () => 'application/json' },
183
+ json: () => Promise.resolve(posts),
184
+ text: () => Promise.resolve(JSON.stringify(posts))
185
+ });
186
+ });
187
+ }
188
+
189
+ it('returns author profiles with stats', async () => {
190
+ mockAuthorFlow(
191
+ [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
192
+ {
193
+ 1: Array.from({ length: 5 }, (_, i) => mockPost({ id: i + 1, author: 1, date: `2025-0${i + 1}-15T10:00:00`, content: '<p>' + 'word '.repeat(300) + '</p>' })),
194
+ 2: Array.from({ length: 3 }, (_, i) => mockPost({ id: i + 10, author: 2, date: `2025-0${i + 1}-15T10:00:00`, content: '<p>' + 'word '.repeat(150) + '</p>' }))
195
+ }
196
+ );
197
+
198
+ const res = await call('wp_audit_author_consistency', { min_posts: 3 });
199
+ const data = parseResult(res);
200
+ expect(data.authors.length).toBe(2);
201
+ expect(data.authors[0].post_count).toBe(5);
202
+ expect(data.authors[0].avg_word_count).toBeGreaterThan(0);
203
+ expect(data.site_average.avg_word_count).toBeGreaterThan(0);
204
+ });
205
+
206
+ it('filters out authors below min_posts', async () => {
207
+ mockAuthorFlow(
208
+ [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
209
+ {
210
+ 1: Array.from({ length: 5 }, (_, i) => mockPost({ id: i + 1, author: 1, content: '<p>' + 'word '.repeat(200) + '</p>' })),
211
+ 2: [mockPost({ id: 10, author: 2, content: '<p>' + 'word '.repeat(200) + '</p>' })]
212
+ }
213
+ );
214
+
215
+ const res = await call('wp_audit_author_consistency', { min_posts: 3 });
216
+ const data = parseResult(res);
217
+ expect(data.authors.length).toBe(1);
218
+ expect(data.authors[0].name).toBe('Alice');
219
+ });
220
+
221
+ it('computes deviation from site average', async () => {
222
+ mockAuthorFlow(
223
+ [{ id: 1, name: 'Short Writer' }, { id: 2, name: 'Long Writer' }],
224
+ {
225
+ 1: Array.from({ length: 4 }, (_, i) => mockPost({ id: i + 1, author: 1, date: `2025-0${i + 1}-15T10:00:00`, content: '<p>' + 'word '.repeat(100) + '</p>' })),
226
+ 2: Array.from({ length: 4 }, (_, i) => mockPost({ id: i + 10, author: 2, date: `2025-0${i + 1}-15T10:00:00`, content: '<p>' + 'word '.repeat(500) + '</p>' }))
227
+ }
228
+ );
229
+
230
+ const res = await call('wp_audit_author_consistency', { min_posts: 3 });
231
+ const data = parseResult(res);
232
+ const short = data.authors.find(a => a.name === 'Short Writer');
233
+ const long = data.authors.find(a => a.name === 'Long Writer');
234
+ expect(short.deviation.word_count_vs_avg).toBeLessThan(0);
235
+ expect(long.deviation.word_count_vs_avg).toBeGreaterThan(0);
236
+ });
237
+
238
+ it('includes media usage stats', async () => {
239
+ mockAuthorFlow(
240
+ [{ id: 1, name: 'Visual Writer' }],
241
+ {
242
+ 1: Array.from({ length: 3 }, (_, i) => mockPost({
243
+ id: i + 1, author: 1, date: `2025-0${i + 1}-15T10:00:00`,
244
+ content: '<p>Text content here with enough words for analysis.</p><img src="img1.jpg"><img src="img2.jpg"><p>' + 'word '.repeat(100) + '</p>'
245
+ }))
246
+ }
247
+ );
248
+
249
+ const res = await call('wp_audit_author_consistency', { min_posts: 3 });
250
+ const data = parseResult(res);
251
+ expect(data.authors[0].avg_media_per_post).toBeGreaterThanOrEqual(2);
252
+ });
253
+
254
+ it('calculates posts_per_month frequency', async () => {
255
+ mockAuthorFlow(
256
+ [{ id: 1, name: 'Prolific' }],
257
+ {
258
+ 1: Array.from({ length: 6 }, (_, i) => mockPost({
259
+ id: i + 1, author: 1,
260
+ date: `2025-0${i + 1}-15T10:00:00`,
261
+ content: '<p>' + 'word '.repeat(200) + '</p>'
262
+ }))
263
+ }
264
+ );
265
+
266
+ const res = await call('wp_audit_author_consistency', { min_posts: 3 });
267
+ const data = parseResult(res);
268
+ expect(data.authors[0].posts_per_month).toBeGreaterThan(0);
269
+ });
270
+ });
271
+
272
+ // =========================================================================
273
+ // wp_build_editorial_calendar
274
+ // =========================================================================
275
+
276
+ describe('wp_build_editorial_calendar', () => {
277
+ function mockCalendarFlow(publishedPosts, scheduledPosts = []) {
278
+ fetch.mockImplementation((url) => {
279
+ const u = typeof url === 'string' ? url : url.toString();
280
+ if (u.includes('status=future')) {
281
+ return Promise.resolve({
282
+ ok: true, status: 200,
283
+ headers: { get: () => 'application/json' },
284
+ json: () => Promise.resolve(scheduledPosts),
285
+ text: () => Promise.resolve(JSON.stringify(scheduledPosts))
286
+ });
287
+ }
288
+ return Promise.resolve({
289
+ ok: true, status: 200,
290
+ headers: { get: () => 'application/json' },
291
+ json: () => Promise.resolve(publishedPosts),
292
+ text: () => Promise.resolve(JSON.stringify(publishedPosts))
293
+ });
294
+ });
295
+ }
296
+
297
+ it('returns calendar with recommended posts per month', async () => {
298
+ const posts = Array.from({ length: 12 }, (_, i) => mockPost({
299
+ id: i + 1,
300
+ date: `2025-${String(i + 1).padStart(2, '0')}-15T10:00:00`,
301
+ categories: [1]
302
+ }));
303
+ mockCalendarFlow(posts);
304
+
305
+ const res = await call('wp_build_editorial_calendar', { months_ahead: 2 });
306
+ const data = parseResult(res);
307
+ expect(data.calendar.length).toBe(2);
308
+ expect(data.calendar[0].recommended_posts).toBeGreaterThanOrEqual(1);
309
+ expect(data.publishing_pattern.avg_posts_per_month).toBeGreaterThan(0);
310
+ });
311
+
312
+ it('detects best publishing days', async () => {
313
+ // All posts on Monday
314
+ const posts = Array.from({ length: 8 }, (_, i) => mockPost({
315
+ id: i + 1,
316
+ date: `2025-${String(i + 1).padStart(2, '0')}-06T10:00:00`, // Various Mondays/other days
317
+ categories: [1]
318
+ }));
319
+ mockCalendarFlow(posts);
320
+
321
+ const res = await call('wp_build_editorial_calendar');
322
+ const data = parseResult(res);
323
+ expect(data.publishing_pattern.best_days).toBeDefined();
324
+ expect(data.publishing_pattern.best_days.length).toBeLessThanOrEqual(3);
325
+ });
326
+
327
+ it('accounts for already scheduled posts', async () => {
328
+ const published = Array.from({ length: 6 }, (_, i) => mockPost({
329
+ id: i + 1,
330
+ date: `2025-${String(i + 1).padStart(2, '0')}-15T10:00:00`
331
+ }));
332
+
333
+ const now = new Date();
334
+ const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 15);
335
+ const futureDate = nextMonth.toISOString();
336
+
337
+ const scheduled = [mockPost({ id: 100, title: 'Future Post', date: futureDate, status: 'future' })];
338
+ mockCalendarFlow(published, scheduled);
339
+
340
+ const res = await call('wp_build_editorial_calendar', { months_ahead: 1 });
341
+ const data = parseResult(res);
342
+ expect(data.calendar[0].already_scheduled).toBeGreaterThanOrEqual(0);
343
+ });
344
+
345
+ it('returns top categories for topic suggestions', async () => {
346
+ const posts = Array.from({ length: 10 }, (_, i) => mockPost({
347
+ id: i + 1,
348
+ date: `2025-${String((i % 12) + 1).padStart(2, '0')}-15T10:00:00`,
349
+ categories: [i % 3 + 1]
350
+ }));
351
+ mockCalendarFlow(posts);
352
+
353
+ const res = await call('wp_build_editorial_calendar');
354
+ const data = parseResult(res);
355
+ expect(data.top_categories.length).toBeGreaterThan(0);
356
+ expect(data.top_categories[0].category_id).toBeDefined();
357
+ });
358
+
359
+ it('handles empty site gracefully', async () => {
360
+ mockCalendarFlow([]);
361
+
362
+ const res = await call('wp_build_editorial_calendar');
363
+ const data = parseResult(res);
364
+ expect(data.analysis_period.posts_analyzed).toBe(0);
365
+ expect(data.calendar.length).toBe(3); // default months_ahead
366
+ });
367
+ });
368
+
369
+ // =========================================================================
370
+ // wp_find_pillar_content_gaps
371
+ // =========================================================================
372
+
373
+ describe('wp_find_pillar_content_gaps', () => {
374
+ function mockGapFlow(categories, tags, postsByCat = {}, postsByTag = {}) {
375
+ fetch.mockImplementation((url) => {
376
+ const u = typeof url === 'string' ? url : url.toString();
377
+ if (u.includes('/categories')) {
378
+ return Promise.resolve({
379
+ ok: true, status: 200,
380
+ headers: { get: () => 'application/json' },
381
+ json: () => Promise.resolve(categories),
382
+ text: () => Promise.resolve(JSON.stringify(categories))
383
+ });
384
+ }
385
+ if (u.includes('/tags')) {
386
+ return Promise.resolve({
387
+ ok: true, status: 200,
388
+ headers: { get: () => 'application/json' },
389
+ json: () => Promise.resolve(tags),
390
+ text: () => Promise.resolve(JSON.stringify(tags))
391
+ });
392
+ }
393
+ // Posts by category
394
+ const catMatch = u.match(/categories=(\d+)/);
395
+ if (catMatch) {
396
+ const posts = postsByCat[catMatch[1]] || [];
397
+ return Promise.resolve({
398
+ ok: true, status: 200,
399
+ headers: { get: () => 'application/json' },
400
+ json: () => Promise.resolve(posts),
401
+ text: () => Promise.resolve(JSON.stringify(posts))
402
+ });
403
+ }
404
+ // Posts by tag
405
+ const tagMatch = u.match(/tags=(\d+)/);
406
+ if (tagMatch) {
407
+ const posts = postsByTag[tagMatch[1]] || [];
408
+ return Promise.resolve({
409
+ ok: true, status: 200,
410
+ headers: { get: () => 'application/json' },
411
+ json: () => Promise.resolve(posts),
412
+ text: () => Promise.resolve(JSON.stringify(posts))
413
+ });
414
+ }
415
+ return Promise.resolve({
416
+ ok: true, status: 200,
417
+ headers: { get: () => 'application/json' },
418
+ json: () => Promise.resolve([]),
419
+ text: () => Promise.resolve('[]')
420
+ });
421
+ });
422
+ }
423
+
424
+ it('detects category gap when no pillar content exists', async () => {
425
+ const shortContent = '<p>' + 'word '.repeat(400) + '</p>'; // ~400 words
426
+ mockGapFlow(
427
+ [{ id: 1, name: 'SEO', slug: 'seo', count: 5, description: 'SEO tips' }],
428
+ [],
429
+ {
430
+ '1': Array.from({ length: 5 }, (_, i) => mockPost({ id: i + 1, content: shortContent, categories: [1] }))
431
+ }
432
+ );
433
+
434
+ const res = await call('wp_find_pillar_content_gaps', { min_cluster_size: 3 });
435
+ const data = parseResult(res);
436
+ expect(data.total_gaps).toBe(1);
437
+ expect(data.gaps[0].name).toBe('SEO');
438
+ expect(data.gaps[0].satellite_posts.length).toBe(5);
439
+ expect(data.gaps[0].recommendation).toContain('pillar');
440
+ });
441
+
442
+ it('skips category when pillar content exists', async () => {
443
+ const pillarContent = '<p>' + 'word '.repeat(2000) + '</p>';
444
+ const shortContent = '<p>' + 'word '.repeat(400) + '</p>';
445
+ mockGapFlow(
446
+ [{ id: 1, name: 'SEO', slug: 'seo', count: 4 }],
447
+ [],
448
+ {
449
+ '1': [
450
+ mockPost({ id: 1, content: pillarContent }), // pillar!
451
+ ...Array.from({ length: 3 }, (_, i) => mockPost({ id: i + 2, content: shortContent }))
452
+ ]
453
+ }
454
+ );
455
+
456
+ const res = await call('wp_find_pillar_content_gaps', { min_cluster_size: 3, min_word_count_pillar: 1500 });
457
+ const data = parseResult(res);
458
+ expect(data.total_gaps).toBe(0);
459
+ });
460
+
461
+ it('detects tag-based gaps', async () => {
462
+ const shortContent = '<p>' + 'word '.repeat(300) + '</p>';
463
+ mockGapFlow(
464
+ [],
465
+ [{ id: 10, name: 'React', slug: 'react', count: 4 }],
466
+ {},
467
+ {
468
+ '10': Array.from({ length: 4 }, (_, i) => mockPost({ id: i + 20, content: shortContent }))
469
+ }
470
+ );
471
+
472
+ const res = await call('wp_find_pillar_content_gaps', { min_cluster_size: 3 });
473
+ const data = parseResult(res);
474
+ expect(data.total_gaps).toBe(1);
475
+ expect(data.gaps[0].type).toBe('tag');
476
+ expect(data.gaps[0].name).toBe('React');
477
+ });
478
+
479
+ it('skips categories below min_cluster_size', async () => {
480
+ mockGapFlow(
481
+ [{ id: 1, name: 'Niche', slug: 'niche', count: 2 }],
482
+ []
483
+ );
484
+
485
+ const res = await call('wp_find_pillar_content_gaps', { min_cluster_size: 3 });
486
+ const data = parseResult(res);
487
+ expect(data.total_gaps).toBe(0);
488
+ });
489
+
490
+ it('sorts gaps by post_count descending', async () => {
491
+ const shortContent = '<p>' + 'word '.repeat(300) + '</p>';
492
+ mockGapFlow(
493
+ [
494
+ { id: 1, name: 'Small', slug: 'small', count: 3 },
495
+ { id: 2, name: 'Big', slug: 'big', count: 8 }
496
+ ],
497
+ [],
498
+ {
499
+ '1': Array.from({ length: 3 }, (_, i) => mockPost({ id: i + 1, content: shortContent })),
500
+ '2': Array.from({ length: 8 }, (_, i) => mockPost({ id: i + 10, content: shortContent }))
501
+ }
502
+ );
503
+
504
+ const res = await call('wp_find_pillar_content_gaps', { min_cluster_size: 3 });
505
+ const data = parseResult(res);
506
+ expect(data.gaps.length).toBe(2);
507
+ expect(data.gaps[0].name).toBe('Big');
508
+ });
509
+ });
510
+
511
+ // =========================================================================
512
+ // wp_audit_internal_link_equity
513
+ // =========================================================================
514
+
515
+ describe('wp_audit_internal_link_equity', () => {
516
+ function mockLinkFlow(posts) {
517
+ fetch.mockImplementation((url) => {
518
+ const u = typeof url === 'string' ? url : url.toString();
519
+ // Return posts for any first-page /posts? call
520
+ if (u.includes('/posts') && u.includes('page=1')) {
521
+ return Promise.resolve({
522
+ ok: true, status: 200,
523
+ headers: { get: () => 'application/json' },
524
+ json: () => Promise.resolve(posts),
525
+ text: () => Promise.resolve(JSON.stringify(posts))
526
+ });
527
+ }
528
+ return Promise.resolve({
529
+ ok: true, status: 200,
530
+ headers: { get: () => 'application/json' },
531
+ json: () => Promise.resolve([]),
532
+ text: () => Promise.resolve('[]')
533
+ });
534
+ });
535
+ }
536
+
537
+ it('identifies orphan pages with zero inbound links', async () => {
538
+ mockLinkFlow([
539
+ mockPost({ id: 1, link: 'https://example.com/post-a/', content: '<p>No links here</p>' }),
540
+ mockPost({ id: 2, link: 'https://example.com/post-b/', content: '<p>Also no links</p>' })
541
+ ]);
542
+
543
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
544
+ const data = parseResult(res);
545
+ expect(data.orphan_pages.count).toBe(2);
546
+ });
547
+
548
+ it('detects well-linked pages as non-orphans', async () => {
549
+ mockLinkFlow([
550
+ mockPost({ id: 1, link: 'https://example.com/post-a/', content: '<p>Link to <a href="https://example.com/post-b/">B</a></p>' }),
551
+ mockPost({ id: 2, link: 'https://example.com/post-b/', content: '<p>Link to <a href="https://example.com/post-a/">A</a></p>' })
552
+ ]);
553
+
554
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
555
+ const data = parseResult(res);
556
+ expect(data.orphan_pages.count).toBe(0);
557
+ });
558
+
559
+ it('identifies under-linked important content (>800 words, <3 inbound)', async () => {
560
+ const longContent = '<p>' + 'word '.repeat(1000) + '</p>';
561
+ mockLinkFlow([
562
+ mockPost({ id: 1, link: 'https://example.com/important/', content: longContent }),
563
+ mockPost({ id: 2, link: 'https://example.com/other/', content: '<p>Short post here.</p>' })
564
+ ]);
565
+
566
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
567
+ const data = parseResult(res);
568
+ // Post 1 has >800 words and 0 inbound links → under-linked
569
+ expect(data.under_linked_important.count).toBeGreaterThan(0);
570
+ expect(data.under_linked_important.pages[0].word_count).toBeGreaterThan(800);
571
+ });
572
+
573
+ it('computes distribution score 100 when no orphans', async () => {
574
+ mockLinkFlow([
575
+ mockPost({ id: 1, link: 'https://example.com/a/', content: '<p><a href="https://example.com/b/">B</a></p>' }),
576
+ mockPost({ id: 2, link: 'https://example.com/b/', content: '<p><a href="https://example.com/a/">A</a></p>' })
577
+ ]);
578
+
579
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
580
+ const data = parseResult(res);
581
+ expect(data.distribution_score).toBe(100);
582
+ });
583
+
584
+ it('returns recommendations for issues found', async () => {
585
+ mockLinkFlow([
586
+ mockPost({ id: 1, link: 'https://example.com/orphan/', content: '<p>No links</p>' })
587
+ ]);
588
+
589
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
590
+ const data = parseResult(res);
591
+ expect(data.recommendations.length).toBeGreaterThan(0);
592
+ expect(data.recommendations[0]).toContain('orphan');
593
+ });
594
+
595
+ it('handles total_pages_analyzed count', async () => {
596
+ mockLinkFlow(Array.from({ length: 5 }, (_, i) => mockPost({
597
+ id: i + 1,
598
+ link: `https://example.com/post-${i + 1}/`,
599
+ content: '<p>Content here</p>'
600
+ })));
601
+
602
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
603
+ const data = parseResult(res);
604
+ expect(data.total_pages_analyzed).toBe(5);
605
+ });
606
+ });
607
+
608
+ // =========================================================================
609
+ // wp_suggest_content_cluster
610
+ // =========================================================================
611
+
612
+ describe('wp_suggest_content_cluster', () => {
613
+ function mockClusterPosts(posts) {
614
+ let firstPostCall = true;
615
+ fetch.mockImplementation((url) => {
616
+ const u = typeof url === 'string' ? url : url.toString();
617
+ // Only return posts on first /posts? call to avoid duplicates across post types
618
+ if (u.includes('/posts') && !u.includes('/pages') && firstPostCall) {
619
+ firstPostCall = false;
620
+ return Promise.resolve({
621
+ ok: true, status: 200,
622
+ headers: { get: () => 'application/json' },
623
+ json: () => Promise.resolve(posts),
624
+ text: () => Promise.resolve(JSON.stringify(posts))
625
+ });
626
+ }
627
+ return Promise.resolve({
628
+ ok: true, status: 200,
629
+ headers: { get: () => 'application/json' },
630
+ json: () => Promise.resolve([]),
631
+ text: () => Promise.resolve(JSON.stringify([]))
632
+ });
633
+ });
634
+ }
635
+
636
+ it('clusters posts around a keyword topic', async () => {
637
+ const posts = [
638
+ mockPost({ id: 1, title: 'SEO Best Practices Guide', content: '<p>SEO optimization search engine ranking keywords meta tags on-page SEO.</p>' }),
639
+ mockPost({ id: 2, title: 'Technical SEO Checklist', content: '<p>Technical SEO crawling indexing sitemap robots.txt structured data SEO.</p>' }),
640
+ mockPost({ id: 3, title: 'Cooking Recipes Collection', content: '<p>Cooking recipes kitchen ingredients preparation meal dinner lunch.</p>' })
641
+ ];
642
+ mockClusterPosts(posts);
643
+
644
+ const res = await call('wp_suggest_content_cluster', { topic: 'SEO', similarity_threshold: 0.01 });
645
+ const data = parseResult(res);
646
+ expect(data.seed.type).toBe('keyword');
647
+ // With very low threshold, SEO-related posts should cluster
648
+ expect(data.cluster_size).toBeGreaterThanOrEqual(0);
649
+ });
650
+
651
+ it('clusters around a post_id seed', async () => {
652
+ const posts = [
653
+ mockPost({ id: 1, title: 'WordPress Performance Guide', content: '<p>WordPress performance optimization caching speed plugins themes.</p>' }),
654
+ mockPost({ id: 2, title: 'Speed Up WordPress Site', content: '<p>Speed WordPress cache CDN minification performance optimization.</p>' }),
655
+ mockPost({ id: 3, title: 'About Our Company', content: '<p>Company history team mission values contact information office.</p>' })
656
+ ];
657
+ mockClusterPosts(posts);
658
+
659
+ const res = await call('wp_suggest_content_cluster', { post_id: 1 });
660
+ const data = parseResult(res);
661
+ expect(data.seed.type).toBe('post');
662
+ expect(data.seed.post_id).toBe(1);
663
+ });
664
+
665
+ it('suggests pillar post (highest word count)', async () => {
666
+ const posts = [
667
+ mockPost({ id: 1, title: 'Short SEO Tip', content: '<p>SEO tip search engine optimization keywords.</p>' }),
668
+ mockPost({ id: 2, title: 'Complete SEO Guide', content: '<p>' + 'SEO optimization search engine ranking keywords '.repeat(100) + '</p>' })
669
+ ];
670
+ mockClusterPosts(posts);
671
+
672
+ const res = await call('wp_suggest_content_cluster', { topic: 'SEO', similarity_threshold: 0.01 });
673
+ const data = parseResult(res);
674
+ if (data.suggested_pillar) {
675
+ expect(data.suggested_pillar.word_count).toBeGreaterThan(0);
676
+ }
677
+ });
678
+
679
+ it('returns similarity bands breakdown', async () => {
680
+ const posts = [
681
+ mockPost({ id: 1, title: 'React Hooks Tutorial', content: '<p>React hooks useState useEffect components state management React.</p>' }),
682
+ mockPost({ id: 2, title: 'React Context API Guide', content: '<p>React context API state management components provider consumer React.</p>' }),
683
+ mockPost({ id: 3, title: 'JavaScript Basics', content: '<p>JavaScript variables functions loops arrays objects DOM manipulation.</p>' })
684
+ ];
685
+ mockClusterPosts(posts);
686
+
687
+ const res = await call('wp_suggest_content_cluster', { topic: 'React' });
688
+ const data = parseResult(res);
689
+ expect(data.similarity_bands).toBeDefined();
690
+ expect(data.similarity_bands.high).toBeDefined();
691
+ expect(data.similarity_bands.medium).toBeDefined();
692
+ expect(data.similarity_bands.low).toBeDefined();
693
+ });
694
+
695
+ it('throws error when neither topic nor post_id provided', async () => {
696
+ const res = await call('wp_suggest_content_cluster', {});
697
+ expect(res.isError).toBe(true);
698
+ expect(res.content[0].text).toContain('topic');
699
+ });
700
+
701
+ it('handles empty results gracefully', async () => {
702
+ mockClusterPosts([]);
703
+
704
+ const res = await call('wp_suggest_content_cluster', { topic: 'nonexistent_xyz' });
705
+ const data = parseResult(res);
706
+ expect(data.cluster_size).toBe(0);
707
+ });
708
+ });
709
+
710
+ // =========================================================================
711
+ // Cross-cutting / additional coverage
712
+ // =========================================================================
713
+
714
+ describe('Editorial Intelligence additional coverage', () => {
715
+ it('wp_suggest_content_updates returns total_analyzed field', async () => {
716
+ fetch.mockImplementation(() => Promise.resolve({
717
+ ok: true, status: 200,
718
+ headers: { get: () => 'application/json' },
719
+ json: () => Promise.resolve([
720
+ mockPost({ id: 1, modified: '2024-01-01T10:00:00', date: '2024-01-01T10:00:00', content: '<p>' + 'word '.repeat(200) + '</p>' })
721
+ ]),
722
+ text: () => Promise.resolve('[]')
723
+ }));
724
+
725
+ const res = await call('wp_suggest_content_updates', { months: 3 });
726
+ const data = parseResult(res);
727
+ expect(data.total_analyzed).toBeGreaterThanOrEqual(1);
728
+ expect(data.stale_threshold_months).toBe(3);
729
+ });
730
+
731
+ it('wp_audit_author_consistency returns total_authors_analyzed', async () => {
732
+ fetch.mockImplementation((url) => {
733
+ const u = typeof url === 'string' ? url : url.toString();
734
+ if (u.includes('/users')) {
735
+ return Promise.resolve({
736
+ ok: true, status: 200,
737
+ headers: { get: () => 'application/json' },
738
+ json: () => Promise.resolve([]),
739
+ text: () => Promise.resolve('[]')
740
+ });
741
+ }
742
+ return Promise.resolve({
743
+ ok: true, status: 200,
744
+ headers: { get: () => 'application/json' },
745
+ json: () => Promise.resolve([]),
746
+ text: () => Promise.resolve('[]')
747
+ });
748
+ });
749
+
750
+ const res = await call('wp_audit_author_consistency');
751
+ const data = parseResult(res);
752
+ expect(data.total_authors_analyzed).toBe(0);
753
+ expect(data.authors).toHaveLength(0);
754
+ });
755
+
756
+ it('wp_build_editorial_calendar returns monthly_breakdown', async () => {
757
+ const posts = Array.from({ length: 6 }, (_, i) => mockPost({
758
+ id: i + 1,
759
+ date: `2025-${String(i + 1).padStart(2, '0')}-15T10:00:00`,
760
+ categories: [1]
761
+ }));
762
+ fetch.mockImplementation((url) => {
763
+ const u = typeof url === 'string' ? url : url.toString();
764
+ if (u.includes('status=future')) {
765
+ return Promise.resolve({
766
+ ok: true, status: 200,
767
+ headers: { get: () => 'application/json' },
768
+ json: () => Promise.resolve([]),
769
+ text: () => Promise.resolve('[]')
770
+ });
771
+ }
772
+ return Promise.resolve({
773
+ ok: true, status: 200,
774
+ headers: { get: () => 'application/json' },
775
+ json: () => Promise.resolve(posts),
776
+ text: () => Promise.resolve(JSON.stringify(posts))
777
+ });
778
+ });
779
+
780
+ const res = await call('wp_build_editorial_calendar');
781
+ const data = parseResult(res);
782
+ expect(data.publishing_pattern.monthly_breakdown).toBeDefined();
783
+ expect(Object.keys(data.publishing_pattern.monthly_breakdown).length).toBeGreaterThan(0);
784
+ });
785
+
786
+ it('wp_audit_internal_link_equity reports outbound_links on orphans', async () => {
787
+ let firstPostCall = true;
788
+ fetch.mockImplementation((url) => {
789
+ const u = typeof url === 'string' ? url : url.toString();
790
+ if (u.includes('/posts') && u.includes('page=1') && firstPostCall) {
791
+ firstPostCall = false;
792
+ return Promise.resolve({
793
+ ok: true, status: 200,
794
+ headers: { get: () => 'application/json' },
795
+ json: () => Promise.resolve([
796
+ mockPost({ id: 1, link: 'https://example.com/linker/', content: '<p><a href="https://example.com/target/">Link</a></p>' }),
797
+ mockPost({ id: 2, link: 'https://example.com/target/', content: '<p>No outbound links</p>' })
798
+ ]),
799
+ text: () => Promise.resolve('[]')
800
+ });
801
+ }
802
+ return Promise.resolve({
803
+ ok: true, status: 200,
804
+ headers: { get: () => 'application/json' },
805
+ json: () => Promise.resolve([]),
806
+ text: () => Promise.resolve('[]')
807
+ });
808
+ });
809
+
810
+ const res = await call('wp_audit_internal_link_equity', { post_types: ['post'] });
811
+ const data = parseResult(res);
812
+ // Post 1 has 1 inbound (none) → orphan with 1 outbound
813
+ const orphan = data.orphan_pages.pages.find(p => p.id === 1);
814
+ expect(orphan).toBeDefined();
815
+ expect(orphan.outbound_links).toBe(1);
816
+ });
817
+ });