@adsim/wordpress-mcp-server 4.6.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -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/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. 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
+ });