@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,145 @@
|
|
|
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
|
+
// Mock data
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
const pageA = {
|
|
22
|
+
id: 1, title: { rendered: 'Home' }, slug: 'home', status: 'publish',
|
|
23
|
+
link: 'https://test.example.com/home/',
|
|
24
|
+
content: { rendered: '<p>Welcome! Visit <a href="https://test.example.com/about/">About Us</a> and <a href="https://test.example.com/services/">Services</a>.</p>' }
|
|
25
|
+
};
|
|
26
|
+
const pageB = {
|
|
27
|
+
id: 2, title: { rendered: 'About' }, slug: 'about', status: 'publish',
|
|
28
|
+
link: 'https://test.example.com/about/',
|
|
29
|
+
content: { rendered: '<p>About us. Go <a href="https://test.example.com/home/">Home</a>.</p>' }
|
|
30
|
+
};
|
|
31
|
+
const pageC = {
|
|
32
|
+
id: 3, title: { rendered: 'Services' }, slug: 'services', status: 'publish',
|
|
33
|
+
link: 'https://test.example.com/services/',
|
|
34
|
+
content: { rendered: '<p>Our services.</p>' }
|
|
35
|
+
};
|
|
36
|
+
const pageD = {
|
|
37
|
+
id: 4, title: { rendered: 'Hidden Page' }, slug: 'hidden', status: 'publish',
|
|
38
|
+
link: 'https://test.example.com/hidden/',
|
|
39
|
+
content: { rendered: '<p>This page is not linked from anywhere. It has many words to test sorting so that we can verify the filter and ordering logic works correctly in our test scenario.</p>' }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// =========================================================================
|
|
43
|
+
// wp_find_orphan_pages
|
|
44
|
+
// =========================================================================
|
|
45
|
+
|
|
46
|
+
describe('wp_find_orphan_pages', () => {
|
|
47
|
+
it('SUCCESS — no orphan pages when all are linked', async () => {
|
|
48
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
49
|
+
mockSuccess([pageA, pageB, pageC]);
|
|
50
|
+
|
|
51
|
+
const res = await call('wp_find_orphan_pages');
|
|
52
|
+
const data = parseResult(res);
|
|
53
|
+
|
|
54
|
+
expect(data.total_orphans).toBe(0);
|
|
55
|
+
expect(data.orphans).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('SUCCESS — all pages are orphans when no cross-links', async () => {
|
|
59
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
60
|
+
const iso1 = { id: 10, title: { rendered: 'Iso1' }, slug: 'iso1', status: 'publish', link: 'https://test.example.com/iso1/', content: { rendered: '<p>No links.</p>' } };
|
|
61
|
+
const iso2 = { id: 11, title: { rendered: 'Iso2' }, slug: 'iso2', status: 'publish', link: 'https://test.example.com/iso2/', content: { rendered: '<p>No links either.</p>' } };
|
|
62
|
+
mockSuccess([iso1, iso2]);
|
|
63
|
+
|
|
64
|
+
const res = await call('wp_find_orphan_pages');
|
|
65
|
+
const data = parseResult(res);
|
|
66
|
+
|
|
67
|
+
expect(data.total_orphans).toBe(2);
|
|
68
|
+
expect(data.orphans).toHaveLength(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('SUCCESS — exclusion by IDs', async () => {
|
|
72
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
73
|
+
mockSuccess([pageA, pageB, pageC, pageD]);
|
|
74
|
+
|
|
75
|
+
const res = await call('wp_find_orphan_pages', { exclude_ids: [4] });
|
|
76
|
+
const data = parseResult(res);
|
|
77
|
+
|
|
78
|
+
const orphanIds = data.orphans.map(o => o.id);
|
|
79
|
+
expect(orphanIds).not.toContain(4);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('SUCCESS — filter by min_words', async () => {
|
|
83
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
84
|
+
const shortPage = { id: 20, title: { rendered: 'Short' }, slug: 'short', status: 'publish', link: 'https://test.example.com/short/', content: { rendered: '<p>Hello.</p>' } };
|
|
85
|
+
const longPage = { id: 21, title: { rendered: 'Long' }, slug: 'long', status: 'publish', link: 'https://test.example.com/long/', content: { rendered: '<p>This is a much longer page with plenty of words to exceed the minimum threshold for the filter to work correctly in our test scenario today and beyond.</p>' } };
|
|
86
|
+
mockSuccess([shortPage, longPage]);
|
|
87
|
+
|
|
88
|
+
const res = await call('wp_find_orphan_pages', { min_words: 10 });
|
|
89
|
+
const data = parseResult(res);
|
|
90
|
+
|
|
91
|
+
expect(data.orphans.every(o => o.word_count >= 10)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('SUCCESS — builds link matrix correctly', async () => {
|
|
95
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
96
|
+
mockSuccess([pageA, pageB, pageC, pageD]);
|
|
97
|
+
|
|
98
|
+
const res = await call('wp_find_orphan_pages');
|
|
99
|
+
const data = parseResult(res);
|
|
100
|
+
|
|
101
|
+
// A linked from B, B linked from A, C linked from A → not orphans
|
|
102
|
+
// D not linked from anyone → orphan
|
|
103
|
+
expect(data.total_orphans).toBe(1);
|
|
104
|
+
expect(data.orphans[0].id).toBe(4);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('SUCCESS — orphans sorted by word count descending', async () => {
|
|
108
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
109
|
+
const short = { id: 30, title: { rendered: 'Short' }, slug: 'short', status: 'publish', link: 'https://test.example.com/short/', content: { rendered: '<p>Short text.</p>' } };
|
|
110
|
+
const long = { id: 31, title: { rendered: 'Long' }, slug: 'long', status: 'publish', link: 'https://test.example.com/long/', content: { rendered: '<p>This page has significantly more words than the short page because it contains a much longer paragraph with many more words and additional content.</p>' } };
|
|
111
|
+
mockSuccess([short, long]);
|
|
112
|
+
|
|
113
|
+
const res = await call('wp_find_orphan_pages');
|
|
114
|
+
const data = parseResult(res);
|
|
115
|
+
|
|
116
|
+
expect(data.orphans).toHaveLength(2);
|
|
117
|
+
expect(data.orphans[0].word_count).toBeGreaterThan(data.orphans[1].word_count);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('AUDIT — logs success entry', async () => {
|
|
121
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
122
|
+
mockSuccess([pageA]);
|
|
123
|
+
|
|
124
|
+
await call('wp_find_orphan_pages');
|
|
125
|
+
|
|
126
|
+
const logs = getAuditLogs();
|
|
127
|
+
const entry = logs.find(l => l.tool === 'wp_find_orphan_pages');
|
|
128
|
+
expect(entry).toBeDefined();
|
|
129
|
+
expect(entry.status).toBe('success');
|
|
130
|
+
expect(entry.action).toBe('find_orphan_pages');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('ERROR — logs error on API failure', async () => {
|
|
134
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
135
|
+
mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
|
|
136
|
+
|
|
137
|
+
const res = await call('wp_find_orphan_pages');
|
|
138
|
+
expect(res.isError).toBe(true);
|
|
139
|
+
|
|
140
|
+
const logs = getAuditLogs();
|
|
141
|
+
const entry = logs.find(l => l.tool === 'wp_find_orphan_pages');
|
|
142
|
+
expect(entry).toBeDefined();
|
|
143
|
+
expect(entry.status).toBe('error');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
// Mock data
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const recentDate = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days ago
|
|
23
|
+
const oldDate = new Date(now - 800 * 24 * 60 * 60 * 1000).toISOString(); // 800 days ago
|
|
24
|
+
|
|
25
|
+
function makeArticle(id, wordCount, modified, categories = [5]) {
|
|
26
|
+
const words = Array(wordCount).fill('word').join(' ');
|
|
27
|
+
return {
|
|
28
|
+
id, title: { rendered: `Post ${id}` }, link: `https://test.example.com/post-${id}/`,
|
|
29
|
+
content: { rendered: `<p>${words}</p>` },
|
|
30
|
+
modified, date: modified, categories
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// =========================================================================
|
|
35
|
+
// wp_find_thin_content
|
|
36
|
+
// =========================================================================
|
|
37
|
+
|
|
38
|
+
describe('wp_find_thin_content', () => {
|
|
39
|
+
it('SUCCESS — no thin content detected', async () => {
|
|
40
|
+
mockSuccess([makeArticle(1, 500, recentDate, [5])]);
|
|
41
|
+
|
|
42
|
+
const res = await call('wp_find_thin_content');
|
|
43
|
+
const data = parseResult(res);
|
|
44
|
+
|
|
45
|
+
expect(data.total_thin).toBe(0);
|
|
46
|
+
expect(data.articles).toHaveLength(0);
|
|
47
|
+
expect(data.total_analyzed).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('ISSUE — too_short detected (word_count < 300)', async () => {
|
|
51
|
+
mockSuccess([makeArticle(1, 200, recentDate, [5])]);
|
|
52
|
+
|
|
53
|
+
const res = await call('wp_find_thin_content');
|
|
54
|
+
const data = parseResult(res);
|
|
55
|
+
|
|
56
|
+
expect(data.total_thin).toBe(1);
|
|
57
|
+
expect(data.articles[0].signals).toContain('too_short');
|
|
58
|
+
expect(data.articles[0].word_count).toBe(200);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('ISSUE — very_short + severity critical with multiple signals', async () => {
|
|
62
|
+
mockSuccess([makeArticle(1, 100, oldDate, [1])]);
|
|
63
|
+
|
|
64
|
+
const res = await call('wp_find_thin_content');
|
|
65
|
+
const data = parseResult(res);
|
|
66
|
+
|
|
67
|
+
expect(data.articles[0].signals).toContain('very_short');
|
|
68
|
+
expect(data.articles[0].severity).toBe('critical');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('ISSUE — outdated detected (days > 730)', async () => {
|
|
72
|
+
mockSuccess([makeArticle(1, 500, oldDate, [5])]);
|
|
73
|
+
|
|
74
|
+
const res = await call('wp_find_thin_content');
|
|
75
|
+
const data = parseResult(res);
|
|
76
|
+
|
|
77
|
+
expect(data.articles[0].signals).toContain('outdated');
|
|
78
|
+
expect(data.articles[0].days_since_update).toBeGreaterThan(730);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ISSUE — uncategorized detected', async () => {
|
|
82
|
+
mockSuccess([makeArticle(1, 200, recentDate, [1])]);
|
|
83
|
+
|
|
84
|
+
const res = await call('wp_find_thin_content');
|
|
85
|
+
const data = parseResult(res);
|
|
86
|
+
|
|
87
|
+
const article = data.articles.find(a => a.id === 1);
|
|
88
|
+
expect(article.signals).toContain('uncategorized');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('ISSUE — 3 signals → severity critical', async () => {
|
|
92
|
+
// very_short + outdated + uncategorized = 3 signals → critical
|
|
93
|
+
mockSuccess([makeArticle(1, 100, oldDate, [1])]);
|
|
94
|
+
|
|
95
|
+
const res = await call('wp_find_thin_content');
|
|
96
|
+
const data = parseResult(res);
|
|
97
|
+
|
|
98
|
+
expect(data.articles[0].signals).toHaveLength(3);
|
|
99
|
+
expect(data.articles[0].severity).toBe('critical');
|
|
100
|
+
expect(data.by_severity.critical).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('SUCCESS — suggested_action correct for signals', async () => {
|
|
104
|
+
// very_short + outdated → delete
|
|
105
|
+
mockSuccess([
|
|
106
|
+
makeArticle(1, 100, oldDate, [5]), // very_short + outdated → delete
|
|
107
|
+
makeArticle(2, 200, recentDate, [5]), // too_short only → expand
|
|
108
|
+
makeArticle(3, 500, oldDate, [5]), // outdated only → update_or_merge
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const res = await call('wp_find_thin_content');
|
|
112
|
+
const data = parseResult(res);
|
|
113
|
+
|
|
114
|
+
const art1 = data.articles.find(a => a.id === 1);
|
|
115
|
+
const art2 = data.articles.find(a => a.id === 2);
|
|
116
|
+
const art3 = data.articles.find(a => a.id === 3);
|
|
117
|
+
expect(art1.suggested_action).toBe('delete');
|
|
118
|
+
expect(art2.suggested_action).toBe('expand');
|
|
119
|
+
expect(art3.suggested_action).toBe('update_or_merge');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('AUDIT — logs success entry', async () => {
|
|
123
|
+
mockSuccess([makeArticle(1, 500, recentDate)]);
|
|
124
|
+
|
|
125
|
+
await call('wp_find_thin_content');
|
|
126
|
+
|
|
127
|
+
const logs = getAuditLogs();
|
|
128
|
+
const entry = logs.find(l => l.tool === 'wp_find_thin_content');
|
|
129
|
+
expect(entry).toBeDefined();
|
|
130
|
+
expect(entry.status).toBe('success');
|
|
131
|
+
expect(entry.action).toBe('audit_seo');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('ERROR — logs error on API failure', async () => {
|
|
135
|
+
mockError(403, '{"code":"rest_forbidden","message":"Forbidden"}');
|
|
136
|
+
|
|
137
|
+
const res = await call('wp_find_thin_content');
|
|
138
|
+
expect(res.isError).toBe(true);
|
|
139
|
+
|
|
140
|
+
const logs = getAuditLogs();
|
|
141
|
+
const entry = logs.find(l => l.tool === 'wp_find_thin_content');
|
|
142
|
+
expect(entry).toBeDefined();
|
|
143
|
+
expect(entry.status).toBe('error');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
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, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
import {
|
|
9
|
+
extractInternalLinks,
|
|
10
|
+
extractExternalLinks,
|
|
11
|
+
extractFocusKeyword,
|
|
12
|
+
calculateRelevanceScore,
|
|
13
|
+
} from '../../../src/linkUtils.js';
|
|
14
|
+
|
|
15
|
+
function call(name, args = {}) {
|
|
16
|
+
return handleToolCall(makeRequest(name, args));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SITE_URL = 'https://test.example.com';
|
|
20
|
+
|
|
21
|
+
// ────────────────────────────────────────────────────────────
|
|
22
|
+
// extractInternalLinks — unit tests
|
|
23
|
+
// ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('extractInternalLinks', () => {
|
|
26
|
+
it('detects internal links and ignores external', () => {
|
|
27
|
+
const html = '<a href="https://test.example.com/hello">Hello</a> <a href="https://other.com/bye">Bye</a>';
|
|
28
|
+
const result = extractInternalLinks(html, SITE_URL);
|
|
29
|
+
expect(result).toHaveLength(1);
|
|
30
|
+
expect(result[0].url).toBe('https://test.example.com/hello');
|
|
31
|
+
expect(result[0].anchor_text).toBe('Hello');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('handles relative hrefs (/)', () => {
|
|
35
|
+
const html = '<a href="/about">About</a> <a href="/contact">Contact</a>';
|
|
36
|
+
const result = extractInternalLinks(html, SITE_URL);
|
|
37
|
+
expect(result).toHaveLength(2);
|
|
38
|
+
expect(result[0].url).toBe('/about');
|
|
39
|
+
expect(result[1].url).toBe('/contact');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ────────────────────────────────────────────────────────────
|
|
44
|
+
// extractExternalLinks — unit tests
|
|
45
|
+
// ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe('extractExternalLinks', () => {
|
|
48
|
+
it('detects external links', () => {
|
|
49
|
+
const html = '<a href="https://test.example.com/hello">Hello</a> <a href="https://other.com/bye">Bye</a>';
|
|
50
|
+
const result = extractExternalLinks(html, SITE_URL);
|
|
51
|
+
expect(result).toHaveLength(1);
|
|
52
|
+
expect(result[0].url).toBe('https://other.com/bye');
|
|
53
|
+
expect(result[0].anchor_text).toBe('Bye');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ────────────────────────────────────────────────────────────
|
|
58
|
+
// extractFocusKeyword — unit tests
|
|
59
|
+
// ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe('extractFocusKeyword', () => {
|
|
62
|
+
it('RankMath detected', () => {
|
|
63
|
+
expect(extractFocusKeyword({ rank_math_focus_keyword: 'seo tips' })).toBe('seo tips');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('Yoast detected', () => {
|
|
67
|
+
expect(extractFocusKeyword({ _yoast_wpseo_focuskw: 'wordpress seo' })).toBe('wordpress seo');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('SEOPress detected', () => {
|
|
71
|
+
expect(extractFocusKeyword({ _seopress_analysis_target_kw: 'content marketing' })).toBe('content marketing');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('AIOSEO detected', () => {
|
|
75
|
+
expect(extractFocusKeyword({ _aioseo_keywords: 'blogging tips' })).toBe('blogging tips');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('no plugin → null', () => {
|
|
79
|
+
expect(extractFocusKeyword({})).toBeNull();
|
|
80
|
+
expect(extractFocusKeyword(null)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ────────────────────────────────────────────────────────────
|
|
85
|
+
// calculateRelevanceScore — unit tests
|
|
86
|
+
// ────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe('calculateRelevanceScore', () => {
|
|
89
|
+
it('common category +3', () => {
|
|
90
|
+
const result = calculateRelevanceScore(
|
|
91
|
+
{ id: 2, title: 'Other Post', date: new Date().toISOString(), categories: [1, 5], meta: {}, link: 'https://test.example.com/other' },
|
|
92
|
+
{ id: 1, categories: [1, 3], linkedUrls: [] },
|
|
93
|
+
[]
|
|
94
|
+
);
|
|
95
|
+
expect(result.breakdown.category_match).toBe(3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('freshness < 3 months +3', () => {
|
|
99
|
+
const recentDate = new Date();
|
|
100
|
+
recentDate.setMonth(recentDate.getMonth() - 1);
|
|
101
|
+
const result = calculateRelevanceScore(
|
|
102
|
+
{ id: 2, title: 'Recent', date: recentDate.toISOString(), categories: [], meta: {}, link: 'https://test.example.com/recent' },
|
|
103
|
+
{ id: 1, categories: [], linkedUrls: [] },
|
|
104
|
+
[]
|
|
105
|
+
);
|
|
106
|
+
expect(result.breakdown.freshness).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('already linked → score -999', () => {
|
|
110
|
+
const result = calculateRelevanceScore(
|
|
111
|
+
{ id: 2, title: 'Other', date: new Date().toISOString(), categories: [], meta: {}, link: 'https://test.example.com/other' },
|
|
112
|
+
{ id: 1, categories: [], linkedUrls: ['https://test.example.com/other'] },
|
|
113
|
+
[]
|
|
114
|
+
);
|
|
115
|
+
expect(result.total).toBe(-999);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ────────────────────────────────────────────────────────────
|
|
120
|
+
// wp_analyze_links — integration tests
|
|
121
|
+
// ────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('wp_analyze_links', () => {
|
|
124
|
+
let consoleSpy;
|
|
125
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
126
|
+
afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
|
|
127
|
+
|
|
128
|
+
const postWithLinks = {
|
|
129
|
+
id: 42,
|
|
130
|
+
title: { rendered: 'Test Post' },
|
|
131
|
+
content: { rendered: '<p>Check <a href="https://test.example.com/page1">Page 1</a> and <a href="https://other.com/ext">External</a></p>' },
|
|
132
|
+
status: 'publish',
|
|
133
|
+
meta: {}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
it('returns correct structure with link checking', async () => {
|
|
137
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
138
|
+
mockSuccess(postWithLinks);
|
|
139
|
+
// HEAD for internal link
|
|
140
|
+
fetch.mockImplementationOnce(() => Promise.resolve({ status: 200 }));
|
|
141
|
+
|
|
142
|
+
const res = await call('wp_analyze_links', { post_id: 42 });
|
|
143
|
+
const data = parseResult(res);
|
|
144
|
+
|
|
145
|
+
expect(data.post_id).toBe(42);
|
|
146
|
+
expect(data.post_title).toBe('Test Post');
|
|
147
|
+
expect(data.internal_links).toHaveLength(1);
|
|
148
|
+
expect(data.internal_links[0].url).toBe('https://test.example.com/page1');
|
|
149
|
+
expect(data.internal_links[0].status).toBe('ok');
|
|
150
|
+
expect(data.internal_links[0].http_code).toBe(200);
|
|
151
|
+
expect(data.external_links).toHaveLength(1);
|
|
152
|
+
expect(data.external_links[0].url).toBe('https://other.com/ext');
|
|
153
|
+
expect(data.summary.total_internal).toBe(1);
|
|
154
|
+
expect(data.summary.total_external).toBe(1);
|
|
155
|
+
expect(data.summary.broken_count).toBe(0);
|
|
156
|
+
|
|
157
|
+
const logs = getAuditLogs();
|
|
158
|
+
const entry = logs.find(l => l.tool === 'wp_analyze_links');
|
|
159
|
+
expect(entry).toBeDefined();
|
|
160
|
+
expect(entry.action).toBe('analyze_links');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('check_broken=false → all status "unchecked"', async () => {
|
|
164
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
165
|
+
mockSuccess(postWithLinks);
|
|
166
|
+
|
|
167
|
+
const res = await call('wp_analyze_links', { post_id: 42, check_broken: false });
|
|
168
|
+
const data = parseResult(res);
|
|
169
|
+
|
|
170
|
+
expect(data.internal_links[0].status).toBe('unchecked');
|
|
171
|
+
expect(data.internal_links[0].http_code).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('NOT blocked by WP_READ_ONLY (read-only tool)', async () => {
|
|
175
|
+
const original = process.env.WP_READ_ONLY;
|
|
176
|
+
process.env.WP_READ_ONLY = 'true';
|
|
177
|
+
try {
|
|
178
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
179
|
+
mockSuccess(postWithLinks);
|
|
180
|
+
fetch.mockImplementationOnce(() => Promise.resolve({ status: 200 }));
|
|
181
|
+
|
|
182
|
+
const res = await call('wp_analyze_links', { post_id: 42 });
|
|
183
|
+
expect(res.isError).toBeUndefined();
|
|
184
|
+
const data = parseResult(res);
|
|
185
|
+
expect(data.post_id).toBe(42);
|
|
186
|
+
} finally {
|
|
187
|
+
if (original === undefined) delete process.env.WP_READ_ONLY;
|
|
188
|
+
else process.env.WP_READ_ONLY = original;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ────────────────────────────────────────────────────────────
|
|
194
|
+
// wp_suggest_internal_links — integration tests
|
|
195
|
+
// ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe('wp_suggest_internal_links', () => {
|
|
198
|
+
let consoleSpy;
|
|
199
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
200
|
+
afterEach(() => { consoleSpy.mockRestore(); _testSetTarget(null); });
|
|
201
|
+
|
|
202
|
+
const currentPost = {
|
|
203
|
+
id: 1,
|
|
204
|
+
title: { rendered: 'Current Post About WordPress SEO' },
|
|
205
|
+
content: { rendered: '<p>Some content with <a href="https://test.example.com/existing-link">existing link</a></p>' },
|
|
206
|
+
status: 'publish',
|
|
207
|
+
categories: [5],
|
|
208
|
+
meta: { rank_math_focus_keyword: 'wordpress seo' }
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const candidate1 = {
|
|
212
|
+
id: 10,
|
|
213
|
+
title: { rendered: 'WordPress SEO Guide' },
|
|
214
|
+
content: { rendered: '' },
|
|
215
|
+
status: 'publish',
|
|
216
|
+
categories: [5],
|
|
217
|
+
date: new Date().toISOString(),
|
|
218
|
+
link: 'https://test.example.com/wordpress-seo-guide',
|
|
219
|
+
meta: { rank_math_focus_keyword: 'seo guide' }
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const candidate2 = {
|
|
223
|
+
id: 20,
|
|
224
|
+
title: { rendered: 'Cooking Tips' },
|
|
225
|
+
content: { rendered: '' },
|
|
226
|
+
status: 'publish',
|
|
227
|
+
categories: [99],
|
|
228
|
+
date: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString(),
|
|
229
|
+
link: 'https://test.example.com/cooking-tips',
|
|
230
|
+
meta: {}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
it('returns suggestions sorted by score', async () => {
|
|
234
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
235
|
+
mockSuccess(currentPost); // GET /posts/1
|
|
236
|
+
mockSuccess([candidate1, candidate2]); // search results
|
|
237
|
+
|
|
238
|
+
const res = await call('wp_suggest_internal_links', { post_id: 1 });
|
|
239
|
+
const data = parseResult(res);
|
|
240
|
+
|
|
241
|
+
expect(data.post_id).toBe(1);
|
|
242
|
+
expect(data.keywords_used).toContain('wordpress seo');
|
|
243
|
+
expect(data.suggestions.length).toBeGreaterThan(0);
|
|
244
|
+
// candidate1 should score higher (same category, recent, title match)
|
|
245
|
+
expect(data.suggestions[0].target_post_id).toBe(10);
|
|
246
|
+
expect(data.suggestions[0].relevance_score).toBeGreaterThan(0);
|
|
247
|
+
if (data.suggestions.length >= 2) {
|
|
248
|
+
expect(data.suggestions[0].relevance_score).toBeGreaterThanOrEqual(data.suggestions[1].relevance_score);
|
|
249
|
+
}
|
|
250
|
+
expect(data.suggestions[0].anchor_text).toBeDefined();
|
|
251
|
+
expect(data.suggestions[0].score_breakdown).toBeDefined();
|
|
252
|
+
|
|
253
|
+
const logs = getAuditLogs();
|
|
254
|
+
const entry = logs.find(l => l.tool === 'wp_suggest_internal_links');
|
|
255
|
+
expect(entry).toBeDefined();
|
|
256
|
+
expect(entry.action).toBe('suggest_links');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('exclude_already_linked filters correctly', async () => {
|
|
260
|
+
_testSetTarget('test', { url: 'https://test.example.com', username: 'u', password: 'p' });
|
|
261
|
+
|
|
262
|
+
const candidateLinked = {
|
|
263
|
+
id: 99,
|
|
264
|
+
title: { rendered: 'Already Linked Post' },
|
|
265
|
+
content: { rendered: '' },
|
|
266
|
+
status: 'publish',
|
|
267
|
+
categories: [5],
|
|
268
|
+
date: new Date().toISOString(),
|
|
269
|
+
link: 'https://test.example.com/existing-link',
|
|
270
|
+
meta: {}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
mockSuccess(currentPost); // GET /posts/1
|
|
274
|
+
mockSuccess([candidateLinked, candidate2]); // search results
|
|
275
|
+
|
|
276
|
+
const res = await call('wp_suggest_internal_links', { post_id: 1, exclude_already_linked: true });
|
|
277
|
+
const data = parseResult(res);
|
|
278
|
+
|
|
279
|
+
expect(data.excluded_already_linked).toBeGreaterThanOrEqual(1);
|
|
280
|
+
const ids = data.suggestions.map(s => s.target_post_id);
|
|
281
|
+
expect(ids).not.toContain(99);
|
|
282
|
+
});
|
|
283
|
+
});
|