@adsim/wordpress-mcp-server 4.5.1 → 4.6.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.
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { acfAdapter } from '../../../../src/plugins/adapters/acf/acfAdapter.js';
3
+
4
+ let consoleSpy;
5
+ const envBackup = {};
6
+
7
+ function saveEnv(...keys) {
8
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
9
+ }
10
+ function restoreEnv() {
11
+ Object.entries(envBackup).forEach(([k, v]) => {
12
+ if (v === undefined) delete process.env[k];
13
+ else process.env[k] = v;
14
+ });
15
+ }
16
+
17
+ beforeEach(() => {
18
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ saveEnv('WP_READ_ONLY');
20
+ delete process.env.WP_READ_ONLY;
21
+ });
22
+ afterEach(() => {
23
+ consoleSpy.mockRestore();
24
+ restoreEnv();
25
+ });
26
+
27
+ // ── Helpers ──
28
+
29
+ function parseResult(result) {
30
+ return JSON.parse(result?.content?.[0]?.text);
31
+ }
32
+
33
+ function getAuditLogs() {
34
+ return consoleSpy.mock.calls
35
+ .map(c => c[0])
36
+ .filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
37
+ .map(m => JSON.parse(m.replace('[AUDIT] ', '')));
38
+ }
39
+
40
+ function mockApi(data, shouldThrow = false) {
41
+ if (shouldThrow) {
42
+ return async () => { throw new Error('WP API 404: Not Found'); };
43
+ }
44
+ return async () => data;
45
+ }
46
+
47
+ function mockApi403() {
48
+ return async () => { throw new Error('WP API 403: Forbidden'); };
49
+ }
50
+
51
+ // ── Mock data ──
52
+
53
+ const mockUpdatedResponse = {
54
+ acf: {
55
+ hero_title: 'New Title',
56
+ price: 49.99,
57
+ },
58
+ };
59
+
60
+ // =========================================================================
61
+ // Tests
62
+ // =========================================================================
63
+
64
+ describe('ACF Adapter — acf_update_fields (write)', () => {
65
+ const getTool = () => acfAdapter.getTools().find(t => t.name === 'acf_update_fields');
66
+
67
+ // 1. Update réussi → retourne les champs mis à jour
68
+ it('successful update returns updated fields', async () => {
69
+ const tool = getTool();
70
+ const result = await tool.handler(
71
+ { id: 42, post_type: 'posts', fields: { hero_title: 'New Title', price: 49.99 } },
72
+ mockApi(mockUpdatedResponse)
73
+ );
74
+ const data = parseResult(result);
75
+ expect(data.id).toBe(42);
76
+ expect(data.post_type).toBe('posts');
77
+ expect(data.updated_fields.hero_title).toBe('New Title');
78
+ expect(data.updated_fields.price).toBe(49.99);
79
+ });
80
+
81
+ // 2. WP_READ_ONLY=true → bloqué, status "blocked" dans le log
82
+ it('blocked when WP_READ_ONLY=true', async () => {
83
+ process.env.WP_READ_ONLY = 'true';
84
+ const tool = getTool();
85
+ const result = await tool.handler(
86
+ { id: 42, fields: { hero_title: 'New' } },
87
+ mockApi(mockUpdatedResponse)
88
+ );
89
+ const data = parseResult(result);
90
+ expect(data.error).toContain('READ-ONLY');
91
+
92
+ const logs = getAuditLogs();
93
+ const entry = logs.find(l => l.tool === 'acf_update_fields');
94
+ expect(entry.status).toBe('blocked');
95
+ });
96
+
97
+ // 3. WP_READ_ONLY=false → exécution normale
98
+ it('executes normally when WP_READ_ONLY is not set', async () => {
99
+ delete process.env.WP_READ_ONLY;
100
+ const tool = getTool();
101
+ const result = await tool.handler(
102
+ { id: 10, fields: { price: 99 } },
103
+ mockApi({ acf: { price: 99 } })
104
+ );
105
+ const data = parseResult(result);
106
+ expect(data.updated_fields.price).toBe(99);
107
+ expect(data.id).toBe(10);
108
+ });
109
+
110
+ // 4. id invalide (string) → erreur validation
111
+ it('rejects non-integer id', async () => {
112
+ const tool = getTool();
113
+ await expect(
114
+ tool.handler({ id: 'abc', fields: { x: 1 } }, mockApi({}))
115
+ ).rejects.toThrow('Validation error');
116
+ });
117
+
118
+ // 5. fields vide {} → erreur
119
+ it('rejects empty fields object', async () => {
120
+ const tool = getTool();
121
+ await expect(
122
+ tool.handler({ id: 42, fields: {} }, mockApi({}))
123
+ ).rejects.toThrow('Validation error');
124
+ });
125
+
126
+ // 6. 404 post not found → erreur propre
127
+ it('propagates 404 error', async () => {
128
+ const tool = getTool();
129
+ await expect(
130
+ tool.handler({ id: 99999, fields: { x: 1 } }, mockApi(null, true))
131
+ ).rejects.toThrow('404');
132
+ });
133
+
134
+ // 7. 403 permission denied → erreur propre
135
+ it('propagates 403 permission error', async () => {
136
+ const tool = getTool();
137
+ await expect(
138
+ tool.handler({ id: 42, fields: { x: 1 } }, mockApi403())
139
+ ).rejects.toThrow('403');
140
+ });
141
+
142
+ // 8. Audit log format correct
143
+ it('logs correct audit format with action update_acf_fields', async () => {
144
+ const tool = getTool();
145
+ await tool.handler(
146
+ { id: 42, post_type: 'posts', fields: { hero_title: 'Updated' } },
147
+ mockApi(mockUpdatedResponse)
148
+ );
149
+ const logs = getAuditLogs();
150
+ const entry = logs.find(l => l.tool === 'acf_update_fields');
151
+ expect(entry).toBeDefined();
152
+ expect(entry.action).toBe('update_acf_fields');
153
+ expect(entry.target).toBe(42);
154
+ expect(entry.status).toBe('success');
155
+ expect(typeof entry.latency_ms).toBe('number');
156
+ });
157
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { applyContextGuard } from '../../../src/plugins/contextGuard.js';
3
+
4
+ let consoleSpy;
5
+
6
+ beforeEach(() => {
7
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
8
+ });
9
+ afterEach(() => {
10
+ consoleSpy.mockRestore();
11
+ });
12
+
13
+ describe('applyContextGuard', () => {
14
+ it('returns data intact when below threshold', () => {
15
+ const data = { title: 'Hello', items: [1, 2, 3] };
16
+ const result = applyContextGuard(data, { toolName: 'test_tool', maxChars: 50000 });
17
+ expect(result).toEqual(data);
18
+ });
19
+
20
+ it('truncates and adds _truncated when above threshold', () => {
21
+ const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
22
+ const result = applyContextGuard(bigArray, { toolName: 'test_tool', maxChars: 500 });
23
+ expect(result._truncated).toBe(true);
24
+ expect(result._original_size).toBeGreaterThan(500);
25
+ expect(typeof result._warning).toBe('string');
26
+ expect(result.data).toBeDefined();
27
+ });
28
+
29
+ it('does NOT truncate when mode is "raw" even if above threshold', () => {
30
+ const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
31
+ const result = applyContextGuard(bigArray, { toolName: 'test_tool', mode: 'raw', maxChars: 500 });
32
+ expect(result._truncated).toBeUndefined();
33
+ expect(Array.isArray(result)).toBe(true);
34
+ expect(result.length).toBe(5000);
35
+ });
36
+
37
+ it('logs correct format to stderr on truncation', () => {
38
+ const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
39
+ applyContextGuard(bigArray, { toolName: 'elementor_get_page', maxChars: 500 });
40
+
41
+ const logCalls = consoleSpy.mock.calls.map(c => c[0]).filter(m => typeof m === 'string');
42
+ const overflow = logCalls.find(m => m.includes('context_overflow'));
43
+ expect(overflow).toBeDefined();
44
+ const parsed = JSON.parse(overflow);
45
+ expect(parsed.tool).toBe('elementor_get_page');
46
+ expect(parsed.event).toBe('context_overflow');
47
+ expect(parsed.truncated).toBe(true);
48
+ expect(typeof parsed.size_chars).toBe('number');
49
+ expect(parsed.max_chars).toBe(500);
50
+ });
51
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { elementorAdapter } from '../../../../src/plugins/adapters/elementor/elementorAdapter.js';
3
+ import { validateAdapter } from '../../../../src/plugins/IPluginAdapter.js';
4
+ import { PluginRegistry } from '../../../../src/plugins/registry.js';
5
+
6
+ let consoleSpy;
7
+
8
+ beforeEach(() => {
9
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
10
+ });
11
+ afterEach(() => {
12
+ consoleSpy.mockRestore();
13
+ });
14
+
15
+ // ── Helpers ──
16
+
17
+ function parseResult(result) {
18
+ return JSON.parse(result?.content?.[0]?.text);
19
+ }
20
+
21
+ function getAuditLogs() {
22
+ return consoleSpy.mock.calls
23
+ .map(c => c[0])
24
+ .filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
25
+ .map(m => JSON.parse(m.replace('[AUDIT] ', '')));
26
+ }
27
+
28
+ function mockApi(data, shouldThrow = false) {
29
+ if (shouldThrow) {
30
+ return async () => { throw new Error('WP API 404: Not Found'); };
31
+ }
32
+ return async () => data;
33
+ }
34
+
35
+ // ── Mock data ──
36
+
37
+ const mockTemplates = [
38
+ { id: 1, title: { rendered: 'Hero Template' }, type: 'page', author: 1, date: '2024-06-01', source: 'local' },
39
+ { id: 2, title: { rendered: 'CTA Section' }, type: 'section', author: 1, date: '2024-06-02', source: 'local' },
40
+ { id: 3, title: { rendered: 'Header Block' }, type: 'block', author: 2, date: '2024-06-03', source: 'remote' },
41
+ { id: 4, title: { rendered: 'Promo Popup' }, type: 'popup', author: 1, date: '2024-06-04', source: 'local' },
42
+ ];
43
+
44
+ const mockTemplateDetail = {
45
+ id: 1,
46
+ title: { rendered: 'Hero Template' },
47
+ type: 'page',
48
+ content: {
49
+ elements: [
50
+ { elType: 'section', elements: [{ elType: 'widget', widgetType: 'heading' }] },
51
+ { elType: 'section', elements: [{ elType: 'widget', widgetType: 'image' }] },
52
+ ],
53
+ },
54
+ };
55
+
56
+ const mockPageData = {
57
+ elements: [
58
+ { elType: 'section', widgetType: null, elements: [
59
+ { elType: 'column', elements: [
60
+ { elType: 'widget', widgetType: 'heading', elements: [] },
61
+ { elType: 'widget', widgetType: 'text-editor', elements: [] },
62
+ ]},
63
+ ]},
64
+ { elType: 'section', widgetType: null, elements: [
65
+ { elType: 'column', elements: [
66
+ { elType: 'widget', widgetType: 'image', elements: [] },
67
+ { elType: 'widget', widgetType: 'heading', elements: [] },
68
+ ]},
69
+ ]},
70
+ ],
71
+ };
72
+
73
+ // =========================================================================
74
+ // Tests
75
+ // =========================================================================
76
+
77
+ describe('Elementor Adapter', () => {
78
+
79
+ // 1. elementor_list_templates → retourne liste avec id/title/type
80
+ it('elementor_list_templates returns list with id/title/type', async () => {
81
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_list_templates');
82
+ const result = await tool.handler({}, mockApi(mockTemplates));
83
+ const data = parseResult(result);
84
+ expect(data.total).toBe(4);
85
+ expect(data.templates[0].id).toBe(1);
86
+ expect(data.templates[0].title).toBe('Hero Template');
87
+ expect(data.templates[0].type).toBe('page');
88
+ expect(data.templates[2].source).toBe('remote');
89
+ });
90
+
91
+ // 2. elementor_list_templates avec type filter → filtre appliqué
92
+ it('elementor_list_templates filters by type', async () => {
93
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_list_templates');
94
+ const result = await tool.handler({ type: 'section' }, mockApi(mockTemplates));
95
+ const data = parseResult(result);
96
+ expect(data.total).toBe(1);
97
+ expect(data.templates[0].id).toBe(2);
98
+ expect(data.templates[0].type).toBe('section');
99
+ });
100
+
101
+ // 3. elementor_get_template → retourne contenu complet
102
+ it('elementor_get_template returns full template content', async () => {
103
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
104
+ const result = await tool.handler({ id: 1 }, mockApi(mockTemplateDetail));
105
+ const data = parseResult(result);
106
+ expect(data.id).toBe(1);
107
+ expect(data.title).toBe('Hero Template');
108
+ expect(data.type).toBe('page');
109
+ expect(data.content).toBeDefined();
110
+ });
111
+
112
+ // 4. elementor_get_template → contextGuard truncate si > 50k
113
+ it('elementor_get_template applies contextGuard on large responses', async () => {
114
+ // Generate a template with > 50000 chars of content
115
+ const bigContent = { elements: [] };
116
+ for (let i = 0; i < 800; i++) {
117
+ bigContent.elements.push({ elType: 'widget', widgetType: 'text-editor', settings: { content: 'x'.repeat(100) } });
118
+ }
119
+ const bigTemplate = { id: 99, title: { rendered: 'Big Template' }, type: 'page', content: bigContent };
120
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
121
+ const result = await tool.handler({ id: 99 }, mockApi(bigTemplate));
122
+ const data = parseResult(result);
123
+ expect(data._truncated).toBe(true);
124
+ expect(data._original_size).toBeGreaterThan(50000);
125
+ expect(data._warning).toContain('truncated');
126
+ });
127
+
128
+ // 5. elementor_get_page_data → retourne widgets_used et elements_count
129
+ it('elementor_get_page_data returns widgets_used and elements_count', async () => {
130
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_page_data');
131
+ const result = await tool.handler({ post_id: 42 }, mockApi(mockPageData));
132
+ const data = parseResult(result);
133
+ expect(data.post_id).toBe(42);
134
+ expect(data.elementor_status).toBe('active');
135
+ expect(data.widgets_used).toContain('heading');
136
+ expect(data.widgets_used).toContain('text-editor');
137
+ expect(data.widgets_used).toContain('image');
138
+ expect(data.elements_count).toBeGreaterThan(0);
139
+ });
140
+
141
+ // 6. elementor_get_template id invalide → erreur validation
142
+ it('elementor_get_template rejects invalid id', async () => {
143
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
144
+ await expect(
145
+ tool.handler({ id: 'abc' }, mockApi({}))
146
+ ).rejects.toThrow('Validation error');
147
+ });
148
+
149
+ // 7. 404 template not found → erreur propre
150
+ it('elementor_get_template propagates 404 error', async () => {
151
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
152
+ await expect(
153
+ tool.handler({ id: 99999 }, mockApi(null, true))
154
+ ).rejects.toThrow('404');
155
+ });
156
+
157
+ // 8. Adapter détecté uniquement si namespace /elementor/v1 présent
158
+ it('registry detects elementor only when namespace is present', async () => {
159
+ const registry = new PluginRegistry();
160
+
161
+ // Without elementor namespace
162
+ await registry.initialize(mockApi({ namespaces: ['wp/v2'] }));
163
+ expect(registry.isActive('elementor')).toBe(false);
164
+
165
+ // With elementor namespace
166
+ registry.active.clear();
167
+ await registry.initialize(mockApi({ namespaces: ['wp/v2', 'elementor/v1'] }));
168
+ expect(registry.isActive('elementor')).toBe(true);
169
+ });
170
+
171
+ // 9. Audit log format correct pour chaque outil
172
+ it('logs correct audit format for all 3 tools', async () => {
173
+ const listTool = elementorAdapter.getTools().find(t => t.name === 'elementor_list_templates');
174
+ const getTool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
175
+ const pageTool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_page_data');
176
+
177
+ await listTool.handler({}, mockApi(mockTemplates));
178
+ await getTool.handler({ id: 1 }, mockApi(mockTemplateDetail));
179
+ await pageTool.handler({ post_id: 42 }, mockApi(mockPageData));
180
+
181
+ const logs = getAuditLogs();
182
+
183
+ const listEntry = logs.find(l => l.tool === 'elementor_list_templates');
184
+ expect(listEntry).toBeDefined();
185
+ expect(listEntry.action).toBe('list_templates');
186
+ expect(listEntry.status).toBe('success');
187
+ expect(typeof listEntry.latency_ms).toBe('number');
188
+
189
+ const getEntry = logs.find(l => l.tool === 'elementor_get_template');
190
+ expect(getEntry).toBeDefined();
191
+ expect(getEntry.action).toBe('read_template');
192
+ expect(getEntry.target).toBe(1);
193
+
194
+ const pageEntry = logs.find(l => l.tool === 'elementor_get_page_data');
195
+ expect(pageEntry).toBeDefined();
196
+ expect(pageEntry.action).toBe('read_page_data');
197
+ expect(pageEntry.target).toBe(42);
198
+ });
199
+
200
+ // 10. Passes validateAdapter
201
+ it('passes validateAdapter without errors', () => {
202
+ const { valid, errors } = validateAdapter(elementorAdapter);
203
+ expect(valid).toBe(true);
204
+ expect(errors).toEqual([]);
205
+ });
206
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateAdapter } from '../../../src/plugins/IPluginAdapter.js';
3
+
4
+ const validAdapter = {
5
+ id: 'acf',
6
+ namespace: 'acf/v3',
7
+ riskLevel: 'medium',
8
+ contextConfig: {
9
+ maxChars: 30000,
10
+ defaultMode: 'compact',
11
+ supportedModes: ['raw', 'compact', 'summary', 'ids_only'],
12
+ },
13
+ getTools: () => [],
14
+ };
15
+
16
+ describe('validateAdapter', () => {
17
+ it('validates a complete adapter', () => {
18
+ const { valid, errors } = validateAdapter(validAdapter);
19
+ expect(valid).toBe(true);
20
+ expect(errors).toEqual([]);
21
+ });
22
+
23
+ it('rejects an adapter without id', () => {
24
+ const { valid, errors } = validateAdapter({ ...validAdapter, id: undefined });
25
+ expect(valid).toBe(false);
26
+ expect(errors.some(e => e.includes('"id"'))).toBe(true);
27
+ });
28
+
29
+ it('rejects an adapter without getTools', () => {
30
+ const { valid, errors } = validateAdapter({ ...validAdapter, getTools: undefined });
31
+ expect(valid).toBe(false);
32
+ expect(errors.some(e => e.includes('"getTools"'))).toBe(true);
33
+ });
34
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { PluginRegistry } from '../../../src/plugins/registry.js';
3
+
4
+ let consoleSpy;
5
+ const envBackup = {};
6
+
7
+ function saveEnv(...keys) {
8
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
9
+ }
10
+ function restoreEnv() {
11
+ Object.entries(envBackup).forEach(([k, v]) => {
12
+ if (v === undefined) delete process.env[k];
13
+ else process.env[k] = v;
14
+ });
15
+ }
16
+
17
+ beforeEach(() => {
18
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ saveEnv('WP_DISABLE_PLUGIN_LAYERS');
20
+ delete process.env.WP_DISABLE_PLUGIN_LAYERS;
21
+ });
22
+ afterEach(() => {
23
+ consoleSpy.mockRestore();
24
+ restoreEnv();
25
+ });
26
+
27
+ function mockApiRequest(namespaces = []) {
28
+ return async () => ({ namespaces });
29
+ }
30
+
31
+ describe('PluginRegistry', () => {
32
+ it('initialize() detects ACF when "acf/v3" in namespaces', async () => {
33
+ const reg = new PluginRegistry();
34
+ await reg.initialize(mockApiRequest(['wp/v2', 'acf/v3']));
35
+ expect(reg.isActive('acf')).toBe(true);
36
+ expect(reg.isActive('elementor')).toBe(false);
37
+ });
38
+
39
+ it('initialize() detects Elementor when "elementor/v1" in namespaces', async () => {
40
+ const reg = new PluginRegistry();
41
+ await reg.initialize(mockApiRequest(['wp/v2', 'elementor/v1', 'elementor/v2']));
42
+ expect(reg.isActive('elementor')).toBe(true);
43
+ expect(reg.isActive('acf')).toBe(false);
44
+ });
45
+
46
+ it('initialize() detects nothing when namespaces is empty', async () => {
47
+ const reg = new PluginRegistry();
48
+ await reg.initialize(mockApiRequest([]));
49
+ expect(reg.isActive('acf')).toBe(false);
50
+ expect(reg.isActive('elementor')).toBe(false);
51
+ expect(reg.isActive('astra')).toBe(false);
52
+ expect(reg.getSummary().active).toEqual([]);
53
+ });
54
+
55
+ it('getAvailableTools() returns [] when WP_DISABLE_PLUGIN_LAYERS=true', async () => {
56
+ const reg = new PluginRegistry();
57
+ await reg.initialize(mockApiRequest(['acf/v3']));
58
+ reg.registerTools('acf', [{ name: 'acf_get_fields' }]);
59
+
60
+ process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
61
+ expect(reg.getAvailableTools()).toEqual([]);
62
+ });
63
+
64
+ it('isActive() returns true for detected plugins and false otherwise', async () => {
65
+ const reg = new PluginRegistry();
66
+ await reg.initialize(mockApiRequest(['acf/v3', 'astra/v1']));
67
+ expect(reg.isActive('acf')).toBe(true);
68
+ expect(reg.isActive('astra')).toBe(true);
69
+ expect(reg.isActive('elementor')).toBe(false);
70
+ expect(reg.isActive('nonexistent')).toBe(false);
71
+ });
72
+
73
+ it('getSummary() returns the correct structure', async () => {
74
+ const reg = new PluginRegistry();
75
+ await reg.initialize(mockApiRequest(['elementor/v1', 'acf/v3']));
76
+ const summary = reg.getSummary();
77
+ expect(summary.active).toContain('acf');
78
+ expect(summary.active).toContain('elementor');
79
+ expect(summary.disabled_by_env).toBe(false);
80
+
81
+ process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
82
+ expect(reg.getSummary().disabled_by_env).toBe(true);
83
+ });
84
+ });
@@ -106,15 +106,15 @@ describe('Plugin Intelligence filtering', () => {
106
106
  // =========================================================================
107
107
 
108
108
  describe('Combined filtering counts', () => {
109
- it('returns all 85 tools when all features enabled', () => {
109
+ it('returns all 86 tools when all features enabled', () => {
110
110
  process.env.WC_CONSUMER_KEY = 'ck_test';
111
111
  process.env.WP_REQUIRE_APPROVAL = 'true';
112
112
  process.env.WP_ENABLE_PLUGIN_INTELLIGENCE = 'true';
113
- expect(getFilteredTools()).toHaveLength(85);
113
+ expect(getFilteredTools()).toHaveLength(86);
114
114
  });
115
115
 
116
- it('returns 63 tools with no optional features (85 - 13wc - 3editorial - 6pi)', () => {
117
- expect(getFilteredTools()).toHaveLength(63);
116
+ it('returns 64 tools with no optional features (86 - 13wc - 3editorial - 6pi)', () => {
117
+ expect(getFilteredTools()).toHaveLength(64);
118
118
  });
119
119
  });
120
120
 
@@ -80,7 +80,7 @@ describe('wp_site_info', () => {
80
80
 
81
81
  // Server info
82
82
  expect(data.server.mcp_version).toBeDefined();
83
- expect(data.server.tools_total).toBe(85);
83
+ expect(data.server.tools_total).toBe(86);
84
84
  expect(typeof data.server.tools_exposed).toBe('number');
85
85
  expect(Array.isArray(data.server.filtered_out)).toBe(true);
86
86
  });
@@ -0,0 +1,101 @@
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, makeRequest, parseResult, getAuditLogs } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+ const envBackup = {};
15
+
16
+ function saveEnv(...keys) {
17
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
18
+ }
19
+ function restoreEnv() {
20
+ Object.entries(envBackup).forEach(([k, v]) => {
21
+ if (v === undefined) delete process.env[k];
22
+ else process.env[k] = v;
23
+ });
24
+ }
25
+
26
+ beforeEach(() => {
27
+ fetch.mockReset();
28
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
29
+ saveEnv('WP_READ_ONLY');
30
+ });
31
+ afterEach(() => {
32
+ consoleSpy.mockRestore();
33
+ restoreEnv();
34
+ });
35
+
36
+ // ── Mock data ──
37
+
38
+ const mockSettings = {
39
+ title: 'My Site',
40
+ description: 'Just another WordPress site',
41
+ url: 'https://test.example.com',
42
+ email: 'admin@test.example.com',
43
+ timezone_string: 'Europe/Brussels',
44
+ date_format: 'Y-m-d',
45
+ time_format: 'H:i',
46
+ language: 'en_US',
47
+ posts_per_page: 10,
48
+ };
49
+
50
+ // =========================================================================
51
+ // wp_get_site_options
52
+ // =========================================================================
53
+
54
+ describe('wp_get_site_options', () => {
55
+ it('returns all options when no keys parameter', async () => {
56
+ fetch.mockResolvedValue(mockSuccess(mockSettings));
57
+ const data = parseResult(await call('wp_get_site_options'));
58
+ expect(data.title).toBe('My Site');
59
+ expect(data.description).toBe('Just another WordPress site');
60
+ expect(data.timezone_string).toBe('Europe/Brussels');
61
+ expect(Object.keys(data).length).toBe(Object.keys(mockSettings).length);
62
+ });
63
+
64
+ it('filters to requested keys only', async () => {
65
+ fetch.mockResolvedValue(mockSuccess(mockSettings));
66
+ const data = parseResult(await call('wp_get_site_options', { keys: ['title', 'language'] }));
67
+ expect(data.title).toBe('My Site');
68
+ expect(data.language).toBe('en_US');
69
+ expect(Object.keys(data)).toEqual(['title', 'language']);
70
+ expect(data.email).toBeUndefined();
71
+ });
72
+
73
+ it('handles 403 (insufficient permissions)', async () => {
74
+ mockError(403, '{"code":"rest_forbidden","message":"Sorry, you are not allowed to manage options."}');
75
+ const res = await call('wp_get_site_options');
76
+ expect(res.isError).toBe(true);
77
+ expect(res.content[0].text).toContain('403');
78
+ });
79
+
80
+ it('logs correct audit format to stderr', async () => {
81
+ fetch.mockResolvedValue(mockSuccess(mockSettings));
82
+ await call('wp_get_site_options', { keys: ['title'] });
83
+ const logs = getAuditLogs();
84
+ const entry = logs.find(l => l.tool === 'wp_get_site_options');
85
+ expect(entry).toBeDefined();
86
+ expect(entry.action).toBe('read_options');
87
+ expect(entry.status).toBe('success');
88
+ expect(typeof entry.latency_ms).toBe('number');
89
+ expect(entry.params.keys_requested).toBe(1);
90
+ expect(entry.params.keys_returned).toBe(1);
91
+ });
92
+
93
+ it('is NOT blocked by WP_READ_ONLY=true', async () => {
94
+ process.env.WP_READ_ONLY = 'true';
95
+ fetch.mockResolvedValue(mockSuccess(mockSettings));
96
+ const res = await call('wp_get_site_options');
97
+ expect(res.isError).toBeUndefined();
98
+ const data = parseResult(res);
99
+ expect(data.title).toBe('My Site');
100
+ });
101
+ });