@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,145 @@
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
+ // Mock data
19
+ // =========================================================================
20
+
21
+ const pageA = {
22
+ id: 1, title: { rendered: 'Home' }, slug: 'home', status: 'publish',
23
+ link: 'https://test.example.com/home/',
24
+ content: { rendered: '<p>Welcome! Visit <a href="https://test.example.com/about/">About Us</a> and <a href="https://test.example.com/services/">Services</a>.</p>' }
25
+ };
26
+ const pageB = {
27
+ id: 2, title: { rendered: 'About' }, slug: 'about', status: 'publish',
28
+ link: 'https://test.example.com/about/',
29
+ content: { rendered: '<p>About us. Go <a href="https://test.example.com/home/">Home</a>.</p>' }
30
+ };
31
+ const pageC = {
32
+ id: 3, title: { rendered: 'Services' }, slug: 'services', status: 'publish',
33
+ link: 'https://test.example.com/services/',
34
+ content: { rendered: '<p>Our services.</p>' }
35
+ };
36
+ const pageD = {
37
+ id: 4, title: { rendered: 'Hidden Page' }, slug: 'hidden', status: 'publish',
38
+ link: 'https://test.example.com/hidden/',
39
+ content: { rendered: '<p>This page is not linked from anywhere. It has many words to test sorting so that we can verify the filter and ordering logic works correctly in our test scenario.</p>' }
40
+ };
41
+
42
+ // =========================================================================
43
+ // wp_find_orphan_pages
44
+ // =========================================================================
45
+
46
+ describe('wp_find_orphan_pages', () => {
47
+ it('SUCCESS — no orphan pages when all are linked', async () => {
48
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
49
+ mockSuccess([pageA, pageB, pageC]);
50
+
51
+ const res = await call('wp_find_orphan_pages');
52
+ const data = parseResult(res);
53
+
54
+ expect(data.total_orphans).toBe(0);
55
+ expect(data.orphans).toHaveLength(0);
56
+ });
57
+
58
+ it('SUCCESS — all pages are orphans when no cross-links', async () => {
59
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
60
+ const iso1 = { id: 10, title: { rendered: 'Iso1' }, slug: 'iso1', status: 'publish', link: 'https://test.example.com/iso1/', content: { rendered: '<p>No links.</p>' } };
61
+ const iso2 = { id: 11, title: { rendered: 'Iso2' }, slug: 'iso2', status: 'publish', link: 'https://test.example.com/iso2/', content: { rendered: '<p>No links either.</p>' } };
62
+ mockSuccess([iso1, iso2]);
63
+
64
+ const res = await call('wp_find_orphan_pages');
65
+ const data = parseResult(res);
66
+
67
+ expect(data.total_orphans).toBe(2);
68
+ expect(data.orphans).toHaveLength(2);
69
+ });
70
+
71
+ it('SUCCESS — exclusion by IDs', async () => {
72
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
73
+ mockSuccess([pageA, pageB, pageC, pageD]);
74
+
75
+ const res = await call('wp_find_orphan_pages', { exclude_ids: [4] });
76
+ const data = parseResult(res);
77
+
78
+ const orphanIds = data.orphans.map(o => o.id);
79
+ expect(orphanIds).not.toContain(4);
80
+ });
81
+
82
+ it('SUCCESS — filter by min_words', async () => {
83
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
84
+ const shortPage = { id: 20, title: { rendered: 'Short' }, slug: 'short', status: 'publish', link: 'https://test.example.com/short/', content: { rendered: '<p>Hello.</p>' } };
85
+ const longPage = { id: 21, title: { rendered: 'Long' }, slug: 'long', status: 'publish', link: 'https://test.example.com/long/', content: { rendered: '<p>This is a much longer page with plenty of words to exceed the minimum threshold for the filter to work correctly in our test scenario today and beyond.</p>' } };
86
+ mockSuccess([shortPage, longPage]);
87
+
88
+ const res = await call('wp_find_orphan_pages', { min_words: 10 });
89
+ const data = parseResult(res);
90
+
91
+ expect(data.orphans.every(o => o.word_count >= 10)).toBe(true);
92
+ });
93
+
94
+ it('SUCCESS — builds link matrix correctly', async () => {
95
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
96
+ mockSuccess([pageA, pageB, pageC, pageD]);
97
+
98
+ const res = await call('wp_find_orphan_pages');
99
+ const data = parseResult(res);
100
+
101
+ // A linked from B, B linked from A, C linked from A → not orphans
102
+ // D not linked from anyone → orphan
103
+ expect(data.total_orphans).toBe(1);
104
+ expect(data.orphans[0].id).toBe(4);
105
+ });
106
+
107
+ it('SUCCESS — orphans sorted by word count descending', async () => {
108
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
109
+ const short = { id: 30, title: { rendered: 'Short' }, slug: 'short', status: 'publish', link: 'https://test.example.com/short/', content: { rendered: '<p>Short text.</p>' } };
110
+ const long = { id: 31, title: { rendered: 'Long' }, slug: 'long', status: 'publish', link: 'https://test.example.com/long/', content: { rendered: '<p>This page has significantly more words than the short page because it contains a much longer paragraph with many more words and additional content.</p>' } };
111
+ mockSuccess([short, long]);
112
+
113
+ const res = await call('wp_find_orphan_pages');
114
+ const data = parseResult(res);
115
+
116
+ expect(data.orphans).toHaveLength(2);
117
+ expect(data.orphans[0].word_count).toBeGreaterThan(data.orphans[1].word_count);
118
+ });
119
+
120
+ it('AUDIT — logs success entry', async () => {
121
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
122
+ mockSuccess([pageA]);
123
+
124
+ await call('wp_find_orphan_pages');
125
+
126
+ const logs = getAuditLogs();
127
+ const entry = logs.find(l => l.tool === 'wp_find_orphan_pages');
128
+ expect(entry).toBeDefined();
129
+ expect(entry.status).toBe('success');
130
+ expect(entry.action).toBe('find_orphan_pages');
131
+ });
132
+
133
+ it('ERROR — logs error on API failure', async () => {
134
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
135
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
136
+
137
+ const res = await call('wp_find_orphan_pages');
138
+ expect(res.isError).toBe(true);
139
+
140
+ const logs = getAuditLogs();
141
+ const entry = logs.find(l => l.tool === 'wp_find_orphan_pages');
142
+ expect(entry).toBeDefined();
143
+ expect(entry.status).toBe('error');
144
+ });
145
+ });
@@ -0,0 +1,145 @@
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 { 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(); });
16
+
17
+ // =========================================================================
18
+ // Mock data
19
+ // =========================================================================
20
+
21
+ const now = new Date();
22
+ const recentDate = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days ago
23
+ const oldDate = new Date(now - 800 * 24 * 60 * 60 * 1000).toISOString(); // 800 days ago
24
+
25
+ function makeArticle(id, wordCount, modified, categories = [5]) {
26
+ const words = Array(wordCount).fill('word').join(' ');
27
+ return {
28
+ id, title: { rendered: `Post ${id}` }, link: `https://test.example.com/post-${id}/`,
29
+ content: { rendered: `<p>${words}</p>` },
30
+ modified, date: modified, categories
31
+ };
32
+ }
33
+
34
+ // =========================================================================
35
+ // wp_find_thin_content
36
+ // =========================================================================
37
+
38
+ describe('wp_find_thin_content', () => {
39
+ it('SUCCESS — no thin content detected', async () => {
40
+ mockSuccess([makeArticle(1, 500, recentDate, [5])]);
41
+
42
+ const res = await call('wp_find_thin_content');
43
+ const data = parseResult(res);
44
+
45
+ expect(data.total_thin).toBe(0);
46
+ expect(data.articles).toHaveLength(0);
47
+ expect(data.total_analyzed).toBe(1);
48
+ });
49
+
50
+ it('ISSUE — too_short detected (word_count < 300)', async () => {
51
+ mockSuccess([makeArticle(1, 200, recentDate, [5])]);
52
+
53
+ const res = await call('wp_find_thin_content');
54
+ const data = parseResult(res);
55
+
56
+ expect(data.total_thin).toBe(1);
57
+ expect(data.articles[0].signals).toContain('too_short');
58
+ expect(data.articles[0].word_count).toBe(200);
59
+ });
60
+
61
+ it('ISSUE — very_short + severity critical with multiple signals', async () => {
62
+ mockSuccess([makeArticle(1, 100, oldDate, [1])]);
63
+
64
+ const res = await call('wp_find_thin_content');
65
+ const data = parseResult(res);
66
+
67
+ expect(data.articles[0].signals).toContain('very_short');
68
+ expect(data.articles[0].severity).toBe('critical');
69
+ });
70
+
71
+ it('ISSUE — outdated detected (days > 730)', async () => {
72
+ mockSuccess([makeArticle(1, 500, oldDate, [5])]);
73
+
74
+ const res = await call('wp_find_thin_content');
75
+ const data = parseResult(res);
76
+
77
+ expect(data.articles[0].signals).toContain('outdated');
78
+ expect(data.articles[0].days_since_update).toBeGreaterThan(730);
79
+ });
80
+
81
+ it('ISSUE — uncategorized detected', async () => {
82
+ mockSuccess([makeArticle(1, 200, recentDate, [1])]);
83
+
84
+ const res = await call('wp_find_thin_content');
85
+ const data = parseResult(res);
86
+
87
+ const article = data.articles.find(a => a.id === 1);
88
+ expect(article.signals).toContain('uncategorized');
89
+ });
90
+
91
+ it('ISSUE — 3 signals → severity critical', async () => {
92
+ // very_short + outdated + uncategorized = 3 signals → critical
93
+ mockSuccess([makeArticle(1, 100, oldDate, [1])]);
94
+
95
+ const res = await call('wp_find_thin_content');
96
+ const data = parseResult(res);
97
+
98
+ expect(data.articles[0].signals).toHaveLength(3);
99
+ expect(data.articles[0].severity).toBe('critical');
100
+ expect(data.by_severity.critical).toBe(1);
101
+ });
102
+
103
+ it('SUCCESS — suggested_action correct for signals', async () => {
104
+ // very_short + outdated → delete
105
+ mockSuccess([
106
+ makeArticle(1, 100, oldDate, [5]), // very_short + outdated → delete
107
+ makeArticle(2, 200, recentDate, [5]), // too_short only → expand
108
+ makeArticle(3, 500, oldDate, [5]), // outdated only → update_or_merge
109
+ ]);
110
+
111
+ const res = await call('wp_find_thin_content');
112
+ const data = parseResult(res);
113
+
114
+ const art1 = data.articles.find(a => a.id === 1);
115
+ const art2 = data.articles.find(a => a.id === 2);
116
+ const art3 = data.articles.find(a => a.id === 3);
117
+ expect(art1.suggested_action).toBe('delete');
118
+ expect(art2.suggested_action).toBe('expand');
119
+ expect(art3.suggested_action).toBe('update_or_merge');
120
+ });
121
+
122
+ it('AUDIT — logs success entry', async () => {
123
+ mockSuccess([makeArticle(1, 500, recentDate)]);
124
+
125
+ await call('wp_find_thin_content');
126
+
127
+ const logs = getAuditLogs();
128
+ const entry = logs.find(l => l.tool === 'wp_find_thin_content');
129
+ expect(entry).toBeDefined();
130
+ expect(entry.status).toBe('success');
131
+ expect(entry.action).toBe('audit_seo');
132
+ });
133
+
134
+ it('ERROR — logs error on API failure', async () => {
135
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
136
+
137
+ const res = await call('wp_find_thin_content');
138
+ expect(res.isError).toBe(true);
139
+
140
+ const logs = getAuditLogs();
141
+ const entry = logs.find(l => l.tool === 'wp_find_thin_content');
142
+ expect(entry).toBeDefined();
143
+ expect(entry.status).toBe('error');
144
+ });
145
+ });
@@ -0,0 +1,283 @@
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, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+ import {
9
+ extractInternalLinks,
10
+ extractExternalLinks,
11
+ extractFocusKeyword,
12
+ calculateRelevanceScore,
13
+ } from '../../../src/linkUtils.js';
14
+
15
+ function call(name, args = {}) {
16
+ return handleToolCall(makeRequest(name, args));
17
+ }
18
+
19
+ const SITE_URL = 'https://test.example.com';
20
+
21
+ // ────────────────────────────────────────────────────────────
22
+ // extractInternalLinks — unit tests
23
+ // ────────────────────────────────────────────────────────────
24
+
25
+ describe('extractInternalLinks', () => {
26
+ it('detects internal links and ignores external', () => {
27
+ const html = '<a href="https://test.example.com/hello">Hello</a> <a href="https://other.com/bye">Bye</a>';
28
+ const result = extractInternalLinks(html, SITE_URL);
29
+ expect(result).toHaveLength(1);
30
+ expect(result[0].url).toBe('https://test.example.com/hello');
31
+ expect(result[0].anchor_text).toBe('Hello');
32
+ });
33
+
34
+ it('handles relative hrefs (/)', () => {
35
+ const html = '<a href="/about">About</a> <a href="/contact">Contact</a>';
36
+ const result = extractInternalLinks(html, SITE_URL);
37
+ expect(result).toHaveLength(2);
38
+ expect(result[0].url).toBe('/about');
39
+ expect(result[1].url).toBe('/contact');
40
+ });
41
+ });
42
+
43
+ // ────────────────────────────────────────────────────────────
44
+ // extractExternalLinks — unit tests
45
+ // ────────────────────────────────────────────────────────────
46
+
47
+ describe('extractExternalLinks', () => {
48
+ it('detects external links', () => {
49
+ const html = '<a href="https://test.example.com/hello">Hello</a> <a href="https://other.com/bye">Bye</a>';
50
+ const result = extractExternalLinks(html, SITE_URL);
51
+ expect(result).toHaveLength(1);
52
+ expect(result[0].url).toBe('https://other.com/bye');
53
+ expect(result[0].anchor_text).toBe('Bye');
54
+ });
55
+ });
56
+
57
+ // ────────────────────────────────────────────────────────────
58
+ // extractFocusKeyword — unit tests
59
+ // ────────────────────────────────────────────────────────────
60
+
61
+ describe('extractFocusKeyword', () => {
62
+ it('RankMath detected', () => {
63
+ expect(extractFocusKeyword({ rank_math_focus_keyword: 'seo tips' })).toBe('seo tips');
64
+ });
65
+
66
+ it('Yoast detected', () => {
67
+ expect(extractFocusKeyword({ _yoast_wpseo_focuskw: 'wordpress seo' })).toBe('wordpress seo');
68
+ });
69
+
70
+ it('SEOPress detected', () => {
71
+ expect(extractFocusKeyword({ _seopress_analysis_target_kw: 'content marketing' })).toBe('content marketing');
72
+ });
73
+
74
+ it('AIOSEO detected', () => {
75
+ expect(extractFocusKeyword({ _aioseo_keywords: 'blogging tips' })).toBe('blogging tips');
76
+ });
77
+
78
+ it('no plugin → null', () => {
79
+ expect(extractFocusKeyword({})).toBeNull();
80
+ expect(extractFocusKeyword(null)).toBeNull();
81
+ });
82
+ });
83
+
84
+ // ────────────────────────────────────────────────────────────
85
+ // calculateRelevanceScore — unit tests
86
+ // ────────────────────────────────────────────────────────────
87
+
88
+ describe('calculateRelevanceScore', () => {
89
+ it('common category +3', () => {
90
+ const result = calculateRelevanceScore(
91
+ { id: 2, title: 'Other Post', date: new Date().toISOString(), categories: [1, 5], meta: {}, link: 'https://test.example.com/other' },
92
+ { id: 1, categories: [1, 3], linkedUrls: [] },
93
+ []
94
+ );
95
+ expect(result.breakdown.category_match).toBe(3);
96
+ });
97
+
98
+ it('freshness < 3 months +3', () => {
99
+ const recentDate = new Date();
100
+ recentDate.setMonth(recentDate.getMonth() - 1);
101
+ const result = calculateRelevanceScore(
102
+ { id: 2, title: 'Recent', date: recentDate.toISOString(), categories: [], meta: {}, link: 'https://test.example.com/recent' },
103
+ { id: 1, categories: [], linkedUrls: [] },
104
+ []
105
+ );
106
+ expect(result.breakdown.freshness).toBe(3);
107
+ });
108
+
109
+ it('already linked → score -999', () => {
110
+ const result = calculateRelevanceScore(
111
+ { id: 2, title: 'Other', date: new Date().toISOString(), categories: [], meta: {}, link: 'https://test.example.com/other' },
112
+ { id: 1, categories: [], linkedUrls: ['https://test.example.com/other'] },
113
+ []
114
+ );
115
+ expect(result.total).toBe(-999);
116
+ });
117
+ });
118
+
119
+ // ────────────────────────────────────────────────────────────
120
+ // wp_analyze_links — integration tests
121
+ // ────────────────────────────────────────────────────────────
122
+
123
+ describe('wp_analyze_links', () => {
124
+ let consoleSpy;
125
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
126
+ afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
127
+
128
+ const postWithLinks = {
129
+ id: 42,
130
+ title: { rendered: 'Test Post' },
131
+ content: { rendered: '<p>Check <a href="https://test.example.com/page1">Page 1</a> and <a href="https://other.com/ext">External</a></p>' },
132
+ status: 'publish',
133
+ meta: {}
134
+ };
135
+
136
+ it('returns correct structure with link checking', async () => {
137
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
138
+ mockSuccess(postWithLinks);
139
+ // HEAD for internal link
140
+ fetch.mockImplementationOnce(() => Promise.resolve({ status: 200 }));
141
+
142
+ const res = await call('wp_analyze_links', { post_id: 42 });
143
+ const data = parseResult(res);
144
+
145
+ expect(data.post_id).toBe(42);
146
+ expect(data.post_title).toBe('Test Post');
147
+ expect(data.internal_links).toHaveLength(1);
148
+ expect(data.internal_links[0].url).toBe('https://test.example.com/page1');
149
+ expect(data.internal_links[0].status).toBe('ok');
150
+ expect(data.internal_links[0].http_code).toBe(200);
151
+ expect(data.external_links).toHaveLength(1);
152
+ expect(data.external_links[0].url).toBe('https://other.com/ext');
153
+ expect(data.summary.total_internal).toBe(1);
154
+ expect(data.summary.total_external).toBe(1);
155
+ expect(data.summary.broken_count).toBe(0);
156
+
157
+ const logs = getAuditLogs();
158
+ const entry = logs.find(l => l.tool === 'wp_analyze_links');
159
+ expect(entry).toBeDefined();
160
+ expect(entry.action).toBe('analyze_links');
161
+ });
162
+
163
+ it('check_broken=false → all status "unchecked"', async () => {
164
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
165
+ mockSuccess(postWithLinks);
166
+
167
+ const res = await call('wp_analyze_links', { post_id: 42, check_broken: false });
168
+ const data = parseResult(res);
169
+
170
+ expect(data.internal_links[0].status).toBe('unchecked');
171
+ expect(data.internal_links[0].http_code).toBeNull();
172
+ });
173
+
174
+ it('NOT blocked by WP_READ_ONLY (read-only tool)', async () => {
175
+ const original = process.env.WP_READ_ONLY;
176
+ process.env.WP_READ_ONLY = 'true';
177
+ try {
178
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
179
+ mockSuccess(postWithLinks);
180
+ fetch.mockImplementationOnce(() => Promise.resolve({ status: 200 }));
181
+
182
+ const res = await call('wp_analyze_links', { post_id: 42 });
183
+ expect(res.isError).toBeUndefined();
184
+ const data = parseResult(res);
185
+ expect(data.post_id).toBe(42);
186
+ } finally {
187
+ if (original === undefined) delete process.env.WP_READ_ONLY;
188
+ else process.env.WP_READ_ONLY = original;
189
+ }
190
+ });
191
+ });
192
+
193
+ // ────────────────────────────────────────────────────────────
194
+ // wp_suggest_internal_links — integration tests
195
+ // ────────────────────────────────────────────────────────────
196
+
197
+ describe('wp_suggest_internal_links', () => {
198
+ let consoleSpy;
199
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
200
+ afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
201
+
202
+ const currentPost = {
203
+ id: 1,
204
+ title: { rendered: 'Current Post About WordPress SEO' },
205
+ content: { rendered: '<p>Some content with <a href="https://test.example.com/existing-link">existing link</a></p>' },
206
+ status: 'publish',
207
+ categories: [5],
208
+ meta: { rank_math_focus_keyword: 'wordpress seo' }
209
+ };
210
+
211
+ const candidate1 = {
212
+ id: 10,
213
+ title: { rendered: 'WordPress SEO Guide' },
214
+ content: { rendered: '' },
215
+ status: 'publish',
216
+ categories: [5],
217
+ date: new Date().toISOString(),
218
+ link: 'https://test.example.com/wordpress-seo-guide',
219
+ meta: { rank_math_focus_keyword: 'seo guide' }
220
+ };
221
+
222
+ const candidate2 = {
223
+ id: 20,
224
+ title: { rendered: 'Cooking Tips' },
225
+ content: { rendered: '' },
226
+ status: 'publish',
227
+ categories: [99],
228
+ date: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString(),
229
+ link: 'https://test.example.com/cooking-tips',
230
+ meta: {}
231
+ };
232
+
233
+ it('returns suggestions sorted by score', async () => {
234
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
235
+ mockSuccess(currentPost); // GET /posts/1
236
+ mockSuccess([candidate1, candidate2]); // search results
237
+
238
+ const res = await call('wp_suggest_internal_links', { post_id: 1 });
239
+ const data = parseResult(res);
240
+
241
+ expect(data.post_id).toBe(1);
242
+ expect(data.keywords_used).toContain('wordpress seo');
243
+ expect(data.suggestions.length).toBeGreaterThan(0);
244
+ // candidate1 should score higher (same category, recent, title match)
245
+ expect(data.suggestions[0].target_post_id).toBe(10);
246
+ expect(data.suggestions[0].relevance_score).toBeGreaterThan(0);
247
+ if (data.suggestions.length >= 2) {
248
+ expect(data.suggestions[0].relevance_score).toBeGreaterThanOrEqual(data.suggestions[1].relevance_score);
249
+ }
250
+ expect(data.suggestions[0].anchor_text).toBeDefined();
251
+ expect(data.suggestions[0].score_breakdown).toBeDefined();
252
+
253
+ const logs = getAuditLogs();
254
+ const entry = logs.find(l => l.tool === 'wp_suggest_internal_links');
255
+ expect(entry).toBeDefined();
256
+ expect(entry.action).toBe('suggest_links');
257
+ });
258
+
259
+ it('exclude_already_linked filters correctly', async () => {
260
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
261
+
262
+ const candidateLinked = {
263
+ id: 99,
264
+ title: { rendered: 'Already Linked Post' },
265
+ content: { rendered: '' },
266
+ status: 'publish',
267
+ categories: [5],
268
+ date: new Date().toISOString(),
269
+ link: 'https://test.example.com/existing-link',
270
+ meta: {}
271
+ };
272
+
273
+ mockSuccess(currentPost); // GET /posts/1
274
+ mockSuccess([candidateLinked, candidate2]); // search results
275
+
276
+ const res = await call('wp_suggest_internal_links', { post_id: 1, exclude_already_linked: true });
277
+ const data = parseResult(res);
278
+
279
+ expect(data.excluded_already_linked).toBeGreaterThanOrEqual(1);
280
+ const ids = data.suggestions.map(s => s.target_post_id);
281
+ expect(ids).not.toContain(99);
282
+ });
283
+ });