@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.
- package/README.md +543 -176
- package/dxt/build-mcpb.sh +7 -0
- package/dxt/manifest.json +189 -0
- package/index.js +3156 -36
- package/package.json +3 -2
- 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/utils/contentCompressor.js +116 -0
- package/src/woocommerceClient.js +88 -0
- package/tests/unit/contentAnalyzer.test.js +397 -0
- package/tests/unit/dxt/manifest.test.js +78 -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/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,251 @@
|
|
|
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, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ────────────────────────────────────────────────────────────
|
|
14
|
+
// wp_submit_for_review
|
|
15
|
+
// ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe('wp_submit_for_review', () => {
|
|
18
|
+
let consoleSpy;
|
|
19
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
20
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
21
|
+
|
|
22
|
+
it('transitions draft → pending on success', async () => {
|
|
23
|
+
mockSuccess({ id: 10, title: { rendered: 'Draft Post' }, status: 'draft', meta: {} });
|
|
24
|
+
mockSuccess({ id: 10, title: { rendered: 'Draft Post' }, status: 'pending', link: 'https://test.example.com/?p=10' });
|
|
25
|
+
|
|
26
|
+
const result = await call('wp_submit_for_review', { id: 10, note: 'Please review' });
|
|
27
|
+
const data = parseResult(result);
|
|
28
|
+
|
|
29
|
+
expect(data.success).toBe(true);
|
|
30
|
+
expect(data.post.id).toBe(10);
|
|
31
|
+
expect(data.post.status).toBe('pending');
|
|
32
|
+
|
|
33
|
+
const logs = getAuditLogs(consoleSpy);
|
|
34
|
+
const entry = logs.find(l => l.tool === 'wp_submit_for_review');
|
|
35
|
+
expect(entry).toBeDefined();
|
|
36
|
+
expect(entry.status).toBe('success');
|
|
37
|
+
expect(entry.action).toBe('submit_for_review');
|
|
38
|
+
expect(entry.target).toBe(10);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns error if post is not in draft status', async () => {
|
|
42
|
+
mockSuccess({ id: 10, title: { rendered: 'Published Post' }, status: 'publish', meta: {} });
|
|
43
|
+
|
|
44
|
+
const result = await call('wp_submit_for_review', { id: 10 });
|
|
45
|
+
|
|
46
|
+
expect(result.isError).toBe(true);
|
|
47
|
+
expect(result.content[0].text).toContain('draft');
|
|
48
|
+
|
|
49
|
+
const logs = getAuditLogs(consoleSpy);
|
|
50
|
+
const entry = logs.find(l => l.tool === 'wp_submit_for_review');
|
|
51
|
+
expect(entry).toBeDefined();
|
|
52
|
+
expect(entry.status).toBe('error');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
56
|
+
process.env.WP_READ_ONLY = 'true';
|
|
57
|
+
try {
|
|
58
|
+
const result = await call('wp_submit_for_review', { id: 10 });
|
|
59
|
+
|
|
60
|
+
expect(result.isError).toBe(true);
|
|
61
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
62
|
+
|
|
63
|
+
const logs = getAuditLogs(consoleSpy);
|
|
64
|
+
const entry = logs.find(l => l.tool === 'wp_submit_for_review');
|
|
65
|
+
expect(entry).toBeDefined();
|
|
66
|
+
expect(entry.status).toBe('blocked');
|
|
67
|
+
} finally {
|
|
68
|
+
delete process.env.WP_READ_ONLY;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ────────────────────────────────────────────────────────────
|
|
74
|
+
// wp_approve_post
|
|
75
|
+
// ────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('wp_approve_post', () => {
|
|
78
|
+
let consoleSpy;
|
|
79
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
80
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
81
|
+
|
|
82
|
+
it('transitions pending → publish on success', async () => {
|
|
83
|
+
mockSuccess({ id: 10, title: { rendered: 'Pending Post' }, status: 'pending', meta: {} });
|
|
84
|
+
mockSuccess({ id: 10, title: { rendered: 'Pending Post' }, status: 'publish', link: 'https://test.example.com/pending-post' });
|
|
85
|
+
|
|
86
|
+
const result = await call('wp_approve_post', { id: 10 });
|
|
87
|
+
const data = parseResult(result);
|
|
88
|
+
|
|
89
|
+
expect(data.success).toBe(true);
|
|
90
|
+
expect(data.post.id).toBe(10);
|
|
91
|
+
expect(data.post.status).toBe('publish');
|
|
92
|
+
|
|
93
|
+
const logs = getAuditLogs(consoleSpy);
|
|
94
|
+
const entry = logs.find(l => l.tool === 'wp_approve_post');
|
|
95
|
+
expect(entry).toBeDefined();
|
|
96
|
+
expect(entry.status).toBe('success');
|
|
97
|
+
expect(entry.action).toBe('approve');
|
|
98
|
+
expect(entry.target).toBe(10);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns error if post is not in pending status', async () => {
|
|
102
|
+
mockSuccess({ id: 10, title: { rendered: 'Draft Post' }, status: 'draft', meta: {} });
|
|
103
|
+
|
|
104
|
+
const result = await call('wp_approve_post', { id: 10 });
|
|
105
|
+
|
|
106
|
+
expect(result.isError).toBe(true);
|
|
107
|
+
expect(result.content[0].text).toContain('pending');
|
|
108
|
+
|
|
109
|
+
const logs = getAuditLogs(consoleSpy);
|
|
110
|
+
const entry = logs.find(l => l.tool === 'wp_approve_post');
|
|
111
|
+
expect(entry).toBeDefined();
|
|
112
|
+
expect(entry.status).toBe('error');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
116
|
+
process.env.WP_READ_ONLY = 'true';
|
|
117
|
+
try {
|
|
118
|
+
const result = await call('wp_approve_post', { id: 10 });
|
|
119
|
+
|
|
120
|
+
expect(result.isError).toBe(true);
|
|
121
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
122
|
+
|
|
123
|
+
const logs = getAuditLogs(consoleSpy);
|
|
124
|
+
const entry = logs.find(l => l.tool === 'wp_approve_post');
|
|
125
|
+
expect(entry).toBeDefined();
|
|
126
|
+
expect(entry.status).toBe('blocked');
|
|
127
|
+
} finally {
|
|
128
|
+
delete process.env.WP_READ_ONLY;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('is blocked by WP_DRAFT_ONLY', async () => {
|
|
133
|
+
process.env.WP_DRAFT_ONLY = 'true';
|
|
134
|
+
try {
|
|
135
|
+
const result = await call('wp_approve_post', { id: 10 });
|
|
136
|
+
|
|
137
|
+
expect(result.isError).toBe(true);
|
|
138
|
+
expect(result.content[0].text).toContain('DRAFT-ONLY');
|
|
139
|
+
|
|
140
|
+
const logs = getAuditLogs(consoleSpy);
|
|
141
|
+
const entry = logs.find(l => l.tool === 'wp_approve_post');
|
|
142
|
+
expect(entry).toBeDefined();
|
|
143
|
+
expect(entry.status).toBe('blocked');
|
|
144
|
+
} finally {
|
|
145
|
+
delete process.env.WP_DRAFT_ONLY;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ────────────────────────────────────────────────────────────
|
|
151
|
+
// wp_reject_post
|
|
152
|
+
// ────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('wp_reject_post', () => {
|
|
155
|
+
let consoleSpy;
|
|
156
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
157
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
158
|
+
|
|
159
|
+
it('transitions pending → draft with reason and incremented rejection count', async () => {
|
|
160
|
+
mockSuccess({ id: 10, title: { rendered: 'Pending Post' }, status: 'pending', meta: { _mcp_rejection_count: 1 } });
|
|
161
|
+
mockSuccess({ id: 10, title: { rendered: 'Pending Post' }, status: 'draft' });
|
|
162
|
+
|
|
163
|
+
const result = await call('wp_reject_post', { id: 10, reason: 'Needs more detail' });
|
|
164
|
+
const data = parseResult(result);
|
|
165
|
+
|
|
166
|
+
expect(data.success).toBe(true);
|
|
167
|
+
expect(data.post.id).toBe(10);
|
|
168
|
+
expect(data.post.status).toBe('draft');
|
|
169
|
+
expect(data.rejection.reason).toBe('Needs more detail');
|
|
170
|
+
expect(data.rejection.count).toBe(2);
|
|
171
|
+
|
|
172
|
+
const logs = getAuditLogs(consoleSpy);
|
|
173
|
+
const entry = logs.find(l => l.tool === 'wp_reject_post');
|
|
174
|
+
expect(entry).toBeDefined();
|
|
175
|
+
expect(entry.status).toBe('success');
|
|
176
|
+
expect(entry.action).toBe('reject');
|
|
177
|
+
expect(entry.target).toBe(10);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns error if post is not in pending status', async () => {
|
|
181
|
+
mockSuccess({ id: 10, title: { rendered: 'Draft Post' }, status: 'draft', meta: {} });
|
|
182
|
+
|
|
183
|
+
const result = await call('wp_reject_post', { id: 10, reason: 'Bad' });
|
|
184
|
+
|
|
185
|
+
expect(result.isError).toBe(true);
|
|
186
|
+
expect(result.content[0].text).toContain('pending');
|
|
187
|
+
|
|
188
|
+
const logs = getAuditLogs(consoleSpy);
|
|
189
|
+
const entry = logs.find(l => l.tool === 'wp_reject_post');
|
|
190
|
+
expect(entry).toBeDefined();
|
|
191
|
+
expect(entry.status).toBe('error');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
195
|
+
process.env.WP_READ_ONLY = 'true';
|
|
196
|
+
try {
|
|
197
|
+
const result = await call('wp_reject_post', { id: 10, reason: 'Bad' });
|
|
198
|
+
|
|
199
|
+
expect(result.isError).toBe(true);
|
|
200
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
201
|
+
|
|
202
|
+
const logs = getAuditLogs(consoleSpy);
|
|
203
|
+
const entry = logs.find(l => l.tool === 'wp_reject_post');
|
|
204
|
+
expect(entry).toBeDefined();
|
|
205
|
+
expect(entry.status).toBe('blocked');
|
|
206
|
+
} finally {
|
|
207
|
+
delete process.env.WP_READ_ONLY;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ────────────────────────────────────────────────────────────
|
|
213
|
+
// WP_REQUIRE_APPROVAL governance
|
|
214
|
+
// ────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe('WP_REQUIRE_APPROVAL=true', () => {
|
|
217
|
+
let consoleSpy;
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
fetch.mockReset();
|
|
220
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
221
|
+
process.env.WP_REQUIRE_APPROVAL = 'true';
|
|
222
|
+
});
|
|
223
|
+
afterEach(() => {
|
|
224
|
+
consoleSpy.mockRestore();
|
|
225
|
+
delete process.env.WP_REQUIRE_APPROVAL;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('blocks wp_update_post when status is publish', async () => {
|
|
229
|
+
const result = await call('wp_update_post', { id: 1, status: 'publish' });
|
|
230
|
+
|
|
231
|
+
expect(result.isError).toBe(true);
|
|
232
|
+
expect(result.content[0].text).toContain('APPROVAL REQUIRED');
|
|
233
|
+
|
|
234
|
+
const logs = getAuditLogs(consoleSpy);
|
|
235
|
+
const entry = logs.find(l => l.tool === 'wp_update_post');
|
|
236
|
+
expect(entry).toBeDefined();
|
|
237
|
+
expect(entry.status).toBe('blocked');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('blocks wp_create_post when status is publish', async () => {
|
|
241
|
+
const result = await call('wp_create_post', { title: 'T', content: 'C', status: 'publish' });
|
|
242
|
+
|
|
243
|
+
expect(result.isError).toBe(true);
|
|
244
|
+
expect(result.content[0].text).toContain('APPROVAL REQUIRED');
|
|
245
|
+
|
|
246
|
+
const logs = getAuditLogs(consoleSpy);
|
|
247
|
+
const entry = logs.find(l => l.tool === 'wp_create_post');
|
|
248
|
+
expect(entry).toBeDefined();
|
|
249
|
+
expect(entry.status).toBe('blocked');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
// Helper
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
function makePost(id, canonical, link) {
|
|
22
|
+
return {
|
|
23
|
+
id, title: { rendered: `Post ${id}` },
|
|
24
|
+
link: link || `https://mysite.example.com/post-${id}/`,
|
|
25
|
+
meta: canonical !== undefined ? { rank_math_canonical_url: canonical } : {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// =========================================================================
|
|
30
|
+
// wp_audit_canonicals
|
|
31
|
+
// =========================================================================
|
|
32
|
+
|
|
33
|
+
describe('wp_audit_canonicals', () => {
|
|
34
|
+
it('SUCCESS — all canonicals valid', async () => {
|
|
35
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
36
|
+
mockSuccess([
|
|
37
|
+
makePost(1, 'https://mysite.example.com/post-1/'),
|
|
38
|
+
makePost(2, 'https://mysite.example.com/post-2/')
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const res = await call('wp_audit_canonicals');
|
|
42
|
+
const data = parseResult(res);
|
|
43
|
+
|
|
44
|
+
expect(data.total_issues).toBe(0);
|
|
45
|
+
expect(data.total_audited).toBe(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('ISSUE — missing_canonical detected', async () => {
|
|
49
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
50
|
+
mockSuccess([makePost(1, '')]);
|
|
51
|
+
|
|
52
|
+
const res = await call('wp_audit_canonicals');
|
|
53
|
+
const data = parseResult(res);
|
|
54
|
+
|
|
55
|
+
expect(data.issues_by_type.missing_canonical).toBe(1);
|
|
56
|
+
expect(data.audits[0].issues).toContain('missing_canonical');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('ISSUE — http_on_https_site detected', async () => {
|
|
60
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
61
|
+
mockSuccess([makePost(1, 'http://test.example.com/post-1/')]);
|
|
62
|
+
|
|
63
|
+
const res = await call('wp_audit_canonicals');
|
|
64
|
+
const data = parseResult(res);
|
|
65
|
+
|
|
66
|
+
expect(data.issues_by_type.http_on_https_site).toBe(1);
|
|
67
|
+
expect(data.audits[0].issues).toContain('http_on_https_site');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('ISSUE — staging_url detected', async () => {
|
|
71
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
72
|
+
mockSuccess([makePost(1, 'https://staging.example.com/post-1/')]);
|
|
73
|
+
|
|
74
|
+
const res = await call('wp_audit_canonicals');
|
|
75
|
+
const data = parseResult(res);
|
|
76
|
+
|
|
77
|
+
expect(data.issues_by_type.staging_url).toBe(1);
|
|
78
|
+
expect(data.audits[0].issues).toContain('staging_url');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ISSUE — wrong_domain detected', async () => {
|
|
82
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
83
|
+
mockSuccess([makePost(1, 'https://other-domain.com/post-1/')]);
|
|
84
|
+
|
|
85
|
+
const res = await call('wp_audit_canonicals');
|
|
86
|
+
const data = parseResult(res);
|
|
87
|
+
|
|
88
|
+
expect(data.issues_by_type.wrong_domain).toBe(1);
|
|
89
|
+
expect(data.audits[0].issues).toContain('wrong_domain');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ISSUE — trailing_slash_mismatch detected', async () => {
|
|
93
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
94
|
+
// Post link has trailing slash, canonical does not
|
|
95
|
+
mockSuccess([makePost(1, 'https://mysite.example.com/post-1', 'https://mysite.example.com/post-1/')]);
|
|
96
|
+
|
|
97
|
+
const res = await call('wp_audit_canonicals');
|
|
98
|
+
const data = parseResult(res);
|
|
99
|
+
|
|
100
|
+
expect(data.issues_by_type.trailing_slash_mismatch).toBe(1);
|
|
101
|
+
expect(data.audits[0].issues).toContain('trailing_slash_mismatch');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('SUCCESS — SEO plugin auto-detected (RankMath vs Yoast)', async () => {
|
|
105
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
106
|
+
|
|
107
|
+
// RankMath detection
|
|
108
|
+
mockSuccess([makePost(1, 'https://mysite.example.com/post-1/')]);
|
|
109
|
+
let res = await call('wp_audit_canonicals');
|
|
110
|
+
let data = parseResult(res);
|
|
111
|
+
expect(data.seo_plugin_detected).toBe('RankMath');
|
|
112
|
+
|
|
113
|
+
// Yoast detection
|
|
114
|
+
fetch.mockReset();
|
|
115
|
+
mockSuccess([{
|
|
116
|
+
id: 2, title: { rendered: 'Post 2' }, link: 'https://mysite.example.com/post-2/',
|
|
117
|
+
meta: { _yoast_wpseo_canonical: 'https://mysite.example.com/post-2/' }
|
|
118
|
+
}]);
|
|
119
|
+
res = await call('wp_audit_canonicals');
|
|
120
|
+
data = parseResult(res);
|
|
121
|
+
expect(data.seo_plugin_detected).toBe('Yoast');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('AUDIT — logs success entry', async () => {
|
|
125
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
126
|
+
mockSuccess([makePost(1, 'https://mysite.example.com/post-1/')]);
|
|
127
|
+
|
|
128
|
+
await call('wp_audit_canonicals');
|
|
129
|
+
|
|
130
|
+
const logs = getAuditLogs();
|
|
131
|
+
const entry = logs.find(l => l.tool === 'wp_audit_canonicals');
|
|
132
|
+
expect(entry).toBeDefined();
|
|
133
|
+
expect(entry.status).toBe('success');
|
|
134
|
+
expect(entry.action).toBe('audit_seo');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('ERROR — logs error on API failure', async () => {
|
|
138
|
+
_testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
|
|
139
|
+
mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
|
|
140
|
+
|
|
141
|
+
const res = await call('wp_audit_canonicals');
|
|
142
|
+
expect(res.isError).toBe(true);
|
|
143
|
+
|
|
144
|
+
const logs = getAuditLogs();
|
|
145
|
+
const entry = logs.find(l => l.tool === 'wp_audit_canonicals');
|
|
146
|
+
expect(entry).toBeDefined();
|
|
147
|
+
expect(entry.status).toBe('error');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -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
|
+
});
|