@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.
- package/.env.example +18 -0
- package/README.md +867 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/plugins/adapters/acf/acfAdapter.js +55 -3
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +395 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/postMeta.test.js +105 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- 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
|
+
});
|