@adsim/wordpress-mcp-server 3.1.0 → 4.5.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 (34) hide show
  1. package/README.md +564 -176
  2. package/dxt/manifest.json +93 -9
  3. package/index.js +3624 -36
  4. package/package.json +1 -1
  5. package/src/confirmationToken.js +64 -0
  6. package/src/contentAnalyzer.js +476 -0
  7. package/src/htmlParser.js +80 -0
  8. package/src/linkUtils.js +158 -0
  9. package/src/pluginDetector.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/pluginDetector.test.js +167 -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/pluginIntelligence.test.js +864 -0
  31. package/tests/unit/tools/site.test.js +6 -1
  32. package/tests/unit/tools/woocommerce.test.js +344 -0
  33. package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
  34. package/tests/unit/tools/woocommerceWrite.test.js +323 -0
@@ -0,0 +1,341 @@
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 instockProducts = [
32
+ { id: 301, name: 'Widget A', sku: 'WA-001', stock_quantity: 3, stock_status: 'instock', price: '15.00', permalink: 'https://shop.example.com/product/widget-a' },
33
+ { id: 302, name: 'Widget B', sku: 'WB-002', stock_quantity: 10, stock_status: 'instock', price: '25.00', permalink: 'https://shop.example.com/product/widget-b' },
34
+ { id: 303, name: 'Widget C', sku: 'WC-003', stock_quantity: 1, stock_status: 'instock', price: '35.00', permalink: 'https://shop.example.com/product/widget-c' },
35
+ ];
36
+
37
+ const outOfStockProducts = [
38
+ { id: 304, name: 'Widget D', sku: 'WD-004', stock_quantity: 0, stock_status: 'outofstock', price: '45.00', permalink: 'https://shop.example.com/product/widget-d' },
39
+ ];
40
+
41
+ const customerOrders = [
42
+ { id: 601, date_created: '2026-02-01T10:00:00', status: 'completed', total: '100.00', customer_id: 10, line_items: [{ name: 'T-Shirt', quantity: 2, total: '39.98' }, { name: 'Hoodie', quantity: 1, total: '49.99' }] },
43
+ { id: 602, date_created: '2026-01-15T10:00:00', status: 'completed', total: '50.00', customer_id: 10, line_items: [{ name: 'T-Shirt', quantity: 1, total: '19.99' }, { name: 'Cap', quantity: 1, total: '15.00' }] },
44
+ { id: 603, date_created: '2025-12-01T10:00:00', status: 'processing', total: '75.00', customer_id: 10, line_items: [{ name: 'Hoodie', quantity: 1, total: '49.99' }, { name: 'T-Shirt', quantity: 1, total: '19.99' }] },
45
+ ];
46
+
47
+ const seoProductPerfect = {
48
+ id: 401, name: 'Premium Organic Cotton T-Shirt', slug: 'premium-organic-cotton-tshirt', status: 'publish',
49
+ description: '<p>This premium organic cotton t-shirt is made from 100% certified organic cotton. Sustainably sourced and ethically manufactured.</p>',
50
+ short_description: '<p>Premium organic cotton t-shirt, sustainably sourced.</p>',
51
+ images: [{ src: 'https://shop.example.com/img/tshirt.jpg', alt: 'Premium organic cotton t-shirt' }],
52
+ price: '29.99', permalink: 'https://shop.example.com/product/premium-organic-cotton-tshirt'
53
+ };
54
+
55
+ const seoProductBad = {
56
+ id: 402, name: 'Shirt', slug: 'product-12345', status: 'publish',
57
+ description: '<p>A shirt.</p>',
58
+ short_description: '',
59
+ images: [],
60
+ price: '', permalink: 'https://shop.example.com/product/product-12345'
61
+ };
62
+
63
+ const wpPost = {
64
+ id: 50, title: { rendered: 'Best Cotton T-Shirts for Summer' },
65
+ content: { rendered: '<p>Looking for the best cotton t-shirts?</p>' },
66
+ excerpt: { rendered: '' },
67
+ meta: { rank_math_focus_keyword: 'cotton t-shirt' },
68
+ categories: [5], tags: [], status: 'publish', slug: 'best-cotton-tshirts',
69
+ link: 'https://test.example.com/best-cotton-tshirts', date: '2026-02-01', modified: '2026-02-01'
70
+ };
71
+
72
+ const wpPostNoMeta = {
73
+ id: 51, title: { rendered: 'Summer Fashion Trends Guide' },
74
+ content: { rendered: '<p>Here are the latest summer fashion trends.</p>' },
75
+ excerpt: { rendered: '' },
76
+ meta: {},
77
+ categories: [5], tags: [], status: 'publish', slug: 'summer-fashion-trends',
78
+ link: 'https://test.example.com/summer-fashion-trends', date: '2026-02-01', modified: '2026-02-01'
79
+ };
80
+
81
+ const wcSearchResults = [
82
+ { id: 401, name: 'Premium Organic Cotton T-Shirt', description: '<p>Made from 100% cotton material for summer wear.</p>', stock_status: 'instock', images: [{ src: 'https://shop.example.com/img/tshirt.jpg' }], price: '29.99', permalink: 'https://shop.example.com/product/premium-organic-cotton-tshirt' },
83
+ { id: 402, name: 'Linen Summer Shirt', description: '<p>Light summer shirt.</p>', stock_status: 'instock', images: [{ src: 'https://shop.example.com/img/linen.jpg' }], price: '39.99', permalink: 'https://shop.example.com/product/linen-summer-shirt' },
84
+ ];
85
+
86
+ // =========================================================================
87
+ // wc_inventory_alert
88
+ // =========================================================================
89
+
90
+ describe('wc_inventory_alert', () => {
91
+ it('SUCCESS — returns products below threshold', async () => {
92
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
93
+ mockSuccess(instockProducts);
94
+ mockSuccess(outOfStockProducts);
95
+
96
+ const res = await call('wc_inventory_alert');
97
+ const data = parseResult(res);
98
+
99
+ expect(data.summary.total_alerts).toBe(3);
100
+ expect(data.summary.out_of_stock_count).toBe(1);
101
+ expect(data.summary.low_stock_count).toBe(2);
102
+ expect(data.summary.threshold_used).toBe(5);
103
+ // Sorted ASC: outofstock (qty 0) first, then qty 1, then qty 3
104
+ expect(data.products[0].id).toBe(304);
105
+ expect(data.products[1].id).toBe(303);
106
+ expect(data.products[2].id).toBe(301);
107
+ });
108
+
109
+ it('SUCCESS — custom threshold filters correctly', async () => {
110
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
111
+ mockSuccess(instockProducts);
112
+ mockSuccess(outOfStockProducts);
113
+
114
+ const res = await call('wc_inventory_alert', { threshold: 2 });
115
+ const data = parseResult(res);
116
+
117
+ // Widget C (qty 1) + Widget D (outofstock) — Widget A (qty 3) exceeds threshold 2
118
+ expect(data.summary.total_alerts).toBe(2);
119
+ expect(data.summary.threshold_used).toBe(2);
120
+ });
121
+
122
+ it('SUCCESS — include_out_of_stock=false excludes outofstock', async () => {
123
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
124
+ mockSuccess(instockProducts);
125
+
126
+ const res = await call('wc_inventory_alert', { include_out_of_stock: false });
127
+ const data = parseResult(res);
128
+
129
+ expect(data.summary.total_alerts).toBe(2);
130
+ expect(data.summary.out_of_stock_count).toBe(0);
131
+ expect(data.summary.low_stock_count).toBe(2);
132
+ });
133
+
134
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
135
+ const original = process.env.WP_READ_ONLY;
136
+ process.env.WP_READ_ONLY = 'true';
137
+ try {
138
+ const res = await call('wc_inventory_alert');
139
+ expect(res.isError).toBe(true);
140
+ expect(res.content[0].text).toContain('READ-ONLY');
141
+ } finally {
142
+ if (original === undefined) delete process.env.WP_READ_ONLY;
143
+ else process.env.WP_READ_ONLY = original;
144
+ }
145
+ });
146
+
147
+ it('ERROR — WC credentials missing', async () => {
148
+ delete process.env.WC_CONSUMER_KEY;
149
+ delete process.env.WC_CONSUMER_SECRET;
150
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
151
+
152
+ const res = await call('wc_inventory_alert');
153
+ expect(res.isError).toBe(true);
154
+ expect(res.content[0].text).toContain('WC_CONSUMER_KEY');
155
+ });
156
+ });
157
+
158
+ // =========================================================================
159
+ // wc_order_intelligence
160
+ // =========================================================================
161
+
162
+ describe('wc_order_intelligence', () => {
163
+ it('SUCCESS — calculates LTV and metrics', async () => {
164
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
165
+ mockSuccess(customerOrders);
166
+
167
+ const res = await call('wc_order_intelligence', { customer_id: 10 });
168
+ const data = parseResult(res);
169
+
170
+ expect(data.customer_id).toBe(10);
171
+ expect(data.total_orders).toBe(3);
172
+ expect(data.total_spent).toBe(225);
173
+ expect(data.average_order_value).toBe(75);
174
+ expect(data.last_order_date).toBe('2026-02-01T10:00:00');
175
+ expect(data.first_order_date).toBe('2025-12-01T10:00:00');
176
+ expect(data.order_frequency_days).toBeTypeOf('number');
177
+ expect(data.recent_orders).toHaveLength(3);
178
+ expect(data.statuses_breakdown.completed).toBe(2);
179
+ expect(data.statuses_breakdown.processing).toBe(1);
180
+ });
181
+
182
+ it('SUCCESS — favourite products ranked by frequency', async () => {
183
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
184
+ mockSuccess(customerOrders);
185
+
186
+ const res = await call('wc_order_intelligence', { customer_id: 10 });
187
+ const data = parseResult(res);
188
+
189
+ // T-Shirt: 2+1+1=4, Hoodie: 1+1=2, Cap: 1
190
+ expect(data.favourite_products[0].name).toBe('T-Shirt');
191
+ expect(data.favourite_products[0].quantity).toBe(4);
192
+ expect(data.favourite_products[1].name).toBe('Hoodie');
193
+ expect(data.favourite_products[1].quantity).toBe(2);
194
+ });
195
+
196
+ it('SUCCESS — customer with no orders', async () => {
197
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
198
+ mockSuccess([]);
199
+
200
+ const res = await call('wc_order_intelligence', { customer_id: 99 });
201
+ const data = parseResult(res);
202
+
203
+ expect(data.total_orders).toBe(0);
204
+ expect(data.total_spent).toBe(0);
205
+ expect(data.average_order_value).toBe(0);
206
+ expect(data.first_order_date).toBeNull();
207
+ expect(data.favourite_products).toHaveLength(0);
208
+ });
209
+
210
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
211
+ const original = process.env.WP_READ_ONLY;
212
+ process.env.WP_READ_ONLY = 'true';
213
+ try {
214
+ const res = await call('wc_order_intelligence', { customer_id: 10 });
215
+ expect(res.isError).toBe(true);
216
+ expect(res.content[0].text).toContain('READ-ONLY');
217
+ } finally {
218
+ if (original === undefined) delete process.env.WP_READ_ONLY;
219
+ else process.env.WP_READ_ONLY = original;
220
+ }
221
+ });
222
+ });
223
+
224
+ // =========================================================================
225
+ // wc_seo_product_audit
226
+ // =========================================================================
227
+
228
+ describe('wc_seo_product_audit', () => {
229
+ it('SUCCESS — audits products with scores', async () => {
230
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
231
+ mockSuccess([seoProductPerfect, seoProductBad]);
232
+
233
+ const res = await call('wc_seo_product_audit');
234
+ const data = parseResult(res);
235
+
236
+ expect(data.total_audited).toBe(2);
237
+ expect(data.products).toHaveLength(2);
238
+ expect(data.average_score).toBeTypeOf('number');
239
+ expect(data.summary.perfect_score_count).toBeGreaterThanOrEqual(0);
240
+ expect(data.summary.needs_attention_count).toBeGreaterThanOrEqual(0);
241
+ });
242
+
243
+ it('SUCCESS — perfect product gets score 100', async () => {
244
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
245
+ mockSuccess([seoProductPerfect]);
246
+
247
+ const res = await call('wc_seo_product_audit');
248
+ const data = parseResult(res);
249
+
250
+ const p = data.products.find(x => x.id === 401);
251
+ expect(p.score).toBe(100);
252
+ expect(p.issues).toHaveLength(0);
253
+ expect(data.summary.perfect_score_count).toBe(1);
254
+ });
255
+
256
+ it('SUCCESS — product with multiple issues gets low score', async () => {
257
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
258
+ mockSuccess([seoProductBad]);
259
+
260
+ const res = await call('wc_seo_product_audit');
261
+ const data = parseResult(res);
262
+
263
+ const p = data.products.find(x => x.id === 402);
264
+ expect(p.score).toBeLessThan(70);
265
+ expect(p.issues.length).toBeGreaterThan(3);
266
+ expect(p.issues).toContain('description_too_short');
267
+ expect(p.issues).toContain('missing_short_description');
268
+ expect(p.issues).toContain('generic_slug');
269
+ expect(p.issues).toContain('missing_image');
270
+ expect(p.issues).toContain('missing_price');
271
+ expect(data.summary.needs_attention_count).toBe(1);
272
+ });
273
+
274
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
275
+ const original = process.env.WP_READ_ONLY;
276
+ process.env.WP_READ_ONLY = 'true';
277
+ try {
278
+ const res = await call('wc_seo_product_audit');
279
+ expect(res.isError).toBe(true);
280
+ expect(res.content[0].text).toContain('READ-ONLY');
281
+ } finally {
282
+ if (original === undefined) delete process.env.WP_READ_ONLY;
283
+ else process.env.WP_READ_ONLY = original;
284
+ }
285
+ });
286
+ });
287
+
288
+ // =========================================================================
289
+ // wc_suggest_product_links
290
+ // =========================================================================
291
+
292
+ describe('wc_suggest_product_links', () => {
293
+ it('SUCCESS — suggests products based on post keywords', async () => {
294
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
295
+ mockSuccess(wpPost); // WP API: get post
296
+ mockSuccess(wcSearchResults); // WC API: search keyword 1
297
+ mockSuccess([]); // WC API: search keyword 2
298
+
299
+ const res = await call('wc_suggest_product_links', { post_id: 50 });
300
+ const data = parseResult(res);
301
+
302
+ expect(data.post_id).toBe(50);
303
+ expect(data.post_title).toBe('Best Cotton T-Shirts for Summer');
304
+ expect(data.keywords_used.length).toBeGreaterThanOrEqual(1);
305
+ expect(data.keywords_used[0]).toBe('cotton t-shirt');
306
+ expect(data.suggestions.length).toBeGreaterThanOrEqual(1);
307
+ expect(data.suggestions[0].product_id).toBeDefined();
308
+ expect(data.suggestions[0].relevance_score).toBeGreaterThan(0);
309
+ expect(data.suggestions[0].anchor_text).toBeDefined();
310
+ });
311
+
312
+ it('SUCCESS — uses title words when no SEO keywords', async () => {
313
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
314
+ mockSuccess(wpPostNoMeta); // WP post with no SEO meta
315
+ mockSuccess(wcSearchResults); // WC search for title word 1
316
+ mockSuccess([]); // WC search for title word 2
317
+
318
+ const res = await call('wc_suggest_product_links', { post_id: 51 });
319
+ const data = parseResult(res);
320
+
321
+ expect(data.post_id).toBe(51);
322
+ expect(data.keywords_used.length).toBeGreaterThan(0);
323
+ // Keywords from title "Summer Fashion Trends Guide" (words > 3 chars)
324
+ expect(data.keywords_used.some(k =>
325
+ ['summer', 'fashion', 'trends', 'guide'].includes(k.toLowerCase())
326
+ )).toBe(true);
327
+ });
328
+
329
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
330
+ const original = process.env.WP_READ_ONLY;
331
+ process.env.WP_READ_ONLY = 'true';
332
+ try {
333
+ const res = await call('wc_suggest_product_links', { post_id: 50 });
334
+ expect(res.isError).toBe(true);
335
+ expect(res.content[0].text).toContain('READ-ONLY');
336
+ } finally {
337
+ if (original === undefined) delete process.env.WP_READ_ONLY;
338
+ else process.env.WP_READ_ONLY = original;
339
+ }
340
+ });
341
+ });
@@ -0,0 +1,323 @@
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: [], description: 'A nice shirt', short_description: 'Shirt'
39
+ };
40
+
41
+ const updatedProduct = {
42
+ id: 101, name: 'T-Shirt Updated', status: 'publish',
43
+ regular_price: '24.99', sale_price: '19.99',
44
+ permalink: 'https://shop.example.com/product/t-shirt'
45
+ };
46
+
47
+ const order1 = {
48
+ id: 500, number: '500', status: 'processing',
49
+ date_created: '2026-02-01T10:00:00',
50
+ customer_id: 10,
51
+ billing: { first_name: 'John', last_name: 'Doe', email: 'john@example.com' },
52
+ total: '69.98', currency: 'EUR',
53
+ line_items: [{ name: 'T-Shirt', quantity: 2, total: '39.98' }],
54
+ shipping: {}, payment_method: 'stripe', transaction_id: 'txn_123', meta_data: []
55
+ };
56
+
57
+ // =========================================================================
58
+ // wc_update_product
59
+ // =========================================================================
60
+
61
+ describe('wc_update_product', () => {
62
+ it('SUCCESS — update without price change', async () => {
63
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
64
+ mockSuccess(updatedProduct); // PUT response
65
+
66
+ const res = await call('wc_update_product', { id: 101, name: 'T-Shirt Updated' });
67
+ const data = parseResult(res);
68
+
69
+ expect(data.success).toBe(true);
70
+ expect(data.product.id).toBe(101);
71
+ expect(data.product.name).toBe('T-Shirt Updated');
72
+
73
+ const logs = getAuditLogs();
74
+ const entry = logs.find(l => l.tool === 'wc_update_product');
75
+ expect(entry).toBeDefined();
76
+ expect(entry.action).toBe('update_product');
77
+ expect(entry.status).toBe('success');
78
+ });
79
+
80
+ it('SUCCESS — price within 20% threshold', async () => {
81
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
82
+ // 1. GET current product (for guardrail check)
83
+ mockSuccess({ ...product1, regular_price: '100.00' });
84
+ // 2. PUT update
85
+ mockSuccess({ ...updatedProduct, regular_price: '115.00' });
86
+
87
+ const res = await call('wc_update_product', { id: 101, regular_price: '115.00' });
88
+ const data = parseResult(res);
89
+
90
+ expect(data.success).toBe(true);
91
+ expect(data.product.regular_price).toBe('115.00');
92
+ });
93
+
94
+ it('BLOCKED — price change >20% without confirmation', async () => {
95
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
96
+ // GET current product
97
+ mockSuccess({ ...product1, regular_price: '100.00' });
98
+
99
+ const res = await call('wc_update_product', { id: 101, regular_price: '150.00' });
100
+ expect(res.isError).toBe(true);
101
+
102
+ const data = JSON.parse(res.content[0].text);
103
+ expect(data.error).toBe('PRICE_GUARDRAIL_TRIGGERED');
104
+ expect(data.change_percent).toBe(50);
105
+ expect(data.current_price).toBe(100);
106
+ expect(data.new_price).toBe(150);
107
+ });
108
+
109
+ it('SUCCESS — price >20% with price_guardrail_confirmed', async () => {
110
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
111
+ // 1. GET current product (guardrail check)
112
+ mockSuccess({ ...product1, regular_price: '100.00' });
113
+ // 2. PUT update
114
+ mockSuccess({ ...updatedProduct, regular_price: '150.00' });
115
+
116
+ const res = await call('wc_update_product', { id: 101, regular_price: '150.00', price_guardrail_confirmed: true });
117
+ const data = parseResult(res);
118
+
119
+ expect(data.success).toBe(true);
120
+ });
121
+
122
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
123
+ const original = process.env.WP_READ_ONLY;
124
+ process.env.WP_READ_ONLY = 'true';
125
+ try {
126
+ const res = await call('wc_update_product', { id: 101, name: 'New' });
127
+ expect(res.isError).toBe(true);
128
+ expect(res.content[0].text).toContain('READ-ONLY');
129
+ } finally {
130
+ if (original === undefined) delete process.env.WP_READ_ONLY;
131
+ else process.env.WP_READ_ONLY = original;
132
+ }
133
+ });
134
+
135
+ it('ERROR — product not found (404)', async () => {
136
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
137
+ mockError(404, '{"code":"woocommerce_rest_product_invalid_id","message":"Invalid ID."}');
138
+
139
+ const res = await call('wc_update_product', { id: 999, name: 'Ghost' });
140
+ expect(res.isError).toBe(true);
141
+ expect(res.content[0].text).toContain('404');
142
+ });
143
+
144
+ it('ERROR — WC credentials missing', async () => {
145
+ delete process.env.WC_CONSUMER_KEY;
146
+ delete process.env.WC_CONSUMER_SECRET;
147
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
148
+
149
+ const res = await call('wc_update_product', { id: 101, name: 'Test' });
150
+ expect(res.isError).toBe(true);
151
+ expect(res.content[0].text).toContain('WC_CONSUMER_KEY');
152
+ });
153
+ });
154
+
155
+ // =========================================================================
156
+ // wc_update_stock
157
+ // =========================================================================
158
+
159
+ describe('wc_update_stock', () => {
160
+ it('SUCCESS — simple product stock update', async () => {
161
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
162
+ // 1. GET current product
163
+ mockSuccess({ ...product1, stock_quantity: 50 });
164
+ // 2. PUT update
165
+ mockSuccess({ ...product1, stock_quantity: 30, manage_stock: true });
166
+
167
+ const res = await call('wc_update_stock', { id: 101, stock_quantity: 30 });
168
+ const data = parseResult(res);
169
+
170
+ expect(data.success).toBe(true);
171
+ expect(data.previous_stock).toBe(50);
172
+ expect(data.new_stock).toBe(30);
173
+ expect(data.variation_id).toBeNull();
174
+
175
+ const logs = getAuditLogs();
176
+ const entry = logs.find(l => l.tool === 'wc_update_stock');
177
+ expect(entry).toBeDefined();
178
+ expect(entry.action).toBe('update_stock');
179
+ });
180
+
181
+ it('SUCCESS — variation stock update', async () => {
182
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
183
+ // 1. GET current variation
184
+ mockSuccess({ id: 201, stock_quantity: 10, manage_stock: true });
185
+ // 2. PUT update variation
186
+ mockSuccess({ id: 201, stock_quantity: 25, manage_stock: true });
187
+
188
+ const res = await call('wc_update_stock', { id: 102, stock_quantity: 25, variation_id: 201 });
189
+ const data = parseResult(res);
190
+
191
+ expect(data.success).toBe(true);
192
+ expect(data.previous_stock).toBe(10);
193
+ expect(data.new_stock).toBe(25);
194
+ expect(data.variation_id).toBe(201);
195
+ });
196
+
197
+ it('ERROR — negative stock rejected (Zod min:0)', async () => {
198
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
199
+
200
+ const res = await call('wc_update_stock', { id: 101, stock_quantity: -5 });
201
+ expect(res.isError).toBe(true);
202
+ expect(res.content[0].text).toContain('stock_quantity');
203
+ });
204
+
205
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
206
+ const original = process.env.WP_READ_ONLY;
207
+ process.env.WP_READ_ONLY = 'true';
208
+ try {
209
+ const res = await call('wc_update_stock', { id: 101, stock_quantity: 10 });
210
+ expect(res.isError).toBe(true);
211
+ expect(res.content[0].text).toContain('READ-ONLY');
212
+ } finally {
213
+ if (original === undefined) delete process.env.WP_READ_ONLY;
214
+ else process.env.WP_READ_ONLY = original;
215
+ }
216
+ });
217
+ });
218
+
219
+ // =========================================================================
220
+ // wc_update_order_status
221
+ // =========================================================================
222
+
223
+ describe('wc_update_order_status', () => {
224
+ it('SUCCESS — valid transition (processing → completed)', async () => {
225
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
226
+ // 1. GET current order
227
+ mockSuccess({ ...order1, status: 'processing' });
228
+ // 2. PUT update status
229
+ mockSuccess({ ...order1, status: 'completed' });
230
+
231
+ const res = await call('wc_update_order_status', { id: 500, status: 'completed' });
232
+ const data = parseResult(res);
233
+
234
+ expect(data.success).toBe(true);
235
+ expect(data.previous_status).toBe('processing');
236
+ expect(data.new_status).toBe('completed');
237
+ expect(data.note_added).toBe(false);
238
+
239
+ const logs = getAuditLogs();
240
+ const entry = logs.find(l => l.tool === 'wc_update_order_status');
241
+ expect(entry).toBeDefined();
242
+ expect(entry.action).toBe('update_order_status');
243
+ });
244
+
245
+ it('ERROR — invalid transition rejected', async () => {
246
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
247
+ // GET current order — status pending
248
+ mockSuccess({ ...order1, status: 'pending' });
249
+
250
+ const res = await call('wc_update_order_status', { id: 500, status: 'completed' });
251
+ expect(res.isError).toBe(true);
252
+
253
+ const data = JSON.parse(res.content[0].text);
254
+ expect(data.error).toBe('INVALID_TRANSITION');
255
+ expect(data.current_status).toBe('pending');
256
+ expect(data.valid_transitions).toContain('processing');
257
+ expect(data.valid_transitions).not.toContain('completed');
258
+ });
259
+
260
+ it('ERROR — terminal status (cancelled → X)', async () => {
261
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
262
+ mockSuccess({ ...order1, status: 'cancelled' });
263
+
264
+ const res = await call('wc_update_order_status', { id: 500, status: 'processing' });
265
+ expect(res.isError).toBe(true);
266
+
267
+ const data = JSON.parse(res.content[0].text);
268
+ expect(data.error).toBe('INVALID_TRANSITION');
269
+ expect(data.valid_transitions).toEqual([]);
270
+ expect(data.message).toContain('terminal');
271
+ });
272
+
273
+ it('SUCCESS — with note', async () => {
274
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
275
+ // 1. GET current order
276
+ mockSuccess({ ...order1, status: 'processing' });
277
+ // 2. PUT update status
278
+ mockSuccess({ ...order1, status: 'completed' });
279
+ // 3. POST note
280
+ mockSuccess({ id: 1, note: 'Shipped via DHL' });
281
+
282
+ const res = await call('wc_update_order_status', { id: 500, status: 'completed', note: 'Shipped via DHL' });
283
+ const data = parseResult(res);
284
+
285
+ expect(data.success).toBe(true);
286
+ expect(data.note_added).toBe(true);
287
+ });
288
+
289
+ it('BLOCKED — WP_READ_ONLY prevents access', async () => {
290
+ const original = process.env.WP_READ_ONLY;
291
+ process.env.WP_READ_ONLY = 'true';
292
+ try {
293
+ const res = await call('wc_update_order_status', { id: 500, status: 'completed' });
294
+ expect(res.isError).toBe(true);
295
+ expect(res.content[0].text).toContain('READ-ONLY');
296
+ } finally {
297
+ if (original === undefined) delete process.env.WP_READ_ONLY;
298
+ else process.env.WP_READ_ONLY = original;
299
+ }
300
+ });
301
+
302
+ it('ERROR — order not found (404)', async () => {
303
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
304
+ mockError(404, '{"code":"woocommerce_rest_shop_order_invalid_id","message":"Invalid ID."}');
305
+
306
+ const res = await call('wc_update_order_status', { id: 999, status: 'completed' });
307
+ expect(res.isError).toBe(true);
308
+ expect(res.content[0].text).toContain('404');
309
+ });
310
+
311
+ it('SUCCESS — transition from failed → processing', async () => {
312
+ _testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
313
+ mockSuccess({ ...order1, status: 'failed' });
314
+ mockSuccess({ ...order1, status: 'processing' });
315
+
316
+ const res = await call('wc_update_order_status', { id: 500, status: 'processing' });
317
+ const data = parseResult(res);
318
+
319
+ expect(data.success).toBe(true);
320
+ expect(data.previous_status).toBe('failed');
321
+ expect(data.new_status).toBe('processing');
322
+ });
323
+ });