@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,268 @@
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 pluginsList = [
22
+ {
23
+ plugin: 'akismet/akismet.php',
24
+ name: 'Akismet',
25
+ version: '5.3',
26
+ status: 'active',
27
+ author: { rendered: 'Automattic' },
28
+ description: { rendered: '<p>Anti-spam plugin</p>' },
29
+ plugin_uri: 'https://akismet.com',
30
+ requires_wp: '6.0',
31
+ requires_php: '7.4',
32
+ network_only: false,
33
+ textdomain: 'akismet',
34
+ },
35
+ {
36
+ plugin: 'hello-dolly/hello.php',
37
+ name: 'Hello Dolly',
38
+ version: '1.7',
39
+ status: 'inactive',
40
+ author: { rendered: 'Matt Mullenweg' },
41
+ description: { rendered: '<p>Hello Dolly</p>' },
42
+ plugin_uri: '',
43
+ requires_wp: '',
44
+ requires_php: '',
45
+ network_only: false,
46
+ textdomain: 'hello-dolly',
47
+ },
48
+ ];
49
+
50
+ // =========================================================================
51
+ // wp_list_plugins
52
+ // =========================================================================
53
+
54
+ describe('wp_list_plugins', () => {
55
+ it('SUCCESS — returns total, active, inactive counts and plugins', async () => {
56
+ mockSuccess(pluginsList);
57
+
58
+ const res = await call('wp_list_plugins');
59
+ const data = parseResult(res);
60
+
61
+ expect(data.total).toBe(2);
62
+ expect(data.active).toBe(1);
63
+ expect(data.inactive).toBe(1);
64
+ expect(data.plugins).toHaveLength(2);
65
+
66
+ const akismet = data.plugins.find(p => p.plugin === 'akismet/akismet.php');
67
+ expect(akismet.name).toBe('Akismet');
68
+ expect(akismet.version).toBe('5.3');
69
+ expect(akismet.status).toBe('active');
70
+ expect(akismet.description).toBe('Anti-spam plugin');
71
+ });
72
+
73
+ it('SUCCESS — status filter is passed to URL', async () => {
74
+ mockSuccess([pluginsList[0]]);
75
+
76
+ await call('wp_list_plugins', { status: 'active' });
77
+
78
+ // Verify the fetch URL includes the status parameter
79
+ const fetchUrl = fetch.mock.calls[0][0];
80
+ expect(fetchUrl).toContain('status=active');
81
+ });
82
+
83
+ it('ERROR 403 — returns specific Administrator role message', async () => {
84
+ mockError(403, '{"code":"rest_forbidden"}');
85
+
86
+ const res = await call('wp_list_plugins');
87
+ expect(res.isError).toBe(true);
88
+ expect(res.content[0].text).toContain('Administrator role');
89
+ expect(res.content[0].text).toContain('activate_plugins');
90
+ });
91
+
92
+ it('AUDIT — logs list action on success', async () => {
93
+ mockSuccess(pluginsList);
94
+
95
+ await call('wp_list_plugins');
96
+
97
+ const logs = getAuditLogs();
98
+ const entry = logs.find(l => l.tool === 'wp_list_plugins');
99
+ expect(entry).toBeDefined();
100
+ expect(entry.status).toBe('success');
101
+ expect(entry.action).toBe('list');
102
+ expect(entry.target_type).toBe('plugin');
103
+ });
104
+ });
105
+
106
+ // =========================================================================
107
+ // wp_activate_plugin
108
+ // =========================================================================
109
+
110
+ describe('wp_activate_plugin', () => {
111
+ const activatedPlugin = {
112
+ plugin: 'hello-dolly/hello.php',
113
+ name: 'Hello Dolly',
114
+ version: '1.7',
115
+ status: 'active',
116
+ };
117
+
118
+ it('SUCCESS — activates plugin and returns status active', async () => {
119
+ mockSuccess(activatedPlugin);
120
+
121
+ const res = await call('wp_activate_plugin', { plugin: 'hello-dolly/hello.php' });
122
+ const data = parseResult(res);
123
+
124
+ expect(data.success).toBe(true);
125
+ expect(data.plugin.status).toBe('active');
126
+ expect(data.plugin.name).toBe('Hello Dolly');
127
+ });
128
+
129
+ it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
130
+ const original = process.env.WP_READ_ONLY;
131
+ process.env.WP_READ_ONLY = 'true';
132
+ try {
133
+ const res = await call('wp_activate_plugin', { plugin: 'hello-dolly/hello.php' });
134
+ expect(res.isError).toBe(true);
135
+ expect(res.content[0].text).toContain('READ-ONLY');
136
+ expect(res.content[0].text).toContain('wp_activate_plugin');
137
+ } finally {
138
+ if (original === undefined) delete process.env.WP_READ_ONLY;
139
+ else process.env.WP_READ_ONLY = original;
140
+ }
141
+ });
142
+
143
+ it('GOVERNANCE — blocked by WP_DISABLE_PLUGIN_MANAGEMENT', async () => {
144
+ const original = process.env.WP_DISABLE_PLUGIN_MANAGEMENT;
145
+ process.env.WP_DISABLE_PLUGIN_MANAGEMENT = 'true';
146
+ try {
147
+ const res = await call('wp_activate_plugin', { plugin: 'hello-dolly/hello.php' });
148
+ expect(res.isError).toBe(true);
149
+ expect(res.content[0].text).toContain('WP_DISABLE_PLUGIN_MANAGEMENT');
150
+ expect(res.content[0].text).toContain('wp_activate_plugin');
151
+ } finally {
152
+ if (original === undefined) delete process.env.WP_DISABLE_PLUGIN_MANAGEMENT;
153
+ else process.env.WP_DISABLE_PLUGIN_MANAGEMENT = original;
154
+ }
155
+ });
156
+
157
+ it('ERROR 403 — returns specific Administrator role message', async () => {
158
+ mockError(403, '{"code":"rest_forbidden"}');
159
+
160
+ const res = await call('wp_activate_plugin', { plugin: 'hello-dolly/hello.php' });
161
+ expect(res.isError).toBe(true);
162
+ expect(res.content[0].text).toContain('Administrator role');
163
+ });
164
+
165
+ it('ERROR 404 — returns message mentioning wp_list_plugins', async () => {
166
+ mockError(404, '{"code":"rest_plugin_not_found"}');
167
+
168
+ const res = await call('wp_activate_plugin', { plugin: 'nonexistent/plugin.php' });
169
+ expect(res.isError).toBe(true);
170
+ expect(res.content[0].text).toContain('wp_list_plugins');
171
+ });
172
+
173
+ it('AUDIT — logs activate action on success', async () => {
174
+ mockSuccess(activatedPlugin);
175
+
176
+ await call('wp_activate_plugin', { plugin: 'hello-dolly/hello.php' });
177
+
178
+ const logs = getAuditLogs();
179
+ const entry = logs.find(l => l.tool === 'wp_activate_plugin');
180
+ expect(entry).toBeDefined();
181
+ expect(entry.status).toBe('success');
182
+ expect(entry.action).toBe('activate');
183
+ expect(entry.target).toBe('hello-dolly/hello.php');
184
+ expect(entry.target_type).toBe('plugin');
185
+ });
186
+ });
187
+
188
+ // =========================================================================
189
+ // wp_deactivate_plugin
190
+ // =========================================================================
191
+
192
+ describe('wp_deactivate_plugin', () => {
193
+ const deactivatedPlugin = {
194
+ plugin: 'akismet/akismet.php',
195
+ name: 'Akismet',
196
+ version: '5.3',
197
+ status: 'inactive',
198
+ };
199
+
200
+ it('SUCCESS — deactivates plugin and returns status inactive', async () => {
201
+ mockSuccess(deactivatedPlugin);
202
+
203
+ const res = await call('wp_deactivate_plugin', { plugin: 'akismet/akismet.php' });
204
+ const data = parseResult(res);
205
+
206
+ expect(data.success).toBe(true);
207
+ expect(data.plugin.status).toBe('inactive');
208
+ expect(data.plugin.name).toBe('Akismet');
209
+ });
210
+
211
+ it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
212
+ const original = process.env.WP_READ_ONLY;
213
+ process.env.WP_READ_ONLY = 'true';
214
+ try {
215
+ const res = await call('wp_deactivate_plugin', { plugin: 'akismet/akismet.php' });
216
+ expect(res.isError).toBe(true);
217
+ expect(res.content[0].text).toContain('READ-ONLY');
218
+ expect(res.content[0].text).toContain('wp_deactivate_plugin');
219
+ } finally {
220
+ if (original === undefined) delete process.env.WP_READ_ONLY;
221
+ else process.env.WP_READ_ONLY = original;
222
+ }
223
+ });
224
+
225
+ it('GOVERNANCE — blocked by WP_DISABLE_PLUGIN_MANAGEMENT', async () => {
226
+ const original = process.env.WP_DISABLE_PLUGIN_MANAGEMENT;
227
+ process.env.WP_DISABLE_PLUGIN_MANAGEMENT = 'true';
228
+ try {
229
+ const res = await call('wp_deactivate_plugin', { plugin: 'akismet/akismet.php' });
230
+ expect(res.isError).toBe(true);
231
+ expect(res.content[0].text).toContain('WP_DISABLE_PLUGIN_MANAGEMENT');
232
+ expect(res.content[0].text).toContain('wp_deactivate_plugin');
233
+ } finally {
234
+ if (original === undefined) delete process.env.WP_DISABLE_PLUGIN_MANAGEMENT;
235
+ else process.env.WP_DISABLE_PLUGIN_MANAGEMENT = original;
236
+ }
237
+ });
238
+
239
+ it('ERROR 403 — returns specific Administrator role message', async () => {
240
+ mockError(403, '{"code":"rest_forbidden"}');
241
+
242
+ const res = await call('wp_deactivate_plugin', { plugin: 'akismet/akismet.php' });
243
+ expect(res.isError).toBe(true);
244
+ expect(res.content[0].text).toContain('Administrator role');
245
+ });
246
+
247
+ it('ERROR 404 — returns message mentioning wp_list_plugins', async () => {
248
+ mockError(404, '{"code":"rest_plugin_not_found"}');
249
+
250
+ const res = await call('wp_deactivate_plugin', { plugin: 'nonexistent/plugin.php' });
251
+ expect(res.isError).toBe(true);
252
+ expect(res.content[0].text).toContain('wp_list_plugins');
253
+ });
254
+
255
+ it('AUDIT — logs deactivate action on success', async () => {
256
+ mockSuccess(deactivatedPlugin);
257
+
258
+ await call('wp_deactivate_plugin', { plugin: 'akismet/akismet.php' });
259
+
260
+ const logs = getAuditLogs();
261
+ const entry = logs.find(l => l.tool === 'wp_deactivate_plugin');
262
+ expect(entry).toBeDefined();
263
+ expect(entry.status).toBe('success');
264
+ expect(entry.action).toBe('deactivate');
265
+ expect(entry.target).toBe('akismet/akismet.php');
266
+ expect(entry.target_type).toBe('plugin');
267
+ });
268
+ });
@@ -0,0 +1,310 @@
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_posts
15
+ // ────────────────────────────────────────────────────────────
16
+
17
+ describe('wp_list_posts', () => {
18
+ let consoleSpy;
19
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
20
+ afterEach(() => { consoleSpy.mockRestore(); });
21
+
22
+ it('returns formatted post list on success', async () => {
23
+ fetch.mockResolvedValue(mockSuccess([
24
+ { id: 1, title: { rendered: 'Post 1' }, status: 'publish', date: '2024-01-01', modified: '2024-01-01', link: 'https://test.example.com/post-1', author: 1, categories: [1], tags: [], excerpt: { rendered: 'excerpt' } }
25
+ ]));
26
+
27
+ const result = await call('wp_list_posts');
28
+ const data = parseResult(result);
29
+
30
+ expect(data.total).toBe(1);
31
+ expect(data.posts[0].id).toBe(1);
32
+ expect(data.posts[0].title).toBe('Post 1');
33
+ expect(data.posts[0].status).toBe('publish');
34
+ });
35
+
36
+ it('logs audit entry with status success', async () => {
37
+ fetch.mockResolvedValue(mockSuccess([
38
+ { id: 1, title: { rendered: 'Post 1' }, status: 'publish', date: '2024-01-01', modified: '2024-01-01', link: 'https://test.example.com/post-1', author: 1, categories: [1], tags: [], excerpt: { rendered: 'excerpt' } }
39
+ ]));
40
+
41
+ await call('wp_list_posts');
42
+ const logs = getAuditLogs(consoleSpy);
43
+ const entry = logs.find(l => l.tool === 'wp_list_posts');
44
+
45
+ expect(entry).toBeDefined();
46
+ expect(entry.status).toBe('success');
47
+ });
48
+ });
49
+
50
+ // ────────────────────────────────────────────────────────────
51
+ // wp_get_post
52
+ // ────────────────────────────────────────────────────────────
53
+
54
+ describe('wp_get_post', () => {
55
+ let consoleSpy;
56
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
57
+ afterEach(() => { consoleSpy.mockRestore(); });
58
+
59
+ it('returns full post data on success', async () => {
60
+ fetch.mockResolvedValue(mockSuccess({
61
+ id: 1, title: { rendered: 'Post 1' }, content: { rendered: '<p>Content</p>' }, excerpt: { rendered: 'excerpt' },
62
+ status: 'publish', date: '2024-01-01', modified: '2024-01-01', link: 'https://test.example.com/post-1',
63
+ slug: 'post-1', categories: [1], tags: [], author: 1, featured_media: 0, comment_status: 'open', meta: {}
64
+ }));
65
+
66
+ const result = await call('wp_get_post', { id: 1 });
67
+ const data = parseResult(result);
68
+
69
+ expect(data.id).toBe(1);
70
+ expect(data.title).toBe('Post 1');
71
+ expect(data.content).toBe('<p>Content</p>');
72
+ expect(data.slug).toBe('post-1');
73
+ });
74
+
75
+ it('returns error on 404', async () => {
76
+ fetch.mockResolvedValue(mockError(404));
77
+
78
+ const result = await call('wp_get_post', { id: 999 });
79
+
80
+ expect(result.isError).toBe(true);
81
+ expect(result.content[0].text).toContain('404');
82
+ });
83
+
84
+ it('logs audit with tool name', async () => {
85
+ fetch.mockResolvedValue(mockSuccess({
86
+ id: 1, title: { rendered: 'Post 1' }, content: { rendered: '<p>Content</p>' }, excerpt: { rendered: 'excerpt' },
87
+ status: 'publish', date: '2024-01-01', modified: '2024-01-01', link: 'https://test.example.com/post-1',
88
+ slug: 'post-1', categories: [1], tags: [], author: 1, featured_media: 0, comment_status: 'open', meta: {}
89
+ }));
90
+
91
+ await call('wp_get_post', { id: 1 });
92
+ const logs = getAuditLogs(consoleSpy);
93
+ const entry = logs.find(l => l.tool === 'wp_get_post');
94
+
95
+ expect(entry).toBeDefined();
96
+ expect(entry.status).toBe('success');
97
+ expect(entry.target).toBe(1);
98
+ });
99
+ });
100
+
101
+ // ────────────────────────────────────────────────────────────
102
+ // wp_create_post
103
+ // ────────────────────────────────────────────────────────────
104
+
105
+ describe('wp_create_post', () => {
106
+ let consoleSpy;
107
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
108
+ afterEach(() => { consoleSpy.mockRestore(); });
109
+
110
+ it('creates a post and returns success shape', async () => {
111
+ fetch.mockResolvedValue(mockSuccess({
112
+ id: 2, title: { rendered: 'New Post' }, status: 'draft', link: 'https://test.example.com/?p=2', slug: 'new-post'
113
+ }));
114
+
115
+ const result = await call('wp_create_post', { title: 'New Post', content: 'Body text' });
116
+ const data = parseResult(result);
117
+
118
+ expect(data.success).toBe(true);
119
+ expect(data.post.id).toBe(2);
120
+ expect(data.post.title).toBe('New Post');
121
+ expect(data.post.status).toBe('draft');
122
+ });
123
+
124
+ it('is blocked by WP_READ_ONLY', async () => {
125
+ process.env.WP_READ_ONLY = 'true';
126
+ try {
127
+ const result = await call('wp_create_post', { title: 'T', content: 'C' });
128
+
129
+ expect(result.isError).toBe(true);
130
+ expect(result.content[0].text).toContain('READ-ONLY');
131
+
132
+ const logs = getAuditLogs(consoleSpy);
133
+ const entry = logs.find(l => l.tool === 'wp_create_post');
134
+ expect(entry).toBeDefined();
135
+ expect(entry.status).toBe('blocked');
136
+ } finally {
137
+ delete process.env.WP_READ_ONLY;
138
+ }
139
+ });
140
+
141
+ it('logs audit on success', async () => {
142
+ fetch.mockResolvedValue(mockSuccess({
143
+ id: 2, title: { rendered: 'New Post' }, status: 'draft', link: 'https://test.example.com/?p=2', slug: 'new-post'
144
+ }));
145
+
146
+ await call('wp_create_post', { title: 'New Post', content: 'Body' });
147
+ const logs = getAuditLogs(consoleSpy);
148
+ const entry = logs.find(l => l.tool === 'wp_create_post');
149
+
150
+ expect(entry).toBeDefined();
151
+ expect(entry.status).toBe('success');
152
+ expect(entry.action).toBe('create');
153
+ });
154
+ });
155
+
156
+ // ────────────────────────────────────────────────────────────
157
+ // wp_update_post
158
+ // ────────────────────────────────────────────────────────────
159
+
160
+ describe('wp_update_post', () => {
161
+ let consoleSpy;
162
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
163
+ afterEach(() => { consoleSpy.mockRestore(); });
164
+
165
+ it('updates a post and returns success shape', async () => {
166
+ fetch.mockResolvedValue(mockSuccess({
167
+ id: 1, title: { rendered: 'Updated' }, status: 'publish', link: 'https://test.example.com/post-1', modified: '2024-01-02'
168
+ }));
169
+
170
+ const result = await call('wp_update_post', { id: 1, title: 'Updated' });
171
+ const data = parseResult(result);
172
+
173
+ expect(data.success).toBe(true);
174
+ expect(data.post.id).toBe(1);
175
+ expect(data.post.title).toBe('Updated');
176
+ expect(data.post.modified).toBe('2024-01-02');
177
+ });
178
+
179
+ it('returns error on 404', async () => {
180
+ fetch.mockResolvedValue(mockError(404));
181
+
182
+ const result = await call('wp_update_post', { id: 999, title: 'Ghost' });
183
+
184
+ expect(result.isError).toBe(true);
185
+ expect(result.content[0].text).toContain('404');
186
+ });
187
+
188
+ it('is blocked by WP_READ_ONLY', async () => {
189
+ process.env.WP_READ_ONLY = 'true';
190
+ try {
191
+ const result = await call('wp_update_post', { id: 1, title: 'T' });
192
+
193
+ expect(result.isError).toBe(true);
194
+ expect(result.content[0].text).toContain('READ-ONLY');
195
+ } finally {
196
+ delete process.env.WP_READ_ONLY;
197
+ }
198
+ });
199
+
200
+ it('logs audit with tool name and target', async () => {
201
+ fetch.mockResolvedValue(mockSuccess({
202
+ id: 1, title: { rendered: 'Updated' }, status: 'publish', link: 'https://test.example.com/post-1', modified: '2024-01-02'
203
+ }));
204
+
205
+ await call('wp_update_post', { id: 1, title: 'Updated' });
206
+ const logs = getAuditLogs(consoleSpy);
207
+ const entry = logs.find(l => l.tool === 'wp_update_post');
208
+
209
+ expect(entry).toBeDefined();
210
+ expect(entry.status).toBe('success');
211
+ expect(entry.target).toBe(1);
212
+ });
213
+ });
214
+
215
+ // ────────────────────────────────────────────────────────────
216
+ // wp_delete_post
217
+ // ────────────────────────────────────────────────────────────
218
+
219
+ describe('wp_delete_post', () => {
220
+ let consoleSpy;
221
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
222
+ afterEach(() => { consoleSpy.mockRestore(); });
223
+
224
+ it('trashes a post and returns success shape', async () => {
225
+ fetch.mockResolvedValue(mockSuccess({
226
+ id: 1, title: { rendered: 'Post 1' }, status: 'trash'
227
+ }));
228
+
229
+ const result = await call('wp_delete_post', { id: 1 });
230
+ const data = parseResult(result);
231
+
232
+ expect(data.success).toBe(true);
233
+ expect(data.post.id).toBe(1);
234
+ expect(data.post.status).toBe('trash');
235
+ });
236
+
237
+ it('returns error on 404', async () => {
238
+ fetch.mockResolvedValue(mockError(404));
239
+
240
+ const result = await call('wp_delete_post', { id: 999 });
241
+
242
+ expect(result.isError).toBe(true);
243
+ expect(result.content[0].text).toContain('404');
244
+ });
245
+
246
+ it('is blocked by WP_READ_ONLY', async () => {
247
+ process.env.WP_READ_ONLY = 'true';
248
+ try {
249
+ const result = await call('wp_delete_post', { id: 1 });
250
+
251
+ expect(result.isError).toBe(true);
252
+ expect(result.content[0].text).toContain('READ-ONLY');
253
+ } finally {
254
+ delete process.env.WP_READ_ONLY;
255
+ }
256
+ });
257
+
258
+ it('logs audit with correct action', async () => {
259
+ fetch.mockResolvedValue(mockSuccess({
260
+ id: 1, title: { rendered: 'Post 1' }, status: 'trash'
261
+ }));
262
+
263
+ await call('wp_delete_post', { id: 1 });
264
+ const logs = getAuditLogs(consoleSpy);
265
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
266
+
267
+ expect(entry).toBeDefined();
268
+ expect(entry.status).toBe('success');
269
+ expect(entry.action).toBe('trash');
270
+ });
271
+ });
272
+
273
+ // ────────────────────────────────────────────────────────────
274
+ // wp_search
275
+ // ────────────────────────────────────────────────────────────
276
+
277
+ describe('wp_search', () => {
278
+ let consoleSpy;
279
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
280
+ afterEach(() => { consoleSpy.mockRestore(); });
281
+
282
+ it('returns search results on success', async () => {
283
+ fetch.mockResolvedValue(mockSuccess([
284
+ { id: 1, title: 'Post 1', url: 'https://test.example.com/post-1', type: 'post', subtype: 'post' }
285
+ ]));
286
+
287
+ const result = await call('wp_search', { search: 'Post' });
288
+ const data = parseResult(result);
289
+
290
+ expect(data.query).toBe('Post');
291
+ expect(data.total).toBe(1);
292
+ expect(data.results[0].id).toBe(1);
293
+ expect(data.results[0].title).toBe('Post 1');
294
+ expect(data.results[0].type).toBe('post');
295
+ });
296
+
297
+ it('logs audit with search action', async () => {
298
+ fetch.mockResolvedValue(mockSuccess([
299
+ { id: 1, title: 'Post 1', url: 'https://test.example.com/post-1', type: 'post', subtype: 'post' }
300
+ ]));
301
+
302
+ await call('wp_search', { search: 'Post' });
303
+ const logs = getAuditLogs(consoleSpy);
304
+ const entry = logs.find(l => l.tool === 'wp_search');
305
+
306
+ expect(entry).toBeDefined();
307
+ expect(entry.status).toBe('success');
308
+ expect(entry.action).toBe('search');
309
+ });
310
+ });