@adsim/wordpress-mcp-server 3.0.0 → 4.4.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 (33) hide show
  1. package/README.md +543 -176
  2. package/dxt/build-mcpb.sh +7 -0
  3. package/dxt/manifest.json +189 -0
  4. package/index.js +3156 -36
  5. package/package.json +3 -2
  6. package/src/confirmationToken.js +64 -0
  7. package/src/contentAnalyzer.js +476 -0
  8. package/src/htmlParser.js +80 -0
  9. package/src/linkUtils.js +158 -0
  10. package/src/utils/contentCompressor.js +116 -0
  11. package/src/woocommerceClient.js +88 -0
  12. package/tests/unit/contentAnalyzer.test.js +397 -0
  13. package/tests/unit/dxt/manifest.test.js +78 -0
  14. package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
  15. package/tests/unit/tools/approval.test.js +251 -0
  16. package/tests/unit/tools/auditCanonicals.test.js +149 -0
  17. package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
  18. package/tests/unit/tools/auditMediaSeo.test.js +123 -0
  19. package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
  20. package/tests/unit/tools/auditTaxonomies.test.js +173 -0
  21. package/tests/unit/tools/contentCompressor.test.js +320 -0
  22. package/tests/unit/tools/contentIntelligence.test.js +2168 -0
  23. package/tests/unit/tools/destructive.test.js +246 -0
  24. package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
  25. package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
  26. package/tests/unit/tools/findOrphanPages.test.js +145 -0
  27. package/tests/unit/tools/findThinContent.test.js +145 -0
  28. package/tests/unit/tools/internalLinks.test.js +283 -0
  29. package/tests/unit/tools/perTargetControls.test.js +228 -0
  30. package/tests/unit/tools/site.test.js +6 -1
  31. package/tests/unit/tools/woocommerce.test.js +344 -0
  32. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  33. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,228 @@
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, getActiveControls, getControlSources, _testSetTarget } from '../../../index.js';
7
+ import { mockSuccess, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ const ENV_KEYS = ['WP_READ_ONLY', 'WP_DRAFT_ONLY', 'WP_DISABLE_DELETE', 'WP_DISABLE_PLUGIN_MANAGEMENT', 'WP_REQUIRE_APPROVAL', 'WP_CONFIRM_DESTRUCTIVE', 'WP_MAX_CALLS_PER_MINUTE'];
14
+ const savedEnv = {};
15
+
16
+ function clearGovEnv() {
17
+ for (const k of ENV_KEYS) {
18
+ savedEnv[k] = process.env[k];
19
+ delete process.env[k];
20
+ }
21
+ }
22
+
23
+ function restoreGovEnv() {
24
+ for (const k of ENV_KEYS) {
25
+ if (savedEnv[k] === undefined) delete process.env[k];
26
+ else process.env[k] = savedEnv[k];
27
+ }
28
+ }
29
+
30
+ // ────────────────────────────────────────────────────────────
31
+ // getActiveControls — unit tests
32
+ // ────────────────────────────────────────────────────────────
33
+
34
+ describe('getActiveControls', () => {
35
+ let consoleSpy;
36
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); clearGovEnv(); });
37
+ afterEach(() => { consoleSpy.mockRestore(); restoreGovEnv(); _testSetTarget(null); });
38
+
39
+ it('returns global env vars when target has no controls', () => {
40
+ process.env.WP_READ_ONLY = 'true';
41
+ process.env.WP_DISABLE_DELETE = 'true';
42
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p' });
43
+
44
+ const c = getActiveControls();
45
+
46
+ expect(c.read_only).toBe(true);
47
+ expect(c.disable_delete).toBe(true);
48
+ expect(c.draft_only).toBe(false);
49
+ expect(c.require_approval).toBe(false);
50
+ expect(c.confirm_destructive).toBe(false);
51
+ expect(c.disable_plugin_management).toBe(false);
52
+ });
53
+
54
+ it('target read_only:true + global false → blocked (OR strict)', () => {
55
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { read_only: true } });
56
+
57
+ const c = getActiveControls();
58
+
59
+ expect(c.read_only).toBe(true);
60
+ });
61
+
62
+ it('global read_only:true + target false → blocked (OR strict)', () => {
63
+ process.env.WP_READ_ONLY = 'true';
64
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { read_only: false } });
65
+
66
+ const c = getActiveControls();
67
+
68
+ expect(c.read_only).toBe(true);
69
+ });
70
+
71
+ it('both global and target true → blocked (OR strict)', () => {
72
+ process.env.WP_READ_ONLY = 'true';
73
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { read_only: true } });
74
+
75
+ const c = getActiveControls();
76
+ const s = getControlSources();
77
+
78
+ expect(c.read_only).toBe(true);
79
+ expect(s.read_only_source).toBe('both');
80
+ });
81
+
82
+ it('max_calls_per_minute: MIN of global and target', () => {
83
+ process.env.WP_MAX_CALLS_PER_MINUTE = '30';
84
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { max_calls_per_minute: 10 } });
85
+
86
+ const c = getActiveControls();
87
+
88
+ expect(c.max_calls_per_minute).toBe(10);
89
+ });
90
+
91
+ it('max_calls_per_minute: global unlimited + target=20 → 20', () => {
92
+ // global is 0 (unlimited) by default
93
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { max_calls_per_minute: 20 } });
94
+
95
+ const c = getActiveControls();
96
+ const s = getControlSources();
97
+
98
+ expect(c.max_calls_per_minute).toBe(20);
99
+ expect(s.max_calls_per_minute_source).toBe('target');
100
+ });
101
+ });
102
+
103
+ // ────────────────────────────────────────────────────────────
104
+ // Governance functions use getActiveControls — integration
105
+ // ────────────────────────────────────────────────────────────
106
+
107
+ describe('Per-target governance integration', () => {
108
+ let consoleSpy;
109
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); clearGovEnv(); });
110
+ afterEach(() => { consoleSpy.mockRestore(); restoreGovEnv(); _testSetTarget(null); });
111
+
112
+ it('target read_only blocks write tools via enforceReadOnly', async () => {
113
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { read_only: true } });
114
+
115
+ const result = await call('wp_create_post', { title: 'T', content: 'C' });
116
+
117
+ expect(result.isError).toBe(true);
118
+ expect(result.content[0].text).toContain('READ-ONLY');
119
+
120
+ const logs = getAuditLogs(consoleSpy);
121
+ const entry = logs.find(l => l.tool === 'wp_create_post');
122
+ expect(entry).toBeDefined();
123
+ expect(entry.status).toBe('blocked');
124
+ });
125
+
126
+ it('target draft_only blocks publish via enforceDraftOnly', async () => {
127
+ _testSetTarget('staging', { url: 'https://staging.example.com', username: 'u', password: 'p', controls: { draft_only: true } });
128
+
129
+ const result = await call('wp_create_post', { title: 'T', content: 'C', status: 'publish' });
130
+
131
+ expect(result.isError).toBe(true);
132
+ expect(result.content[0].text).toContain('DRAFT-ONLY');
133
+
134
+ const logs = getAuditLogs(consoleSpy);
135
+ const entry = logs.find(l => l.tool === 'wp_create_post');
136
+ expect(entry).toBeDefined();
137
+ expect(entry.status).toBe('error');
138
+ });
139
+ });
140
+
141
+ // ────────────────────────────────────────────────────────────
142
+ // wp_site_info — per-target controls
143
+ // ────────────────────────────────────────────────────────────
144
+
145
+ describe('wp_site_info with per-target controls', () => {
146
+ let consoleSpy;
147
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); clearGovEnv(); });
148
+ afterEach(() => { consoleSpy.mockRestore(); restoreGovEnv(); _testSetTarget(null); });
149
+
150
+ const siteInfoResponse = { name: 'Test', description: 'T', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
151
+ const userMeResponse = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
152
+ const postTypesResponse = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
153
+
154
+ it('enterprise_controls reflects target controls', async () => {
155
+ _testSetTarget('prod', { url: 'https://test.example.com', username: 'testuser', password: 'testpass', controls: { disable_delete: true, confirm_destructive: true } });
156
+ mockSuccess(siteInfoResponse);
157
+ mockSuccess(userMeResponse);
158
+ mockSuccess(postTypesResponse);
159
+
160
+ const res = await call('wp_site_info');
161
+ const data = parseResult(res);
162
+
163
+ expect(data.enterprise_controls.delete_disabled).toBe(true);
164
+ expect(data.enterprise_controls.confirm_destructive).toBe(true);
165
+ expect(data.enterprise_controls.read_only).toBe(false);
166
+ });
167
+
168
+ it('controls_source indicates "target" when restriction comes from target', async () => {
169
+ _testSetTarget('prod', { url: 'https://test.example.com', username: 'testuser', password: 'testpass', controls: { disable_delete: true } });
170
+ mockSuccess(siteInfoResponse);
171
+ mockSuccess(userMeResponse);
172
+ mockSuccess(postTypesResponse);
173
+
174
+ const res = await call('wp_site_info');
175
+ const data = parseResult(res);
176
+
177
+ expect(data.enterprise_controls.disable_delete_source).toBe('target');
178
+ expect(data.enterprise_controls.read_only_source).toBe('none');
179
+ });
180
+ });
181
+
182
+ // ────────────────────────────────────────────────────────────
183
+ // wp_set_target — audit log and controls persistence
184
+ // ────────────────────────────────────────────────────────────
185
+
186
+ describe('wp_set_target with per-target controls', () => {
187
+ let consoleSpy;
188
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); clearGovEnv(); });
189
+ afterEach(() => { consoleSpy.mockRestore(); restoreGovEnv(); _testSetTarget(null); });
190
+
191
+ it('audit log contains effective_controls after switch', async () => {
192
+ // Pre-populate two targets via _testSetTarget for the first, then manually set second
193
+ _testSetTarget('staging', { url: 'https://staging.example.com', username: 'u', password: 'p', controls: { draft_only: true, disable_delete: true } });
194
+
195
+ // Now call wp_set_target to switch to staging (already current, but validates the flow)
196
+ const result = await call('wp_set_target', { site: 'staging' });
197
+ const data = parseResult(result);
198
+
199
+ expect(data.success).toBe(true);
200
+ expect(data.effective_controls).toBeDefined();
201
+ expect(data.effective_controls.draft_only).toBe(true);
202
+ expect(data.effective_controls.disable_delete).toBe(true);
203
+ expect(data.effective_controls.read_only).toBe(false);
204
+
205
+ const logs = getAuditLogs(consoleSpy);
206
+ const entry = logs.find(l => l.tool === 'wp_set_target' && l.status === 'success');
207
+ expect(entry).toBeDefined();
208
+ expect(entry.effective_controls).toBeDefined();
209
+ expect(entry.effective_controls.draft_only).toBe(true);
210
+ });
211
+
212
+ it('per-target controls survive a wp_set_target switch', async () => {
213
+ // Set up two targets
214
+ _testSetTarget('dev', { url: 'https://dev.example.com', username: 'u', password: 'p' });
215
+ _testSetTarget('prod', { url: 'https://prod.example.com', username: 'u', password: 'p', controls: { read_only: true } });
216
+ // Current is now 'prod'. Switch to prod explicitly to test persistence.
217
+
218
+ const result = await call('wp_set_target', { site: 'prod' });
219
+ const data = parseResult(result);
220
+
221
+ expect(data.effective_controls.read_only).toBe(true);
222
+
223
+ // Verify the control actually blocks writes
224
+ const writeResult = await call('wp_create_post', { title: 'T', content: 'C' });
225
+ expect(writeResult.isError).toBe(true);
226
+ expect(writeResult.content[0].text).toContain('READ-ONLY');
227
+ });
228
+ });
@@ -72,10 +72,15 @@ describe('wp_site_info', () => {
72
72
  expect(typeof data.enterprise_controls.draft_only).toBe('boolean');
73
73
  expect(typeof data.enterprise_controls.delete_disabled).toBe('boolean');
74
74
  expect(typeof data.enterprise_controls.plugin_management_disabled).toBe('boolean');
75
+ expect(typeof data.enterprise_controls.confirm_destructive).toBe('boolean');
76
+
77
+ // Control sources
78
+ expect(data.enterprise_controls.read_only_source).toBeDefined();
79
+ expect(['global', 'target', 'both', 'none']).toContain(data.enterprise_controls.read_only_source);
75
80
 
76
81
  // Server info
77
82
  expect(data.server.mcp_version).toBeDefined();
78
- expect(data.server.tools_count).toBe(35);
83
+ expect(data.server.tools_count).toBe(79);
79
84
  });
80
85
 
81
86
  it('SUCCESS — enterprise_controls reflect WP_READ_ONLY env var', async () => {
@@ -0,0 +1,344 @@
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 { 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(() => {
15
+ fetch.mockReset();
16
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
17
+ process.env.WC_CONSUMER_KEY = 'ck_test';
18
+ process.env.WC_CONSUMER_SECRET = 'cs_test';
19
+ });
20
+ afterEach(() => {
21
+ consoleSpy.mockRestore();
22
+ _testSetTarget(null);
23
+ delete process.env.WC_CONSUMER_KEY;
24
+ delete process.env.WC_CONSUMER_SECRET;
25
+ });
26
+
27
+ // =========================================================================
28
+ // Mock data
29
+ // =========================================================================
30
+
31
+ const product1 = {
32
+ id: 101, name: 'T-Shirt', slug: 't-shirt', status: 'publish',
33
+ price: '19.99', regular_price: '24.99', sale_price: '19.99',
34
+ stock_status: 'instock', stock_quantity: 50,
35
+ categories: [{ id: 5, name: 'Clothing' }],
36
+ images: [{ src: 'https://shop.example.com/img/tshirt.jpg' }],
37
+ permalink: 'https://shop.example.com/product/t-shirt',
38
+ type: 'simple', variations: []
39
+ };
40
+
41
+ const product2 = {
42
+ id: 102, name: 'Hoodie', slug: 'hoodie', status: 'publish',
43
+ price: '49.99', regular_price: '49.99', sale_price: '',
44
+ stock_status: 'instock', stock_quantity: 20,
45
+ categories: [{ id: 5, name: 'Clothing' }],
46
+ images: [{ src: 'https://shop.example.com/img/hoodie.jpg' }],
47
+ permalink: 'https://shop.example.com/product/hoodie',
48
+ type: 'variable', variations: [201, 202]
49
+ };
50
+
51
+ const order1 = {
52
+ id: 500, number: '500', status: 'processing',
53
+ date_created: '2026-02-01T10:00:00',
54
+ customer_id: 10,
55
+ billing: { first_name: 'John', last_name: 'Doe', email: 'john@example.com' },
56
+ total: '69.98', currency: 'EUR',
57
+ line_items: [{ name: 'T-Shirt', quantity: 2, total: '39.98' }, { name: 'Hoodie', quantity: 1, total: '49.99' }],
58
+ shipping: {}, payment_method: 'stripe', transaction_id: 'txn_123', meta_data: []
59
+ };
60
+
61
+ const customer1 = {
62
+ id: 10, first_name: 'John', last_name: 'Doe', email: 'john@example.com',
63
+ date_created: '2025-06-15T08:00:00', orders_count: 5, total_spent: '350.00', username: 'johndoe'
64
+ };
65
+
66
+ // =========================================================================
67
+ // wc_list_products
68
+ // =========================================================================
69
+
70
+ describe('wc_list_products', () => {
71
+ it('SUCCESS — returns product list', async () => {
72
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
73
+ mockSuccess([product1, product2]);
74
+
75
+ const res = await call('wc_list_products');
76
+ const data = parseResult(res);
77
+
78
+ expect(data.total).toBe(2);
79
+ expect(data.products[0].id).toBe(101);
80
+ expect(data.products[0].name).toBe('T-Shirt');
81
+ expect(data.products[0].price).toBe('19.99');
82
+ expect(data.products[0].categories[0].name).toBe('Clothing');
83
+ expect(data.products[0].image).toBe('https://shop.example.com/img/tshirt.jpg');
84
+
85
+ const logs = getAuditLogs();
86
+ const entry = logs.find(l => l.tool === 'wc_list_products');
87
+ expect(entry).toBeDefined();
88
+ expect(entry.status).toBe('success');
89
+ });
90
+
91
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
92
+ const original = process.env.WP_READ_ONLY;
93
+ process.env.WP_READ_ONLY = 'true';
94
+ try {
95
+ const res = await call('wc_list_products');
96
+ expect(res.isError).toBe(true);
97
+ expect(res.content[0].text).toContain('READ-ONLY');
98
+ } finally {
99
+ if (original === undefined) delete process.env.WP_READ_ONLY;
100
+ else process.env.WP_READ_ONLY = original;
101
+ }
102
+ });
103
+
104
+ it('ERROR — WC credentials missing', async () => {
105
+ delete process.env.WC_CONSUMER_KEY;
106
+ delete process.env.WC_CONSUMER_SECRET;
107
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
108
+
109
+ const res = await call('wc_list_products');
110
+ expect(res.isError).toBe(true);
111
+ expect(res.content[0].text).toContain('WC_CONSUMER_KEY');
112
+ });
113
+ });
114
+
115
+ // =========================================================================
116
+ // wc_get_product
117
+ // =========================================================================
118
+
119
+ describe('wc_get_product', () => {
120
+ it('SUCCESS — returns full product details', async () => {
121
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
122
+ mockSuccess(product1);
123
+
124
+ const res = await call('wc_get_product', { id: 101 });
125
+ const data = parseResult(res);
126
+
127
+ expect(data.id).toBe(101);
128
+ expect(data.name).toBe('T-Shirt');
129
+ expect(data.price).toBe('19.99');
130
+ });
131
+
132
+ it('SUCCESS — variable product includes variations_detail', async () => {
133
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
134
+ mockSuccess(product2); // variable product with variations [201, 202]
135
+ mockSuccess([
136
+ { id: 201, sku: 'hoodie-s', price: '49.99', regular_price: '49.99', sale_price: '', stock_status: 'instock', stock_quantity: 10, attributes: [{ name: 'Size', option: 'S' }] },
137
+ { id: 202, sku: 'hoodie-l', price: '49.99', regular_price: '49.99', sale_price: '', stock_status: 'instock', stock_quantity: 10, attributes: [{ name: 'Size', option: 'L' }] }
138
+ ]);
139
+
140
+ const res = await call('wc_get_product', { id: 102 });
141
+ const data = parseResult(res);
142
+
143
+ expect(data.id).toBe(102);
144
+ expect(data.type).toBe('variable');
145
+ expect(data.variations_detail).toHaveLength(2);
146
+ expect(data.variations_detail[0].sku).toBe('hoodie-s');
147
+ expect(data.variations_detail[1].attributes[0].option).toBe('L');
148
+ });
149
+
150
+ it('ERROR — product not found (404)', async () => {
151
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
152
+ mockError(404, '{"code":"woocommerce_rest_product_invalid_id","message":"Invalid ID."}');
153
+
154
+ const res = await call('wc_get_product', { id: 999 });
155
+ expect(res.isError).toBe(true);
156
+ expect(res.content[0].text).toContain('404');
157
+ });
158
+ });
159
+
160
+ // =========================================================================
161
+ // wc_list_orders
162
+ // =========================================================================
163
+
164
+ describe('wc_list_orders', () => {
165
+ it('SUCCESS — returns order list', async () => {
166
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
167
+ mockSuccess([order1]);
168
+
169
+ const res = await call('wc_list_orders');
170
+ const data = parseResult(res);
171
+
172
+ expect(data.total).toBe(1);
173
+ expect(data.orders[0].id).toBe(500);
174
+ expect(data.orders[0].status).toBe('processing');
175
+ expect(data.orders[0].billing_name).toBe('John Doe');
176
+ expect(data.orders[0].billing_email).toBe('john@example.com');
177
+ expect(data.orders[0].total).toBe('69.98');
178
+ expect(data.orders[0].line_items).toHaveLength(2);
179
+ });
180
+
181
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
182
+ const original = process.env.WP_READ_ONLY;
183
+ process.env.WP_READ_ONLY = 'true';
184
+ try {
185
+ const res = await call('wc_list_orders');
186
+ expect(res.isError).toBe(true);
187
+ expect(res.content[0].text).toContain('READ-ONLY');
188
+ } finally {
189
+ if (original === undefined) delete process.env.WP_READ_ONLY;
190
+ else process.env.WP_READ_ONLY = original;
191
+ }
192
+ });
193
+
194
+ it('SUCCESS — filter by status', async () => {
195
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
196
+ mockSuccess([order1]);
197
+
198
+ const res = await call('wc_list_orders', { status: 'processing' });
199
+ const data = parseResult(res);
200
+ expect(data.orders[0].status).toBe('processing');
201
+
202
+ // Verify the fetch URL contained the status param
203
+ const fetchUrl = fetch.mock.calls[0][0];
204
+ expect(fetchUrl).toContain('status=processing');
205
+ });
206
+ });
207
+
208
+ // =========================================================================
209
+ // wc_get_order
210
+ // =========================================================================
211
+
212
+ describe('wc_get_order', () => {
213
+ it('SUCCESS — returns full order details', async () => {
214
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
215
+ mockSuccess(order1);
216
+
217
+ const res = await call('wc_get_order', { id: 500 });
218
+ const data = parseResult(res);
219
+
220
+ expect(data.id).toBe(500);
221
+ expect(data.billing.first_name).toBe('John');
222
+ expect(data.payment_method).toBe('stripe');
223
+ expect(data.line_items).toHaveLength(2);
224
+ });
225
+
226
+ it('ERROR — order not found (404)', async () => {
227
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
228
+ mockError(404, '{"code":"woocommerce_rest_shop_order_invalid_id","message":"Invalid ID."}');
229
+
230
+ const res = await call('wc_get_order', { id: 999 });
231
+ expect(res.isError).toBe(true);
232
+ expect(res.content[0].text).toContain('404');
233
+ });
234
+ });
235
+
236
+ // =========================================================================
237
+ // wc_list_customers
238
+ // =========================================================================
239
+
240
+ describe('wc_list_customers', () => {
241
+ it('SUCCESS — returns customer list', async () => {
242
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
243
+ mockSuccess([customer1]);
244
+
245
+ const res = await call('wc_list_customers');
246
+ const data = parseResult(res);
247
+
248
+ expect(data.total).toBe(1);
249
+ expect(data.customers[0].id).toBe(10);
250
+ expect(data.customers[0].first_name).toBe('John');
251
+ expect(data.customers[0].email).toBe('john@example.com');
252
+ expect(data.customers[0].orders_count).toBe(5);
253
+ expect(data.customers[0].total_spent).toBe('350.00');
254
+ });
255
+
256
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
257
+ const original = process.env.WP_READ_ONLY;
258
+ process.env.WP_READ_ONLY = 'true';
259
+ try {
260
+ const res = await call('wc_list_customers');
261
+ expect(res.isError).toBe(true);
262
+ expect(res.content[0].text).toContain('READ-ONLY');
263
+ } finally {
264
+ if (original === undefined) delete process.env.WP_READ_ONLY;
265
+ else process.env.WP_READ_ONLY = original;
266
+ }
267
+ });
268
+ });
269
+
270
+ // =========================================================================
271
+ // wc_price_guardrail
272
+ // =========================================================================
273
+
274
+ describe('wc_price_guardrail', () => {
275
+ it('SAFE — price change within default threshold (20%)', async () => {
276
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
277
+ mockSuccess({ id: 101, name: 'T-Shirt', price: '100.00' });
278
+
279
+ const res = await call('wc_price_guardrail', { product_id: 101, new_price: 110 });
280
+ const data = parseResult(res);
281
+
282
+ expect(data.safe).toBe(true);
283
+ expect(data.current_price).toBe(100);
284
+ expect(data.new_price).toBe(110);
285
+ expect(data.change_percent).toBe(10);
286
+ expect(data.message).toContain('within threshold');
287
+
288
+ const logs = getAuditLogs();
289
+ const entry = logs.find(l => l.tool === 'wc_price_guardrail');
290
+ expect(entry).toBeDefined();
291
+ expect(entry.action).toBe('price_check');
292
+ });
293
+
294
+ it('UNSAFE — price change exceeds threshold', async () => {
295
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
296
+ mockSuccess({ id: 101, name: 'T-Shirt', price: '100.00' });
297
+
298
+ const res = await call('wc_price_guardrail', { product_id: 101, new_price: 150 });
299
+ const data = parseResult(res);
300
+
301
+ expect(data.safe).toBe(false);
302
+ expect(data.change_percent).toBe(50);
303
+ expect(data.requires_confirmation).toBe(true);
304
+ expect(data.message).toContain('exceeds threshold');
305
+ });
306
+
307
+ it('CUSTOM THRESHOLD — respects threshold_percent param', async () => {
308
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
309
+ mockSuccess({ id: 101, name: 'T-Shirt', price: '100.00' });
310
+
311
+ const res = await call('wc_price_guardrail', { product_id: 101, new_price: 108, threshold_percent: 5 });
312
+ const data = parseResult(res);
313
+
314
+ expect(data.safe).toBe(false);
315
+ expect(data.change_percent).toBe(8);
316
+ expect(data.message).toContain('5%');
317
+ });
318
+
319
+ it('ERROR — product not found', async () => {
320
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
321
+ mockError(404, '{"code":"woocommerce_rest_product_invalid_id","message":"Invalid ID."}');
322
+
323
+ const res = await call('wc_price_guardrail', { product_id: 999, new_price: 50 });
324
+ expect(res.isError).toBe(true);
325
+ expect(res.content[0].text).toContain('404');
326
+ });
327
+
328
+ it('NOT blocked by WP_READ_ONLY (analysis only)', async () => {
329
+ const original = process.env.WP_READ_ONLY;
330
+ process.env.WP_READ_ONLY = 'true';
331
+ try {
332
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
333
+ mockSuccess({ id: 101, name: 'T-Shirt', price: '100.00' });
334
+
335
+ const res = await call('wc_price_guardrail', { product_id: 101, new_price: 110 });
336
+ expect(res.isError).toBeUndefined();
337
+ const data = parseResult(res);
338
+ expect(data.safe).toBe(true);
339
+ } finally {
340
+ if (original === undefined) delete process.env.WP_READ_ONLY;
341
+ else process.env.WP_READ_ONLY = original;
342
+ }
343
+ });
344
+ });