@adsim/wordpress-mcp-server 3.0.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +543 -176
  2. package/dxt/build-mcpb.sh +7 -0
  3. package/dxt/manifest.json +189 -0
  4. package/index.js +3156 -36
  5. package/package.json +3 -2
  6. package/src/confirmationToken.js +64 -0
  7. package/src/contentAnalyzer.js +476 -0
  8. package/src/htmlParser.js +80 -0
  9. package/src/linkUtils.js +158 -0
  10. package/src/utils/contentCompressor.js +116 -0
  11. package/src/woocommerceClient.js +88 -0
  12. package/tests/unit/contentAnalyzer.test.js +397 -0
  13. package/tests/unit/dxt/manifest.test.js +78 -0
  14. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  15. package/tests/unit/tools/approval.test.js +251 -0
  16. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  17. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  18. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  19. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  20. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  21. package/tests/unit/tools/contentCompressor.test.js +320 -0
  22. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  23. package/tests/unit/tools/destructive.test.js +246 -0
  24. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  25. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  26. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  27. package/tests/unit/tools/findThinContent.test.js +145 -0
  28. package/tests/unit/tools/internalLinks.test.js +283 -0
  29. package/tests/unit/tools/perTargetControls.test.js +228 -0
  30. package/tests/unit/tools/site.test.js +6 -1
  31. package/tests/unit/tools/woocommerce.test.js +344 -0
  32. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  33. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,397 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ calculateReadabilityScore,
4
+ countSyllablesFr,
5
+ extractTransitionWords,
6
+ countPassiveSentences,
7
+ extractHeadingsOutline,
8
+ detectContentSections
9
+ } from '../../src/contentAnalyzer.js';
10
+
11
+ // =========================================================================
12
+ // countSyllablesFr
13
+ // =========================================================================
14
+
15
+ describe('countSyllablesFr', () => {
16
+ it('counts syllables for common French words', () => {
17
+ expect(countSyllablesFr('maison')).toBe(2);
18
+ expect(countSyllablesFr('a')).toBe(1);
19
+ expect(countSyllablesFr('le')).toBe(1);
20
+ });
21
+
22
+ it('handles accented vowels', () => {
23
+ expect(countSyllablesFr('éléphant')).toBe(3);
24
+ expect(countSyllablesFr('château')).toBe(2);
25
+ });
26
+
27
+ it('returns 1 for single-syllable words', () => {
28
+ expect(countSyllablesFr('chat')).toBe(1);
29
+ expect(countSyllablesFr('bras')).toBe(1);
30
+ });
31
+
32
+ it('returns 0 for empty/null input', () => {
33
+ expect(countSyllablesFr('')).toBe(0);
34
+ expect(countSyllablesFr(null)).toBe(0);
35
+ expect(countSyllablesFr(undefined)).toBe(0);
36
+ });
37
+
38
+ it('minimum 1 syllable for any non-empty word', () => {
39
+ expect(countSyllablesFr('x')).toBe(1);
40
+ expect(countSyllablesFr('bcd')).toBe(1);
41
+ });
42
+ });
43
+
44
+ // =========================================================================
45
+ // calculateReadabilityScore
46
+ // =========================================================================
47
+
48
+ describe('calculateReadabilityScore', () => {
49
+ it('returns high score for simple short sentences', () => {
50
+ const html = '<p>Le chat est petit. Il dort bien. Il mange.</p>';
51
+ const result = calculateReadabilityScore(html);
52
+ expect(result.score).toBeGreaterThan(60);
53
+ expect(result.level).toMatch(/facile/);
54
+ expect(result.words).toBeGreaterThan(0);
55
+ expect(result.sentences).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('returns lower score for complex long sentences', () => {
59
+ const complex = '<p>La problématique fondamentale de la conceptualisation épistémologique contemporaine réside dans la difficulté intrinsèque de réconcilier les paradigmes herméneutiques avec les méthodologies quantitatives traditionnellement utilisées dans les sciences sociales et les humanités numériques.</p>';
60
+ const result = calculateReadabilityScore(complex);
61
+ expect(result.score).toBeLessThan(50);
62
+ });
63
+
64
+ it('returns all required fields', () => {
65
+ const result = calculateReadabilityScore('<p>Un texte simple.</p>');
66
+ expect(result).toHaveProperty('score');
67
+ expect(result).toHaveProperty('sentences');
68
+ expect(result).toHaveProperty('words');
69
+ expect(result).toHaveProperty('syllables');
70
+ expect(result).toHaveProperty('avg_words_per_sentence');
71
+ expect(result).toHaveProperty('avg_syllables_per_word');
72
+ expect(result).toHaveProperty('level');
73
+ });
74
+
75
+ it('score clamped between 0 and 100', () => {
76
+ const result = calculateReadabilityScore('<p>Mot.</p>');
77
+ expect(result.score).toBeGreaterThanOrEqual(0);
78
+ expect(result.score).toBeLessThanOrEqual(100);
79
+ });
80
+
81
+ it('handles empty HTML', () => {
82
+ const result = calculateReadabilityScore('');
83
+ expect(result.words).toBeLessThanOrEqual(1);
84
+ });
85
+ });
86
+
87
+ // =========================================================================
88
+ // extractTransitionWords
89
+ // =========================================================================
90
+
91
+ describe('extractTransitionWords', () => {
92
+ it('finds transition words in French text', () => {
93
+ const text = 'Le produit est bon. Cependant, il est cher. De plus, la livraison est lente. En effet, le délai est trop long.';
94
+ const result = extractTransitionWords(text);
95
+ expect(result.count).toBeGreaterThan(0);
96
+ expect(result.words_found).toContain('cependant');
97
+ expect(result.words_found).toContain('de plus');
98
+ expect(result.words_found).toContain('en effet');
99
+ });
100
+
101
+ it('returns density relative to sentence count', () => {
102
+ const text = 'Premièrement, on analyse. Ensuite, on conclut.';
103
+ const result = extractTransitionWords(text);
104
+ expect(result.density).toBeGreaterThan(0);
105
+ expect(typeof result.density).toBe('number');
106
+ });
107
+
108
+ it('returns empty for text without transitions', () => {
109
+ const text = 'Le chat dort. Il mange. Il joue.';
110
+ const result = extractTransitionWords(text);
111
+ expect(result.count).toBe(0);
112
+ expect(result.words_found).toHaveLength(0);
113
+ });
114
+
115
+ it('returns empty for null/empty input', () => {
116
+ expect(extractTransitionWords('')).toEqual({ count: 0, density: 0, words_found: [] });
117
+ expect(extractTransitionWords(null)).toEqual({ count: 0, density: 0, words_found: [] });
118
+ });
119
+ });
120
+
121
+ // =========================================================================
122
+ // countPassiveSentences
123
+ // =========================================================================
124
+
125
+ describe('countPassiveSentences', () => {
126
+ it('detects French passive voice', () => {
127
+ const text = 'Le rapport est rédigé par le comité. La décision a été prise hier.';
128
+ const result = countPassiveSentences(text);
129
+ expect(result.count).toBeGreaterThan(0);
130
+ expect(result.ratio).toBeGreaterThan(0);
131
+ });
132
+
133
+ it('returns zero for active voice text', () => {
134
+ const text = 'Le chat mange la souris. Le chien court vite.';
135
+ const result = countPassiveSentences(text);
136
+ expect(result.count).toBe(0);
137
+ });
138
+
139
+ it('returns all required fields', () => {
140
+ const result = countPassiveSentences('Une phrase active.');
141
+ expect(result).toHaveProperty('count');
142
+ expect(result).toHaveProperty('total_sentences');
143
+ expect(result).toHaveProperty('ratio');
144
+ });
145
+
146
+ it('handles empty input', () => {
147
+ const result = countPassiveSentences('');
148
+ expect(result.count).toBe(0);
149
+ expect(result.total_sentences).toBe(0);
150
+ });
151
+ });
152
+
153
+ // =========================================================================
154
+ // extractHeadingsOutline
155
+ // =========================================================================
156
+
157
+ describe('extractHeadingsOutline', () => {
158
+ it('extracts H1-H6 headings', () => {
159
+ const html = '<h1>Title</h1><h2>Section A</h2><h3>Sub A.1</h3><h2>Section B</h2>';
160
+ const result = extractHeadingsOutline(html);
161
+ expect(result).toHaveLength(4);
162
+ expect(result[0]).toEqual({ level: 1, text: 'Title' });
163
+ expect(result[1]).toEqual({ level: 2, text: 'Section A' });
164
+ expect(result[2]).toEqual({ level: 3, text: 'Sub A.1' });
165
+ expect(result[3]).toEqual({ level: 2, text: 'Section B' });
166
+ });
167
+
168
+ it('returns empty array for no headings', () => {
169
+ expect(extractHeadingsOutline('<p>Just text</p>')).toEqual([]);
170
+ });
171
+
172
+ it('handles empty/null input', () => {
173
+ expect(extractHeadingsOutline('')).toEqual([]);
174
+ expect(extractHeadingsOutline(null)).toEqual([]);
175
+ });
176
+ });
177
+
178
+ // =========================================================================
179
+ // detectContentSections
180
+ // =========================================================================
181
+
182
+ describe('detectContentSections', () => {
183
+ it('detects intro before first H2', () => {
184
+ const html = '<p>This is a long enough introduction paragraph with enough content.</p><h2>First Section</h2><p>Body.</p>';
185
+ const result = detectContentSections(html);
186
+ expect(result.has_intro).toBe(true);
187
+ });
188
+
189
+ it('detects conclusion heading', () => {
190
+ const html = '<h2>Introduction</h2><p>Text.</p><h2>Conclusion</h2><p>Final.</p>';
191
+ const result = detectContentSections(html);
192
+ expect(result.has_conclusion).toBe(true);
193
+ });
194
+
195
+ it('detects FAQ section', () => {
196
+ const html = '<h2>FAQ</h2><p>Question?</p>';
197
+ const result = detectContentSections(html);
198
+ expect(result.has_faq).toBe(true);
199
+ });
200
+
201
+ it('counts lists, tables, images', () => {
202
+ const html = '<ul><li>A</li></ul><ol><li>B</li></ol><table><tr><td>C</td></tr></table><img src="x.jpg"><img src="y.jpg">';
203
+ const result = detectContentSections(html);
204
+ expect(result.lists_count).toBe(2);
205
+ expect(result.tables_count).toBe(1);
206
+ expect(result.images_count).toBe(2);
207
+ });
208
+
209
+ it('returns correct headings_count', () => {
210
+ const html = '<h2>A</h2><h3>B</h3><h2>C</h2>';
211
+ const result = detectContentSections(html);
212
+ expect(result.headings_count).toBe(3);
213
+ });
214
+
215
+ it('handles empty input', () => {
216
+ const result = detectContentSections('');
217
+ expect(result.has_intro).toBe(false);
218
+ expect(result.has_conclusion).toBe(false);
219
+ expect(result.has_faq).toBe(false);
220
+ expect(result.lists_count).toBe(0);
221
+ });
222
+
223
+ it('no intro when H2 is at the start', () => {
224
+ const html = '<h2>Section</h2><p>Body text here.</p>';
225
+ const result = detectContentSections(html);
226
+ expect(result.has_intro).toBe(false);
227
+ });
228
+ });
229
+
230
+ // =========================================================================
231
+ // TF-IDF: buildTFIDFVectors, computeCosineSimilarity, findDuplicatePairs
232
+ // =========================================================================
233
+
234
+ import {
235
+ buildTFIDFVectors,
236
+ computeCosineSimilarity,
237
+ findDuplicatePairs,
238
+ extractEntities,
239
+ computeTextDiff
240
+ } from '../../src/contentAnalyzer.js';
241
+
242
+ describe('buildTFIDFVectors', () => {
243
+ it('builds vectors with correct terms from 2 documents', () => {
244
+ const docs = [
245
+ { id: 1, text: 'marketing digital stratégie entreprise marketing' },
246
+ { id: 2, text: 'cuisine française recette traditionnelle cuisine' }
247
+ ];
248
+ const { vectors, terms } = buildTFIDFVectors(docs);
249
+ expect(vectors.size).toBe(2);
250
+ expect(terms.size).toBeGreaterThan(0);
251
+ expect(vectors.get(1).has('marketing')).toBe(true);
252
+ expect(vectors.get(2).has('cuisine')).toBe(true);
253
+ });
254
+
255
+ it('filters French stop words', () => {
256
+ const docs = [{ id: 1, text: 'les des une pour dans par sur avec marketing' }];
257
+ const { vectors } = buildTFIDFVectors(docs);
258
+ const vec = vectors.get(1);
259
+ expect(vec.has('les')).toBe(false);
260
+ expect(vec.has('des')).toBe(false);
261
+ expect(vec.has('marketing')).toBe(true);
262
+ });
263
+
264
+ it('filters tokens shorter than 3 characters', () => {
265
+ const docs = [{ id: 1, text: 'le un marketing de la stratégie' }];
266
+ const { vectors } = buildTFIDFVectors(docs);
267
+ const vec = vectors.get(1);
268
+ expect(vec.has('le')).toBe(false);
269
+ expect(vec.has('un')).toBe(false);
270
+ expect(vec.has('marketing')).toBe(true);
271
+ });
272
+ });
273
+
274
+ describe('computeCosineSimilarity', () => {
275
+ it('returns 1.0 for identical vectors', () => {
276
+ const vec = new Map([['marketing', 0.5], ['digital', 0.3]]);
277
+ const sim = computeCosineSimilarity(vec, vec);
278
+ expect(sim).toBeCloseTo(1.0, 5);
279
+ });
280
+
281
+ it('returns 0.0 for orthogonal vectors', () => {
282
+ const vec1 = new Map([['marketing', 0.5]]);
283
+ const vec2 = new Map([['cuisine', 0.5]]);
284
+ const sim = computeCosineSimilarity(vec1, vec2);
285
+ expect(sim).toBe(0);
286
+ });
287
+
288
+ it('returns 0.0 for empty vector', () => {
289
+ const vec1 = new Map();
290
+ const vec2 = new Map([['marketing', 0.5]]);
291
+ expect(computeCosineSimilarity(vec1, vec2)).toBe(0);
292
+ expect(computeCosineSimilarity(null, vec2)).toBe(0);
293
+ });
294
+ });
295
+
296
+ describe('findDuplicatePairs', () => {
297
+ it('finds pair when two texts are similar', () => {
298
+ const docs = [
299
+ { id: 1, title: 'A', text: 'marketing digital stratégie entreprise contenu référencement naturel visibilité clients campagne publicitaire conversion' },
300
+ { id: 2, title: 'B', text: 'marketing digital stratégie société contenu référencement naturel visibilité clients campagne publicitaire résultats' },
301
+ { id: 3, title: 'C', text: 'cuisine française recette traditionnelle plats gastronomie ingrédients frais terroir culinaire desserts pâtisserie' }
302
+ ];
303
+ const pairs = findDuplicatePairs(docs, 0.5);
304
+ expect(pairs.length).toBeGreaterThanOrEqual(1);
305
+ const hasPair12 = pairs.some(p => (p.doc1_id === 1 && p.doc2_id === 2) || (p.doc1_id === 2 && p.doc2_id === 1));
306
+ expect(hasPair12).toBe(true);
307
+ });
308
+
309
+ it('returns 0 pairs for completely different texts', () => {
310
+ const docs = [
311
+ { id: 1, title: 'A', text: 'marketing digital stratégie entreprise contenu référencement' },
312
+ { id: 2, title: 'B', text: 'cuisine française recette traditionnelle plats gastronomie' }
313
+ ];
314
+ const pairs = findDuplicatePairs(docs, 0.7);
315
+ expect(pairs).toHaveLength(0);
316
+ });
317
+ });
318
+
319
+ // =========================================================================
320
+ // extractEntities
321
+ // =========================================================================
322
+
323
+ describe('extractEntities', () => {
324
+ it('detects named entities from text', () => {
325
+ const text = 'Le site utilise Google Analytics pour le suivi. À Bruxelles, Marie Martin gère le projet.';
326
+ const entities = extractEntities(text);
327
+ expect(entities.length).toBeGreaterThan(0);
328
+ const names = entities.map(e => e.name);
329
+ expect(names).toContain('Google Analytics');
330
+ });
331
+
332
+ it('filters sentence-start common words from exclusion list', () => {
333
+ const text = 'Le chat dort. Cette maison est belle. Pour les clients, WordPress est important.';
334
+ const entities = extractEntities(text);
335
+ const names = entities.map(e => e.name);
336
+ expect(names).not.toContain('Le');
337
+ expect(names).not.toContain('Cette');
338
+ expect(names).not.toContain('Pour');
339
+ });
340
+
341
+ it('groups consecutive capitalized words', () => {
342
+ const text = 'Nous utilisons Google Cloud Platform pour héberger le site.';
343
+ const entities = extractEntities(text);
344
+ const names = entities.map(e => e.name);
345
+ expect(names).toContain('Google Cloud Platform');
346
+ });
347
+
348
+ it('classifies brand, location, person correctly', () => {
349
+ const text = 'Le site WordPress est hébergé en Belgique. Marie Martin gère le contenu.';
350
+ const entities = extractEntities(text);
351
+ const wp = entities.find(e => e.name === 'WordPress');
352
+ expect(wp).toBeDefined();
353
+ expect(wp.type).toBe('brand');
354
+ const be = entities.find(e => e.name === 'Belgique');
355
+ expect(be).toBeDefined();
356
+ expect(be.type).toBe('location');
357
+ const mm = entities.find(e => e.name === 'Marie Martin');
358
+ expect(mm).toBeDefined();
359
+ expect(mm.type).toBe('person');
360
+ });
361
+
362
+ it('returns empty array for text without entities', () => {
363
+ const text = 'le chat dort sur le canapé.';
364
+ const entities = extractEntities(text);
365
+ expect(entities).toHaveLength(0);
366
+ });
367
+ });
368
+
369
+ // =========================================================================
370
+ // computeTextDiff
371
+ // =========================================================================
372
+
373
+ describe('computeTextDiff', () => {
374
+ it('returns zero changes for identical texts', () => {
375
+ const text = 'Ligne un\nLigne deux\nLigne trois';
376
+ const diff = computeTextDiff(text, text);
377
+ expect(diff.change_ratio).toBe(0);
378
+ expect(diff.lines_added).toBe(0);
379
+ expect(diff.lines_removed).toBe(0);
380
+ });
381
+
382
+ it('returns high change_ratio for completely different texts', () => {
383
+ const a = 'Première ligne\nDeuxième ligne';
384
+ const b = 'Tout autre contenu\nRien en commun';
385
+ const diff = computeTextDiff(a, b);
386
+ expect(diff.change_ratio).toBeGreaterThan(0.9);
387
+ });
388
+
389
+ it('detects added lines correctly', () => {
390
+ const a = 'Ligne un\nLigne deux';
391
+ const b = 'Ligne un\nLigne deux\nLigne trois\nLigne quatre';
392
+ const diff = computeTextDiff(a, b);
393
+ expect(diff.lines_added).toBe(2);
394
+ expect(diff.lines_removed).toBe(0);
395
+ expect(diff.lines_unchanged).toBe(2);
396
+ });
397
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tests for DXT manifest.json validation.
3
+ *
4
+ * Ensures the manifest conforms to MCPB specification v0.3.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const manifestPath = resolve(__dirname, '../../../dxt/manifest.json');
14
+
15
+ let manifest;
16
+
17
+ describe('DXT manifest.json', () => {
18
+ it('is valid JSON', () => {
19
+ const raw = readFileSync(manifestPath, 'utf-8');
20
+ manifest = JSON.parse(raw);
21
+ expect(manifest).toBeDefined();
22
+ });
23
+
24
+ it('has required top-level fields', () => {
25
+ expect(manifest.manifest_version).toBe('0.3');
26
+ expect(typeof manifest.name).toBe('string');
27
+ expect(typeof manifest.version).toBe('string');
28
+ expect(typeof manifest.description).toBe('string');
29
+ expect(manifest.author).toBeDefined();
30
+ expect(typeof manifest.author.name).toBe('string');
31
+ expect(manifest.server).toBeDefined();
32
+ });
33
+
34
+ it('server.type is "node"', () => {
35
+ expect(manifest.server.type).toBe('node');
36
+ });
37
+
38
+ it('server.entry_point is set', () => {
39
+ expect(typeof manifest.server.entry_point).toBe('string');
40
+ expect(manifest.server.entry_point.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ it('server.mcp_config.env maps WordPress credentials', () => {
44
+ const env = manifest.server.mcp_config.env;
45
+ expect(env.WP_API_URL).toBe('${user_config.wp_api_url}');
46
+ expect(env.WP_API_USERNAME).toBe('${user_config.wp_api_username}');
47
+ expect(env.WP_API_PASSWORD).toBe('${user_config.wp_api_password}');
48
+ });
49
+
50
+ it('user_config has wp_api_url, wp_api_username, wp_api_password', () => {
51
+ const cfg = manifest.user_config;
52
+ expect(cfg.wp_api_url).toBeDefined();
53
+ expect(cfg.wp_api_username).toBeDefined();
54
+ expect(cfg.wp_api_password).toBeDefined();
55
+ });
56
+
57
+ it('all user_config fields are required', () => {
58
+ expect(manifest.user_config.wp_api_url.required).toBe(true);
59
+ expect(manifest.user_config.wp_api_username.required).toBe(true);
60
+ expect(manifest.user_config.wp_api_password.required).toBe(true);
61
+ });
62
+
63
+ it('wp_api_password is marked as sensitive', () => {
64
+ expect(manifest.user_config.wp_api_password.sensitive).toBe(true);
65
+ });
66
+
67
+ it('declares at least 30 tools', () => {
68
+ expect(Array.isArray(manifest.tools)).toBe(true);
69
+ expect(manifest.tools.length).toBeGreaterThanOrEqual(30);
70
+ });
71
+
72
+ it('each tool has name and description', () => {
73
+ for (const tool of manifest.tools) {
74
+ expect(typeof tool.name).toBe('string');
75
+ expect(typeof tool.description).toBe('string');
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,192 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, _testSetTarget } from '../../../index.js';
7
+ import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
15
+ afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
16
+
17
+ // =========================================================================
18
+ // Helpers
19
+ // =========================================================================
20
+
21
+ const recentDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
22
+ const oldDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString();
23
+
24
+ function makeAuthor(id, { bio = '', avatarUrls = {} } = {}) {
25
+ return { id, name: `Author ${id}`, slug: `author-${id}`, description: bio, avatar_urls: avatarUrls };
26
+ }
27
+
28
+ function makePostWithContent(id, content, { author = 1, modified = recentDate } = {}) {
29
+ return {
30
+ id, title: { rendered: `Post ${id}` }, link: `https://test.example.com/post-${id}/`,
31
+ content: { rendered: content }, author, date: modified, modified, meta: {}
32
+ };
33
+ }
34
+
35
+ // Perfect post: all E-E-A-T signals present (must exceed 800 words for content_word_count_expert)
36
+ const fillerSentences = Array(160).fill('This detailed analysis covers important aspects of the topic thoroughly.').join(' ');
37
+ const perfectContent = `
38
+ <p>I've been working on this project for years and my experience with client projects has taught me a lot.</p>
39
+ <p>According to a recent study, 75% of websites need improvement. Source: research shows this clearly.</p>
40
+ <p>Visit <a href="https://other-site.com/page1">External 1</a> and <a href="https://other-site.com/page2">External 2</a>
41
+ and <a href="https://wikipedia.org/wiki/SEO">Wikipedia on SEO</a>.</p>
42
+ <p>John Smith from Google confirmed these findings in his rapport.</p>
43
+ <p>${fillerSentences}</p>
44
+ <script type="application/ld+json">{"@type":"Article"}</script>
45
+ `;
46
+
47
+ const emptyContent = '<p>Short.</p>';
48
+
49
+ // =========================================================================
50
+ // wp_analyze_eeat_signals
51
+ // =========================================================================
52
+
53
+ describe('wp_analyze_eeat_signals', () => {
54
+ it('SUCCESS — perfect score (all signals present)', async () => {
55
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
56
+ // 1. Fetch posts
57
+ mockSuccess([makePostWithContent(1, perfectContent)]);
58
+ // 2. Fetch author
59
+ mockSuccess(makeAuthor(1, { bio: 'Expert in SEO with 10 years of experience', avatarUrls: { '96': 'https://test.example.com/avatar.jpg' } }));
60
+
61
+ const res = await call('wp_analyze_eeat_signals');
62
+ const data = parseResult(res);
63
+
64
+ expect(data.analyses[0].scores.total).toBe(100);
65
+ expect(data.analyses[0].scores.experience).toBe(25);
66
+ expect(data.analyses[0].scores.expertise).toBe(25);
67
+ expect(data.analyses[0].scores.authoritativeness).toBe(25);
68
+ expect(data.analyses[0].scores.trustworthiness).toBe(25);
69
+ });
70
+
71
+ it('SUCCESS — zero score (no signals)', async () => {
72
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
73
+ mockSuccess([makePostWithContent(1, emptyContent, { modified: oldDate })]);
74
+ mockSuccess(makeAuthor(1));
75
+
76
+ const res = await call('wp_analyze_eeat_signals');
77
+ const data = parseResult(res);
78
+
79
+ expect(data.analyses[0].scores.total).toBe(0);
80
+ expect(data.analyses[0].signals_present).toHaveLength(0);
81
+ expect(data.analyses[0].signals_missing.length).toBeGreaterThan(0);
82
+ });
83
+
84
+ it('SIGNAL — author_has_bio detected', async () => {
85
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
86
+ mockSuccess([makePostWithContent(1, emptyContent)]);
87
+ mockSuccess(makeAuthor(1, { bio: 'I am an expert in WordPress development.' }));
88
+
89
+ const res = await call('wp_analyze_eeat_signals');
90
+ const data = parseResult(res);
91
+
92
+ expect(data.analyses[0].signals_present).toContain('author_has_bio');
93
+ expect(data.analyses[0].scores.experience).toBeGreaterThanOrEqual(10);
94
+ });
95
+
96
+ it('SIGNAL — has_authoritative_sources detected', async () => {
97
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
98
+ const content = '<p>See <a href="https://external.com/a">link1</a> and <a href="https://external.com/b">link2</a> and <a href="https://wikipedia.org/wiki/Test">Wikipedia</a>.</p>';
99
+ mockSuccess([makePostWithContent(1, content)]);
100
+ mockSuccess(makeAuthor(1));
101
+
102
+ const res = await call('wp_analyze_eeat_signals');
103
+ const data = parseResult(res);
104
+
105
+ expect(data.analyses[0].signals_present).toContain('has_authoritative_sources');
106
+ expect(data.analyses[0].signals_present).toContain('has_outbound_links');
107
+ });
108
+
109
+ it('SIGNAL — content_has_data detected', async () => {
110
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
111
+ const content = '<p>Our conversion rate improved by 45% after the optimization.</p>';
112
+ mockSuccess([makePostWithContent(1, content)]);
113
+ mockSuccess(makeAuthor(1));
114
+
115
+ const res = await call('wp_analyze_eeat_signals');
116
+ const data = parseResult(res);
117
+
118
+ expect(data.analyses[0].signals_present).toContain('content_has_data');
119
+ expect(data.analyses[0].scores.expertise).toBeGreaterThanOrEqual(8);
120
+ });
121
+
122
+ it('SIGNAL — has_structured_data detected', async () => {
123
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
124
+ const content = '<p>Text.</p><script type="application/ld+json">{"@type":"Article"}</script>';
125
+ mockSuccess([makePostWithContent(1, content)]);
126
+ mockSuccess(makeAuthor(1));
127
+
128
+ const res = await call('wp_analyze_eeat_signals');
129
+ const data = parseResult(res);
130
+
131
+ expect(data.analyses[0].signals_present).toContain('has_structured_data');
132
+ expect(data.analyses[0].scores.trustworthiness).toBeGreaterThanOrEqual(7);
133
+ });
134
+
135
+ it('SUCCESS — priority_fixes returns max 3 items', async () => {
136
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
137
+ mockSuccess([makePostWithContent(1, emptyContent, { modified: oldDate })]);
138
+ mockSuccess(makeAuthor(1));
139
+
140
+ const res = await call('wp_analyze_eeat_signals');
141
+ const data = parseResult(res);
142
+
143
+ expect(data.analyses[0].priority_fixes.length).toBeLessThanOrEqual(3);
144
+ expect(data.analyses[0].priority_fixes.length).toBeGreaterThan(0);
145
+ // Should be sorted by impact descending
146
+ if (data.analyses[0].priority_fixes.length >= 2) {
147
+ expect(data.analyses[0].priority_fixes[0].potential_points).toBeGreaterThanOrEqual(data.analyses[0].priority_fixes[1].potential_points);
148
+ }
149
+ });
150
+
151
+ it('SUCCESS — post_ids parameter respected', async () => {
152
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
153
+ mockSuccess([makePostWithContent(42, emptyContent)]);
154
+ mockSuccess(makeAuthor(1));
155
+
156
+ const res = await call('wp_analyze_eeat_signals', { post_ids: [42] });
157
+ const data = parseResult(res);
158
+
159
+ expect(data.analyses[0].id).toBe(42);
160
+ expect(data.total_analyzed).toBe(1);
161
+ // Verify fetch was called with include parameter
162
+ const fetchUrl = fetch.mock.calls[0][0];
163
+ expect(fetchUrl).toContain('include=42');
164
+ });
165
+
166
+ it('AUDIT — logs success entry', async () => {
167
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
168
+ mockSuccess([makePostWithContent(1, emptyContent)]);
169
+ mockSuccess(makeAuthor(1));
170
+
171
+ await call('wp_analyze_eeat_signals');
172
+
173
+ const logs = getAuditLogs();
174
+ const entry = logs.find(l => l.tool === 'wp_analyze_eeat_signals');
175
+ expect(entry).toBeDefined();
176
+ expect(entry.status).toBe('success');
177
+ expect(entry.action).toBe('audit_seo');
178
+ });
179
+
180
+ it('ERROR — logs error on API failure', async () => {
181
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
182
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
183
+
184
+ const res = await call('wp_analyze_eeat_signals');
185
+ expect(res.isError).toBe(true);
186
+
187
+ const logs = getAuditLogs();
188
+ const entry = logs.find(l => l.tool === 'wp_analyze_eeat_signals');
189
+ expect(entry).toBeDefined();
190
+ expect(entry.status).toBe('error');
191
+ });
192
+ });