@adsim/wordpress-mcp-server 3.1.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 (31) hide show
  1. package/README.md +543 -176
  2. package/dxt/manifest.json +86 -9
  3. package/index.js +3156 -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/utils/contentCompressor.js +116 -0
  10. package/src/woocommerceClient.js +88 -0
  11. package/tests/unit/contentAnalyzer.test.js +397 -0
  12. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  13. package/tests/unit/tools/approval.test.js +251 -0
  14. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  15. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  16. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  17. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  18. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  19. package/tests/unit/tools/contentCompressor.test.js +320 -0
  20. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  21. package/tests/unit/tools/destructive.test.js +246 -0
  22. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  23. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  24. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  25. package/tests/unit/tools/findThinContent.test.js +145 -0
  26. package/tests/unit/tools/internalLinks.test.js +283 -0
  27. package/tests/unit/tools/perTargetControls.test.js +228 -0
  28. package/tests/unit/tools/site.test.js +6 -1
  29. package/tests/unit/tools/woocommerce.test.js +344 -0
  30. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  31. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,246 @@
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 { generateToken, validateToken } from '../../../src/confirmationToken.js';
8
+ import { mockSuccess, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
9
+
10
+ function call(name, args = {}) {
11
+ return handleToolCall(makeRequest(name, args));
12
+ }
13
+
14
+ // ────────────────────────────────────────────────────────────
15
+ // Token unit tests
16
+ // ────────────────────────────────────────────────────────────
17
+
18
+ describe('generateToken', () => {
19
+ it('returns correct format mcp_{action}_{postId}_{timestamp}_{hash4}', () => {
20
+ const token = generateToken(42, 'trash');
21
+ const parts = token.split('_');
22
+
23
+ expect(token).toMatch(/^mcp_trash_42_\d+_[a-f0-9]{4}$/);
24
+ expect(parts[0]).toBe('mcp');
25
+ expect(parts[1]).toBe('trash');
26
+ expect(parts[2]).toBe('42');
27
+ expect(parts[3]).toMatch(/^\d+$/);
28
+ expect(parts[4]).toMatch(/^[a-f0-9]{4}$/);
29
+ });
30
+ });
31
+
32
+ describe('validateToken', () => {
33
+ it('accepts a valid token', () => {
34
+ const token = generateToken(10, 'trash');
35
+ const result = validateToken(token, 10, 'trash');
36
+
37
+ expect(result.valid).toBe(true);
38
+ expect(result.reason).toBeUndefined();
39
+ });
40
+
41
+ it('rejects an expired token', () => {
42
+ // Generate token, then mock Date.now to be 120s later
43
+ const token = generateToken(10, 'trash');
44
+ const originalNow = Date.now;
45
+ Date.now = () => originalNow() + 120_000; // 120s later
46
+ try {
47
+ const result = validateToken(token, 10, 'trash', 60);
48
+
49
+ expect(result.valid).toBe(false);
50
+ expect(result.reason).toBe('Token expired');
51
+ } finally {
52
+ Date.now = originalNow;
53
+ }
54
+ });
55
+
56
+ it('rejects a token with wrong postId', () => {
57
+ const token = generateToken(10, 'trash');
58
+ const result = validateToken(token, 999, 'trash');
59
+
60
+ expect(result.valid).toBe(false);
61
+ expect(result.reason).toBe('Token does not match post or action');
62
+ });
63
+
64
+ it('rejects a token with wrong action', () => {
65
+ const token = generateToken(10, 'trash');
66
+ const result = validateToken(token, 10, 'permanent_delete');
67
+
68
+ expect(result.valid).toBe(false);
69
+ expect(result.reason).toBe('Token does not match post or action');
70
+ });
71
+
72
+ it('rejects a token with altered hash', () => {
73
+ const token = generateToken(10, 'trash');
74
+ // Flip last character of hash
75
+ const altered = token.slice(0, -1) + (token.at(-1) === 'a' ? 'b' : 'a');
76
+ const result = validateToken(altered, 10, 'trash');
77
+
78
+ expect(result.valid).toBe(false);
79
+ expect(result.reason).toBe('Invalid token hash');
80
+ });
81
+ });
82
+
83
+ // ────────────────────────────────────────────────────────────
84
+ // wp_delete_post — two-step confirmation
85
+ // ────────────────────────────────────────────────────────────
86
+
87
+ describe('wp_delete_post with WP_CONFIRM_DESTRUCTIVE=true', () => {
88
+ let consoleSpy;
89
+ beforeEach(() => {
90
+ fetch.mockReset();
91
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
92
+ process.env.WP_CONFIRM_DESTRUCTIVE = 'true';
93
+ });
94
+ afterEach(() => {
95
+ consoleSpy.mockRestore();
96
+ delete process.env.WP_CONFIRM_DESTRUCTIVE;
97
+ });
98
+
99
+ it('returns confirmation_required when no token provided', async () => {
100
+ mockSuccess({ id: 5, title: { rendered: 'My Post' }, status: 'publish', meta: {} });
101
+
102
+ const result = await call('wp_delete_post', { id: 5 });
103
+ const data = parseResult(result);
104
+
105
+ expect(result.isError).toBeUndefined();
106
+ expect(data.status).toBe('confirmation_required');
107
+ expect(data.post_id).toBe(5);
108
+ expect(data.post_title).toBe('My Post');
109
+ expect(data.action).toBe('trash');
110
+ expect(data.confirmation_token).toMatch(/^mcp_trash_5_\d+_[a-f0-9]{4}$/);
111
+ expect(data.expires_in).toBe(60);
112
+ expect(data.message).toContain('trashed');
113
+
114
+ const logs = getAuditLogs(consoleSpy);
115
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
116
+ expect(entry).toBeDefined();
117
+ expect(entry.action).toBe('delete_requested');
118
+ expect(entry.status).toBe('pending');
119
+ expect(entry.target).toBe(5);
120
+ });
121
+
122
+ it('executes delete when valid token provided', async () => {
123
+ const token = generateToken(5, 'trash');
124
+ mockSuccess({ id: 5, title: { rendered: 'My Post' }, status: 'trash' });
125
+
126
+ const result = await call('wp_delete_post', { id: 5, confirmation_token: token });
127
+ const data = parseResult(result);
128
+
129
+ expect(data.success).toBe(true);
130
+ expect(data.post.id).toBe(5);
131
+ expect(data.post.status).toBe('trash');
132
+
133
+ const logs = getAuditLogs(consoleSpy);
134
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
135
+ expect(entry).toBeDefined();
136
+ expect(entry.action).toBe('trash');
137
+ expect(entry.status).toBe('success');
138
+ });
139
+
140
+ it('returns error when token is expired', async () => {
141
+ const token = generateToken(5, 'trash');
142
+ const originalNow = Date.now;
143
+ Date.now = () => originalNow() + 120_000;
144
+ try {
145
+ const result = await call('wp_delete_post', { id: 5, confirmation_token: token });
146
+
147
+ expect(result.isError).toBe(true);
148
+ expect(result.content[0].text).toContain('Invalid or expired confirmation token');
149
+
150
+ const logs = getAuditLogs(consoleSpy);
151
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
152
+ expect(entry).toBeDefined();
153
+ expect(entry.action).toBe('trash');
154
+ expect(entry.status).toBe('error');
155
+ } finally {
156
+ Date.now = originalNow;
157
+ }
158
+ });
159
+ });
160
+
161
+ describe('wp_delete_post with WP_CONFIRM_DESTRUCTIVE=false (default)', () => {
162
+ let consoleSpy;
163
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
164
+ afterEach(() => { consoleSpy.mockRestore(); });
165
+
166
+ it('deletes directly without confirmation', async () => {
167
+ mockSuccess({ id: 5, title: { rendered: 'My Post' }, status: 'trash' });
168
+
169
+ const result = await call('wp_delete_post', { id: 5 });
170
+ const data = parseResult(result);
171
+
172
+ expect(data.success).toBe(true);
173
+ expect(data.post.id).toBe(5);
174
+ expect(data.post.status).toBe('trash');
175
+
176
+ const logs = getAuditLogs(consoleSpy);
177
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
178
+ expect(entry).toBeDefined();
179
+ expect(entry.action).toBe('trash');
180
+ expect(entry.status).toBe('success');
181
+ });
182
+ });
183
+
184
+ describe('wp_delete_post — governance priority', () => {
185
+ let consoleSpy;
186
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
187
+ afterEach(() => {
188
+ consoleSpy.mockRestore();
189
+ delete process.env.WP_DISABLE_DELETE;
190
+ delete process.env.WP_CONFIRM_DESTRUCTIVE;
191
+ });
192
+
193
+ it('WP_DISABLE_DELETE blocks before confirmation flow', async () => {
194
+ process.env.WP_DISABLE_DELETE = 'true';
195
+ process.env.WP_CONFIRM_DESTRUCTIVE = 'true';
196
+
197
+ const result = await call('wp_delete_post', { id: 5 });
198
+
199
+ expect(result.isError).toBe(true);
200
+ expect(result.content[0].text).toContain('WP_DISABLE_DELETE');
201
+
202
+ const logs = getAuditLogs(consoleSpy);
203
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
204
+ expect(entry).toBeDefined();
205
+ expect(entry.status).toBe('blocked');
206
+ });
207
+ });
208
+
209
+ // ────────────────────────────────────────────────────────────
210
+ // wp_delete_revision — two-step confirmation
211
+ // ────────────────────────────────────────────────────────────
212
+
213
+ describe('wp_delete_revision with WP_CONFIRM_DESTRUCTIVE=true', () => {
214
+ let consoleSpy;
215
+ beforeEach(() => {
216
+ fetch.mockReset();
217
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
218
+ process.env.WP_CONFIRM_DESTRUCTIVE = 'true';
219
+ });
220
+ afterEach(() => {
221
+ consoleSpy.mockRestore();
222
+ delete process.env.WP_CONFIRM_DESTRUCTIVE;
223
+ });
224
+
225
+ it('returns confirmation_required when no token provided', async () => {
226
+ const result = await call('wp_delete_revision', { post_id: 1, revision_id: 42 });
227
+ const data = parseResult(result);
228
+
229
+ expect(result.isError).toBeUndefined();
230
+ expect(data.status).toBe('confirmation_required');
231
+ expect(data.revision_id).toBe(42);
232
+ expect(data.post_id).toBe(1);
233
+ expect(data.action).toBe('delete_revision');
234
+ expect(data.confirmation_token).toMatch(/^mcp_delete_revision_42_\d+_[a-f0-9]{4}$/);
235
+ expect(data.expires_in).toBe(60);
236
+ expect(data.message).toContain('Revision #42');
237
+ expect(data.message).toContain('post #1');
238
+
239
+ const logs = getAuditLogs(consoleSpy);
240
+ const entry = logs.find(l => l.tool === 'wp_delete_revision');
241
+ expect(entry).toBeDefined();
242
+ expect(entry.action).toBe('delete_requested');
243
+ expect(entry.status).toBe('pending');
244
+ expect(entry.target).toBe(42);
245
+ });
246
+ });
@@ -0,0 +1,222 @@
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, link) {
22
+ return {
23
+ id, title: { rendered: `Post ${id}` },
24
+ link: link || `https://mysite.example.com/post-${id}/`,
25
+ content: { rendered: content }
26
+ };
27
+ }
28
+
29
+ function mockHeadResponse(status) {
30
+ return fetch.mockImplementationOnce(() => Promise.resolve({ status, ok: status < 400 }));
31
+ }
32
+
33
+ function mockHeadTimeout() {
34
+ return fetch.mockImplementationOnce(() => {
35
+ const err = new Error('aborted');
36
+ err.name = 'AbortError';
37
+ return Promise.reject(err);
38
+ });
39
+ }
40
+
41
+ function mockHeadNetworkError() {
42
+ return fetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED')));
43
+ }
44
+
45
+ // =========================================================================
46
+ // wp_find_broken_internal_links
47
+ // =========================================================================
48
+
49
+ describe('wp_find_broken_internal_links', () => {
50
+ it('SUCCESS — no broken links', async () => {
51
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
52
+ const html = '<p>Read <a href="https://mysite.example.com/other/">more</a></p>';
53
+ mockSuccess([makePost(1, html)]);
54
+ mockHeadResponse(200);
55
+
56
+ const res = await call('wp_find_broken_internal_links');
57
+ const data = parseResult(res);
58
+
59
+ expect(data.broken_links).toHaveLength(0);
60
+ expect(data.total_broken).toBe(0);
61
+ expect(data.total_posts_scanned).toBe(1);
62
+ expect(data.total_links_checked).toBe(1);
63
+ });
64
+
65
+ it('ISSUE — 404 detected as not_found', async () => {
66
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
67
+ const html = '<p>See <a href="https://mysite.example.com/dead-page/">dead</a></p>';
68
+ mockSuccess([makePost(1, html)]);
69
+ mockHeadResponse(404);
70
+
71
+ const res = await call('wp_find_broken_internal_links');
72
+ const data = parseResult(res);
73
+
74
+ expect(data.total_broken).toBe(1);
75
+ expect(data.broken_links[0].issue_type).toBe('not_found');
76
+ expect(data.broken_links[0].status_code).toBe(404);
77
+ });
78
+
79
+ it('ISSUE — 301 detected as redirect', async () => {
80
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
81
+ const html = '<p>See <a href="https://mysite.example.com/old-slug/">old</a></p>';
82
+ mockSuccess([makePost(1, html)]);
83
+ mockHeadResponse(301);
84
+
85
+ const res = await call('wp_find_broken_internal_links');
86
+ const data = parseResult(res);
87
+
88
+ expect(data.total_redirects).toBe(1);
89
+ expect(data.broken_links[0].issue_type).toBe('redirect');
90
+ expect(data.broken_links[0].status_code).toBe(301);
91
+ });
92
+
93
+ it('ISSUE — 302 detected as redirect', async () => {
94
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
95
+ const html = '<p>See <a href="https://mysite.example.com/temp/">temp</a></p>';
96
+ mockSuccess([makePost(1, html)]);
97
+ mockHeadResponse(302);
98
+
99
+ const res = await call('wp_find_broken_internal_links');
100
+ const data = parseResult(res);
101
+
102
+ expect(data.total_redirects).toBe(1);
103
+ expect(data.broken_links[0].issue_type).toBe('redirect');
104
+ });
105
+
106
+ it('PARAM — include_redirects: false excludes redirects', async () => {
107
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
108
+ const html = '<p>See <a href="https://mysite.example.com/moved/">moved</a></p>';
109
+ mockSuccess([makePost(1, html)]);
110
+ mockHeadResponse(301);
111
+
112
+ const res = await call('wp_find_broken_internal_links', { include_redirects: false });
113
+ const data = parseResult(res);
114
+
115
+ expect(data.total_redirects).toBe(1);
116
+ expect(data.broken_links).toHaveLength(0);
117
+ });
118
+
119
+ it('ISSUE — timeout detected', async () => {
120
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
121
+ const html = '<p>See <a href="https://mysite.example.com/slow/">slow</a></p>';
122
+ mockSuccess([makePost(1, html)]);
123
+ mockHeadTimeout();
124
+
125
+ const res = await call('wp_find_broken_internal_links');
126
+ const data = parseResult(res);
127
+
128
+ expect(data.total_timeouts).toBe(1);
129
+ expect(data.broken_links[0].issue_type).toBe('timeout');
130
+ });
131
+
132
+ it('ISSUE — network error classified', async () => {
133
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
134
+ const html = '<p>See <a href="https://mysite.example.com/down/">down</a></p>';
135
+ mockSuccess([makePost(1, html)]);
136
+ mockHeadNetworkError();
137
+
138
+ const res = await call('wp_find_broken_internal_links');
139
+ const data = parseResult(res);
140
+
141
+ expect(data.total_broken).toBe(1);
142
+ expect(data.broken_links[0].issue_type).toBe('network_error');
143
+ });
144
+
145
+ it('DEDUP — same URL in 2 posts triggers only 1 HEAD request', async () => {
146
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
147
+ const sharedLink = 'https://mysite.example.com/shared/';
148
+ const html1 = `<p>See <a href="${sharedLink}">link</a></p>`;
149
+ const html2 = `<p>Also <a href="${sharedLink}">link</a></p>`;
150
+ mockSuccess([makePost(1, html1), makePost(2, html2)]);
151
+ mockHeadResponse(404);
152
+
153
+ const res = await call('wp_find_broken_internal_links');
154
+ const data = parseResult(res);
155
+
156
+ // 1 unique URL → 1 HEAD request (after the initial wpApiCall mock)
157
+ expect(data.total_links_checked).toBe(1);
158
+ // But broken_links has 2 entries (one per source post)
159
+ expect(data.broken_links).toHaveLength(2);
160
+ });
161
+
162
+ it('BATCH — batch_size=2 on 4 links creates correct batches', async () => {
163
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
164
+ const html = `<p>
165
+ <a href="https://mysite.example.com/a/">a</a>
166
+ <a href="https://mysite.example.com/b/">b</a>
167
+ <a href="https://mysite.example.com/c/">c</a>
168
+ <a href="https://mysite.example.com/d/">d</a>
169
+ </p>`;
170
+ mockSuccess([makePost(1, html)]);
171
+ // 4 HEAD requests, all 200
172
+ mockHeadResponse(200);
173
+ mockHeadResponse(200);
174
+ mockHeadResponse(200);
175
+ mockHeadResponse(200);
176
+
177
+ const res = await call('wp_find_broken_internal_links', { batch_size: 2, delay_ms: 0 });
178
+ const data = parseResult(res);
179
+
180
+ expect(data.total_links_checked).toBe(4);
181
+ expect(data.broken_links).toHaveLength(0);
182
+ });
183
+
184
+ it('ANCHOR — anchor text extracted correctly', async () => {
185
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
186
+ const html = '<p>Read our <a href="https://mysite.example.com/guide/">complete guide</a> now.</p>';
187
+ mockSuccess([makePost(1, html)]);
188
+ mockHeadResponse(404);
189
+
190
+ const res = await call('wp_find_broken_internal_links');
191
+ const data = parseResult(res);
192
+
193
+ expect(data.broken_links[0].anchor_text).toBe('complete guide');
194
+ });
195
+
196
+ it('AUDIT — logs success entry', async () => {
197
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
198
+ mockSuccess([makePost(1, '<p>No links here.</p>')]);
199
+
200
+ await call('wp_find_broken_internal_links');
201
+
202
+ const logs = getAuditLogs();
203
+ const entry = logs.find(l => l.tool === 'wp_find_broken_internal_links');
204
+ expect(entry).toBeDefined();
205
+ expect(entry.status).toBe('success');
206
+ expect(entry.action).toBe('audit_seo');
207
+ expect(entry.params.total_links_checked).toBeDefined();
208
+ });
209
+
210
+ it('ERROR — logs error on API failure', async () => {
211
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
212
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
213
+
214
+ const res = await call('wp_find_broken_internal_links');
215
+ expect(res.isError).toBe(true);
216
+
217
+ const logs = getAuditLogs();
218
+ const entry = logs.find(l => l.tool === 'wp_find_broken_internal_links');
219
+ expect(entry).toBeDefined();
220
+ expect(entry.status).toBe('error');
221
+ });
222
+ });
@@ -0,0 +1,183 @@
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 = '2025-12-01T10:00:00';
22
+ const oldDate = '2023-01-15T10:00:00';
23
+
24
+ function makePostWithKw(id, keyword, date = recentDate) {
25
+ return {
26
+ id, title: { rendered: `Post ${id}` },
27
+ link: `https://mysite.example.com/post-${id}/`,
28
+ date,
29
+ meta: keyword ? { rank_math_focus_keyword: keyword } : {}
30
+ };
31
+ }
32
+
33
+ // =========================================================================
34
+ // wp_find_keyword_cannibalization
35
+ // =========================================================================
36
+
37
+ describe('wp_find_keyword_cannibalization', () => {
38
+ it('SUCCESS — no cannibalization (all unique keywords)', async () => {
39
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
40
+ mockSuccess([
41
+ makePostWithKw(1, 'seo tips'),
42
+ makePostWithKw(2, 'wordpress security'),
43
+ makePostWithKw(3, 'content marketing')
44
+ ]);
45
+
46
+ const res = await call('wp_find_keyword_cannibalization');
47
+ const data = parseResult(res);
48
+
49
+ expect(data.total_groups).toBe(0);
50
+ expect(data.cannibalization_groups).toHaveLength(0);
51
+ expect(data.posts_with_keyword).toBe(3);
52
+ });
53
+
54
+ it('ISSUE — exact group detected (2 articles same keyword)', async () => {
55
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
56
+ mockSuccess([
57
+ makePostWithKw(1, 'seo tips'),
58
+ makePostWithKw(2, 'seo tips'),
59
+ makePostWithKw(3, 'other keyword')
60
+ ]);
61
+
62
+ const res = await call('wp_find_keyword_cannibalization');
63
+ const data = parseResult(res);
64
+
65
+ expect(data.total_groups).toBe(1);
66
+ expect(data.cannibalization_groups[0].articles_count).toBe(2);
67
+ expect(data.articles_affected).toBe(2);
68
+ });
69
+
70
+ it('MODE — normalized groups variants together', async () => {
71
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
72
+ mockSuccess([
73
+ makePostWithKw(1, 'SEO Tips'),
74
+ makePostWithKw(2, 'seo-tips'),
75
+ makePostWithKw(3, 'Séo Tips')
76
+ ]);
77
+
78
+ const res = await call('wp_find_keyword_cannibalization', { similarity_mode: 'normalized' });
79
+ const data = parseResult(res);
80
+
81
+ expect(data.total_groups).toBe(1);
82
+ expect(data.cannibalization_groups[0].articles_count).toBe(3);
83
+ expect(data.cannibalization_groups[0].variants.length).toBeGreaterThanOrEqual(1);
84
+ });
85
+
86
+ it('MODE — exact does NOT group variants', async () => {
87
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
88
+ mockSuccess([
89
+ makePostWithKw(1, 'SEO Tips'),
90
+ makePostWithKw(2, 'seo tips'),
91
+ makePostWithKw(3, 'SEO Tips')
92
+ ]);
93
+
94
+ const res = await call('wp_find_keyword_cannibalization', { similarity_mode: 'exact' });
95
+ const data = parseResult(res);
96
+
97
+ // 'SEO Tips' (2 articles) is one group, 'seo tips' is alone
98
+ expect(data.total_groups).toBe(1);
99
+ expect(data.cannibalization_groups[0].articles_count).toBe(2);
100
+ });
101
+
102
+ it('SUCCESS — articles without keyword are ignored', async () => {
103
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
104
+ mockSuccess([
105
+ makePostWithKw(1, 'seo tips'),
106
+ makePostWithKw(2, null),
107
+ makePostWithKw(3, '')
108
+ ]);
109
+
110
+ const res = await call('wp_find_keyword_cannibalization');
111
+ const data = parseResult(res);
112
+
113
+ expect(data.posts_with_keyword).toBe(1);
114
+ expect(data.posts_without_keyword).toBe(2);
115
+ expect(data.total_groups).toBe(0);
116
+ });
117
+
118
+ it('ACTION — consolidate_301 when dates differ greatly', async () => {
119
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
120
+ mockSuccess([
121
+ makePostWithKw(1, 'seo tips', oldDate),
122
+ makePostWithKw(2, 'seo tips', recentDate)
123
+ ]);
124
+
125
+ const res = await call('wp_find_keyword_cannibalization');
126
+ const data = parseResult(res);
127
+
128
+ expect(data.cannibalization_groups[0].recommended_action).toBe('consolidate_301');
129
+ });
130
+
131
+ it('ACTION — differentiate when 2 articles with close dates', async () => {
132
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
133
+ mockSuccess([
134
+ makePostWithKw(1, 'seo tips', '2025-11-01T10:00:00'),
135
+ makePostWithKw(2, 'seo tips', '2025-12-01T10:00:00')
136
+ ]);
137
+
138
+ const res = await call('wp_find_keyword_cannibalization');
139
+ const data = parseResult(res);
140
+
141
+ expect(data.cannibalization_groups[0].recommended_action).toBe('differentiate');
142
+ });
143
+
144
+ it('ACTION — merge when 3+ articles', async () => {
145
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
146
+ mockSuccess([
147
+ makePostWithKw(1, 'seo tips'),
148
+ makePostWithKw(2, 'seo tips'),
149
+ makePostWithKw(3, 'seo tips')
150
+ ]);
151
+
152
+ const res = await call('wp_find_keyword_cannibalization');
153
+ const data = parseResult(res);
154
+
155
+ expect(data.cannibalization_groups[0].recommended_action).toBe('merge');
156
+ });
157
+
158
+ it('AUDIT — logs success entry', async () => {
159
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
160
+ mockSuccess([makePostWithKw(1, 'seo tips')]);
161
+
162
+ await call('wp_find_keyword_cannibalization');
163
+
164
+ const logs = getAuditLogs();
165
+ const entry = logs.find(l => l.tool === 'wp_find_keyword_cannibalization');
166
+ expect(entry).toBeDefined();
167
+ expect(entry.status).toBe('success');
168
+ expect(entry.action).toBe('audit_seo');
169
+ });
170
+
171
+ it('ERROR — logs error on API failure', async () => {
172
+ _testSetTarget('test', { url: 'https://mysite.example.com', username: 'u', password: 'p' });
173
+ mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
174
+
175
+ const res = await call('wp_find_keyword_cannibalization');
176
+ expect(res.isError).toBe(true);
177
+
178
+ const logs = getAuditLogs();
179
+ const entry = logs.find(l => l.tool === 'wp_find_keyword_cannibalization');
180
+ expect(entry).toBeDefined();
181
+ expect(entry.status).toBe('error');
182
+ });
183
+ });