@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,653 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall } from '../../../index.js';
|
|
7
|
+
import { makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mockFetchJson(data, status = 200) {
|
|
14
|
+
return Promise.resolve({
|
|
15
|
+
ok: status >= 200 && status < 400,
|
|
16
|
+
status,
|
|
17
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
18
|
+
json: () => Promise.resolve(data),
|
|
19
|
+
text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data))
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mockFetchText(text, status = 200) {
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
ok: status >= 200 && status < 400,
|
|
26
|
+
status,
|
|
27
|
+
headers: { get: (h) => h === 'content-type' ? 'text/html' : null },
|
|
28
|
+
json: () => Promise.reject(new Error('not json')),
|
|
29
|
+
text: () => Promise.resolve(text)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let consoleSpy;
|
|
34
|
+
const envBackup = {};
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
fetch.mockReset();
|
|
38
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
39
|
+
envBackup.WP_READ_ONLY = process.env.WP_READ_ONLY;
|
|
40
|
+
delete process.env.WP_READ_ONLY;
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
consoleSpy.mockRestore();
|
|
44
|
+
if (envBackup.WP_READ_ONLY === undefined) delete process.env.WP_READ_ONLY;
|
|
45
|
+
else process.env.WP_READ_ONLY = envBackup.WP_READ_ONLY;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Helper: homepage with hreflang tags
|
|
49
|
+
const hreflangHomepage = `<html><head>
|
|
50
|
+
<link rel="alternate" hreflang="fr" href="https://example.com/fr/" />
|
|
51
|
+
<link rel="alternate" hreflang="nl" href="https://example.com/nl/" />
|
|
52
|
+
<link rel="alternate" hreflang="en" href="https://example.com/en/" />
|
|
53
|
+
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
|
|
54
|
+
</head><body></body></html>`;
|
|
55
|
+
|
|
56
|
+
// ════════════════════════════════════════════════════════════
|
|
57
|
+
// wp_detect_multilingual_plugin
|
|
58
|
+
// ════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
describe('wp_detect_multilingual_plugin', () => {
|
|
61
|
+
it('detects WPML via REST API', async () => {
|
|
62
|
+
fetch.mockImplementation((url) => {
|
|
63
|
+
if (url.toString().includes('/wpml/v1/languages')) {
|
|
64
|
+
return mockFetchJson([
|
|
65
|
+
{ code: 'en', english_name: 'English', default_locale: true },
|
|
66
|
+
{ code: 'fr', english_name: 'French' },
|
|
67
|
+
{ code: 'nl', english_name: 'Dutch' }
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
return mockFetchJson([], 404);
|
|
71
|
+
});
|
|
72
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
73
|
+
const data = parseResult(res);
|
|
74
|
+
expect(data.plugin).toBe('wpml');
|
|
75
|
+
expect(data.api_available).toBe(true);
|
|
76
|
+
expect(data.detection_method).toBe('rest_api');
|
|
77
|
+
expect(data.languages).toHaveLength(3);
|
|
78
|
+
expect(data.default_language).toBe('en');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('detects Polylang Pro via REST API', async () => {
|
|
82
|
+
fetch.mockImplementation((url) => {
|
|
83
|
+
if (url.toString().includes('/wpml/v1')) return mockFetchJson({}, 404);
|
|
84
|
+
if (url.toString().includes('/pll/v1/languages')) {
|
|
85
|
+
return mockFetchJson([
|
|
86
|
+
{ slug: 'fr', name: 'Français', is_default: true },
|
|
87
|
+
{ slug: 'nl', name: 'Nederlands' }
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
return mockFetchJson({}, 404);
|
|
91
|
+
});
|
|
92
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
93
|
+
const data = parseResult(res);
|
|
94
|
+
expect(data.plugin).toBe('polylang_pro');
|
|
95
|
+
expect(data.api_available).toBe(true);
|
|
96
|
+
expect(data.languages).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('detects Polylang Free via mu-plugin endpoint', async () => {
|
|
100
|
+
fetch.mockImplementation((url) => {
|
|
101
|
+
if (url.toString().includes('/wpml/v1')) return mockFetchJson({}, 404);
|
|
102
|
+
if (url.toString().includes('/pll/v1')) return mockFetchJson({}, 404);
|
|
103
|
+
if (url.toString().includes('/mcp-diagnostics/v1/polylang/languages')) {
|
|
104
|
+
return mockFetchJson({ languages: [
|
|
105
|
+
{ slug: 'fr', name: 'Français', is_default: true },
|
|
106
|
+
{ slug: 'en', name: 'English' }
|
|
107
|
+
]});
|
|
108
|
+
}
|
|
109
|
+
return mockFetchJson({}, 404);
|
|
110
|
+
});
|
|
111
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
112
|
+
const data = parseResult(res);
|
|
113
|
+
expect(data.plugin).toBe('polylang_free');
|
|
114
|
+
expect(data.api_available).toBe(true);
|
|
115
|
+
expect(data.detection_method).toBe('rest_api');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('detects Polylang Free via hreflang parsing', async () => {
|
|
119
|
+
fetch.mockImplementation((url) => {
|
|
120
|
+
const u = url.toString();
|
|
121
|
+
if (u.includes('/wpml/v1') || u.includes('/pll/v1') || u.includes('/mcp-diagnostics/v1/polylang') || u.includes('/translatepress/v1')) return mockFetchJson({}, 404);
|
|
122
|
+
// Homepage fetch for hreflang
|
|
123
|
+
return mockFetchText(hreflangHomepage);
|
|
124
|
+
});
|
|
125
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
126
|
+
const data = parseResult(res);
|
|
127
|
+
expect(data.plugin).toBe('polylang_free');
|
|
128
|
+
expect(data.api_available).toBe(false);
|
|
129
|
+
expect(data.detection_method).toBe('hreflang_parsing');
|
|
130
|
+
expect(data.languages).toHaveLength(3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('detects TranslatePress via REST API', async () => {
|
|
134
|
+
fetch.mockImplementation((url) => {
|
|
135
|
+
const u = url.toString();
|
|
136
|
+
if (u.includes('/wpml/v1') || u.includes('/pll/v1') || u.includes('/mcp-diagnostics/v1/polylang')) return mockFetchJson({}, 404);
|
|
137
|
+
if (u.includes('/translatepress/v1/languages')) {
|
|
138
|
+
return mockFetchJson([
|
|
139
|
+
{ code: 'en', english_name: 'English', is_default: true },
|
|
140
|
+
{ code: 'de', english_name: 'German' }
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
return mockFetchJson({}, 404);
|
|
144
|
+
});
|
|
145
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
146
|
+
const data = parseResult(res);
|
|
147
|
+
expect(data.plugin).toBe('translatepress');
|
|
148
|
+
expect(data.api_available).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null when no plugin detected', async () => {
|
|
152
|
+
fetch.mockImplementation(() => mockFetchJson({}, 404));
|
|
153
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
154
|
+
const data = parseResult(res);
|
|
155
|
+
expect(data.plugin).toBeNull();
|
|
156
|
+
expect(data.detection_method).toBe('none');
|
|
157
|
+
expect(data.languages).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ════════════════════════════════════════════════════════════
|
|
162
|
+
// wp_list_languages
|
|
163
|
+
// ════════════════════════════════════════════════════════════
|
|
164
|
+
|
|
165
|
+
describe('wp_list_languages', () => {
|
|
166
|
+
it('lists WPML languages with details', async () => {
|
|
167
|
+
fetch.mockImplementation((url) => {
|
|
168
|
+
if (url.toString().includes('/wpml/v1/languages')) {
|
|
169
|
+
return mockFetchJson([
|
|
170
|
+
{ code: 'en', english_name: 'English', native_name: 'English', default_locale: true, locale: 'en_US', country_flag_url: 'https://flag.com/en.png' },
|
|
171
|
+
{ code: 'fr', english_name: 'French', native_name: 'Français', locale: 'fr_FR', country_flag_url: 'https://flag.com/fr.png' },
|
|
172
|
+
{ code: 'nl', english_name: 'Dutch', native_name: 'Nederlands', locale: 'nl_NL' }
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
return mockFetchJson([], 404);
|
|
176
|
+
});
|
|
177
|
+
const res = await call('wp_list_languages');
|
|
178
|
+
const data = parseResult(res);
|
|
179
|
+
expect(data.plugin).toBe('wpml');
|
|
180
|
+
expect(data.languages).toHaveLength(3);
|
|
181
|
+
expect(data.languages[0].code).toBe('en');
|
|
182
|
+
expect(data.languages[0].is_default).toBe(true);
|
|
183
|
+
expect(data.languages[1].native_name).toBe('Français');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('lists Polylang Free languages via hreflang fallback', async () => {
|
|
187
|
+
fetch.mockImplementation((url) => {
|
|
188
|
+
const u = url.toString();
|
|
189
|
+
if (u.includes('/wpml/v1') || u.includes('/pll/v1') || u.includes('/mcp-diagnostics/v1/polylang') || u.includes('/translatepress/v1')) return mockFetchJson({}, 404);
|
|
190
|
+
return mockFetchText(hreflangHomepage);
|
|
191
|
+
});
|
|
192
|
+
const res = await call('wp_list_languages');
|
|
193
|
+
const data = parseResult(res);
|
|
194
|
+
expect(data.plugin).toBe('polylang_free');
|
|
195
|
+
expect(data.languages).toHaveLength(3);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns fallback when no plugin detected', async () => {
|
|
199
|
+
fetch.mockImplementation(() => mockFetchJson({}, 404));
|
|
200
|
+
const res = await call('wp_list_languages');
|
|
201
|
+
const data = parseResult(res);
|
|
202
|
+
expect(data.plugin).toBeNull();
|
|
203
|
+
expect(data.languages).toHaveLength(1);
|
|
204
|
+
expect(data.languages[0].code).toBe('default');
|
|
205
|
+
expect(data.languages[0].is_default).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ════════════════════════════════════════════════════════════
|
|
210
|
+
// wp_get_post_translations
|
|
211
|
+
// ════════════════════════════════════════════════════════════
|
|
212
|
+
|
|
213
|
+
describe('wp_get_post_translations', () => {
|
|
214
|
+
it('gets translations via WPML', async () => {
|
|
215
|
+
fetch.mockImplementation((url) => {
|
|
216
|
+
const u = url.toString();
|
|
217
|
+
// Detection phase: WPML detected
|
|
218
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
219
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }]);
|
|
220
|
+
}
|
|
221
|
+
// Translations endpoint
|
|
222
|
+
if (u.includes('/wpml/v1/translations?post_id=10')) {
|
|
223
|
+
return mockFetchJson({ source_language: 'en', translations: { fr: { post_id: 20 } } });
|
|
224
|
+
}
|
|
225
|
+
// Fetch translated post
|
|
226
|
+
if (u.includes('/posts/20')) {
|
|
227
|
+
return mockFetchJson({ id: 20, title: { rendered: 'Mon Article' }, link: 'https://example.com/fr/mon-article', status: 'publish', modified: '2024-06-01', meta: { _yoast_wpseo_title: 'SEO Title FR' } });
|
|
228
|
+
}
|
|
229
|
+
return mockFetchJson([], 404);
|
|
230
|
+
});
|
|
231
|
+
const res = await call('wp_get_post_translations', { post_id: 10 });
|
|
232
|
+
const data = parseResult(res);
|
|
233
|
+
expect(data.source_post_id).toBe(10);
|
|
234
|
+
expect(data.source_lang).toBe('en');
|
|
235
|
+
expect(data.translations.fr.post_id).toBe(20);
|
|
236
|
+
expect(data.translations.fr.title).toBe('Mon Article');
|
|
237
|
+
expect(data.translations.fr.has_seo_meta).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('returns empty translations when post not translated', async () => {
|
|
241
|
+
fetch.mockImplementation((url) => {
|
|
242
|
+
const u = url.toString();
|
|
243
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
244
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }]);
|
|
245
|
+
}
|
|
246
|
+
if (u.includes('/wpml/v1/translations')) {
|
|
247
|
+
return mockFetchJson({ source_language: 'en', translations: {} });
|
|
248
|
+
}
|
|
249
|
+
return mockFetchJson([], 404);
|
|
250
|
+
});
|
|
251
|
+
const res = await call('wp_get_post_translations', { post_id: 50 });
|
|
252
|
+
const data = parseResult(res);
|
|
253
|
+
expect(data.translation_count).toBe(0);
|
|
254
|
+
expect(data.translations).toEqual({});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('returns no-plugin message when no multilingual detected', async () => {
|
|
258
|
+
fetch.mockImplementation(() => mockFetchJson({}, 404));
|
|
259
|
+
const res = await call('wp_get_post_translations', { post_id: 10 });
|
|
260
|
+
const data = parseResult(res);
|
|
261
|
+
expect(data.multilingual).toBe(false);
|
|
262
|
+
expect(data.message).toContain('No multilingual plugin');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('handles 404 via WPML gracefully', async () => {
|
|
266
|
+
fetch.mockImplementation((url) => {
|
|
267
|
+
const u = url.toString();
|
|
268
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
269
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }]);
|
|
270
|
+
}
|
|
271
|
+
if (u.includes('/wpml/v1/translations')) {
|
|
272
|
+
return mockFetchJson({ source_language: 'en', translations: { fr: { post_id: 999 } } });
|
|
273
|
+
}
|
|
274
|
+
if (u.includes('/posts/999')) return mockFetchJson({}, 404);
|
|
275
|
+
return mockFetchJson([], 404);
|
|
276
|
+
});
|
|
277
|
+
const res = await call('wp_get_post_translations', { post_id: 10 });
|
|
278
|
+
const data = parseResult(res);
|
|
279
|
+
expect(data.translations.fr.post_id).toBe(999);
|
|
280
|
+
expect(data.translations.fr.status).toBe('unknown');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ════════════════════════════════════════════════════════════
|
|
285
|
+
// wp_audit_translation_coverage
|
|
286
|
+
// ════════════════════════════════════════════════════════════
|
|
287
|
+
|
|
288
|
+
describe('wp_audit_translation_coverage', () => {
|
|
289
|
+
it('returns coverage percentages for partial translation', async () => {
|
|
290
|
+
fetch.mockImplementation((url) => {
|
|
291
|
+
const u = url.toString();
|
|
292
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
293
|
+
return mockFetchJson([
|
|
294
|
+
{ code: 'en', english_name: 'English', default_locale: true },
|
|
295
|
+
{ code: 'fr', english_name: 'French' }
|
|
296
|
+
]);
|
|
297
|
+
}
|
|
298
|
+
// EN posts: 10 posts
|
|
299
|
+
if (u.includes('/posts?') && u.includes('wpml_language=en')) {
|
|
300
|
+
const posts = Array.from({ length: 10 }, (_, i) => ({ id: i + 1, title: { rendered: `Post ${i + 1}` }, content: { rendered: `<p>${'word '.repeat(100)}</p>` }, link: `https://example.com/post-${i + 1}`, status: 'publish' }));
|
|
301
|
+
return mockFetchJson(posts);
|
|
302
|
+
}
|
|
303
|
+
// FR posts: 6 posts
|
|
304
|
+
if (u.includes('/posts?') && u.includes('wpml_language=fr')) {
|
|
305
|
+
return mockFetchJson(Array.from({ length: 6 }, (_, i) => ({ id: 100 + i, title: { rendered: `Article ${i + 1}` }, content: { rendered: '<p>Contenu</p>' }, link: `https://example.com/fr/article-${i + 1}` })));
|
|
306
|
+
}
|
|
307
|
+
// EN pages: 5
|
|
308
|
+
if (u.includes('/pages?') && u.includes('wpml_language=en')) {
|
|
309
|
+
return mockFetchJson(Array.from({ length: 5 }, (_, i) => ({ id: 50 + i, title: { rendered: `Page ${i + 1}` }, content: { rendered: '<p>content</p>' }, link: `https://example.com/page-${i + 1}` })));
|
|
310
|
+
}
|
|
311
|
+
// FR pages: 5 (100%)
|
|
312
|
+
if (u.includes('/pages?') && u.includes('wpml_language=fr')) {
|
|
313
|
+
return mockFetchJson(Array.from({ length: 5 }, (_, i) => ({ id: 200 + i })));
|
|
314
|
+
}
|
|
315
|
+
return mockFetchJson([], 404);
|
|
316
|
+
});
|
|
317
|
+
const res = await call('wp_audit_translation_coverage', { post_types: ['post', 'page'] });
|
|
318
|
+
const data = parseResult(res);
|
|
319
|
+
expect(data.coverage_by_language).toHaveLength(2);
|
|
320
|
+
const frCov = data.coverage_by_language.find(c => c.lang_code === 'fr');
|
|
321
|
+
expect(frCov).toBeDefined();
|
|
322
|
+
const frPosts = frCov.post_type_coverage.find(t => t.post_type === 'post');
|
|
323
|
+
expect(frPosts.percentage).toBe(60); // 6/10
|
|
324
|
+
expect(frPosts.missing_count).toBe(4);
|
|
325
|
+
expect(data.ga4_note).toContain('GA4');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('returns no-multilingual message when no plugin', async () => {
|
|
329
|
+
fetch.mockImplementation(() => mockFetchJson({}, 404));
|
|
330
|
+
const res = await call('wp_audit_translation_coverage');
|
|
331
|
+
const data = parseResult(res);
|
|
332
|
+
expect(data.multilingual).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ════════════════════════════════════════════════════════════
|
|
337
|
+
// wp_find_missing_seo_translations
|
|
338
|
+
// ════════════════════════════════════════════════════════════
|
|
339
|
+
|
|
340
|
+
describe('wp_find_missing_seo_translations', () => {
|
|
341
|
+
it('detects missing SEO fields in translations', async () => {
|
|
342
|
+
fetch.mockImplementation((url) => {
|
|
343
|
+
const u = url.toString();
|
|
344
|
+
// WPML detected
|
|
345
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
346
|
+
return mockFetchJson([
|
|
347
|
+
{ code: 'en', english_name: 'English', default_locale: true },
|
|
348
|
+
{ code: 'fr', english_name: 'French' }
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
// Source posts with SEO meta (WPML uses wpml_language param)
|
|
352
|
+
if (u.includes('/posts?') && u.includes('wpml_language=en')) {
|
|
353
|
+
return mockFetchJson([{
|
|
354
|
+
id: 1, title: { rendered: 'SEO Post' }, meta: { _yoast_wpseo_title: 'SEO Title', _yoast_wpseo_metadesc: 'Description' }, yoast_head_json: { title: 'SEO Title', description: 'Description', og_title: 'OG Title', og_description: 'OG Desc' }
|
|
355
|
+
}]);
|
|
356
|
+
}
|
|
357
|
+
// WPML translations
|
|
358
|
+
if (u.includes('/wpml/v1/translations?post_id=1')) {
|
|
359
|
+
return mockFetchJson({ source_language: 'en', translations: { fr: { post_id: 100 } } });
|
|
360
|
+
}
|
|
361
|
+
// FR translation post — missing og_title, og_description
|
|
362
|
+
if (u.includes('/posts/100')) {
|
|
363
|
+
return mockFetchJson({ id: 100, title: { rendered: 'Article SEO' }, meta: { _yoast_wpseo_title: 'Titre SEO', _yoast_wpseo_metadesc: 'Desc FR' }, yoast_head_json: { title: 'Titre SEO', description: 'Desc FR' } });
|
|
364
|
+
}
|
|
365
|
+
return mockFetchJson([], 404);
|
|
366
|
+
});
|
|
367
|
+
const res = await call('wp_find_missing_seo_translations', { source_lang: 'en' });
|
|
368
|
+
const data = parseResult(res);
|
|
369
|
+
expect(data.total_missing).toBe(1);
|
|
370
|
+
expect(data.results[0].missing_fields).toContain('og_title');
|
|
371
|
+
expect(data.results[0].missing_fields).toContain('og_description');
|
|
372
|
+
expect(data.results[0].source_values.og_title).toBe('OG Title');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('returns empty when all SEO fields present', async () => {
|
|
376
|
+
fetch.mockImplementation((url) => {
|
|
377
|
+
const u = url.toString();
|
|
378
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
379
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }]);
|
|
380
|
+
}
|
|
381
|
+
if (u.includes('/posts?') && u.includes('wpml_language=en')) {
|
|
382
|
+
return mockFetchJson([{
|
|
383
|
+
id: 1, title: { rendered: 'Post' }, meta: { _yoast_wpseo_title: 'T', _yoast_wpseo_metadesc: 'D' }, yoast_head_json: { title: 'T', description: 'D', og_title: 'OG', og_description: 'OGD' }
|
|
384
|
+
}]);
|
|
385
|
+
}
|
|
386
|
+
if (u.includes('/wpml/v1/translations')) {
|
|
387
|
+
return mockFetchJson({ source_language: 'en', translations: { fr: { post_id: 50 } } });
|
|
388
|
+
}
|
|
389
|
+
if (u.includes('/posts/50')) {
|
|
390
|
+
return mockFetchJson({ id: 50, title: { rendered: 'Art' }, meta: { _yoast_wpseo_title: 'T FR', _yoast_wpseo_metadesc: 'D FR' }, yoast_head_json: { title: 'T FR', description: 'D FR', og_title: 'OG FR', og_description: 'OGD FR' } });
|
|
391
|
+
}
|
|
392
|
+
return mockFetchJson([], 404);
|
|
393
|
+
});
|
|
394
|
+
const res = await call('wp_find_missing_seo_translations', { source_lang: 'en' });
|
|
395
|
+
const data = parseResult(res);
|
|
396
|
+
expect(data.total_missing).toBe(0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('returns no-plugin message', async () => {
|
|
400
|
+
fetch.mockImplementation(() => mockFetchJson({}, 404));
|
|
401
|
+
const res = await call('wp_find_missing_seo_translations');
|
|
402
|
+
const data = parseResult(res);
|
|
403
|
+
expect(data.multilingual).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ════════════════════════════════════════════════════════════
|
|
408
|
+
// wp_sync_seo_meta_translations
|
|
409
|
+
// ════════════════════════════════════════════════════════════
|
|
410
|
+
|
|
411
|
+
describe('wp_sync_seo_meta_translations', () => {
|
|
412
|
+
function setupWpmlWithSeo() {
|
|
413
|
+
fetch.mockImplementation((url, opts) => {
|
|
414
|
+
const u = url.toString();
|
|
415
|
+
const method = opts?.method || 'GET';
|
|
416
|
+
// WPML detection
|
|
417
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
418
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }, { code: 'nl', english_name: 'Dutch' }]);
|
|
419
|
+
}
|
|
420
|
+
// Source post
|
|
421
|
+
if (u.includes('/posts/10') && method === 'GET') {
|
|
422
|
+
return mockFetchJson({ id: 10, title: { rendered: 'Source' }, meta: { _yoast_wpseo_title: 'SEO Title EN', _yoast_wpseo_metadesc: 'Description EN' }, yoast_head_json: { title: 'SEO Title EN', description: 'Description EN', og_title: 'OG Title EN', og_description: 'OG Desc EN' } });
|
|
423
|
+
}
|
|
424
|
+
// Translations
|
|
425
|
+
if (u.includes('/wpml/v1/translations?post_id=10')) {
|
|
426
|
+
return mockFetchJson({ source_language: 'en', translations: { fr: { post_id: 20 }, nl: { post_id: 30 } } });
|
|
427
|
+
}
|
|
428
|
+
// FR target post (has existing title, no description)
|
|
429
|
+
if (u.includes('/posts/20') && method === 'GET') {
|
|
430
|
+
return mockFetchJson({ id: 20, title: { rendered: 'FR Post' }, meta: { _yoast_wpseo_title: 'Titre FR existant' }, link: 'https://example.com/fr/post', status: 'publish', modified: '2024-01-01' });
|
|
431
|
+
}
|
|
432
|
+
// NL target post (empty SEO)
|
|
433
|
+
if (u.includes('/posts/30') && method === 'GET') {
|
|
434
|
+
return mockFetchJson({ id: 30, title: { rendered: 'NL Post' }, meta: {}, link: 'https://example.com/nl/post', status: 'publish', modified: '2024-01-01' });
|
|
435
|
+
}
|
|
436
|
+
// POST for sync
|
|
437
|
+
if (method === 'POST' && (u.includes('/posts/20') || u.includes('/posts/30'))) {
|
|
438
|
+
return mockFetchJson({ success: true });
|
|
439
|
+
}
|
|
440
|
+
return mockFetchJson([], 404);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
it('dry_run=true returns preview without modifying', async () => {
|
|
445
|
+
setupWpmlWithSeo();
|
|
446
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
447
|
+
post_id: 10, source_lang: 'en', target_langs: ['fr', 'nl'], dry_run: true
|
|
448
|
+
});
|
|
449
|
+
const data = parseResult(res);
|
|
450
|
+
expect(data.dry_run).toBe(true);
|
|
451
|
+
expect(data.targets).toHaveLength(2);
|
|
452
|
+
expect(data.targets[0].status).toBe('preview');
|
|
453
|
+
expect(data.source.seo_plugin).toBe('yoast');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('blocks on WP_READ_ONLY when dry_run=false', async () => {
|
|
457
|
+
process.env.WP_READ_ONLY = 'true';
|
|
458
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
459
|
+
post_id: 10, source_lang: 'en', target_langs: ['fr'], dry_run: false
|
|
460
|
+
});
|
|
461
|
+
expect(res.isError).toBe(true);
|
|
462
|
+
expect(res.content[0].text).toContain('READ-ONLY');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('overwrite_existing=false skips fields already present', async () => {
|
|
466
|
+
setupWpmlWithSeo();
|
|
467
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
468
|
+
post_id: 10, source_lang: 'en', target_langs: ['fr'],
|
|
469
|
+
fields: ['title', 'description'], dry_run: true, overwrite_existing: false
|
|
470
|
+
});
|
|
471
|
+
const data = parseResult(res);
|
|
472
|
+
const frTarget = data.targets.find(t => t.lang === 'fr');
|
|
473
|
+
expect(frTarget.fields_skipped).toContain('title'); // FR already has _yoast_wpseo_title
|
|
474
|
+
expect(frTarget.fields_synced).toContain('description');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('syncs with specific fields selection', async () => {
|
|
478
|
+
setupWpmlWithSeo();
|
|
479
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
480
|
+
post_id: 10, source_lang: 'en', target_langs: ['nl'],
|
|
481
|
+
fields: ['title', 'description'], dry_run: false
|
|
482
|
+
});
|
|
483
|
+
const data = parseResult(res);
|
|
484
|
+
expect(data.dry_run).toBe(false);
|
|
485
|
+
const nlTarget = data.targets.find(t => t.lang === 'nl');
|
|
486
|
+
expect(nlTarget.status).toBe('synced');
|
|
487
|
+
expect(nlTarget.fields_synced).toContain('title');
|
|
488
|
+
expect(nlTarget.fields_synced).toContain('description');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('handles missing translation for a target lang', async () => {
|
|
492
|
+
fetch.mockImplementation((url) => {
|
|
493
|
+
const u = url.toString();
|
|
494
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
495
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'de', english_name: 'German' }]);
|
|
496
|
+
}
|
|
497
|
+
if (u.includes('/posts/10')) {
|
|
498
|
+
return mockFetchJson({ id: 10, meta: { _yoast_wpseo_title: 'Title' } });
|
|
499
|
+
}
|
|
500
|
+
if (u.includes('/wpml/v1/translations')) {
|
|
501
|
+
return mockFetchJson({ source_language: 'en', translations: {} });
|
|
502
|
+
}
|
|
503
|
+
return mockFetchJson([], 404);
|
|
504
|
+
});
|
|
505
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
506
|
+
post_id: 10, source_lang: 'en', target_langs: ['de'], dry_run: true
|
|
507
|
+
});
|
|
508
|
+
const data = parseResult(res);
|
|
509
|
+
expect(data.targets[0].status).toBe('error');
|
|
510
|
+
expect(data.targets[0].error).toContain('No translation found');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('dry_run=true bypasses WP_READ_ONLY', async () => {
|
|
514
|
+
process.env.WP_READ_ONLY = 'true';
|
|
515
|
+
setupWpmlWithSeo();
|
|
516
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
517
|
+
post_id: 10, source_lang: 'en', target_langs: ['fr'], dry_run: true
|
|
518
|
+
});
|
|
519
|
+
const data = parseResult(res);
|
|
520
|
+
expect(data.dry_run).toBe(true);
|
|
521
|
+
expect(data.targets[0].status).toBe('preview');
|
|
522
|
+
expect(res.isError).toBeUndefined();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('syncs all fields when fields=["all"]', async () => {
|
|
526
|
+
setupWpmlWithSeo();
|
|
527
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
528
|
+
post_id: 10, source_lang: 'en', target_langs: ['nl'], fields: ['all'], dry_run: true
|
|
529
|
+
});
|
|
530
|
+
const data = parseResult(res);
|
|
531
|
+
const nlTarget = data.targets.find(t => t.lang === 'nl');
|
|
532
|
+
expect(nlTarget.fields_synced.length).toBeGreaterThanOrEqual(2);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('returns source SEO plugin info', async () => {
|
|
536
|
+
setupWpmlWithSeo();
|
|
537
|
+
const res = await call('wp_sync_seo_meta_translations', {
|
|
538
|
+
post_id: 10, source_lang: 'en', target_langs: ['fr'], dry_run: true
|
|
539
|
+
});
|
|
540
|
+
const data = parseResult(res);
|
|
541
|
+
expect(data.source.seo_plugin).toBe('yoast');
|
|
542
|
+
expect(data.source.post_id).toBe(10);
|
|
543
|
+
expect(data.source.fields_available.length).toBeGreaterThan(0);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ════════════════════════════════════════════════════════════
|
|
548
|
+
// Additional edge cases
|
|
549
|
+
// ════════════════════════════════════════════════════════════
|
|
550
|
+
|
|
551
|
+
describe('Multilingual edge cases', () => {
|
|
552
|
+
it('wp_detect: version detection from plugins list', async () => {
|
|
553
|
+
fetch.mockImplementation((url) => {
|
|
554
|
+
if (url.toString().includes('/wpml/v1/languages')) {
|
|
555
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }]);
|
|
556
|
+
}
|
|
557
|
+
if (url.toString().includes('/wp/v2/plugins')) {
|
|
558
|
+
return mockFetchJson([{ plugin: 'sitepress-multilingual-cms/sitepress.php', version: '4.6.5', status: 'active' }]);
|
|
559
|
+
}
|
|
560
|
+
return mockFetchJson([], 404);
|
|
561
|
+
});
|
|
562
|
+
const res = await call('wp_detect_multilingual_plugin');
|
|
563
|
+
const data = parseResult(res);
|
|
564
|
+
expect(data.plugin).toBe('wpml');
|
|
565
|
+
expect(data.version).toBe('4.6.5');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('wp_list_languages with include_post_count', async () => {
|
|
569
|
+
fetch.mockImplementation((url) => {
|
|
570
|
+
const u = url.toString();
|
|
571
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
572
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }]);
|
|
573
|
+
}
|
|
574
|
+
// Post count queries
|
|
575
|
+
if (u.includes('/posts?per_page=1') && u.includes('wpml_language=en')) {
|
|
576
|
+
return mockFetchJson([{ id: 1 }]);
|
|
577
|
+
}
|
|
578
|
+
if (u.includes('/posts?per_page=1') && u.includes('wpml_language=fr')) {
|
|
579
|
+
return mockFetchJson([{ id: 100 }]);
|
|
580
|
+
}
|
|
581
|
+
return mockFetchJson([], 404);
|
|
582
|
+
});
|
|
583
|
+
const res = await call('wp_list_languages', { include_post_count: true });
|
|
584
|
+
const data = parseResult(res);
|
|
585
|
+
expect(data.languages[0].post_count).toBe(1);
|
|
586
|
+
expect(data.languages[1].post_count).toBe(1);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('wp_get_post_translations with Polylang Pro', async () => {
|
|
590
|
+
fetch.mockImplementation((url) => {
|
|
591
|
+
const u = url.toString();
|
|
592
|
+
if (u.includes('/wpml/v1')) return mockFetchJson({}, 404);
|
|
593
|
+
if (u.includes('/pll/v1/languages')) {
|
|
594
|
+
return mockFetchJson([{ slug: 'fr', name: 'Français', is_default: true }, { slug: 'nl', name: 'Nederlands' }]);
|
|
595
|
+
}
|
|
596
|
+
if (u.includes('/pll/v1/posts/5/translations')) {
|
|
597
|
+
return mockFetchJson({ fr: 5, nl: 15 });
|
|
598
|
+
}
|
|
599
|
+
if (u.includes('/posts/15')) {
|
|
600
|
+
return mockFetchJson({ id: 15, title: { rendered: 'NL Post' }, link: 'https://example.com/nl/post', status: 'publish', modified: '2024-03-01', meta: {} });
|
|
601
|
+
}
|
|
602
|
+
return mockFetchJson([], 404);
|
|
603
|
+
});
|
|
604
|
+
const res = await call('wp_get_post_translations', { post_id: 5 });
|
|
605
|
+
const data = parseResult(res);
|
|
606
|
+
expect(data.source_lang).toBe('fr');
|
|
607
|
+
expect(data.translations.nl.post_id).toBe(15);
|
|
608
|
+
expect(data.translations.nl.title).toBe('NL Post');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('wp_audit_translation_coverage with 100% coverage', async () => {
|
|
612
|
+
fetch.mockImplementation((url) => {
|
|
613
|
+
const u = url.toString();
|
|
614
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
615
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }]);
|
|
616
|
+
}
|
|
617
|
+
// Both languages have same post count
|
|
618
|
+
if (u.includes('/posts?')) {
|
|
619
|
+
return mockFetchJson(Array.from({ length: 5 }, (_, i) => ({ id: i + 1, title: { rendered: `Post ${i}` }, content: { rendered: '<p>content</p>' }, link: `https://example.com/p-${i}` })));
|
|
620
|
+
}
|
|
621
|
+
if (u.includes('/pages?')) {
|
|
622
|
+
return mockFetchJson([{ id: 50, title: { rendered: 'Page' }, content: { rendered: '<p>c</p>' }, link: 'https://example.com/page' }]);
|
|
623
|
+
}
|
|
624
|
+
return mockFetchJson([], 404);
|
|
625
|
+
});
|
|
626
|
+
const res = await call('wp_audit_translation_coverage', { post_types: ['post', 'page'] });
|
|
627
|
+
const data = parseResult(res);
|
|
628
|
+
const frCov = data.coverage_by_language.find(c => c.lang_code === 'fr');
|
|
629
|
+
expect(frCov.overall_percentage).toBe(100);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('wp_audit_translation_coverage with 0% (no translations)', async () => {
|
|
633
|
+
fetch.mockImplementation((url) => {
|
|
634
|
+
const u = url.toString();
|
|
635
|
+
if (u.includes('/wpml/v1/languages')) {
|
|
636
|
+
return mockFetchJson([{ code: 'en', english_name: 'English', default_locale: true }, { code: 'fr', english_name: 'French' }]);
|
|
637
|
+
}
|
|
638
|
+
if (u.includes('wpml_language=en')) {
|
|
639
|
+
return mockFetchJson(Array.from({ length: 10 }, (_, i) => ({ id: i + 1, title: { rendered: `P${i}` }, content: { rendered: '<p>w</p>' }, link: `url${i}` })));
|
|
640
|
+
}
|
|
641
|
+
if (u.includes('wpml_language=fr')) {
|
|
642
|
+
return mockFetchJson([]);
|
|
643
|
+
}
|
|
644
|
+
return mockFetchJson([], 404);
|
|
645
|
+
});
|
|
646
|
+
const res = await call('wp_audit_translation_coverage', { post_types: ['post'] });
|
|
647
|
+
const data = parseResult(res);
|
|
648
|
+
const frCov = data.coverage_by_language.find(c => c.lang_code === 'fr');
|
|
649
|
+
const frPosts = frCov.post_type_coverage.find(t => t.post_type === 'post');
|
|
650
|
+
expect(frPosts.percentage).toBe(0);
|
|
651
|
+
expect(frPosts.missing_count).toBe(10);
|
|
652
|
+
});
|
|
653
|
+
});
|