@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.
Files changed (34) hide show
  1. package/README.md +564 -176
  2. package/dxt/manifest.json +93 -9
  3. package/index.js +3624 -36
  4. package/package.json +1 -1
  5. package/src/confirmationToken.js +64 -0
  6. package/src/contentAnalyzer.js +476 -0
  7. package/src/htmlParser.js +80 -0
  8. package/src/linkUtils.js +158 -0
  9. package/src/pluginDetector.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/pluginDetector.test.js +167 -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/pluginIntelligence.test.js +864 -0
  31. package/tests/unit/tools/site.test.js +6 -1
  32. package/tests/unit/tools/woocommerce.test.js +344 -0
  33. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  34. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,192 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, _testSetTarget } from '../../../index.js';
7
+ import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
15
+ afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
16
+
17
+ // =========================================================================
18
+ // Helpers
19
+ // =========================================================================
20
+
21
+ const recentDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
22
+ const oldDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString();
23
+
24
+ function makeAuthor(id, { bio = '', avatarUrls = {} } = {}) {
25
+ return { id, name: `Author ${id}`, slug: `author-${id}`, description: bio, avatar_urls: avatarUrls };
26
+ }
27
+
28
+ function makePostWithContent(id, content, { author = 1, modified = recentDate } = {}) {
29
+ return {
30
+ id, title: { rendered: `Post ${id}` }, link: `https://test.example.com/post-${id}/`,
31
+ content: { rendered: content }, author, date: modified, modified, meta: {}
32
+ };
33
+ }
34
+
35
+ // Perfect post: all E-E-A-T signals present (must exceed 800 words for content_word_count_expert)
36
+ const fillerSentences = Array(160).fill('This detailed analysis covers important aspects of the topic thoroughly.').join(' ');
37
+ const perfectContent = `
38
+ <p>I've been working on this project for years and my experience with client projects has taught me a lot.</p>
39
+ <p>According to a recent study, 75% of websites need improvement. Source: research shows this clearly.</p>
40
+ <p>Visit <a href="https://other-site.com/page1">External 1</a> and <a href="https://other-site.com/page2">External 2</a>
41
+ and <a href="https://wikipedia.org/wiki/SEO">Wikipedia on SEO</a>.</p>
42
+ <p>John Smith from Google confirmed these findings in his rapport.</p>
43
+ <p>${fillerSentences}</p>
44
+ <script type="application/ld+json">{"@type":"Article"}</script>
45
+ `;
46
+
47
+ const emptyContent = '<p>Short.</p>';
48
+
49
+ // =========================================================================
50
+ // wp_analyze_eeat_signals
51
+ // =========================================================================
52
+
53
+ describe('wp_analyze_eeat_signals', () => {
54
+ it('SUCCESS — perfect score (all signals present)', async () => {
55
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
56
+ // 1. Fetch posts
57
+ mockSuccess([makePostWithContent(1, perfectContent)]);
58
+ // 2. Fetch author
59
+ mockSuccess(makeAuthor(1, { bio: 'Expert in SEO with 10 years of experience', avatarUrls: { '96': 'https://test.example.com/avatar.jpg' } }));
60
+
61
+ const res = await call('wp_analyze_eeat_signals');
62
+ const data = parseResult(res);
63
+
64
+ expect(data.analyses[0].scores.total).toBe(100);
65
+ expect(data.analyses[0].scores.experience).toBe(25);
66
+ expect(data.analyses[0].scores.expertise).toBe(25);
67
+ expect(data.analyses[0].scores.authoritativeness).toBe(25);
68
+ expect(data.analyses[0].scores.trustworthiness).toBe(25);
69
+ });
70
+
71
+ it('SUCCESS — zero score (no signals)', async () => {
72
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
73
+ mockSuccess([makePostWithContent(1, emptyContent, { modified: oldDate })]);
74
+ mockSuccess(makeAuthor(1));
75
+
76
+ const res = await call('wp_analyze_eeat_signals');
77
+ const data = parseResult(res);
78
+
79
+ expect(data.analyses[0].scores.total).toBe(0);
80
+ expect(data.analyses[0].signals_present).toHaveLength(0);
81
+ expect(data.analyses[0].signals_missing.length).toBeGreaterThan(0);
82
+ });
83
+
84
+ it('SIGNAL — author_has_bio detected', async () => {
85
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
86
+ mockSuccess([makePostWithContent(1, emptyContent)]);
87
+ mockSuccess(makeAuthor(1, { bio: 'I am an expert in WordPress development.' }));
88
+
89
+ const res = await call('wp_analyze_eeat_signals');
90
+ const data = parseResult(res);
91
+
92
+ expect(data.analyses[0].signals_present).toContain('author_has_bio');
93
+ expect(data.analyses[0].scores.experience).toBeGreaterThanOrEqual(10);
94
+ });
95
+
96
+ it('SIGNAL — has_authoritative_sources detected', async () => {
97
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
98
+ const content = '<p>See <a href="https://external.com/a">link1</a> and <a href="https://external.com/b">link2</a> and <a href="https://wikipedia.org/wiki/Test">Wikipedia</a>.</p>';
99
+ mockSuccess([makePostWithContent(1, content)]);
100
+ mockSuccess(makeAuthor(1));
101
+
102
+ const res = await call('wp_analyze_eeat_signals');
103
+ const data = parseResult(res);
104
+
105
+ expect(data.analyses[0].signals_present).toContain('has_authoritative_sources');
106
+ expect(data.analyses[0].signals_present).toContain('has_outbound_links');
107
+ });
108
+
109
+ it('SIGNAL — content_has_data detected', async () => {
110
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
111
+ const content = '<p>Our conversion rate improved by 45% after the optimization.</p>';
112
+ mockSuccess([makePostWithContent(1, content)]);
113
+ mockSuccess(makeAuthor(1));
114
+
115
+ const res = await call('wp_analyze_eeat_signals');
116
+ const data = parseResult(res);
117
+
118
+ expect(data.analyses[0].signals_present).toContain('content_has_data');
119
+ expect(data.analyses[0].scores.expertise).toBeGreaterThanOrEqual(8);
120
+ });
121
+
122
+ it('SIGNAL — has_structured_data detected', async () => {
123
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
124
+ const content = '<p>Text.</p><script type="application/ld+json">{"@type":"Article"}</script>';
125
+ mockSuccess([makePostWithContent(1, content)]);
126
+ mockSuccess(makeAuthor(1));
127
+
128
+ const res = await call('wp_analyze_eeat_signals');
129
+ const data = parseResult(res);
130
+
131
+ expect(data.analyses[0].signals_present).toContain('has_structured_data');
132
+ expect(data.analyses[0].scores.trustworthiness).toBeGreaterThanOrEqual(7);
133
+ });
134
+
135
+ it('SUCCESS — priority_fixes returns max 3 items', async () => {
136
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
137
+ mockSuccess([makePostWithContent(1, emptyContent, { modified: oldDate })]);
138
+ mockSuccess(makeAuthor(1));
139
+
140
+ const res = await call('wp_analyze_eeat_signals');
141
+ const data = parseResult(res);
142
+
143
+ expect(data.analyses[0].priority_fixes.length).toBeLessThanOrEqual(3);
144
+ expect(data.analyses[0].priority_fixes.length).toBeGreaterThan(0);
145
+ // Should be sorted by impact descending
146
+ if (data.analyses[0].priority_fixes.length >= 2) {
147
+ expect(data.analyses[0].priority_fixes[0].potential_points).toBeGreaterThanOrEqual(data.analyses[0].priority_fixes[1].potential_points);
148
+ }
149
+ });
150
+
151
+ it('SUCCESS — post_ids parameter respected', async () => {
152
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
153
+ mockSuccess([makePostWithContent(42, emptyContent)]);
154
+ mockSuccess(makeAuthor(1));
155
+
156
+ const res = await call('wp_analyze_eeat_signals', { post_ids: [42] });
157
+ const data = parseResult(res);
158
+
159
+ expect(data.analyses[0].id).toBe(42);
160
+ expect(data.total_analyzed).toBe(1);
161
+ // Verify fetch was called with include parameter
162
+ const fetchUrl = fetch.mock.calls[0][0];
163
+ expect(fetchUrl).toContain('include=42');
164
+ });
165
+
166
+ it('AUDIT — logs success entry', async () => {
167
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
168
+ mockSuccess([makePostWithContent(1, emptyContent)]);
169
+ mockSuccess(makeAuthor(1));
170
+
171
+ await call('wp_analyze_eeat_signals');
172
+
173
+ const logs = getAuditLogs();
174
+ const entry = logs.find(l => l.tool === 'wp_analyze_eeat_signals');
175
+ expect(entry).toBeDefined();
176
+ expect(entry.status).toBe('success');
177
+ expect(entry.action).toBe('audit_seo');
178
+ });
179
+
180
+ it('ERROR — logs error on API failure', async () => {
181
+ _testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
182
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
183
+
184
+ const res = await call('wp_analyze_eeat_signals');
185
+ expect(res.isError).toBe(true);
186
+
187
+ const logs = getAuditLogs();
188
+ const entry = logs.find(l => l.tool === 'wp_analyze_eeat_signals');
189
+ expect(entry).toBeDefined();
190
+ expect(entry.status).toBe('error');
191
+ });
192
+ });
@@ -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
+ });