@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.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/LICENSE +1 -1
  4. package/README.md +596 -135
  5. package/index.js +1367 -0
  6. package/package.json +21 -33
  7. package/src/auth/bearer.js +72 -0
  8. package/src/transport/http.js +264 -0
  9. package/tests/helpers/mockWpRequest.js +135 -0
  10. package/tests/unit/governance.test.js +260 -0
  11. package/tests/unit/tools/comments.test.js +170 -0
  12. package/tests/unit/tools/media.test.js +279 -0
  13. package/tests/unit/tools/pages.test.js +222 -0
  14. package/tests/unit/tools/plugins.test.js +268 -0
  15. package/tests/unit/tools/posts.test.js +310 -0
  16. package/tests/unit/tools/revisions.test.js +299 -0
  17. package/tests/unit/tools/search.test.js +190 -0
  18. package/tests/unit/tools/seo.test.js +248 -0
  19. package/tests/unit/tools/site.test.js +133 -0
  20. package/tests/unit/tools/taxonomies.test.js +220 -0
  21. package/tests/unit/tools/themes.test.js +163 -0
  22. package/tests/unit/tools/users.test.js +113 -0
  23. package/tests/unit/transport/http.test.js +300 -0
  24. package/vitest.config.js +12 -0
  25. package/dist/constants.d.ts +0 -13
  26. package/dist/constants.d.ts.map +0 -1
  27. package/dist/constants.js +0 -10
  28. package/dist/constants.js.map +0 -1
  29. package/dist/index.d.ts +0 -3
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -33
  32. package/dist/index.js.map +0 -1
  33. package/dist/schemas/index.d.ts +0 -308
  34. package/dist/schemas/index.d.ts.map +0 -1
  35. package/dist/schemas/index.js +0 -191
  36. package/dist/schemas/index.js.map +0 -1
  37. package/dist/services/formatters.d.ts +0 -22
  38. package/dist/services/formatters.d.ts.map +0 -1
  39. package/dist/services/formatters.js +0 -52
  40. package/dist/services/formatters.js.map +0 -1
  41. package/dist/services/wp-client.d.ts +0 -38
  42. package/dist/services/wp-client.d.ts.map +0 -1
  43. package/dist/services/wp-client.js +0 -102
  44. package/dist/services/wp-client.js.map +0 -1
  45. package/dist/tools/content.d.ts +0 -4
  46. package/dist/tools/content.d.ts.map +0 -1
  47. package/dist/tools/content.js +0 -196
  48. package/dist/tools/content.js.map +0 -1
  49. package/dist/tools/posts.d.ts +0 -4
  50. package/dist/tools/posts.d.ts.map +0 -1
  51. package/dist/tools/posts.js +0 -179
  52. package/dist/tools/posts.js.map +0 -1
  53. package/dist/tools/seo.d.ts +0 -4
  54. package/dist/tools/seo.d.ts.map +0 -1
  55. package/dist/tools/seo.js +0 -241
  56. package/dist/tools/seo.js.map +0 -1
  57. package/dist/tools/taxonomy.d.ts +0 -4
  58. package/dist/tools/taxonomy.d.ts.map +0 -1
  59. package/dist/tools/taxonomy.js +0 -82
  60. package/dist/tools/taxonomy.js.map +0 -1
  61. package/dist/types.d.ts +0 -160
  62. package/dist/types.d.ts.map +0 -1
  63. package/dist/types.js +0 -3
  64. 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
+ });