@adsim/wordpress-mcp-server 3.1.0 → 4.5.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.
- package/README.md +564 -176
- package/dxt/manifest.json +93 -9
- package/index.js +3624 -36
- package/package.json +1 -1
- package/src/confirmationToken.js +64 -0
- package/src/contentAnalyzer.js +476 -0
- package/src/htmlParser.js +80 -0
- package/src/linkUtils.js +158 -0
- package/src/pluginDetector.js +158 -0
- package/src/utils/contentCompressor.js +116 -0
- package/src/woocommerceClient.js +88 -0
- package/tests/unit/contentAnalyzer.test.js +397 -0
- package/tests/unit/pluginDetector.test.js +167 -0
- package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
- package/tests/unit/tools/approval.test.js +251 -0
- package/tests/unit/tools/auditCanonicals.test.js +149 -0
- package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
- package/tests/unit/tools/auditMediaSeo.test.js +123 -0
- package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
- package/tests/unit/tools/auditTaxonomies.test.js +173 -0
- package/tests/unit/tools/contentCompressor.test.js +320 -0
- package/tests/unit/tools/contentIntelligence.test.js +2168 -0
- package/tests/unit/tools/destructive.test.js +246 -0
- package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
- package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
- package/tests/unit/tools/findOrphanPages.test.js +145 -0
- package/tests/unit/tools/findThinContent.test.js +145 -0
- package/tests/unit/tools/internalLinks.test.js +283 -0
- package/tests/unit/tools/perTargetControls.test.js +228 -0
- package/tests/unit/tools/pluginIntelligence.test.js +864 -0
- package/tests/unit/tools/site.test.js +6 -1
- package/tests/unit/tools/woocommerce.test.js +344 -0
- package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
- package/tests/unit/tools/woocommerceWrite.test.js +323 -0
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
function makePost(content) {
|
|
18
|
+
return {
|
|
19
|
+
id: 1, title: { rendered: 'Test Post' }, slug: 'test-post', status: 'publish',
|
|
20
|
+
link: 'https://test.example.com/test-post/',
|
|
21
|
+
content: { rendered: content },
|
|
22
|
+
meta: {}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// =========================================================================
|
|
27
|
+
// wp_audit_heading_structure
|
|
28
|
+
// =========================================================================
|
|
29
|
+
|
|
30
|
+
describe('wp_audit_heading_structure', () => {
|
|
31
|
+
it('SUCCESS — clean heading structure', async () => {
|
|
32
|
+
mockSuccess(makePost('<h2>Introduction</h2><p>Text.</p><h3>Details</h3><p>More text.</p><h2>Conclusion</h2><p>End.</p>'));
|
|
33
|
+
|
|
34
|
+
const res = await call('wp_audit_heading_structure', { id: 1 });
|
|
35
|
+
const data = parseResult(res);
|
|
36
|
+
|
|
37
|
+
expect(data.score).toBe(100);
|
|
38
|
+
expect(data.issues).toHaveLength(0);
|
|
39
|
+
expect(data.headings).toHaveLength(3);
|
|
40
|
+
expect(data.summary.h2_count).toBe(2);
|
|
41
|
+
expect(data.summary.h3_count).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('ISSUE — H1 in content', async () => {
|
|
45
|
+
mockSuccess(makePost('<h1>Bad Title</h1><h2>Section</h2><p>Text.</p>'));
|
|
46
|
+
|
|
47
|
+
const res = await call('wp_audit_heading_structure', { id: 1 });
|
|
48
|
+
const data = parseResult(res);
|
|
49
|
+
|
|
50
|
+
const h1Issue = data.issues.find(i => i.type === 'h1_in_content');
|
|
51
|
+
expect(h1Issue).toBeDefined();
|
|
52
|
+
expect(h1Issue.count).toBe(1);
|
|
53
|
+
expect(data.score).toBeLessThan(100);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ISSUE — heading level skip', async () => {
|
|
57
|
+
mockSuccess(makePost('<h2>Section</h2><h4>Subsub</h4><p>Text.</p>'));
|
|
58
|
+
|
|
59
|
+
const res = await call('wp_audit_heading_structure', { id: 1 });
|
|
60
|
+
const data = parseResult(res);
|
|
61
|
+
|
|
62
|
+
const skipIssue = data.issues.find(i => i.type === 'heading_skip');
|
|
63
|
+
expect(skipIssue).toBeDefined();
|
|
64
|
+
expect(skipIssue.from).toBe('H2');
|
|
65
|
+
expect(skipIssue.to).toBe('H4');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ISSUE — empty heading', async () => {
|
|
69
|
+
mockSuccess(makePost('<h2></h2><h2>Good Heading</h2>'));
|
|
70
|
+
|
|
71
|
+
const res = await call('wp_audit_heading_structure', { id: 1 });
|
|
72
|
+
const data = parseResult(res);
|
|
73
|
+
|
|
74
|
+
const emptyIssue = data.issues.find(i => i.type === 'empty_heading');
|
|
75
|
+
expect(emptyIssue).toBeDefined();
|
|
76
|
+
expect(emptyIssue.count).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('ISSUE — keyword stuffing in H2s', async () => {
|
|
80
|
+
mockSuccess(makePost(
|
|
81
|
+
'<h2>Best SEO Tips</h2><h2>SEO Tips Guide</h2><h2>SEO Tips Tutorial</h2><h2>More Stuff</h2>'
|
|
82
|
+
));
|
|
83
|
+
|
|
84
|
+
const res = await call('wp_audit_heading_structure', { id: 1, focus_keyword: 'SEO Tips' });
|
|
85
|
+
const data = parseResult(res);
|
|
86
|
+
|
|
87
|
+
const stuffing = data.issues.find(i => i.type === 'keyword_stuffing');
|
|
88
|
+
expect(stuffing).toBeDefined();
|
|
89
|
+
expect(stuffing.count).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ISSUE — no H2 headings', async () => {
|
|
93
|
+
mockSuccess(makePost('<h3>Minor heading</h3><p>Text.</p>'));
|
|
94
|
+
|
|
95
|
+
const res = await call('wp_audit_heading_structure', { id: 1 });
|
|
96
|
+
const data = parseResult(res);
|
|
97
|
+
|
|
98
|
+
const noH2 = data.issues.find(i => i.type === 'no_h2');
|
|
99
|
+
expect(noH2).toBeDefined();
|
|
100
|
+
expect(data.summary.h2_count).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('ISSUE — keyword absent from all H2s', async () => {
|
|
104
|
+
mockSuccess(makePost('<h2>Introduction</h2><h2>Conclusion</h2>'));
|
|
105
|
+
|
|
106
|
+
const res = await call('wp_audit_heading_structure', { id: 1, focus_keyword: 'WordPress' });
|
|
107
|
+
const data = parseResult(res);
|
|
108
|
+
|
|
109
|
+
const absent = data.issues.find(i => i.type === 'keyword_absent_h2');
|
|
110
|
+
expect(absent).toBeDefined();
|
|
111
|
+
expect(absent.message).toContain('WordPress');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('SUCCESS — correct score with multiple deductions', async () => {
|
|
115
|
+
// H1 in content: -20, heading skip (H1→H3): -10, empty heading: -10, no_h2: -15 → 45
|
|
116
|
+
mockSuccess(makePost('<h1>Bad H1</h1><h3>Skip</h3><h3></h3>'));
|
|
117
|
+
|
|
118
|
+
const res = await call('wp_audit_heading_structure', { id: 1 });
|
|
119
|
+
const data = parseResult(res);
|
|
120
|
+
|
|
121
|
+
expect(data.score).toBe(45);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('SUCCESS — multiple issues combined', async () => {
|
|
125
|
+
mockSuccess(makePost(
|
|
126
|
+
'<h1>Bad</h1><h2>SEO SEO SEO</h2><h4>Skip</h4><h2></h2><h2>SEO keyword</h2><h2>SEO again</h2>'
|
|
127
|
+
));
|
|
128
|
+
|
|
129
|
+
const res = await call('wp_audit_heading_structure', { id: 1, focus_keyword: 'SEO' });
|
|
130
|
+
const data = parseResult(res);
|
|
131
|
+
|
|
132
|
+
const issueTypes = data.issues.map(i => i.type);
|
|
133
|
+
expect(issueTypes).toContain('h1_in_content');
|
|
134
|
+
expect(issueTypes).toContain('heading_skip');
|
|
135
|
+
expect(issueTypes).toContain('empty_heading');
|
|
136
|
+
expect(data.score).toBeLessThan(100);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('AUDIT — logs audit entry', async () => {
|
|
140
|
+
mockSuccess(makePost('<h2>Hello</h2>'));
|
|
141
|
+
|
|
142
|
+
await call('wp_audit_heading_structure', { id: 1 });
|
|
143
|
+
|
|
144
|
+
const logs = getAuditLogs();
|
|
145
|
+
const entry = logs.find(l => l.tool === 'wp_audit_heading_structure');
|
|
146
|
+
expect(entry).toBeDefined();
|
|
147
|
+
expect(entry.status).toBe('success');
|
|
148
|
+
expect(entry.action).toBe('audit_heading_structure');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
// wp_audit_media_seo
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
describe('wp_audit_media_seo', () => {
|
|
22
|
+
it('SUCCESS — empty audit when no images', async () => {
|
|
23
|
+
mockSuccess([]);
|
|
24
|
+
|
|
25
|
+
const res = await call('wp_audit_media_seo');
|
|
26
|
+
const data = parseResult(res);
|
|
27
|
+
|
|
28
|
+
expect(data.summary.total_audited).toBe(0);
|
|
29
|
+
expect(data.summary.average_score).toBe(0);
|
|
30
|
+
expect(data.media).toHaveLength(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('SUCCESS — detects missing alt text', async () => {
|
|
34
|
+
mockSuccess([
|
|
35
|
+
{ id: 1, title: { rendered: 'Photo' }, source_url: 'https://test.example.com/wp-content/uploads/photo.jpg', alt_text: '', media_details: {} }
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const res = await call('wp_audit_media_seo');
|
|
39
|
+
const data = parseResult(res);
|
|
40
|
+
|
|
41
|
+
expect(data.media[0].issues).toContain('missing_alt');
|
|
42
|
+
expect(data.summary.issues_breakdown.missing_alt).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('SUCCESS — detects filename used as alt text', async () => {
|
|
46
|
+
mockSuccess([
|
|
47
|
+
{ id: 2, title: { rendered: 'My Photo' }, source_url: 'https://test.example.com/wp-content/uploads/my-photo.jpg', alt_text: 'my photo', media_details: {} }
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const res = await call('wp_audit_media_seo');
|
|
51
|
+
const data = parseResult(res);
|
|
52
|
+
|
|
53
|
+
expect(data.media[0].issues).toContain('filename_as_alt');
|
|
54
|
+
expect(data.summary.issues_breakdown.filename_as_alt).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('SUCCESS — detects alt text too short', async () => {
|
|
58
|
+
mockSuccess([
|
|
59
|
+
{ id: 3, title: { rendered: 'Icon' }, source_url: 'https://test.example.com/wp-content/uploads/icon.png', alt_text: 'img', media_details: {} }
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const res = await call('wp_audit_media_seo');
|
|
63
|
+
const data = parseResult(res);
|
|
64
|
+
|
|
65
|
+
expect(data.media[0].issues).toContain('alt_too_short');
|
|
66
|
+
expect(data.summary.issues_breakdown.alt_too_short).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('SUCCESS — scans inline images when post_id provided', async () => {
|
|
70
|
+
// 1. GET media library (empty)
|
|
71
|
+
mockSuccess([]);
|
|
72
|
+
// 2. GET post content with inline images
|
|
73
|
+
mockSuccess({
|
|
74
|
+
id: 10, title: { rendered: 'Blog Post' },
|
|
75
|
+
content: { rendered: '<p>Text</p><img src="https://test.example.com/inline.jpg" alt=""><img src="https://test.example.com/good.jpg" alt="A good image description">' }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const res = await call('wp_audit_media_seo', { post_id: 10 });
|
|
79
|
+
const data = parseResult(res);
|
|
80
|
+
|
|
81
|
+
expect(data.inline_images).toHaveLength(2);
|
|
82
|
+
expect(data.inline_images[0].alt).toBe('');
|
|
83
|
+
expect(data.inline_images[1].alt).toBe('A good image description');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('SUCCESS — calculates correct scores', async () => {
|
|
87
|
+
mockSuccess([
|
|
88
|
+
{ id: 1, title: { rendered: 'Good' }, source_url: 'https://test.example.com/wp-content/uploads/landscape.jpg', alt_text: 'Beautiful mountain landscape at sunset', media_details: {} },
|
|
89
|
+
{ id: 2, title: { rendered: 'Bad' }, source_url: 'https://test.example.com/wp-content/uploads/photo.jpg', alt_text: '', media_details: {} }
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const res = await call('wp_audit_media_seo');
|
|
93
|
+
const data = parseResult(res);
|
|
94
|
+
|
|
95
|
+
expect(data.media[0].score).toBe(100);
|
|
96
|
+
expect(data.media[1].score).toBe(60);
|
|
97
|
+
expect(data.summary.average_score).toBe(80);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('AUDIT — logs success entry', async () => {
|
|
101
|
+
mockSuccess([]);
|
|
102
|
+
|
|
103
|
+
await call('wp_audit_media_seo');
|
|
104
|
+
|
|
105
|
+
const logs = getAuditLogs();
|
|
106
|
+
const entry = logs.find(l => l.tool === 'wp_audit_media_seo');
|
|
107
|
+
expect(entry).toBeDefined();
|
|
108
|
+
expect(entry.status).toBe('success');
|
|
109
|
+
expect(entry.action).toBe('audit_media_seo');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ERROR — logs error on API failure', async () => {
|
|
113
|
+
mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
|
|
114
|
+
|
|
115
|
+
const res = await call('wp_audit_media_seo');
|
|
116
|
+
expect(res.isError).toBe(true);
|
|
117
|
+
|
|
118
|
+
const logs = getAuditLogs();
|
|
119
|
+
const entry = logs.find(l => l.tool === 'wp_audit_media_seo');
|
|
120
|
+
expect(entry).toBeDefined();
|
|
121
|
+
expect(entry.status).toBe('error');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -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
|
+
});
|