@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.
- package/README.md +543 -176
- package/dxt/build-mcpb.sh +7 -0
- package/dxt/manifest.json +189 -0
- package/index.js +3156 -36
- package/package.json +3 -2
- package/src/confirmationToken.js +64 -0
- package/src/contentAnalyzer.js +476 -0
- package/src/htmlParser.js +80 -0
- package/src/linkUtils.js +158 -0
- package/src/utils/contentCompressor.js +116 -0
- package/src/woocommerceClient.js +88 -0
- package/tests/unit/contentAnalyzer.test.js +397 -0
- package/tests/unit/dxt/manifest.test.js +78 -0
- package/tests/unit/tools/analyzeEeatSignals.test.js +192 -0
- package/tests/unit/tools/approval.test.js +251 -0
- package/tests/unit/tools/auditCanonicals.test.js +149 -0
- package/tests/unit/tools/auditHeadingStructure.test.js +150 -0
- package/tests/unit/tools/auditMediaSeo.test.js +123 -0
- package/tests/unit/tools/auditOutboundLinks.test.js +175 -0
- package/tests/unit/tools/auditTaxonomies.test.js +173 -0
- package/tests/unit/tools/contentCompressor.test.js +320 -0
- package/tests/unit/tools/contentIntelligence.test.js +2168 -0
- package/tests/unit/tools/destructive.test.js +246 -0
- package/tests/unit/tools/findBrokenInternalLinks.test.js +222 -0
- package/tests/unit/tools/findKeywordCannibalization.test.js +183 -0
- package/tests/unit/tools/findOrphanPages.test.js +145 -0
- package/tests/unit/tools/findThinContent.test.js +145 -0
- package/tests/unit/tools/internalLinks.test.js +283 -0
- package/tests/unit/tools/perTargetControls.test.js +228 -0
- package/tests/unit/tools/site.test.js +6 -1
- package/tests/unit/tools/woocommerce.test.js +344 -0
- package/tests/unit/tools/woocommerceIntelligence.test.js +341 -0
- 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
|
+
});
|