@adsim/wordpress-mcp-server 4.6.0 → 5.1.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 +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -0,0 +1,188 @@
|
|
|
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 { makeRequest, mockSuccess, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let consoleSpy;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fetch.mockReset();
|
|
17
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
18
|
+
_testSetTarget('site1', { url: 'https://example.com', auth: 'Basic dGVzdDp0ZXN0' });
|
|
19
|
+
delete process.env.WP_READ_ONLY;
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
consoleSpy.mockRestore();
|
|
23
|
+
delete process.env.WP_READ_ONLY;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function mockPost(overrides = {}) {
|
|
27
|
+
const { title, content, excerpt, ...rest } = overrides;
|
|
28
|
+
return {
|
|
29
|
+
id: 1,
|
|
30
|
+
title: { rendered: title || 'Test Post' },
|
|
31
|
+
content: { rendered: content || '<p>Hello world. Replace me please.</p>' },
|
|
32
|
+
excerpt: { rendered: excerpt || '' },
|
|
33
|
+
status: 'publish',
|
|
34
|
+
slug: 'test-post',
|
|
35
|
+
link: 'https://example.com/test-post/',
|
|
36
|
+
date: '2025-06-15T10:00:00',
|
|
37
|
+
modified: '2025-06-15T10:00:00',
|
|
38
|
+
meta: { custom_key: 'old_value' },
|
|
39
|
+
...rest
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// =========================================================================
|
|
44
|
+
// 1. dry_run: true → returns preview without modifying
|
|
45
|
+
// =========================================================================
|
|
46
|
+
describe('wp_bulk_update', () => {
|
|
47
|
+
it('dry_run=true returns preview without modifying posts', async () => {
|
|
48
|
+
// GET /posts?... → list of IDs
|
|
49
|
+
mockSuccess([{ id: 1 }, { id: 2 }]);
|
|
50
|
+
// GET /posts/1 → post detail for preview
|
|
51
|
+
mockSuccess(mockPost({ id: 1 }));
|
|
52
|
+
// GET /posts/2 → post detail for preview
|
|
53
|
+
mockSuccess(mockPost({ id: 2, title: 'Post 2' }));
|
|
54
|
+
|
|
55
|
+
const res = await call('wp_bulk_update', {
|
|
56
|
+
filters: { status: 'publish' },
|
|
57
|
+
operations: [{ type: 'replace_text', params: { search: 'Hello', replace: 'Hi' } }],
|
|
58
|
+
dry_run: true
|
|
59
|
+
});
|
|
60
|
+
const data = parseResult(res);
|
|
61
|
+
expect(data.mode).toBe('dry_run');
|
|
62
|
+
expect(data.posts_affected).toBe(2);
|
|
63
|
+
expect(data.operations_preview).toHaveLength(2);
|
|
64
|
+
expect(data.warning).toContain('dry_run=false');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// =========================================================================
|
|
68
|
+
// 2. dry_run: false + confirm: false → preview + warning
|
|
69
|
+
// =========================================================================
|
|
70
|
+
it('dry_run=false confirm=false returns preview with confirmation warning', async () => {
|
|
71
|
+
mockSuccess([{ id: 1 }]);
|
|
72
|
+
mockSuccess(mockPost({ id: 1 }));
|
|
73
|
+
|
|
74
|
+
const res = await call('wp_bulk_update', {
|
|
75
|
+
filters: { category_id: 5 },
|
|
76
|
+
operations: [{ type: 'update_status', params: { status: 'draft' } }],
|
|
77
|
+
dry_run: false,
|
|
78
|
+
confirm: false
|
|
79
|
+
});
|
|
80
|
+
const data = parseResult(res);
|
|
81
|
+
expect(data.mode).toBe('preview');
|
|
82
|
+
expect(data.warning).toContain('confirm=true');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// =========================================================================
|
|
86
|
+
// 3. dry_run: false + confirm: true → executes + audit log
|
|
87
|
+
// =========================================================================
|
|
88
|
+
it('dry_run=false confirm=true executes bulk update', async () => {
|
|
89
|
+
// GET /posts?... → IDs
|
|
90
|
+
mockSuccess([{ id: 1 }]);
|
|
91
|
+
// GET /posts/1 → post detail
|
|
92
|
+
mockSuccess(mockPost({ id: 1 }));
|
|
93
|
+
// POST /posts/1 → updated
|
|
94
|
+
mockSuccess(mockPost({ id: 1, title: 'Updated' }));
|
|
95
|
+
|
|
96
|
+
const res = await call('wp_bulk_update', {
|
|
97
|
+
filters: { status: 'publish' },
|
|
98
|
+
operations: [{ type: 'update_status', params: { status: 'draft' } }],
|
|
99
|
+
dry_run: false,
|
|
100
|
+
confirm: true
|
|
101
|
+
});
|
|
102
|
+
const data = parseResult(res);
|
|
103
|
+
expect(data.success).toBe(true);
|
|
104
|
+
expect(data.posts_updated).toBe(1);
|
|
105
|
+
expect(data.posts_failed).toHaveLength(0);
|
|
106
|
+
expect(data.duration_ms).toBeGreaterThanOrEqual(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// =========================================================================
|
|
110
|
+
// 4. replace_text → correct changes preview
|
|
111
|
+
// =========================================================================
|
|
112
|
+
it('replace_text operation shows correct occurrences in preview', async () => {
|
|
113
|
+
mockSuccess(mockPost({ id: 1, content: '<p>foo bar foo baz foo</p>' }));
|
|
114
|
+
|
|
115
|
+
const res = await call('wp_bulk_update', {
|
|
116
|
+
post_ids: [1],
|
|
117
|
+
operations: [{ type: 'replace_text', params: { search: 'foo', replace: 'qux' } }],
|
|
118
|
+
dry_run: true
|
|
119
|
+
});
|
|
120
|
+
const data = parseResult(res);
|
|
121
|
+
const changes = data.operations_preview[0].changes;
|
|
122
|
+
expect(changes[0].type).toBe('replace_text');
|
|
123
|
+
expect(changes[0].occurrences).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// =========================================================================
|
|
127
|
+
// 5. update_meta → meta correctly updated
|
|
128
|
+
// =========================================================================
|
|
129
|
+
it('update_meta operation updates meta in execute mode', async () => {
|
|
130
|
+
mockSuccess(mockPost({ id: 1, meta: { my_key: 'old' } }));
|
|
131
|
+
mockSuccess(mockPost({ id: 1, meta: { my_key: 'new_val' } }));
|
|
132
|
+
|
|
133
|
+
const res = await call('wp_bulk_update', {
|
|
134
|
+
post_ids: [1],
|
|
135
|
+
operations: [{ type: 'update_meta', params: { key: 'my_key', value: 'new_val' } }],
|
|
136
|
+
dry_run: false,
|
|
137
|
+
confirm: true
|
|
138
|
+
});
|
|
139
|
+
const data = parseResult(res);
|
|
140
|
+
expect(data.success).toBe(true);
|
|
141
|
+
expect(data.posts_updated).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// =========================================================================
|
|
145
|
+
// 6. limit respected (max 500)
|
|
146
|
+
// =========================================================================
|
|
147
|
+
it('respects limit of 500 maximum', async () => {
|
|
148
|
+
const manyIds = Array.from({ length: 600 }, (_, i) => i + 1);
|
|
149
|
+
// post_ids mode: no filter query, goes straight to preview GETs (max 10)
|
|
150
|
+
for (let i = 0; i < 10; i++) {
|
|
151
|
+
mockSuccess(mockPost({ id: i + 1 }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const res = await call('wp_bulk_update', {
|
|
155
|
+
post_ids: manyIds,
|
|
156
|
+
operations: [{ type: 'update_status', params: { status: 'draft' } }],
|
|
157
|
+
dry_run: true,
|
|
158
|
+
limit: 600
|
|
159
|
+
});
|
|
160
|
+
const data = parseResult(res);
|
|
161
|
+
expect(data.posts_affected).toBe(500);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// =========================================================================
|
|
165
|
+
// 7. WP_READ_ONLY=true → blocked
|
|
166
|
+
// =========================================================================
|
|
167
|
+
it('is blocked when WP_READ_ONLY=true', async () => {
|
|
168
|
+
process.env.WP_READ_ONLY = 'true';
|
|
169
|
+
|
|
170
|
+
const res = await call('wp_bulk_update', {
|
|
171
|
+
post_ids: [1],
|
|
172
|
+
operations: [{ type: 'update_status', params: { status: 'draft' } }]
|
|
173
|
+
});
|
|
174
|
+
expect(res.isError).toBe(true);
|
|
175
|
+
expect(res.content[0].text).toContain('READ-ONLY');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// =========================================================================
|
|
179
|
+
// 8. post_ids empty + filters empty → explicit error
|
|
180
|
+
// =========================================================================
|
|
181
|
+
it('returns error when neither post_ids nor filters provided', async () => {
|
|
182
|
+
const res = await call('wp_bulk_update', {
|
|
183
|
+
operations: [{ type: 'update_status', params: { status: 'draft' } }]
|
|
184
|
+
});
|
|
185
|
+
expect(res.isError).toBe(true);
|
|
186
|
+
expect(res.content[0].text).toContain('post_ids or at least one filter');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,397 @@
|
|
|
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
|
+
// SITE HEALTH
|
|
15
|
+
// ════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
describe('wp_get_site_health_status', () => {
|
|
18
|
+
let consoleSpy;
|
|
19
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
20
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
21
|
+
|
|
22
|
+
it('returns health score with issue counts', async () => {
|
|
23
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
24
|
+
{ label: 'PHP update', status: 'critical', badge: { label: 'Security' }, description: 'Old PHP', test: 'php_version' },
|
|
25
|
+
{ label: 'HTTPS', status: 'good', badge: { label: 'Security' }, description: 'HTTPS active', test: 'https_status' },
|
|
26
|
+
{ label: 'Debug mode', status: 'recommended', badge: { label: 'Performance' }, description: 'Debug on', test: 'debug_enabled' }
|
|
27
|
+
]));
|
|
28
|
+
const result = await call('wp_get_site_health_status');
|
|
29
|
+
const data = parseResult(result);
|
|
30
|
+
expect(data.score).toBe('critical');
|
|
31
|
+
expect(data.total_issues).toBe(3);
|
|
32
|
+
expect(data.counts.critical).toBe(1);
|
|
33
|
+
expect(data.counts.recommended).toBe(1);
|
|
34
|
+
expect(data.counts.good).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns good when no issues', async () => {
|
|
38
|
+
fetch.mockResolvedValue(mockSuccess([]));
|
|
39
|
+
const result = await call('wp_get_site_health_status');
|
|
40
|
+
const data = parseResult(result);
|
|
41
|
+
expect(data.score).toBe('good');
|
|
42
|
+
expect(data.total_issues).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('logs audit entry', async () => {
|
|
46
|
+
fetch.mockResolvedValue(mockSuccess([]));
|
|
47
|
+
await call('wp_get_site_health_status');
|
|
48
|
+
const logs = getAuditLogs();
|
|
49
|
+
expect(logs.find(l => l.tool === 'wp_get_site_health_status')).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('wp_list_site_health_issues', () => {
|
|
54
|
+
let consoleSpy;
|
|
55
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
56
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
57
|
+
|
|
58
|
+
it('returns all issues', async () => {
|
|
59
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
60
|
+
{ label: 'Issue 1', status: 'critical', badge: { label: 'Security' }, description: '<p>Desc 1</p>', actions: '', test: 'test_1' },
|
|
61
|
+
{ label: 'Issue 2', status: 'recommended', badge: { label: 'Perf' }, description: '<p>Desc 2</p>', actions: '', test: 'test_2' }
|
|
62
|
+
]));
|
|
63
|
+
const result = await call('wp_list_site_health_issues');
|
|
64
|
+
const data = parseResult(result);
|
|
65
|
+
expect(data.total).toBe(2);
|
|
66
|
+
expect(data.filter).toBe('all');
|
|
67
|
+
expect(data.issues[0].label).toBe('Issue 1');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('filters by severity', async () => {
|
|
71
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
72
|
+
{ label: 'Critical', status: 'critical', badge: {}, description: '', actions: '', test: 't1' },
|
|
73
|
+
{ label: 'Good', status: 'good', badge: {}, description: '', actions: '', test: 't2' }
|
|
74
|
+
]));
|
|
75
|
+
const result = await call('wp_list_site_health_issues', { severity: 'critical' });
|
|
76
|
+
const data = parseResult(result);
|
|
77
|
+
expect(data.total).toBe(1);
|
|
78
|
+
expect(data.issues[0].label).toBe('Critical');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('wp_get_site_health_info', () => {
|
|
83
|
+
let consoleSpy;
|
|
84
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
85
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
86
|
+
|
|
87
|
+
it('returns full system info summary', async () => {
|
|
88
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
89
|
+
'wp-server': { label: 'Server', fields: { php_version: { label: 'PHP', value: '8.2.0' }, server_architecture: { label: 'Arch', value: 'Linux x86_64' } } },
|
|
90
|
+
'wp-database': { label: 'Database', fields: { server_version: { label: 'MySQL', value: '8.0.33' } } }
|
|
91
|
+
}));
|
|
92
|
+
const result = await call('wp_get_site_health_info');
|
|
93
|
+
const data = parseResult(result);
|
|
94
|
+
expect(data['wp-server'].label).toBe('Server');
|
|
95
|
+
expect(data['wp-server'].fields.php_version).toBe('8.2.0');
|
|
96
|
+
expect(data['wp-database'].fields.server_version).toBe('8.0.33');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns single section when requested', async () => {
|
|
100
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
101
|
+
'wp-server': { label: 'Server', fields: { php_version: { label: 'PHP', value: '8.2.0' } } },
|
|
102
|
+
'wp-database': { label: 'Database', fields: { server_version: { label: 'MySQL', value: '8.0.33' } } }
|
|
103
|
+
}));
|
|
104
|
+
const result = await call('wp_get_site_health_info', { section: 'wp-server' });
|
|
105
|
+
const data = parseResult(result);
|
|
106
|
+
expect(data.section).toBe('wp-server');
|
|
107
|
+
expect(data.label).toBe('Server');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('errors on invalid section', async () => {
|
|
111
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
112
|
+
'wp-server': { label: 'Server', fields: {} }
|
|
113
|
+
}));
|
|
114
|
+
const result = await call('wp_get_site_health_info', { section: 'nonexistent' });
|
|
115
|
+
expect(result.isError).toBe(true);
|
|
116
|
+
expect(result.content[0].text).toContain('not found');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ════════════════════════════════════════════════════════════
|
|
121
|
+
// DEBUG & CRON
|
|
122
|
+
// ════════════════════════════════════════════════════════════
|
|
123
|
+
|
|
124
|
+
describe('wp_get_debug_log', () => {
|
|
125
|
+
let consoleSpy;
|
|
126
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
127
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
128
|
+
|
|
129
|
+
it('returns debug log lines', async () => {
|
|
130
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
131
|
+
lines: ['[01-Jan-2024] PHP Warning: test', '[01-Jan-2024] PHP Fatal error: crash'],
|
|
132
|
+
total_lines: 2,
|
|
133
|
+
file_size: 1024,
|
|
134
|
+
last_modified: '2024-01-01T00:00:00Z'
|
|
135
|
+
}));
|
|
136
|
+
const result = await call('wp_get_debug_log', { lines: 50, level: 'all' });
|
|
137
|
+
const data = parseResult(result);
|
|
138
|
+
expect(data.lines_returned).toBe(2);
|
|
139
|
+
expect(data.file_size).toBe(1024);
|
|
140
|
+
expect(data.lines).toHaveLength(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('passes correct basePath for mcp-diagnostics', async () => {
|
|
144
|
+
fetch.mockResolvedValue(mockSuccess({ lines: [], total_lines: 0 }));
|
|
145
|
+
await call('wp_get_debug_log');
|
|
146
|
+
const [url] = fetch.mock.calls[0];
|
|
147
|
+
expect(url).toContain('/wp-json/mcp-diagnostics/v1/debug-log');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns error on 404 (plugin not installed)', async () => {
|
|
151
|
+
fetch.mockResolvedValue(mockError(404, 'No route'));
|
|
152
|
+
const result = await call('wp_get_debug_log');
|
|
153
|
+
expect(result.isError).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('wp_get_cron_events', () => {
|
|
158
|
+
let consoleSpy;
|
|
159
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
160
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
161
|
+
|
|
162
|
+
it('returns cron events with overdue detection', async () => {
|
|
163
|
+
const pastTimestamp = Math.floor(Date.now() / 1000) - 3600;
|
|
164
|
+
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
|
|
165
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
166
|
+
events: [
|
|
167
|
+
{ hook: 'wp_cron_test', args: [], schedule: 'hourly', interval: 3600, next_run: pastTimestamp },
|
|
168
|
+
{ hook: 'wp_update_check', args: [], schedule: 'twicedaily', interval: 43200, next_run: futureTimestamp }
|
|
169
|
+
]
|
|
170
|
+
}));
|
|
171
|
+
const result = await call('wp_get_cron_events');
|
|
172
|
+
const data = parseResult(result);
|
|
173
|
+
expect(data.total).toBe(2);
|
|
174
|
+
expect(data.events[0].overdue).toBe(true);
|
|
175
|
+
expect(data.events[1].overdue).toBe(false);
|
|
176
|
+
expect(data.events[0].next_run_date).toBeTruthy();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('filters by hook name', async () => {
|
|
180
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
181
|
+
events: [
|
|
182
|
+
{ hook: 'wp_cron_test', args: [], schedule: 'hourly', interval: 3600, next_run: 1704067200 },
|
|
183
|
+
{ hook: 'other_hook', args: [], schedule: 'daily', interval: 86400, next_run: 1704067200 }
|
|
184
|
+
]
|
|
185
|
+
}));
|
|
186
|
+
const result = await call('wp_get_cron_events', { hook: 'wp_cron_test' });
|
|
187
|
+
const data = parseResult(result);
|
|
188
|
+
expect(data.total).toBe(1);
|
|
189
|
+
expect(data.events[0].hook).toBe('wp_cron_test');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('wp_get_transients', () => {
|
|
194
|
+
let consoleSpy;
|
|
195
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
196
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
197
|
+
|
|
198
|
+
it('returns transients with expiration info', async () => {
|
|
199
|
+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
|
|
200
|
+
const pastExp = Math.floor(Date.now() / 1000) - 3600;
|
|
201
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
202
|
+
transients: [
|
|
203
|
+
{ key: 'cache_data', expiration: futureExp, size_bytes: 512 },
|
|
204
|
+
{ key: 'old_cache', expiration: pastExp, size_bytes: 128 }
|
|
205
|
+
]
|
|
206
|
+
}));
|
|
207
|
+
const result = await call('wp_get_transients');
|
|
208
|
+
const data = parseResult(result);
|
|
209
|
+
expect(data.total).toBe(2);
|
|
210
|
+
expect(data.transients[0].expired).toBe(false);
|
|
211
|
+
expect(data.transients[1].expired).toBe(true);
|
|
212
|
+
expect(data.transients[0].expiration_date).toBeTruthy();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('handles transients with no expiry', async () => {
|
|
216
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
217
|
+
transients: [
|
|
218
|
+
{ key: 'permanent', expiration: 0, size_bytes: 256 }
|
|
219
|
+
]
|
|
220
|
+
}));
|
|
221
|
+
const result = await call('wp_get_transients');
|
|
222
|
+
const data = parseResult(result);
|
|
223
|
+
expect(data.transients[0].expiration_date).toBe('no expiry');
|
|
224
|
+
expect(data.transients[0].expired).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('passes correct basePath', async () => {
|
|
228
|
+
fetch.mockResolvedValue(mockSuccess({ transients: [] }));
|
|
229
|
+
await call('wp_get_transients', { filter: 'expired', search: 'cache' });
|
|
230
|
+
const [url] = fetch.mock.calls[0];
|
|
231
|
+
expect(url).toContain('/wp-json/mcp-diagnostics/v1/transients');
|
|
232
|
+
expect(url).toContain('filter=expired');
|
|
233
|
+
expect(url).toContain('search=cache');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ════════════════════════════════════════════════════════════
|
|
238
|
+
// PLUGIN COMPATIBILITY
|
|
239
|
+
// ════════════════════════════════════════════════════════════
|
|
240
|
+
|
|
241
|
+
describe('wp_check_php_compatibility', () => {
|
|
242
|
+
let consoleSpy;
|
|
243
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
244
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
245
|
+
|
|
246
|
+
it('reports compatible and incompatible plugins', async () => {
|
|
247
|
+
// First call: /plugins, Second call: /info (site health)
|
|
248
|
+
fetch
|
|
249
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
250
|
+
ok: true, status: 200,
|
|
251
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
252
|
+
json: () => Promise.resolve([
|
|
253
|
+
{ plugin: 'akismet/akismet.php', name: 'Akismet', version: '5.0', status: 'active', requires_php: '7.4' },
|
|
254
|
+
{ plugin: 'legacy/legacy.php', name: 'Legacy Plugin', version: '1.0', status: 'active', requires_php: '8.3' },
|
|
255
|
+
{ plugin: 'noinfo/noinfo.php', name: 'No Info', version: '2.0', status: 'active', requires_php: null },
|
|
256
|
+
{ plugin: 'inactive/inactive.php', name: 'Inactive', version: '1.0', status: 'inactive', requires_php: '7.0' }
|
|
257
|
+
]),
|
|
258
|
+
text: () => Promise.resolve('[]')
|
|
259
|
+
}))
|
|
260
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
261
|
+
ok: true, status: 200,
|
|
262
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
263
|
+
json: () => Promise.resolve({
|
|
264
|
+
'wp-server': { label: 'Server', fields: { php_version: { label: 'PHP', value: '8.2.0' } } }
|
|
265
|
+
}),
|
|
266
|
+
text: () => Promise.resolve('{}')
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
const result = await call('wp_check_php_compatibility');
|
|
270
|
+
const data = parseResult(result);
|
|
271
|
+
expect(data.php_version).toBe('8.2.0');
|
|
272
|
+
expect(data.total_active).toBe(3); // Only active plugins
|
|
273
|
+
expect(data.incompatible_count).toBe(1); // legacy requires 8.3, current is 8.2
|
|
274
|
+
const akismet = data.plugins.find(p => p.name === 'Akismet');
|
|
275
|
+
expect(akismet.status).toBe('compatible');
|
|
276
|
+
const legacy = data.plugins.find(p => p.name === 'Legacy Plugin');
|
|
277
|
+
expect(legacy.status).toBe('incompatible');
|
|
278
|
+
const noinfo = data.plugins.find(p => p.name === 'No Info');
|
|
279
|
+
expect(noinfo.status).toBe('unknown');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('logs audit entry', async () => {
|
|
283
|
+
fetch
|
|
284
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
285
|
+
ok: true, status: 200,
|
|
286
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
287
|
+
json: () => Promise.resolve([]),
|
|
288
|
+
text: () => Promise.resolve('[]')
|
|
289
|
+
}))
|
|
290
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
291
|
+
ok: true, status: 200,
|
|
292
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
293
|
+
json: () => Promise.resolve({ 'wp-server': { label: 'Server', fields: { php_version: { value: '8.2.0' } } } }),
|
|
294
|
+
text: () => Promise.resolve('{}')
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
await call('wp_check_php_compatibility');
|
|
298
|
+
const logs = getAuditLogs();
|
|
299
|
+
expect(logs.find(l => l.tool === 'wp_check_php_compatibility')).toBeDefined();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('wp_get_active_hooks', () => {
|
|
304
|
+
let consoleSpy;
|
|
305
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
306
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
307
|
+
|
|
308
|
+
it('returns hooks with callbacks', async () => {
|
|
309
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
310
|
+
hooks: [
|
|
311
|
+
{ name: 'init', type: 'action', callbacks: [{ function: 'wp_init', priority: 10, accepted_args: 0 }] },
|
|
312
|
+
{ name: 'the_content', type: 'filter', callbacks: [{ function: 'wpautop', priority: 10, accepted_args: 1 }, { function: 'do_shortcode', priority: 11, accepted_args: 1 }] }
|
|
313
|
+
]
|
|
314
|
+
}));
|
|
315
|
+
const result = await call('wp_get_active_hooks', { type: 'all' });
|
|
316
|
+
const data = parseResult(result);
|
|
317
|
+
expect(data.total).toBe(2);
|
|
318
|
+
expect(data.hooks[0].name).toBe('init');
|
|
319
|
+
expect(data.hooks[0].type).toBe('action');
|
|
320
|
+
expect(data.hooks[1].callbacks).toHaveLength(2);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('passes search and type params', async () => {
|
|
324
|
+
fetch.mockResolvedValue(mockSuccess({ hooks: [] }));
|
|
325
|
+
await call('wp_get_active_hooks', { type: 'filters', search: 'content', per_page: 25 });
|
|
326
|
+
const [url] = fetch.mock.calls[0];
|
|
327
|
+
expect(url).toContain('/wp-json/mcp-diagnostics/v1/hooks');
|
|
328
|
+
expect(url).toContain('type=filters');
|
|
329
|
+
expect(url).toContain('search=content');
|
|
330
|
+
expect(url).toContain('per_page=25');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('returns error on 404 (plugin not installed)', async () => {
|
|
334
|
+
fetch.mockResolvedValue(mockError(404, 'No route'));
|
|
335
|
+
const result = await call('wp_get_active_hooks');
|
|
336
|
+
expect(result.isError).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ════════════════════════════════════════════════════════════
|
|
341
|
+
// READ-ONLY CHECK — All diagnostic tools are read-only
|
|
342
|
+
// ════════════════════════════════════════════════════════════
|
|
343
|
+
|
|
344
|
+
describe('Diagnostic tools are not blocked by WP_READ_ONLY', () => {
|
|
345
|
+
let consoleSpy;
|
|
346
|
+
beforeEach(() => {
|
|
347
|
+
fetch.mockReset();
|
|
348
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
349
|
+
process.env.WP_READ_ONLY = 'true';
|
|
350
|
+
});
|
|
351
|
+
afterEach(() => {
|
|
352
|
+
consoleSpy.mockRestore();
|
|
353
|
+
delete process.env.WP_READ_ONLY;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const readOnlyTools = [
|
|
357
|
+
'wp_get_site_health_status',
|
|
358
|
+
'wp_list_site_health_issues',
|
|
359
|
+
'wp_get_site_health_info',
|
|
360
|
+
'wp_get_debug_log',
|
|
361
|
+
'wp_get_cron_events',
|
|
362
|
+
'wp_get_transients',
|
|
363
|
+
'wp_check_php_compatibility',
|
|
364
|
+
'wp_get_active_hooks'
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
readOnlyTools.forEach(toolName => {
|
|
368
|
+
it(`${toolName} is not blocked by WP_READ_ONLY`, async () => {
|
|
369
|
+
// Mock a generic successful response
|
|
370
|
+
fetch.mockResolvedValue(mockSuccess(
|
|
371
|
+
toolName.includes('list') || toolName.includes('events') || toolName.includes('hooks')
|
|
372
|
+
? []
|
|
373
|
+
: {}
|
|
374
|
+
));
|
|
375
|
+
// For wp_check_php_compatibility which makes 2 calls
|
|
376
|
+
if (toolName === 'wp_check_php_compatibility') {
|
|
377
|
+
fetch
|
|
378
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
379
|
+
ok: true, status: 200,
|
|
380
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
381
|
+
json: () => Promise.resolve([]),
|
|
382
|
+
text: () => Promise.resolve('[]')
|
|
383
|
+
}))
|
|
384
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
385
|
+
ok: true, status: 200,
|
|
386
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
387
|
+
json: () => Promise.resolve({ 'wp-server': { label: 'S', fields: { php_version: { value: '8.2' } } } }),
|
|
388
|
+
text: () => Promise.resolve('{}')
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const result = await call(toolName);
|
|
393
|
+
// Should NOT be blocked
|
|
394
|
+
expect(result.isError).toBeFalsy();
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|