@adsim/wordpress-mcp-server 1.0.0 → 3.0.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/.env.example +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/LICENSE +1 -1
- package/README.md +596 -135
- package/index.js +1367 -0
- package/package.json +21 -33
- package/src/auth/bearer.js +72 -0
- package/src/transport/http.js +264 -0
- package/tests/helpers/mockWpRequest.js +135 -0
- package/tests/unit/governance.test.js +260 -0
- package/tests/unit/tools/comments.test.js +170 -0
- package/tests/unit/tools/media.test.js +279 -0
- package/tests/unit/tools/pages.test.js +222 -0
- package/tests/unit/tools/plugins.test.js +268 -0
- package/tests/unit/tools/posts.test.js +310 -0
- package/tests/unit/tools/revisions.test.js +299 -0
- package/tests/unit/tools/search.test.js +190 -0
- package/tests/unit/tools/seo.test.js +248 -0
- package/tests/unit/tools/site.test.js +133 -0
- package/tests/unit/tools/taxonomies.test.js +220 -0
- package/tests/unit/tools/themes.test.js +163 -0
- package/tests/unit/tools/users.test.js +113 -0
- package/tests/unit/transport/http.test.js +300 -0
- package/vitest.config.js +12 -0
- package/dist/constants.d.ts +0 -13
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -10
- package/dist/constants.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -33
- package/dist/index.js.map +0 -1
- package/dist/schemas/index.d.ts +0 -308
- package/dist/schemas/index.d.ts.map +0 -1
- package/dist/schemas/index.js +0 -191
- package/dist/schemas/index.js.map +0 -1
- package/dist/services/formatters.d.ts +0 -22
- package/dist/services/formatters.d.ts.map +0 -1
- package/dist/services/formatters.js +0 -52
- package/dist/services/formatters.js.map +0 -1
- package/dist/services/wp-client.d.ts +0 -38
- package/dist/services/wp-client.d.ts.map +0 -1
- package/dist/services/wp-client.js +0 -102
- package/dist/services/wp-client.js.map +0 -1
- package/dist/tools/content.d.ts +0 -4
- package/dist/tools/content.d.ts.map +0 -1
- package/dist/tools/content.js +0 -196
- package/dist/tools/content.js.map +0 -1
- package/dist/tools/posts.d.ts +0 -4
- package/dist/tools/posts.d.ts.map +0 -1
- package/dist/tools/posts.js +0 -179
- package/dist/tools/posts.js.map +0 -1
- package/dist/tools/seo.d.ts +0 -4
- package/dist/tools/seo.d.ts.map +0 -1
- package/dist/tools/seo.js +0 -241
- package/dist/tools/seo.js.map +0 -1
- package/dist/tools/taxonomy.d.ts +0 -4
- package/dist/tools/taxonomy.d.ts.map +0 -1
- package/dist/tools/taxonomy.js +0 -82
- package/dist/tools/taxonomy.js.map +0 -1
- package/dist/types.d.ts +0 -160
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
// Shared mock data
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
const yoastPost = {
|
|
22
|
+
id: 1,
|
|
23
|
+
title: { rendered: 'Test Post' },
|
|
24
|
+
slug: 'test-post',
|
|
25
|
+
link: 'https://test.example.com/test-post',
|
|
26
|
+
status: 'publish',
|
|
27
|
+
meta: {
|
|
28
|
+
_yoast_wpseo_title: 'SEO Title',
|
|
29
|
+
_yoast_wpseo_metadesc: 'Meta description',
|
|
30
|
+
_yoast_wpseo_focuskw: 'keyword',
|
|
31
|
+
_yoast_wpseo_canonical: 'https://test.example.com/test-post',
|
|
32
|
+
_yoast_wpseo_meta_robots_noindex: '0',
|
|
33
|
+
_yoast_wpseo_meta_robots_nofollow: '0',
|
|
34
|
+
},
|
|
35
|
+
yoast_head_json: {
|
|
36
|
+
title: 'SEO Title',
|
|
37
|
+
description: 'Meta description',
|
|
38
|
+
canonical: 'https://test.example.com/test-post',
|
|
39
|
+
robots: { index: 'index', follow: 'follow' },
|
|
40
|
+
og_title: 'OG Title',
|
|
41
|
+
og_description: 'OG Desc',
|
|
42
|
+
og_image: [{ url: 'https://test.example.com/img.jpg' }],
|
|
43
|
+
schema: {},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// =========================================================================
|
|
48
|
+
// wp_get_seo_meta
|
|
49
|
+
// =========================================================================
|
|
50
|
+
|
|
51
|
+
describe('wp_get_seo_meta', () => {
|
|
52
|
+
it('SUCCESS — detects Yoast and returns seo fields', async () => {
|
|
53
|
+
mockSuccess(yoastPost);
|
|
54
|
+
|
|
55
|
+
const res = await call('wp_get_seo_meta', { id: 1 });
|
|
56
|
+
const data = parseResult(res);
|
|
57
|
+
|
|
58
|
+
expect(data.id).toBe(1);
|
|
59
|
+
expect(data.title).toBe('Test Post');
|
|
60
|
+
expect(data.seo.plugin).toBe('yoast');
|
|
61
|
+
expect(data.seo.title).toBe('SEO Title');
|
|
62
|
+
expect(data.seo.description).toBe('Meta description');
|
|
63
|
+
expect(data.seo.focus_keyword).toBe('keyword');
|
|
64
|
+
expect(data.seo.canonical).toBe('https://test.example.com/test-post');
|
|
65
|
+
expect(data.seo.robots.noindex).toBe(false);
|
|
66
|
+
expect(data.seo.robots.nofollow).toBe(false);
|
|
67
|
+
expect(data.seo.og_title).toBe('OG Title');
|
|
68
|
+
expect(data.seo.og_description).toBe('OG Desc');
|
|
69
|
+
expect(data.seo.og_image).toBe('https://test.example.com/img.jpg');
|
|
70
|
+
expect(data.seo.schema).toEqual({});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ERROR 403 — returns error message', async () => {
|
|
74
|
+
mockError(403, '{"code":"rest_forbidden"}');
|
|
75
|
+
|
|
76
|
+
const res = await call('wp_get_seo_meta', { id: 1 });
|
|
77
|
+
expect(res.isError).toBe(true);
|
|
78
|
+
expect(res.content[0].text).toContain('403');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ERROR 404 — returns error message', async () => {
|
|
82
|
+
mockError(404, '{"code":"rest_post_invalid_id"}');
|
|
83
|
+
|
|
84
|
+
const res = await call('wp_get_seo_meta', { id: 99999 });
|
|
85
|
+
expect(res.isError).toBe(true);
|
|
86
|
+
expect(res.content[0].text).toContain('404');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('AUDIT — logs read_seo action on success', async () => {
|
|
90
|
+
mockSuccess(yoastPost);
|
|
91
|
+
|
|
92
|
+
await call('wp_get_seo_meta', { id: 1 });
|
|
93
|
+
|
|
94
|
+
const logs = getAuditLogs();
|
|
95
|
+
const entry = logs.find(l => l.tool === 'wp_get_seo_meta');
|
|
96
|
+
expect(entry).toBeDefined();
|
|
97
|
+
expect(entry.status).toBe('success');
|
|
98
|
+
expect(entry.action).toBe('read_seo');
|
|
99
|
+
expect(entry.target).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// =========================================================================
|
|
104
|
+
// wp_update_seo_meta
|
|
105
|
+
// =========================================================================
|
|
106
|
+
|
|
107
|
+
describe('wp_update_seo_meta', () => {
|
|
108
|
+
it('SUCCESS — detects Yoast and updates title + description', async () => {
|
|
109
|
+
// First fetch: read current post to detect plugin
|
|
110
|
+
mockSuccess(yoastPost);
|
|
111
|
+
// Second fetch: write the meta update
|
|
112
|
+
mockSuccess({});
|
|
113
|
+
|
|
114
|
+
const res = await call('wp_update_seo_meta', { id: 1, title: 'New SEO Title', description: 'New desc' });
|
|
115
|
+
const data = parseResult(res);
|
|
116
|
+
|
|
117
|
+
expect(data.success).toBe(true);
|
|
118
|
+
expect(data.plugin).toBe('yoast');
|
|
119
|
+
expect(data.fields_updated).toContain('title');
|
|
120
|
+
expect(data.fields_updated).toContain('description');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
|
|
124
|
+
const original = process.env.WP_READ_ONLY;
|
|
125
|
+
process.env.WP_READ_ONLY = 'true';
|
|
126
|
+
try {
|
|
127
|
+
const res = await call('wp_update_seo_meta', { id: 1, title: 'Blocked' });
|
|
128
|
+
expect(res.isError).toBe(true);
|
|
129
|
+
expect(res.content[0].text).toContain('READ-ONLY');
|
|
130
|
+
expect(res.content[0].text).toContain('wp_update_seo_meta');
|
|
131
|
+
} finally {
|
|
132
|
+
if (original === undefined) delete process.env.WP_READ_ONLY;
|
|
133
|
+
else process.env.WP_READ_ONLY = original;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('ERROR 403 — returns error message', async () => {
|
|
138
|
+
mockError(403, '{"code":"rest_forbidden"}');
|
|
139
|
+
|
|
140
|
+
const res = await call('wp_update_seo_meta', { id: 1, title: 'Forbidden' });
|
|
141
|
+
expect(res.isError).toBe(true);
|
|
142
|
+
expect(res.content[0].text).toContain('403');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('ERROR 404 — returns error message', async () => {
|
|
146
|
+
mockError(404, '{"code":"rest_post_invalid_id"}');
|
|
147
|
+
|
|
148
|
+
const res = await call('wp_update_seo_meta', { id: 99999, title: 'Missing' });
|
|
149
|
+
expect(res.isError).toBe(true);
|
|
150
|
+
expect(res.content[0].text).toContain('404');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('AUDIT — logs update_seo action on success', async () => {
|
|
154
|
+
mockSuccess(yoastPost);
|
|
155
|
+
mockSuccess({});
|
|
156
|
+
|
|
157
|
+
await call('wp_update_seo_meta', { id: 1, title: 'Audit Test' });
|
|
158
|
+
|
|
159
|
+
const logs = getAuditLogs();
|
|
160
|
+
const entry = logs.find(l => l.tool === 'wp_update_seo_meta');
|
|
161
|
+
expect(entry).toBeDefined();
|
|
162
|
+
expect(entry.status).toBe('success');
|
|
163
|
+
expect(entry.action).toBe('update_seo');
|
|
164
|
+
expect(entry.target).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// =========================================================================
|
|
169
|
+
// wp_audit_seo
|
|
170
|
+
// =========================================================================
|
|
171
|
+
|
|
172
|
+
describe('wp_audit_seo', () => {
|
|
173
|
+
const auditPosts = [
|
|
174
|
+
{
|
|
175
|
+
id: 1,
|
|
176
|
+
title: { rendered: 'Good Post' },
|
|
177
|
+
slug: 'good',
|
|
178
|
+
link: 'https://test.example.com/good',
|
|
179
|
+
meta: {
|
|
180
|
+
_yoast_wpseo_title: 'Good SEO Title Here Enough Length',
|
|
181
|
+
_yoast_wpseo_metadesc: 'This is a properly written meta description that has enough length to be considered good by SEO standards for search results.',
|
|
182
|
+
_yoast_wpseo_focuskw: 'good',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 2,
|
|
187
|
+
title: { rendered: 'Bad Post' },
|
|
188
|
+
slug: 'bad',
|
|
189
|
+
link: 'https://test.example.com/bad',
|
|
190
|
+
meta: {},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
it('SUCCESS — audits posts and calculates scores correctly', async () => {
|
|
195
|
+
mockSuccess(auditPosts);
|
|
196
|
+
|
|
197
|
+
const res = await call('wp_audit_seo', { post_type: 'post' });
|
|
198
|
+
const data = parseResult(res);
|
|
199
|
+
|
|
200
|
+
// Summary checks
|
|
201
|
+
expect(data.summary.total_audited).toBe(2);
|
|
202
|
+
expect(data.summary.seo_plugin).toBe('yoast');
|
|
203
|
+
|
|
204
|
+
// Good post: score 100 (title 30+ chars, desc 120+ chars, keyword present, keyword in title)
|
|
205
|
+
const goodPost = data.posts.find(p => p.id === 1);
|
|
206
|
+
expect(goodPost.score).toBe(100);
|
|
207
|
+
expect(goodPost.issues).toHaveLength(0);
|
|
208
|
+
|
|
209
|
+
// Bad post: missing_seo_title (-30), missing_meta_description (-30), missing_focus_keyword (-20) = 20
|
|
210
|
+
const badPost = data.posts.find(p => p.id === 2);
|
|
211
|
+
expect(badPost.score).toBe(20);
|
|
212
|
+
expect(badPost.issues).toContain('missing_seo_title');
|
|
213
|
+
expect(badPost.issues).toContain('missing_meta_description');
|
|
214
|
+
expect(badPost.issues).toContain('missing_focus_keyword');
|
|
215
|
+
|
|
216
|
+
// Average score = Math.round((100 + 20) / 2) = 60
|
|
217
|
+
expect(data.summary.average_score).toBe(60);
|
|
218
|
+
|
|
219
|
+
// Issues breakdown
|
|
220
|
+
expect(data.summary.issues_breakdown.missing_seo_title).toBe(1);
|
|
221
|
+
expect(data.summary.issues_breakdown.missing_meta_description).toBe(1);
|
|
222
|
+
expect(data.summary.issues_breakdown.missing_focus_keyword).toBe(1);
|
|
223
|
+
expect(data.summary.issues_breakdown.title_length_issues).toBe(0);
|
|
224
|
+
expect(data.summary.issues_breakdown.description_length_issues).toBe(0);
|
|
225
|
+
expect(data.summary.issues_breakdown.keyword_not_in_title).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('ERROR 403 — returns error message', async () => {
|
|
229
|
+
mockError(403, '{"code":"rest_forbidden"}');
|
|
230
|
+
|
|
231
|
+
const res = await call('wp_audit_seo', { post_type: 'post' });
|
|
232
|
+
expect(res.isError).toBe(true);
|
|
233
|
+
expect(res.content[0].text).toContain('403');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('AUDIT — logs audit_seo action on success', async () => {
|
|
237
|
+
mockSuccess(auditPosts);
|
|
238
|
+
|
|
239
|
+
await call('wp_audit_seo', { post_type: 'post' });
|
|
240
|
+
|
|
241
|
+
const logs = getAuditLogs();
|
|
242
|
+
const entry = logs.find(l => l.tool === 'wp_audit_seo');
|
|
243
|
+
expect(entry).toBeDefined();
|
|
244
|
+
expect(entry.status).toBe('success');
|
|
245
|
+
expect(entry.action).toBe('audit_seo');
|
|
246
|
+
expect(entry.params.avg_score).toBe(60);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
// Shared mock data
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
const siteInfoResponse = {
|
|
22
|
+
name: 'Test Site',
|
|
23
|
+
description: 'Just a test',
|
|
24
|
+
url: 'https://test.example.com',
|
|
25
|
+
gmt_offset: 1,
|
|
26
|
+
timezone_string: 'Europe/Brussels',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const userMeResponse = {
|
|
30
|
+
id: 1,
|
|
31
|
+
name: 'Admin',
|
|
32
|
+
slug: 'admin',
|
|
33
|
+
roles: ['administrator'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const postTypesResponse = {
|
|
37
|
+
post: { slug: 'post', name: 'Posts', rest_base: 'posts' },
|
|
38
|
+
page: { slug: 'page', name: 'Pages', rest_base: 'pages' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// =========================================================================
|
|
42
|
+
// wp_site_info
|
|
43
|
+
// =========================================================================
|
|
44
|
+
|
|
45
|
+
describe('wp_site_info', () => {
|
|
46
|
+
it('SUCCESS — returns site, current_user, post_types, enterprise_controls, and server info', async () => {
|
|
47
|
+
// wp_site_info makes 3 sequential fetch calls:
|
|
48
|
+
// 1. GET /wp-json (direct fetch)
|
|
49
|
+
// 2. GET /wp-json/wp/v2/users/me (direct fetch)
|
|
50
|
+
// 3. GET /wp-json/wp/v2/types (via wpApiCall)
|
|
51
|
+
mockSuccess(siteInfoResponse);
|
|
52
|
+
mockSuccess(userMeResponse);
|
|
53
|
+
mockSuccess(postTypesResponse);
|
|
54
|
+
|
|
55
|
+
const res = await call('wp_site_info');
|
|
56
|
+
const data = parseResult(res);
|
|
57
|
+
|
|
58
|
+
// Site info
|
|
59
|
+
expect(data.site.name).toBe('Test Site');
|
|
60
|
+
expect(data.site.description).toBe('Just a test');
|
|
61
|
+
|
|
62
|
+
// Current user
|
|
63
|
+
expect(data.current_user.name).toBe('Admin');
|
|
64
|
+
expect(data.current_user.roles).toEqual(['administrator']);
|
|
65
|
+
|
|
66
|
+
// Post types
|
|
67
|
+
expect(data.post_types).toHaveLength(2);
|
|
68
|
+
|
|
69
|
+
// Enterprise controls
|
|
70
|
+
expect(data.enterprise_controls).toBeDefined();
|
|
71
|
+
expect(typeof data.enterprise_controls.read_only).toBe('boolean');
|
|
72
|
+
expect(typeof data.enterprise_controls.draft_only).toBe('boolean');
|
|
73
|
+
expect(typeof data.enterprise_controls.delete_disabled).toBe('boolean');
|
|
74
|
+
expect(typeof data.enterprise_controls.plugin_management_disabled).toBe('boolean');
|
|
75
|
+
|
|
76
|
+
// Server info
|
|
77
|
+
expect(data.server.mcp_version).toBeDefined();
|
|
78
|
+
expect(data.server.tools_count).toBe(35);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('SUCCESS — enterprise_controls reflect WP_READ_ONLY env var', async () => {
|
|
82
|
+
const original = process.env.WP_READ_ONLY;
|
|
83
|
+
process.env.WP_READ_ONLY = 'true';
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
mockSuccess(siteInfoResponse);
|
|
87
|
+
mockSuccess(userMeResponse);
|
|
88
|
+
mockSuccess(postTypesResponse);
|
|
89
|
+
|
|
90
|
+
const res = await call('wp_site_info');
|
|
91
|
+
const data = parseResult(res);
|
|
92
|
+
|
|
93
|
+
expect(data.enterprise_controls.read_only).toBe(true);
|
|
94
|
+
} finally {
|
|
95
|
+
if (original === undefined) delete process.env.WP_READ_ONLY;
|
|
96
|
+
else process.env.WP_READ_ONLY = original;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('AUDIT — logs info action on success', async () => {
|
|
101
|
+
mockSuccess(siteInfoResponse);
|
|
102
|
+
mockSuccess(userMeResponse);
|
|
103
|
+
mockSuccess(postTypesResponse);
|
|
104
|
+
|
|
105
|
+
await call('wp_site_info');
|
|
106
|
+
|
|
107
|
+
const logs = getAuditLogs();
|
|
108
|
+
const entry = logs.find(l => l.tool === 'wp_site_info');
|
|
109
|
+
expect(entry).toBeDefined();
|
|
110
|
+
expect(entry.status).toBe('success');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// =========================================================================
|
|
115
|
+
// wp_set_target
|
|
116
|
+
// =========================================================================
|
|
117
|
+
|
|
118
|
+
describe('wp_set_target', () => {
|
|
119
|
+
it('ERROR — site not found when no targets configured', async () => {
|
|
120
|
+
const res = await call('wp_set_target', { site: 'staging' });
|
|
121
|
+
expect(res.isError).toBe(true);
|
|
122
|
+
expect(res.content[0].text).toContain('not found');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('AUDIT — logs error on site not found', async () => {
|
|
126
|
+
await call('wp_set_target', { site: 'staging' });
|
|
127
|
+
|
|
128
|
+
const logs = getAuditLogs();
|
|
129
|
+
const entry = logs.find(l => l.tool === 'wp_set_target');
|
|
130
|
+
expect(entry).toBeDefined();
|
|
131
|
+
expect(entry.status).toBe('error');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
// ────────────────────────────────────────────────────────────
|
|
14
|
+
// wp_list_categories
|
|
15
|
+
// ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe('wp_list_categories', () => {
|
|
18
|
+
let consoleSpy;
|
|
19
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
20
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
21
|
+
|
|
22
|
+
it('returns formatted category list on success', async () => {
|
|
23
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
24
|
+
{ id: 1, name: 'Uncategorized', slug: 'uncategorized', description: '', parent: 0, count: 5 }
|
|
25
|
+
]));
|
|
26
|
+
|
|
27
|
+
const result = await call('wp_list_categories');
|
|
28
|
+
const data = parseResult(result);
|
|
29
|
+
|
|
30
|
+
expect(data.total).toBe(1);
|
|
31
|
+
expect(data.categories).toHaveLength(1);
|
|
32
|
+
expect(data.categories[0].id).toBe(1);
|
|
33
|
+
expect(data.categories[0].name).toBe('Uncategorized');
|
|
34
|
+
expect(data.categories[0].slug).toBe('uncategorized');
|
|
35
|
+
expect(data.categories[0].description).toBe('');
|
|
36
|
+
expect(data.categories[0].parent).toBe(0);
|
|
37
|
+
expect(data.categories[0].count).toBe(5);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns error on 403', async () => {
|
|
41
|
+
fetch.mockResolvedValue(mockError(403));
|
|
42
|
+
|
|
43
|
+
const result = await call('wp_list_categories');
|
|
44
|
+
|
|
45
|
+
expect(result.isError).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns error on 404', async () => {
|
|
49
|
+
fetch.mockResolvedValue(mockError(404));
|
|
50
|
+
|
|
51
|
+
const result = await call('wp_list_categories');
|
|
52
|
+
|
|
53
|
+
expect(result.isError).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('logs audit entry with status success', async () => {
|
|
57
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
58
|
+
{ id: 1, name: 'Uncategorized', slug: 'uncategorized', description: '', parent: 0, count: 5 }
|
|
59
|
+
]));
|
|
60
|
+
|
|
61
|
+
await call('wp_list_categories');
|
|
62
|
+
const logs = getAuditLogs(consoleSpy);
|
|
63
|
+
const entry = logs.find(l => l.tool === 'wp_list_categories');
|
|
64
|
+
|
|
65
|
+
expect(entry).toBeDefined();
|
|
66
|
+
expect(entry.status).toBe('success');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ────────────────────────────────────────────────────────────
|
|
71
|
+
// wp_list_tags
|
|
72
|
+
// ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('wp_list_tags', () => {
|
|
75
|
+
let consoleSpy;
|
|
76
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
77
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
78
|
+
|
|
79
|
+
it('returns formatted tag list on success', async () => {
|
|
80
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
81
|
+
{ id: 10, name: 'javascript', slug: 'javascript', count: 3 }
|
|
82
|
+
]));
|
|
83
|
+
|
|
84
|
+
const result = await call('wp_list_tags');
|
|
85
|
+
const data = parseResult(result);
|
|
86
|
+
|
|
87
|
+
expect(data.total).toBe(1);
|
|
88
|
+
expect(data.tags).toHaveLength(1);
|
|
89
|
+
expect(data.tags[0].id).toBe(10);
|
|
90
|
+
expect(data.tags[0].name).toBe('javascript');
|
|
91
|
+
expect(data.tags[0].slug).toBe('javascript');
|
|
92
|
+
expect(data.tags[0].count).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns error on 403', async () => {
|
|
96
|
+
fetch.mockResolvedValue(mockError(403));
|
|
97
|
+
|
|
98
|
+
const result = await call('wp_list_tags');
|
|
99
|
+
|
|
100
|
+
expect(result.isError).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns error on 404', async () => {
|
|
104
|
+
fetch.mockResolvedValue(mockError(404));
|
|
105
|
+
|
|
106
|
+
const result = await call('wp_list_tags');
|
|
107
|
+
|
|
108
|
+
expect(result.isError).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('logs audit entry with status success', async () => {
|
|
112
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
113
|
+
{ id: 10, name: 'javascript', slug: 'javascript', count: 3 }
|
|
114
|
+
]));
|
|
115
|
+
|
|
116
|
+
await call('wp_list_tags');
|
|
117
|
+
const logs = getAuditLogs(consoleSpy);
|
|
118
|
+
const entry = logs.find(l => l.tool === 'wp_list_tags');
|
|
119
|
+
|
|
120
|
+
expect(entry).toBeDefined();
|
|
121
|
+
expect(entry.status).toBe('success');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ────────────────────────────────────────────────────────────
|
|
126
|
+
// wp_create_taxonomy_term
|
|
127
|
+
// ────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('wp_create_taxonomy_term', () => {
|
|
130
|
+
let consoleSpy;
|
|
131
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
132
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
133
|
+
|
|
134
|
+
it('creates a category on success', async () => {
|
|
135
|
+
fetch.mockResolvedValue(mockSuccess({ id: 2, name: 'Tech', slug: 'tech' }));
|
|
136
|
+
|
|
137
|
+
const result = await call('wp_create_taxonomy_term', { taxonomy: 'category', name: 'Tech' });
|
|
138
|
+
const data = parseResult(result);
|
|
139
|
+
|
|
140
|
+
expect(data.success).toBe(true);
|
|
141
|
+
expect(data.message).toContain('category');
|
|
142
|
+
expect(data.message).toContain('Tech');
|
|
143
|
+
expect(data.term.id).toBe(2);
|
|
144
|
+
expect(data.term.name).toBe('Tech');
|
|
145
|
+
expect(data.term.slug).toBe('tech');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('creates a tag on success', async () => {
|
|
149
|
+
fetch.mockResolvedValue(mockSuccess({ id: 11, name: 'react', slug: 'react' }));
|
|
150
|
+
|
|
151
|
+
const result = await call('wp_create_taxonomy_term', { taxonomy: 'tag', name: 'react' });
|
|
152
|
+
const data = parseResult(result);
|
|
153
|
+
|
|
154
|
+
expect(data.success).toBe(true);
|
|
155
|
+
expect(data.message).toContain('tag');
|
|
156
|
+
expect(data.message).toContain('react');
|
|
157
|
+
expect(data.term.id).toBe(11);
|
|
158
|
+
expect(data.term.name).toBe('react');
|
|
159
|
+
expect(data.term.slug).toBe('react');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('is blocked in READ-ONLY mode (governance)', async () => {
|
|
163
|
+
process.env.WP_READ_ONLY = 'true';
|
|
164
|
+
|
|
165
|
+
const result = await call('wp_create_taxonomy_term', { taxonomy: 'category', name: 'Blocked' });
|
|
166
|
+
|
|
167
|
+
expect(result.isError).toBe(true);
|
|
168
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
169
|
+
|
|
170
|
+
delete process.env.WP_READ_ONLY;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects invalid taxonomy value', async () => {
|
|
174
|
+
const result = await call('wp_create_taxonomy_term', { taxonomy: 'invalid', name: 'Test' });
|
|
175
|
+
|
|
176
|
+
expect(result.isError).toBe(true);
|
|
177
|
+
expect(result.content[0].text).toContain('taxonomy');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns error on 403', async () => {
|
|
181
|
+
fetch.mockResolvedValue(mockError(403));
|
|
182
|
+
|
|
183
|
+
const result = await call('wp_create_taxonomy_term', { taxonomy: 'category', name: 'Forbidden' });
|
|
184
|
+
|
|
185
|
+
expect(result.isError).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('returns error on 404', async () => {
|
|
189
|
+
fetch.mockResolvedValue(mockError(404));
|
|
190
|
+
|
|
191
|
+
const result = await call('wp_create_taxonomy_term', { taxonomy: 'tag', name: 'Missing' });
|
|
192
|
+
|
|
193
|
+
expect(result.isError).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('logs audit entry with status success on create', async () => {
|
|
197
|
+
fetch.mockResolvedValue(mockSuccess({ id: 2, name: 'Tech', slug: 'tech' }));
|
|
198
|
+
|
|
199
|
+
await call('wp_create_taxonomy_term', { taxonomy: 'category', name: 'Tech' });
|
|
200
|
+
const logs = getAuditLogs(consoleSpy);
|
|
201
|
+
const entry = logs.find(l => l.tool === 'wp_create_taxonomy_term');
|
|
202
|
+
|
|
203
|
+
expect(entry).toBeDefined();
|
|
204
|
+
expect(entry.status).toBe('success');
|
|
205
|
+
expect(entry.target).toBe(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('logs audit entry with status blocked in READ-ONLY mode', async () => {
|
|
209
|
+
process.env.WP_READ_ONLY = 'true';
|
|
210
|
+
|
|
211
|
+
await call('wp_create_taxonomy_term', { taxonomy: 'category', name: 'Blocked' });
|
|
212
|
+
const logs = getAuditLogs(consoleSpy);
|
|
213
|
+
const entry = logs.find(l => l.tool === 'wp_create_taxonomy_term');
|
|
214
|
+
|
|
215
|
+
expect(entry).toBeDefined();
|
|
216
|
+
expect(entry.status).toBe('blocked');
|
|
217
|
+
|
|
218
|
+
delete process.env.WP_READ_ONLY;
|
|
219
|
+
});
|
|
220
|
+
});
|