@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,175 @@
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
+ function makePost(id, content) {
22
+ return {
23
+ id, title: { rendered: `Post ${id}` },
24
+ link: `https://mysite.example.com/post-${id}/`,
25
+ content: { rendered: content }
26
+ };
27
+ }
28
+
29
+ // =========================================================================
30
+ // wp_audit_outbound_links
31
+ // =========================================================================
32
+
33
+ describe('wp_audit_outbound_links', () => {
34
+ it('STATUS — no_outbound when article has no external links', async () => {
35
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
36
+ const html = '<p>Just text, no links at all.</p>';
37
+ mockSuccess([makePost(1, html)]);
38
+
39
+ const res = await call('wp_audit_outbound_links');
40
+ const data = parseResult(res);
41
+
42
+ expect(data.articles[0].status).toBe('no_outbound');
43
+ expect(data.articles[0].outbound_count).toBe(0);
44
+ expect(data.by_status.no_outbound).toBe(1);
45
+ });
46
+
47
+ it('STATUS — good when article has appropriate external links', async () => {
48
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
49
+ const html = '<p>See <a href="https://external.com/page">external</a> and <a href="https://other.com/page">other</a>.</p>';
50
+ mockSuccess([makePost(1, html)]);
51
+
52
+ const res = await call('wp_audit_outbound_links');
53
+ const data = parseResult(res);
54
+
55
+ expect(data.articles[0].status).toBe('good');
56
+ expect(data.articles[0].outbound_count).toBe(2);
57
+ expect(data.by_status.good).toBe(1);
58
+ });
59
+
60
+ it('STATUS — excessive when too many external links', async () => {
61
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
62
+ // Generate 20 external links
63
+ const links = Array.from({ length: 20 }, (_, i) =>
64
+ `<a href="https://site${i}.com/page">link${i}</a>`
65
+ ).join(' ');
66
+ const html = `<p>${links}</p>`;
67
+ mockSuccess([makePost(1, html)]);
68
+
69
+ const res = await call('wp_audit_outbound_links', { max_outbound: 15 });
70
+ const data = parseResult(res);
71
+
72
+ expect(data.articles[0].status).toBe('excessive');
73
+ expect(data.articles[0].outbound_count).toBe(20);
74
+ expect(data.by_status.excessive).toBe(1);
75
+ });
76
+
77
+ it('FILTER — internal links NOT counted as external', async () => {
78
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
79
+ const html = `<p>
80
+ <a href="https://mysite.example.com/other-post/">internal</a>
81
+ <a href="https://external.com/page">external</a>
82
+ </p>`;
83
+ mockSuccess([makePost(1, html)]);
84
+
85
+ const res = await call('wp_audit_outbound_links');
86
+ const data = parseResult(res);
87
+
88
+ // Only the external link should be counted
89
+ expect(data.articles[0].outbound_count).toBe(1);
90
+ expect(data.articles[0].external_domains).toContain('external.com');
91
+ expect(data.articles[0].external_domains).not.toContain('mysite.example.com');
92
+ });
93
+
94
+ it('AUTH — authoritative domains identified correctly', async () => {
95
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
96
+ const html = `<p>
97
+ <a href="https://en.wikipedia.org/wiki/SEO">wiki</a>
98
+ <a href="https://www.cdc.gov/health">gov</a>
99
+ <a href="https://randomsite.com/page">random</a>
100
+ </p>`;
101
+ mockSuccess([makePost(1, html)]);
102
+
103
+ const res = await call('wp_audit_outbound_links');
104
+ const data = parseResult(res);
105
+
106
+ expect(data.articles[0].authoritative_count).toBe(2);
107
+ expect(data.posts_with_authoritative_sources).toBe(1);
108
+ // Top cited domains should include authoritative flag
109
+ const wikiDomain = data.top_cited_domains.find(d => d.domain === 'en.wikipedia.org');
110
+ expect(wikiDomain).toBeDefined();
111
+ expect(wikiDomain.is_authoritative).toBe(true);
112
+ });
113
+
114
+ it('RANKING — top_cited_domains sorted by count DESC', async () => {
115
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
116
+ const html = `<p>
117
+ <a href="https://siteA.com/1">a1</a>
118
+ <a href="https://siteB.com/1">b1</a>
119
+ <a href="https://siteB.com/2">b2</a>
120
+ <a href="https://siteC.com/1">c1</a>
121
+ <a href="https://siteC.com/2">c2</a>
122
+ <a href="https://siteC.com/3">c3</a>
123
+ </p>`;
124
+ mockSuccess([makePost(1, html)]);
125
+
126
+ const res = await call('wp_audit_outbound_links');
127
+ const data = parseResult(res);
128
+
129
+ expect(data.top_cited_domains[0].domain).toBe('sitec.com');
130
+ expect(data.top_cited_domains[0].count).toBe(3);
131
+ expect(data.top_cited_domains[1].domain).toBe('siteb.com');
132
+ expect(data.top_cited_domains[1].count).toBe(2);
133
+ });
134
+
135
+ it('RATIO — outbound_ratio calculated correctly', async () => {
136
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
137
+ // 100 words + 2 external links → ratio = 2 / (100/100) = 2.0
138
+ const words = Array(100).fill('word').join(' ');
139
+ const html = `<p>${words} <a href="https://ext1.com/p">ext1</a> <a href="https://ext2.com/p">ext2</a></p>`;
140
+ mockSuccess([makePost(1, html)]);
141
+
142
+ const res = await call('wp_audit_outbound_links');
143
+ const data = parseResult(res);
144
+
145
+ // Word count includes link text, so it's slightly more than 100
146
+ expect(data.articles[0].outbound_ratio).toBeGreaterThan(0);
147
+ expect(typeof data.articles[0].outbound_ratio).toBe('number');
148
+ });
149
+
150
+ it('AUDIT — logs success entry', async () => {
151
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
152
+ mockSuccess([makePost(1, '<p>Text.</p>')]);
153
+
154
+ await call('wp_audit_outbound_links');
155
+
156
+ const logs = getAuditLogs();
157
+ const entry = logs.find(l => l.tool === 'wp_audit_outbound_links');
158
+ expect(entry).toBeDefined();
159
+ expect(entry.status).toBe('success');
160
+ expect(entry.action).toBe('audit_seo');
161
+ });
162
+
163
+ it('ERROR — logs error on API failure', async () => {
164
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
165
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
166
+
167
+ const res = await call('wp_audit_outbound_links');
168
+ expect(res.isError).toBe(true);
169
+
170
+ const logs = getAuditLogs();
171
+ const entry = logs.find(l => l.tool === 'wp_audit_outbound_links');
172
+ expect(entry).toBeDefined();
173
+ expect(entry.status).toBe('error');
174
+ });
175
+ });
@@ -0,0 +1,173 @@
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
+ function makeTag(id, name, count = 5) {
22
+ return { id, name, slug: name.toLowerCase().replace(/\s+/g, '-'), count, description: '' };
23
+ }
24
+
25
+ function makeCategory(id, name, count = 5, description = 'A category about ' + name) {
26
+ return { id, name, slug: name.toLowerCase().replace(/\s+/g, '-'), count, description };
27
+ }
28
+
29
+ // =========================================================================
30
+ // wp_audit_taxonomies
31
+ // =========================================================================
32
+
33
+ describe('wp_audit_taxonomies', () => {
34
+ it('SUCCESS — no issues detected', async () => {
35
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
36
+ mockSuccess([makeTag(1, 'WordPress', 10), makeTag(2, 'SEO', 8)]);
37
+ mockSuccess([makeCategory(1, 'Tutorials', 15, 'Step by step tutorials'), makeCategory(2, 'News', 12, 'Latest news')]);
38
+
39
+ const res = await call('wp_audit_taxonomies');
40
+ const data = parseResult(res);
41
+
42
+ expect(data.total_issues).toBe(0);
43
+ expect(data.crawl_waste_score).toBe(0);
44
+ });
45
+
46
+ it('ISSUE — empty tag detected', async () => {
47
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
48
+ mockSuccess([makeTag(1, 'WordPress', 0), makeTag(2, 'SEO', 8)]);
49
+ mockSuccess([makeCategory(1, 'Tutorials', 15, 'Tutorials desc')]);
50
+
51
+ const res = await call('wp_audit_taxonomies');
52
+ const data = parseResult(res);
53
+
54
+ expect(data.tags.issues.empty).toHaveLength(1);
55
+ expect(data.tags.issues.empty[0].name).toBe('WordPress');
56
+ expect(data.tags.issues.empty[0].issue_type).toBe('empty');
57
+ });
58
+
59
+ it('ISSUE — single_post tag detected', async () => {
60
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
61
+ mockSuccess([makeTag(1, 'Niche Topic', 1)]);
62
+ mockSuccess([makeCategory(1, 'General', 10, 'General desc')]);
63
+
64
+ const res = await call('wp_audit_taxonomies');
65
+ const data = parseResult(res);
66
+
67
+ expect(data.tags.issues.single_post).toHaveLength(1);
68
+ expect(data.tags.issues.single_post[0].issue_type).toBe('single_post');
69
+ });
70
+
71
+ it('ISSUE — category missing_description detected', async () => {
72
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
73
+ mockSuccess([makeTag(1, 'SEO', 10)]);
74
+ mockSuccess([makeCategory(1, 'Tutorials', 15, ''), makeCategory(2, 'News', 12, 'Has description')]);
75
+
76
+ const res = await call('wp_audit_taxonomies');
77
+ const data = parseResult(res);
78
+
79
+ expect(data.categories.issues.missing_description).toHaveLength(1);
80
+ expect(data.categories.issues.missing_description[0].name).toBe('Tutorials');
81
+ });
82
+
83
+ it('DEDUP — exact duplicates grouped', async () => {
84
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
85
+ mockSuccess([makeTag(1, 'seo', 5), makeTag(2, 'SEO', 3), makeTag(3, 'WordPress', 10)]);
86
+ mockSuccess([makeCategory(1, 'General', 10, 'Desc')]);
87
+
88
+ const res = await call('wp_audit_taxonomies');
89
+ const data = parseResult(res);
90
+
91
+ expect(data.tags.issues.duplicate_groups.length).toBeGreaterThanOrEqual(1);
92
+ const group = data.tags.issues.duplicate_groups[0];
93
+ expect(group.terms.length).toBeGreaterThanOrEqual(2);
94
+ expect(group.similarity).toBe('exact');
95
+ });
96
+
97
+ it('DEDUP — near duplicates via Levenshtein grouped', async () => {
98
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
99
+ // 'wordpress' and 'wordpres' differ by 1 char, both >= 4 chars
100
+ mockSuccess([makeTag(1, 'WordPress', 5), makeTag(2, 'Wordpres', 3)]);
101
+ mockSuccess([makeCategory(1, 'General', 10, 'Desc')]);
102
+
103
+ const res = await call('wp_audit_taxonomies');
104
+ const data = parseResult(res);
105
+
106
+ expect(data.tags.issues.duplicate_groups.length).toBeGreaterThanOrEqual(1);
107
+ const group = data.tags.issues.duplicate_groups[0];
108
+ expect(group.similarity).toBe('near');
109
+ });
110
+
111
+ it('PARAM — check_tags: false skips tags', async () => {
112
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
113
+ mockSuccess([makeCategory(1, 'Tutorials', 15, 'Desc')]);
114
+
115
+ const res = await call('wp_audit_taxonomies', { check_tags: false });
116
+ const data = parseResult(res);
117
+
118
+ expect(data.tags).toBeUndefined();
119
+ expect(data.categories).toBeDefined();
120
+ });
121
+
122
+ it('PARAM — check_categories: false skips categories', async () => {
123
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
124
+ mockSuccess([makeTag(1, 'SEO', 10)]);
125
+
126
+ const res = await call('wp_audit_taxonomies', { check_categories: false });
127
+ const data = parseResult(res);
128
+
129
+ expect(data.categories).toBeUndefined();
130
+ expect(data.tags).toBeDefined();
131
+ });
132
+
133
+ it('SCORE — crawl_waste_score calculated correctly', async () => {
134
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
135
+ // 2 tags: 1 empty (issue) + 1 ok, 2 cats: 1 missing_desc (issue) + 1 ok → 2 issues / 4 terms = 50%
136
+ mockSuccess([makeTag(1, 'Empty', 0), makeTag(2, 'OK', 10)]);
137
+ mockSuccess([makeCategory(1, 'NoDesc', 5, ''), makeCategory(2, 'WithDesc', 5, 'Good')]);
138
+
139
+ const res = await call('wp_audit_taxonomies', { detect_duplicates: false });
140
+ const data = parseResult(res);
141
+
142
+ expect(data.crawl_waste_score).toBe(50);
143
+ });
144
+
145
+ it('AUDIT — logs success and error', async () => {
146
+ // Success
147
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
148
+ mockSuccess([makeTag(1, 'SEO', 10)]);
149
+ mockSuccess([makeCategory(1, 'General', 5, 'Desc')]);
150
+
151
+ await call('wp_audit_taxonomies');
152
+
153
+ let logs = getAuditLogs();
154
+ let entry = logs.find(l => l.tool === 'wp_audit_taxonomies');
155
+ expect(entry).toBeDefined();
156
+ expect(entry.status).toBe('success');
157
+ expect(entry.action).toBe('audit_seo');
158
+
159
+ // Error
160
+ fetch.mockReset();
161
+ consoleSpy.mockClear();
162
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
163
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
164
+
165
+ const res = await call('wp_audit_taxonomies');
166
+ expect(res.isError).toBe(true);
167
+
168
+ logs = getAuditLogs();
169
+ entry = logs.find(l => l.tool === 'wp_audit_taxonomies');
170
+ expect(entry).toBeDefined();
171
+ expect(entry.status).toBe('error');
172
+ });
173
+ });
@@ -0,0 +1,320 @@
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
+ import { stripHtml, extractLinksOnly, truncateContent, summarizePost, applyContentFormat } from '../../../src/utils/contentCompressor.js';
9
+
10
+ function call(name, args = {}) {
11
+ return handleToolCall(makeRequest(name, args));
12
+ }
13
+
14
+ let consoleSpy;
15
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
16
+ afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
17
+
18
+ // =========================================================================
19
+ // Helpers
20
+ // =========================================================================
21
+
22
+ function makeWpPost(id, content = '<p>Content</p>', extras = {}) {
23
+ return {
24
+ id,
25
+ title: { rendered: `Post ${id}` },
26
+ content: { rendered: content },
27
+ excerpt: { rendered: `Excerpt ${id}` },
28
+ status: 'publish',
29
+ date: '2025-06-01',
30
+ modified: '2025-06-02',
31
+ link: `https://test.example.com/post-${id}`,
32
+ slug: `post-${id}`,
33
+ categories: [1],
34
+ tags: [2],
35
+ author: 1,
36
+ featured_media: 0,
37
+ comment_status: 'open',
38
+ meta: {},
39
+ ...extras
40
+ };
41
+ }
42
+
43
+ // =========================================================================
44
+ // Unit tests — contentCompressor.js
45
+ // =========================================================================
46
+
47
+ describe('contentCompressor — stripHtml', () => {
48
+ it('strips tags and decodes entities', () => {
49
+ const html = '<p>Hello &amp; <strong>world</strong></p>';
50
+ expect(stripHtml(html)).toBe('Hello & world');
51
+ });
52
+
53
+ it('removes script and style blocks', () => {
54
+ const html = '<p>text</p><script>alert("x")</script><style>.a{}</style><p>more</p>';
55
+ expect(stripHtml(html)).not.toContain('alert');
56
+ expect(stripHtml(html)).not.toContain('.a{}');
57
+ expect(stripHtml(html)).toContain('text');
58
+ expect(stripHtml(html)).toContain('more');
59
+ });
60
+
61
+ it('returns empty string for falsy input', () => {
62
+ expect(stripHtml('')).toBe('');
63
+ expect(stripHtml(null)).toBe('');
64
+ expect(stripHtml(undefined)).toBe('');
65
+ });
66
+ });
67
+
68
+ describe('contentCompressor — extractLinksOnly', () => {
69
+ const siteUrl = 'https://mysite.example.com';
70
+
71
+ it('extracts internal links only', () => {
72
+ const html = `<p>
73
+ <a href="https://mysite.example.com/about/">About</a>
74
+ <a href="https://external.com/page">External</a>
75
+ </p>`;
76
+ const links = extractLinksOnly(html, siteUrl);
77
+ expect(links).toHaveLength(1);
78
+ expect(links[0].text).toBe('About');
79
+ expect(links[0].href).toBe('https://mysite.example.com/about/');
80
+ });
81
+
82
+ it('handles relative links', () => {
83
+ const html = '<a href="/contact/">Contact</a>';
84
+ const links = extractLinksOnly(html, siteUrl);
85
+ expect(links).toHaveLength(1);
86
+ expect(links[0].href).toBe('https://mysite.example.com/contact/');
87
+ });
88
+
89
+ it('uses [no anchor text] for empty anchors', () => {
90
+ const html = '<a href="https://mysite.example.com/page/"></a>';
91
+ const links = extractLinksOnly(html, siteUrl);
92
+ expect(links[0].text).toBe('[no anchor text]');
93
+ });
94
+
95
+ it('returns empty array for no html', () => {
96
+ expect(extractLinksOnly('', siteUrl)).toEqual([]);
97
+ expect(extractLinksOnly(null, siteUrl)).toEqual([]);
98
+ });
99
+ });
100
+
101
+ describe('contentCompressor — truncateContent', () => {
102
+ it('does not truncate short content', () => {
103
+ expect(truncateContent('short text', 100)).toBe('short text');
104
+ });
105
+
106
+ it('truncates at word boundary', () => {
107
+ const text = 'word1 word2 word3 word4 word5';
108
+ const result = truncateContent(text, 15);
109
+ expect(result).toContain('[truncated:');
110
+ expect(result).not.toContain('word4');
111
+ });
112
+
113
+ it('returns empty string for falsy input', () => {
114
+ expect(truncateContent('', 100)).toBe('');
115
+ expect(truncateContent(null, 100)).toBe('');
116
+ });
117
+
118
+ it('no limit when maxChars is 0', () => {
119
+ const longText = 'a'.repeat(50000);
120
+ expect(truncateContent(longText, 0)).toBe(longText);
121
+ });
122
+ });
123
+
124
+ describe('contentCompressor — summarizePost', () => {
125
+ it('filters to requested fields only', () => {
126
+ const post = { id: 1, title: 'T', content: 'C', slug: 's', status: 'publish', date: '2025-01-01' };
127
+ const result = summarizePost(post, ['id', 'title']);
128
+ expect(Object.keys(result)).toEqual(['id', 'title']);
129
+ expect(result.id).toBe(1);
130
+ });
131
+
132
+ it('returns full post when fields is empty or null', () => {
133
+ const post = { id: 1, title: 'T', content: 'C' };
134
+ expect(summarizePost(post, [])).toBe(post);
135
+ expect(summarizePost(post, null)).toBe(post);
136
+ });
137
+
138
+ it('ignores non-existent fields gracefully', () => {
139
+ const post = { id: 1, title: 'T' };
140
+ const result = summarizePost(post, ['id', 'nonexistent']);
141
+ expect(result).toEqual({ id: 1 });
142
+ });
143
+ });
144
+
145
+ describe('contentCompressor — applyContentFormat', () => {
146
+ const siteUrl = 'https://mysite.example.com';
147
+
148
+ it('html mode — truncates long content', () => {
149
+ const post = { id: 1, content: 'a'.repeat(20000) };
150
+ const result = applyContentFormat(post, 'html', siteUrl, 15000);
151
+ expect(result._content_format).toBe('html');
152
+ expect(result.content.length).toBeLessThan(20000);
153
+ expect(result.content).toContain('[truncated:');
154
+ });
155
+
156
+ it('text mode — strips HTML and truncates', () => {
157
+ const post = { id: 1, content: '<p>Hello <strong>world</strong></p>' };
158
+ const result = applyContentFormat(post, 'text', siteUrl, 15000);
159
+ expect(result._content_format).toBe('text');
160
+ expect(result.content).toContain('Hello world');
161
+ expect(result.content).not.toContain('<p>');
162
+ });
163
+
164
+ it('links_only mode — extracts internal links', () => {
165
+ const html = '<a href="https://mysite.example.com/page/">Page</a><a href="https://ext.com/x">Ext</a>';
166
+ const post = { id: 1, content: html };
167
+ const result = applyContentFormat(post, 'links_only', siteUrl);
168
+ expect(result._content_format).toBe('links_only');
169
+ expect(result.content).toBeNull();
170
+ expect(result.internal_links).toHaveLength(1);
171
+ expect(result.internal_links[0].text).toBe('Page');
172
+ });
173
+
174
+ it('returns post unchanged if no content', () => {
175
+ const post = { id: 1 };
176
+ expect(applyContentFormat(post, 'html', siteUrl)).toBe(post);
177
+ });
178
+ });
179
+
180
+ // =========================================================================
181
+ // Integration tests — wp_get_post with content_format & fields
182
+ // =========================================================================
183
+
184
+ describe('wp_get_post — content_format', () => {
185
+ it('DEFAULT — returns truncated HTML (html mode)', async () => {
186
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
187
+ const longContent = '<p>' + 'word '.repeat(5000) + '</p>';
188
+ mockSuccess(makeWpPost(1, longContent));
189
+
190
+ const res = await call('wp_get_post', { id: 1 });
191
+ const data = parseResult(res);
192
+
193
+ expect(data._content_format).toBe('html');
194
+ expect(data.id).toBe(1);
195
+ expect(data.title).toBe('Post 1');
196
+ });
197
+
198
+ it('TEXT — strips HTML tags', async () => {
199
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
200
+ mockSuccess(makeWpPost(1, '<p>Hello <strong>world</strong></p>'));
201
+
202
+ const res = await call('wp_get_post', { id: 1, content_format: 'text' });
203
+ const data = parseResult(res);
204
+
205
+ expect(data._content_format).toBe('text');
206
+ expect(data.content).toContain('Hello world');
207
+ expect(data.content).not.toContain('<p>');
208
+ });
209
+
210
+ it('LINKS_ONLY — extracts internal links', async () => {
211
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
212
+ const html = '<p>See <a href="https://test.example.com/other/">other</a> and <a href="https://ext.com/x">ext</a></p>';
213
+ mockSuccess(makeWpPost(1, html));
214
+
215
+ const res = await call('wp_get_post', { id: 1, content_format: 'links_only' });
216
+ const data = parseResult(res);
217
+
218
+ expect(data._content_format).toBe('links_only');
219
+ expect(data.content).toBeNull();
220
+ expect(data.internal_links).toHaveLength(1);
221
+ expect(data.internal_links[0].text).toBe('other');
222
+ expect(data.internal_links[0].href).toBe('https://test.example.com/other/');
223
+ });
224
+ });
225
+
226
+ describe('wp_get_post — fields filter', () => {
227
+ it('returns only requested fields', async () => {
228
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
229
+ mockSuccess(makeWpPost(1));
230
+
231
+ const res = await call('wp_get_post', { id: 1, fields: ['id', 'title', 'slug'] });
232
+ const data = parseResult(res);
233
+
234
+ expect(Object.keys(data).sort()).toEqual(['id', 'slug', 'title']);
235
+ expect(data.id).toBe(1);
236
+ expect(data.title).toBe('Post 1');
237
+ expect(data.slug).toBe('post-1');
238
+ });
239
+
240
+ it('fields + content_format work together', async () => {
241
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
242
+ mockSuccess(makeWpPost(1, '<p>Hello <b>world</b></p>'));
243
+
244
+ const res = await call('wp_get_post', { id: 1, fields: ['id', 'content'], content_format: 'text' });
245
+ const data = parseResult(res);
246
+
247
+ expect(Object.keys(data).sort()).toEqual(['content', 'id']);
248
+ expect(data.content).toContain('Hello world');
249
+ expect(data.content).not.toContain('<p>');
250
+ });
251
+ });
252
+
253
+ // =========================================================================
254
+ // Integration tests — wp_list_posts with mode
255
+ // =========================================================================
256
+
257
+ describe('wp_list_posts — mode', () => {
258
+ function makeWpListPost(id) {
259
+ return {
260
+ id,
261
+ title: { rendered: `Post ${id}` },
262
+ status: 'publish',
263
+ date: '2025-06-01',
264
+ modified: '2025-06-02',
265
+ link: `https://test.example.com/post-${id}`,
266
+ slug: `post-${id}`,
267
+ author: 1,
268
+ categories: [1],
269
+ tags: [],
270
+ excerpt: { rendered: `Excerpt for post ${id}` }
271
+ };
272
+ }
273
+
274
+ it('FULL — returns all fields (default)', async () => {
275
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
276
+ mockSuccess([makeWpListPost(1), makeWpListPost(2)]);
277
+
278
+ const res = await call('wp_list_posts');
279
+ const data = parseResult(res);
280
+
281
+ expect(data.total).toBe(2);
282
+ expect(data.posts[0].excerpt).toBeDefined();
283
+ expect(data.posts[0].author).toBeDefined();
284
+ });
285
+
286
+ it('IDS_ONLY — returns flat array of IDs', async () => {
287
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
288
+ mockSuccess([makeWpListPost(1), makeWpListPost(2), makeWpListPost(3)]);
289
+
290
+ const res = await call('wp_list_posts', { mode: 'ids_only' });
291
+ const data = parseResult(res);
292
+
293
+ expect(data.mode).toBe('ids_only');
294
+ expect(data.ids).toEqual([1, 2, 3]);
295
+ expect(data.posts).toBeUndefined();
296
+ });
297
+
298
+ it('SUMMARY — returns id/title/slug/date/status/link only', async () => {
299
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
300
+ mockSuccess([makeWpListPost(1)]);
301
+
302
+ const res = await call('wp_list_posts', { mode: 'summary' });
303
+ const data = parseResult(res);
304
+
305
+ expect(data.mode).toBe('summary');
306
+ expect(data.posts).toHaveLength(1);
307
+ const p = data.posts[0];
308
+ expect(Object.keys(p).sort()).toEqual(['date', 'id', 'link', 'slug', 'status', 'title']);
309
+ expect(p.excerpt).toBeUndefined();
310
+ expect(p.author).toBeUndefined();
311
+ expect(p.categories).toBeUndefined();
312
+ });
313
+
314
+ it('VALIDATION — rejects invalid mode', async () => {
315
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
316
+
317
+ const res = await call('wp_list_posts', { mode: 'invalid' });
318
+ expect(res.isError).toBe(true);
319
+ });
320
+ });