@adsim/wordpress-mcp-server 4.6.0 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/README.md +867 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/plugins/adapters/acf/acfAdapter.js +55 -3
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +395 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/postMeta.test.js +105 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall, _testSetTarget } from '../../../index.js';
|
|
7
|
+
import { makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mockFetchJson(data, status = 200) {
|
|
14
|
+
return Promise.resolve({
|
|
15
|
+
ok: status >= 200 && status < 400,
|
|
16
|
+
status,
|
|
17
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
18
|
+
json: () => Promise.resolve(data),
|
|
19
|
+
text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data))
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mockFetchText(text, status = 200) {
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
ok: status >= 200 && status < 400,
|
|
26
|
+
status,
|
|
27
|
+
headers: { get: (h) => h === 'content-type' ? 'text/html' : null },
|
|
28
|
+
json: () => Promise.reject(new Error('not json')),
|
|
29
|
+
text: () => Promise.resolve(text)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let consoleSpy;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
fetch.mockReset();
|
|
36
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
37
|
+
process.env.WC_CONSUMER_KEY = 'ck_test';
|
|
38
|
+
process.env.WC_CONSUMER_SECRET = 'cs_test';
|
|
39
|
+
_testSetTarget('shop', { url: 'https://shop.example.com', username: 'u', password: 'p' });
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
consoleSpy.mockRestore();
|
|
43
|
+
_testSetTarget(null);
|
|
44
|
+
delete process.env.WC_CONSUMER_KEY;
|
|
45
|
+
delete process.env.WC_CONSUMER_SECRET;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ════════════════════════════════════════════════════════════
|
|
49
|
+
// wc_audit_product_seo
|
|
50
|
+
// ════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
describe('wc_audit_product_seo', () => {
|
|
53
|
+
it('returns perfect score for well-optimized product', async () => {
|
|
54
|
+
fetch.mockImplementation((u) => {
|
|
55
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([{
|
|
56
|
+
id: 1, name: 'Premium Organic Cotton T-Shirt Blue', slug: 'premium-organic-cotton-t-shirt-blue',
|
|
57
|
+
status: 'publish', description: '<p>' + 'A'.repeat(200) + '</p>',
|
|
58
|
+
images: [{ src: 'https://img.com/1.jpg', alt: 'Blue organic cotton t-shirt front view' }],
|
|
59
|
+
meta_data: [{ key: '_custom_schema_jsonld', value: '{"@type":"Product"}' }]
|
|
60
|
+
}]);
|
|
61
|
+
return mockFetchJson({});
|
|
62
|
+
});
|
|
63
|
+
const res = await call('wc_audit_product_seo');
|
|
64
|
+
const d = parseResult(res);
|
|
65
|
+
expect(d.products[0].score).toBe(100);
|
|
66
|
+
expect(d.products[0].issues).toHaveLength(0);
|
|
67
|
+
expect(d.summary.avg_score).toBe(100);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('penalizes short title (-15pts)', async () => {
|
|
71
|
+
fetch.mockImplementation((u) => {
|
|
72
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([{
|
|
73
|
+
id: 2, name: 'Shirt', slug: 'shirt', status: 'publish',
|
|
74
|
+
description: '<p>' + 'B'.repeat(200) + '</p>',
|
|
75
|
+
images: [{ src: 'https://img.com/2.jpg', alt: 'Shirt image' }],
|
|
76
|
+
meta_data: [{ key: '_custom_schema_jsonld', value: '{}' }]
|
|
77
|
+
}]);
|
|
78
|
+
return mockFetchJson({});
|
|
79
|
+
});
|
|
80
|
+
const res = await call('wc_audit_product_seo');
|
|
81
|
+
const d = parseResult(res);
|
|
82
|
+
expect(d.products[0].score).toBe(85);
|
|
83
|
+
expect(d.products[0].issues.some(i => i.field === 'name')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('penalizes generic slug "product-123" (-20pts)', async () => {
|
|
87
|
+
fetch.mockImplementation((u) => {
|
|
88
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([{
|
|
89
|
+
id: 3, name: 'A Decent Product Name For SEO Purposes', slug: 'product-123', status: 'publish',
|
|
90
|
+
description: '<p>' + 'C'.repeat(200) + '</p>',
|
|
91
|
+
images: [{ src: 'https://img.com/3.jpg', alt: 'Product image alt' }],
|
|
92
|
+
meta_data: [{ key: '_custom_schema_jsonld', value: '{}' }]
|
|
93
|
+
}]);
|
|
94
|
+
return mockFetchJson({});
|
|
95
|
+
});
|
|
96
|
+
const res = await call('wc_audit_product_seo');
|
|
97
|
+
const d = parseResult(res);
|
|
98
|
+
expect(d.products[0].issues.some(i => i.field === 'slug')).toBe(true);
|
|
99
|
+
expect(d.products[0].score).toBe(80);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('penalizes missing image alt (-15pts)', async () => {
|
|
103
|
+
fetch.mockImplementation((u) => {
|
|
104
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([{
|
|
105
|
+
id: 4, name: 'A Good Product Name For SEO Testing', slug: 'good-product-name', status: 'publish',
|
|
106
|
+
description: '<p>' + 'D'.repeat(200) + '</p>',
|
|
107
|
+
images: [{ src: 'https://img.com/4.jpg', alt: '' }],
|
|
108
|
+
meta_data: [{ key: '_custom_schema_jsonld', value: '{}' }]
|
|
109
|
+
}]);
|
|
110
|
+
return mockFetchJson({});
|
|
111
|
+
});
|
|
112
|
+
const res = await call('wc_audit_product_seo');
|
|
113
|
+
const d = parseResult(res);
|
|
114
|
+
expect(d.products[0].issues.some(i => i.field === 'image_alt')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('detects schema from _custom_schema_jsonld', async () => {
|
|
118
|
+
fetch.mockImplementation((u) => {
|
|
119
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([{
|
|
120
|
+
id: 5, name: 'Schema Product With Proper SEO Setup', slug: 'schema-product', status: 'publish',
|
|
121
|
+
description: '<p>' + 'E'.repeat(200) + '</p>',
|
|
122
|
+
images: [{ src: 'https://img.com/5.jpg', alt: 'Schema product image' }],
|
|
123
|
+
meta_data: [{ key: '_custom_schema_jsonld', value: '{"@type":"Product"}' }]
|
|
124
|
+
}]);
|
|
125
|
+
return mockFetchJson({});
|
|
126
|
+
});
|
|
127
|
+
const res = await call('wc_audit_product_seo');
|
|
128
|
+
const d = parseResult(res);
|
|
129
|
+
expect(d.products[0].issues.some(i => i.field === 'schema')).toBe(false);
|
|
130
|
+
expect(d.audit_config.schema_sources_checked).toContain('custom_jsonld');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns error when WC not configured', async () => {
|
|
134
|
+
delete process.env.WC_CONSUMER_KEY;
|
|
135
|
+
delete process.env.WC_CONSUMER_SECRET;
|
|
136
|
+
const res = await call('wc_audit_product_seo');
|
|
137
|
+
expect(res.content[0].text).toContain('WooCommerce');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ════════════════════════════════════════════════════════════
|
|
142
|
+
// wc_find_abandoned_carts_pattern
|
|
143
|
+
// ════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
describe('wc_find_abandoned_carts_pattern', () => {
|
|
146
|
+
it('returns data from abandoned cart plugin source', async () => {
|
|
147
|
+
fetch.mockImplementation((u) => {
|
|
148
|
+
if (u.includes('/wc-abandoned-carts')) return mockFetchJson({
|
|
149
|
+
available: true, source: 'abandoned_cart_plugin',
|
|
150
|
+
carts: [
|
|
151
|
+
{ cart_value: 99.99, abandoned_at: '2026-03-01T14:00:00Z', products: [{ product_id: 10, name: 'Widget', quantity: 2 }] },
|
|
152
|
+
{ cart_value: 49.99, abandoned_at: '2026-03-02T10:00:00Z', products: [{ product_id: 20, name: 'Gadget', quantity: 1 }] }
|
|
153
|
+
]
|
|
154
|
+
});
|
|
155
|
+
if (u.includes('/wc/v3/orders')) return mockFetchJson([]);
|
|
156
|
+
if (u.includes('/wc/v3/reports')) return mockFetchJson([{ total_orders: 10 }]);
|
|
157
|
+
return mockFetchJson({});
|
|
158
|
+
});
|
|
159
|
+
const res = await call('wc_find_abandoned_carts_pattern');
|
|
160
|
+
const d = parseResult(res);
|
|
161
|
+
expect(d.available).toBe(true);
|
|
162
|
+
expect(d.source).toBe('abandoned_cart_plugin');
|
|
163
|
+
expect(d.total_abandoned).toBe(2);
|
|
164
|
+
expect(d.avg_cart_value).toBeCloseTo(74.99, 1);
|
|
165
|
+
expect(d.patterns.top_abandoned_products.length).toBeGreaterThan(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns WC sessions data', async () => {
|
|
169
|
+
fetch.mockImplementation((u) => {
|
|
170
|
+
if (u.includes('/wc-abandoned-carts')) return mockFetchJson({
|
|
171
|
+
available: true, source: 'wc_sessions',
|
|
172
|
+
carts: [{ cart_value: 30, abandoned_at: '2026-03-05T16:00:00Z', products: [] }]
|
|
173
|
+
});
|
|
174
|
+
if (u.includes('/wc/v3')) return mockFetchJson([]);
|
|
175
|
+
return mockFetchJson({});
|
|
176
|
+
});
|
|
177
|
+
const res = await call('wc_find_abandoned_carts_pattern');
|
|
178
|
+
const d = parseResult(res);
|
|
179
|
+
expect(d.source).toBe('wc_sessions');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns available=false when no source detected', async () => {
|
|
183
|
+
fetch.mockImplementation((u) => {
|
|
184
|
+
if (u.includes('/wc-abandoned-carts')) return mockFetchJson({ available: false });
|
|
185
|
+
return mockFetchJson({});
|
|
186
|
+
});
|
|
187
|
+
const res = await call('wc_find_abandoned_carts_pattern');
|
|
188
|
+
const d = parseResult(res);
|
|
189
|
+
expect(d.available).toBe(false);
|
|
190
|
+
expect(d.setup_options).toBeTruthy();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('handles mu-plugin not installed gracefully', async () => {
|
|
194
|
+
fetch.mockImplementation(() => mockFetchJson({ code: 'rest_no_route' }, 404));
|
|
195
|
+
const res = await call('wc_find_abandoned_carts_pattern');
|
|
196
|
+
const d = parseResult(res);
|
|
197
|
+
expect(d.available).toBe(false);
|
|
198
|
+
expect(d.message).toContain('mu-plugin');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ════════════════════════════════════════════════════════════
|
|
203
|
+
// wc_audit_checkout_friction
|
|
204
|
+
// ════════════════════════════════════════════════════════════
|
|
205
|
+
|
|
206
|
+
describe('wc_audit_checkout_friction', () => {
|
|
207
|
+
it('returns low friction for optimal setup', async () => {
|
|
208
|
+
fetch.mockImplementation((u) => {
|
|
209
|
+
if (u.includes('/wc/v3/settings/general')) return mockFetchJson([
|
|
210
|
+
{ id: 'woocommerce_enable_guest_checkout', value: 'yes' },
|
|
211
|
+
{ id: 'woocommerce_enable_coupons', value: 'yes' }
|
|
212
|
+
]);
|
|
213
|
+
if (u.includes('/wc/v3/settings/advanced')) return mockFetchJson([]);
|
|
214
|
+
// Checkout HTML with few required fields
|
|
215
|
+
if (u.includes('/checkout') || u.includes('page_id')) return mockFetchText(
|
|
216
|
+
'<html><body class="woocommerce-checkout"><form><input name="billing_first_name" required><input name="billing_last_name" required><input name="billing_email" required></form></body></html>'
|
|
217
|
+
);
|
|
218
|
+
return mockFetchJson({});
|
|
219
|
+
});
|
|
220
|
+
const res = await call('wc_audit_checkout_friction');
|
|
221
|
+
const d = parseResult(res);
|
|
222
|
+
expect(d.friction_score).toBeLessThanOrEqual(3);
|
|
223
|
+
expect(d.friction_grade).toBe('Low');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('adds 1.5pts for guest checkout disabled', async () => {
|
|
227
|
+
fetch.mockImplementation((u) => {
|
|
228
|
+
if (u.includes('/wc/v3/settings/general')) return mockFetchJson([
|
|
229
|
+
{ id: 'woocommerce_enable_guest_checkout', value: 'no' },
|
|
230
|
+
{ id: 'woocommerce_enable_coupons', value: 'yes' }
|
|
231
|
+
]);
|
|
232
|
+
if (u.includes('/wc/v3/settings/advanced')) return mockFetchJson([]);
|
|
233
|
+
return mockFetchJson({});
|
|
234
|
+
});
|
|
235
|
+
const res = await call('wc_audit_checkout_friction');
|
|
236
|
+
const d = parseResult(res);
|
|
237
|
+
const guestCheck = d.checks.find(c => c.name === 'guest_checkout');
|
|
238
|
+
expect(guestCheck.friction_points).toBe(1.5);
|
|
239
|
+
expect(d.friction_score).toBeGreaterThanOrEqual(1.5);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('returns high friction for problematic setup', async () => {
|
|
243
|
+
fetch.mockImplementation((u) => {
|
|
244
|
+
if (u.includes('/wc/v3/settings/general')) return mockFetchJson([
|
|
245
|
+
{ id: 'woocommerce_enable_guest_checkout', value: 'no' },
|
|
246
|
+
{ id: 'woocommerce_enable_coupons', value: 'no' }
|
|
247
|
+
]);
|
|
248
|
+
if (u.includes('/wc/v3/settings/advanced')) return mockFetchJson([]);
|
|
249
|
+
return mockFetchJson({});
|
|
250
|
+
});
|
|
251
|
+
const res = await call('wc_audit_checkout_friction');
|
|
252
|
+
const d = parseResult(res);
|
|
253
|
+
expect(d.friction_score).toBeGreaterThanOrEqual(2.5);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('falls back to options-only when checkout protected', async () => {
|
|
257
|
+
fetch.mockImplementation((u) => {
|
|
258
|
+
if (u.includes('/wc/v3/settings/general')) return mockFetchJson([
|
|
259
|
+
{ id: 'woocommerce_enable_guest_checkout', value: 'no' }
|
|
260
|
+
]);
|
|
261
|
+
if (u.includes('/wc/v3/settings/advanced')) return mockFetchJson([]);
|
|
262
|
+
// Checkout redirects to my-account (login required)
|
|
263
|
+
if (u.includes('/checkout')) return mockFetchText('<html><body>my-account login page</body></html>');
|
|
264
|
+
return mockFetchJson({});
|
|
265
|
+
});
|
|
266
|
+
const res = await call('wc_audit_checkout_friction');
|
|
267
|
+
const d = parseResult(res);
|
|
268
|
+
expect(d.data_sources).toContain('wc_options');
|
|
269
|
+
expect(d.data_sources).not.toContain('html_analysis');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ════════════════════════════════════════════════════════════
|
|
274
|
+
// wc_get_product_performance
|
|
275
|
+
// ════════════════════════════════════════════════════════════
|
|
276
|
+
|
|
277
|
+
describe('wc_get_product_performance', () => {
|
|
278
|
+
it('returns trend "up" when sales increase', async () => {
|
|
279
|
+
fetch.mockImplementation((u) => {
|
|
280
|
+
if (u.includes('/wc/v3/products/42') && !u.includes('variations')) return mockFetchJson({
|
|
281
|
+
id: 42, name: 'Widget', sku: 'WDG-001', status: 'publish', stock_quantity: 100, price: '29.99'
|
|
282
|
+
});
|
|
283
|
+
if (u.includes('/wc/v3/orders') && u.includes('product=42')) {
|
|
284
|
+
// Current period: higher sales
|
|
285
|
+
if (!u.includes('before=')) return mockFetchJson([
|
|
286
|
+
{ status: 'completed', line_items: [{ product_id: 42, quantity: 10, total: '299.90' }] },
|
|
287
|
+
{ status: 'completed', line_items: [{ product_id: 42, quantity: 5, total: '149.95' }] }
|
|
288
|
+
]);
|
|
289
|
+
// Previous period: lower sales
|
|
290
|
+
return mockFetchJson([
|
|
291
|
+
{ status: 'completed', line_items: [{ product_id: 42, quantity: 3, total: '89.97' }] }
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
if (u.includes('/wc/v3/reports/top_sellers')) return mockFetchJson([{ product_id: 42 }]);
|
|
295
|
+
return mockFetchJson({});
|
|
296
|
+
});
|
|
297
|
+
const res = await call('wc_get_product_performance', { product_id: 42 });
|
|
298
|
+
const d = parseResult(res);
|
|
299
|
+
expect(d.product.id).toBe(42);
|
|
300
|
+
expect(d.metrics.units_sold).toBe(15);
|
|
301
|
+
expect(d.trend.direction).toBe('up');
|
|
302
|
+
expect(d.rank_in_top_sellers).toBe(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('returns trend "down" when sales decrease', async () => {
|
|
306
|
+
fetch.mockImplementation((u) => {
|
|
307
|
+
if (u.includes('/wc/v3/products/43')) return mockFetchJson({
|
|
308
|
+
id: 43, name: 'Gadget', sku: '', status: 'publish', stock_quantity: 5, price: '19.99'
|
|
309
|
+
});
|
|
310
|
+
if (u.includes('/wc/v3/orders') && u.includes('product=43')) {
|
|
311
|
+
if (!u.includes('before=')) return mockFetchJson([
|
|
312
|
+
{ status: 'completed', line_items: [{ product_id: 43, quantity: 2, total: '39.98' }] }
|
|
313
|
+
]);
|
|
314
|
+
return mockFetchJson([
|
|
315
|
+
{ status: 'completed', line_items: [{ product_id: 43, quantity: 10, total: '199.90' }] }
|
|
316
|
+
]);
|
|
317
|
+
}
|
|
318
|
+
if (u.includes('/wc/v3/reports')) return mockFetchJson([]);
|
|
319
|
+
return mockFetchJson({});
|
|
320
|
+
});
|
|
321
|
+
const res = await call('wc_get_product_performance', { product_id: 43 });
|
|
322
|
+
const d = parseResult(res);
|
|
323
|
+
expect(d.trend.direction).toBe('down');
|
|
324
|
+
expect(d.rank_in_top_sellers).toBe(null);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('returns trend "stable" for minimal change', async () => {
|
|
328
|
+
fetch.mockImplementation((u) => {
|
|
329
|
+
if (u.includes('/wc/v3/products/44')) return mockFetchJson({
|
|
330
|
+
id: 44, name: 'Stable Item', sku: 'STB', status: 'publish', stock_quantity: 50, price: '10.00'
|
|
331
|
+
});
|
|
332
|
+
if (u.includes('/wc/v3/orders') && u.includes('product=44')) {
|
|
333
|
+
if (!u.includes('before=')) return mockFetchJson([
|
|
334
|
+
{ status: 'completed', line_items: [{ product_id: 44, quantity: 10, total: '100.00' }] }
|
|
335
|
+
]);
|
|
336
|
+
return mockFetchJson([
|
|
337
|
+
{ status: 'completed', line_items: [{ product_id: 44, quantity: 10, total: '100.00' }] }
|
|
338
|
+
]);
|
|
339
|
+
}
|
|
340
|
+
if (u.includes('/wc/v3/reports')) return mockFetchJson([]);
|
|
341
|
+
return mockFetchJson({});
|
|
342
|
+
});
|
|
343
|
+
const res = await call('wc_get_product_performance', { product_id: 44 });
|
|
344
|
+
const d = parseResult(res);
|
|
345
|
+
expect(d.trend.direction).toBe('stable');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('errors on non-existent product', async () => {
|
|
349
|
+
fetch.mockImplementation(() => mockFetchJson({ code: 'woocommerce_rest_product_invalid_id' }, 404));
|
|
350
|
+
const res = await call('wc_get_product_performance', { product_id: 999 });
|
|
351
|
+
expect(res.isError).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ════════════════════════════════════════════════════════════
|
|
356
|
+
// wc_audit_stock_alerts
|
|
357
|
+
// ════════════════════════════════════════════════════════════
|
|
358
|
+
|
|
359
|
+
describe('wc_audit_stock_alerts', () => {
|
|
360
|
+
it('detects out-of-stock products', async () => {
|
|
361
|
+
fetch.mockImplementation((u) => {
|
|
362
|
+
if (u.includes('/wc/v3/products') && u.includes('page=1') && !u.includes('variations')) return mockFetchJson([
|
|
363
|
+
{ id: 50, name: 'Empty Product', sku: 'EMP', type: 'simple', manage_stock: true, stock_quantity: 0, stock_status: 'outofstock', status: 'publish', variations: [] }
|
|
364
|
+
]);
|
|
365
|
+
if (u.includes('/wc/v3/orders')) return mockFetchJson([{ date_created: '2026-02-15T10:00:00' }]);
|
|
366
|
+
return mockFetchJson([]);
|
|
367
|
+
});
|
|
368
|
+
const res = await call('wc_audit_stock_alerts');
|
|
369
|
+
const d = parseResult(res);
|
|
370
|
+
expect(d.out_of_stock.length).toBe(1);
|
|
371
|
+
expect(d.out_of_stock[0].id).toBe(50);
|
|
372
|
+
expect(d.alert).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('detects low-stock products below threshold', async () => {
|
|
376
|
+
fetch.mockImplementation((u) => {
|
|
377
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([
|
|
378
|
+
{ id: 51, name: 'Low Stock Item', sku: 'LOW', type: 'simple', manage_stock: true, stock_quantity: 3, stock_status: 'instock', status: 'publish', variations: [] }
|
|
379
|
+
]);
|
|
380
|
+
if (u.includes('/wc/v3/orders')) return mockFetchJson([]);
|
|
381
|
+
return mockFetchJson([]);
|
|
382
|
+
});
|
|
383
|
+
const res = await call('wc_audit_stock_alerts', { threshold: 5 });
|
|
384
|
+
const d = parseResult(res);
|
|
385
|
+
expect(d.low_stock.length).toBe(1);
|
|
386
|
+
expect(d.low_stock[0].stock_quantity).toBe(3);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('returns clean when all stock is OK', async () => {
|
|
390
|
+
fetch.mockImplementation((u) => {
|
|
391
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([
|
|
392
|
+
{ id: 52, name: 'Well Stocked', sku: 'WS', type: 'simple', manage_stock: true, stock_quantity: 100, stock_status: 'instock', status: 'publish', variations: [] }
|
|
393
|
+
]);
|
|
394
|
+
return mockFetchJson([]);
|
|
395
|
+
});
|
|
396
|
+
const res = await call('wc_audit_stock_alerts');
|
|
397
|
+
const d = parseResult(res);
|
|
398
|
+
expect(d.out_of_stock).toHaveLength(0);
|
|
399
|
+
expect(d.low_stock).toHaveLength(0);
|
|
400
|
+
expect(d.alert).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('includes variations when requested', async () => {
|
|
404
|
+
fetch.mockImplementation((u) => {
|
|
405
|
+
if (u.includes('/wc/v3/products') && u.includes('page=1') && !u.includes('variations')) return mockFetchJson([
|
|
406
|
+
{ id: 53, name: 'Variable Product', sku: 'VP', type: 'variable', manage_stock: false, stock_quantity: null, stock_status: 'instock', status: 'publish', variations: [530, 531] }
|
|
407
|
+
]);
|
|
408
|
+
if (u.includes('/variations')) return mockFetchJson([
|
|
409
|
+
{ id: 530, sku: 'VP-S', manage_stock: true, stock_quantity: 0, stock_status: 'outofstock', attributes: [{ option: 'Small' }] },
|
|
410
|
+
{ id: 531, sku: 'VP-L', manage_stock: true, stock_quantity: 2, stock_status: 'instock', attributes: [{ option: 'Large' }] }
|
|
411
|
+
]);
|
|
412
|
+
return mockFetchJson([]);
|
|
413
|
+
});
|
|
414
|
+
const res = await call('wc_audit_stock_alerts', { include_variations: true });
|
|
415
|
+
const d = parseResult(res);
|
|
416
|
+
expect(d.out_of_stock.some(p => p.id === 530)).toBe(true);
|
|
417
|
+
expect(d.low_stock.some(p => p.id === 531)).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ════════════════════════════════════════════════════════════
|
|
422
|
+
// wc_find_duplicate_products
|
|
423
|
+
// ════════════════════════════════════════════════════════════
|
|
424
|
+
|
|
425
|
+
describe('wc_find_duplicate_products', () => {
|
|
426
|
+
it('detects exact SKU duplicates', async () => {
|
|
427
|
+
fetch.mockImplementation((u) => {
|
|
428
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([
|
|
429
|
+
{ id: 60, name: 'Product A', sku: 'SAME-SKU', slug: 'product-a', status: 'publish', price: '10' },
|
|
430
|
+
{ id: 61, name: 'Product B', sku: 'SAME-SKU', slug: 'product-b', status: 'publish', price: '15' }
|
|
431
|
+
]);
|
|
432
|
+
return mockFetchJson({});
|
|
433
|
+
});
|
|
434
|
+
const res = await call('wc_find_duplicate_products');
|
|
435
|
+
const d = parseResult(res);
|
|
436
|
+
expect(d.duplicate_groups.length).toBe(1);
|
|
437
|
+
expect(['sku_exact', 'multiple']).toContain(d.duplicate_groups[0].match_type);
|
|
438
|
+
expect(d.duplicate_groups[0].products).toHaveLength(2);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('detects similar titles above threshold (85%)', async () => {
|
|
442
|
+
fetch.mockImplementation((u) => {
|
|
443
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([
|
|
444
|
+
{ id: 62, name: 'Blue Cotton T-Shirt', sku: 'A1', slug: 'blue-cotton-t-shirt', status: 'publish', price: '20' },
|
|
445
|
+
{ id: 63, name: 'Blue Cotton T-Shirts', sku: 'A2', slug: 'blue-cotton-t-shirts', status: 'publish', price: '20' }
|
|
446
|
+
]);
|
|
447
|
+
return mockFetchJson({});
|
|
448
|
+
});
|
|
449
|
+
const res = await call('wc_find_duplicate_products', { similarity_threshold: 0.8 });
|
|
450
|
+
const d = parseResult(res);
|
|
451
|
+
expect(d.duplicate_groups.length).toBe(1);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('does not flag products below similarity threshold', async () => {
|
|
455
|
+
fetch.mockImplementation((u) => {
|
|
456
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([
|
|
457
|
+
{ id: 64, name: 'Red Shoes', sku: 'X1', slug: 'red-shoes', status: 'publish', price: '50' },
|
|
458
|
+
{ id: 65, name: 'Blue Jacket', sku: 'X2', slug: 'blue-jacket', status: 'publish', price: '80' }
|
|
459
|
+
]);
|
|
460
|
+
return mockFetchJson({});
|
|
461
|
+
});
|
|
462
|
+
const res = await call('wc_find_duplicate_products', { similarity_threshold: 0.8 });
|
|
463
|
+
const d = parseResult(res);
|
|
464
|
+
expect(d.duplicate_groups).toHaveLength(0);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('groups transitive duplicates (A≈B and B≈C → same group)', async () => {
|
|
468
|
+
fetch.mockImplementation((u) => {
|
|
469
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([
|
|
470
|
+
{ id: 70, name: 'Widget Pro', sku: '', slug: 'widget-pro', status: 'publish', price: '10' },
|
|
471
|
+
{ id: 71, name: 'Widget Pro+', sku: '', slug: 'widget-pro-plus', status: 'publish', price: '12' },
|
|
472
|
+
{ id: 72, name: 'Widget Pro++', sku: '', slug: 'widget-pro-plus-plus', status: 'publish', price: '15' }
|
|
473
|
+
]);
|
|
474
|
+
return mockFetchJson({});
|
|
475
|
+
});
|
|
476
|
+
const res = await call('wc_find_duplicate_products', { similarity_threshold: 0.7 });
|
|
477
|
+
const d = parseResult(res);
|
|
478
|
+
// All three should be in one group via transitivity
|
|
479
|
+
expect(d.duplicate_groups.length).toBe(1);
|
|
480
|
+
expect(d.duplicate_groups[0].products).toHaveLength(3);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ════════════════════════════════════════════════════════════
|
|
485
|
+
// wc_audit_pricing_consistency
|
|
486
|
+
// ════════════════════════════════════════════════════════════
|
|
487
|
+
|
|
488
|
+
describe('wc_audit_pricing_consistency', () => {
|
|
489
|
+
it('detects CRITICAL: sale price >= regular price', async () => {
|
|
490
|
+
fetch.mockImplementation((u) => {
|
|
491
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([{
|
|
492
|
+
id: 80, name: 'Bad Sale', type: 'simple', status: 'publish', price: '80',
|
|
493
|
+
regular_price: '80', sale_price: '100', on_sale: true,
|
|
494
|
+
date_on_sale_to: null, variations: []
|
|
495
|
+
}]);
|
|
496
|
+
return mockFetchJson([]);
|
|
497
|
+
});
|
|
498
|
+
const res = await call('wc_audit_pricing_consistency');
|
|
499
|
+
const d = parseResult(res);
|
|
500
|
+
expect(d.inconsistencies.some(i => i.type === 'sale_gte_regular' && i.severity === 'critical')).toBe(true);
|
|
501
|
+
expect(d.summary.critical).toBeGreaterThan(0);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('detects HIGH: minimal discount < 10%', async () => {
|
|
505
|
+
fetch.mockImplementation((u) => {
|
|
506
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([{
|
|
507
|
+
id: 81, name: 'Small Discount', type: 'simple', status: 'publish', price: '95',
|
|
508
|
+
regular_price: '100', sale_price: '95', on_sale: true,
|
|
509
|
+
date_on_sale_to: null, variations: []
|
|
510
|
+
}]);
|
|
511
|
+
return mockFetchJson([]);
|
|
512
|
+
});
|
|
513
|
+
const res = await call('wc_audit_pricing_consistency');
|
|
514
|
+
const d = parseResult(res);
|
|
515
|
+
expect(d.inconsistencies.some(i => i.type === 'minimal_discount' && i.severity === 'high')).toBe(true);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('detects HIGH: variation without price', async () => {
|
|
519
|
+
fetch.mockImplementation((u) => {
|
|
520
|
+
if (u.includes('/wc/v3/products') && u.includes('page=1') && !u.includes('variations')) return mockFetchJson([{
|
|
521
|
+
id: 82, name: 'Variable Product', type: 'variable', status: 'publish', price: '30',
|
|
522
|
+
regular_price: '30', sale_price: '', on_sale: false,
|
|
523
|
+
date_on_sale_to: null, variations: [820]
|
|
524
|
+
}]);
|
|
525
|
+
if (u.includes('/variations')) return mockFetchJson([{
|
|
526
|
+
id: 820, regular_price: '', sale_price: '', on_sale: false, date_on_sale_to: null
|
|
527
|
+
}]);
|
|
528
|
+
return mockFetchJson([]);
|
|
529
|
+
});
|
|
530
|
+
const res = await call('wc_audit_pricing_consistency');
|
|
531
|
+
const d = parseResult(res);
|
|
532
|
+
expect(d.inconsistencies.some(i => i.type === 'variation_no_price' && i.severity === 'high')).toBe(true);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('returns clean for properly priced products', async () => {
|
|
536
|
+
fetch.mockImplementation((u) => {
|
|
537
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([{
|
|
538
|
+
id: 83, name: 'Good Product', type: 'simple', status: 'publish', price: '49.99',
|
|
539
|
+
regular_price: '59.99', sale_price: '49.99', on_sale: true,
|
|
540
|
+
date_on_sale_to: null, variations: []
|
|
541
|
+
}]);
|
|
542
|
+
return mockFetchJson([]);
|
|
543
|
+
});
|
|
544
|
+
const res = await call('wc_audit_pricing_consistency');
|
|
545
|
+
const d = parseResult(res);
|
|
546
|
+
expect(d.inconsistencies).toHaveLength(0);
|
|
547
|
+
expect(d.clean_count).toBe(1);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('detects expired sale dates', async () => {
|
|
551
|
+
fetch.mockImplementation((u) => {
|
|
552
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([{
|
|
553
|
+
id: 84, name: 'Expired Sale', type: 'simple', status: 'publish', price: '40',
|
|
554
|
+
regular_price: '50', sale_price: '40', on_sale: true,
|
|
555
|
+
date_on_sale_to: '2020-01-01T00:00:00', variations: []
|
|
556
|
+
}]);
|
|
557
|
+
return mockFetchJson([]);
|
|
558
|
+
});
|
|
559
|
+
const res = await call('wc_audit_pricing_consistency');
|
|
560
|
+
const d = parseResult(res);
|
|
561
|
+
expect(d.inconsistencies.some(i => i.type === 'expired_sale')).toBe(true);
|
|
562
|
+
expect(d.summary.low).toBeGreaterThan(0);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// ════════════════════════════════════════════════════════════
|
|
567
|
+
// Additional coverage
|
|
568
|
+
// ════════════════════════════════════════════════════════════
|
|
569
|
+
|
|
570
|
+
describe('wc_audit_product_seo — additional', () => {
|
|
571
|
+
it('filters by min_score', async () => {
|
|
572
|
+
fetch.mockImplementation((u) => {
|
|
573
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([
|
|
574
|
+
{ id: 90, name: 'Perfect SEO Product Name For Testing', slug: 'perfect-seo', status: 'publish', description: '<p>' + 'X'.repeat(200) + '</p>', images: [{ src: 'img.jpg', alt: 'Alt text for image' }], meta_data: [{ key: '_custom_schema_jsonld', value: '{}' }] },
|
|
575
|
+
{ id: 91, name: 'Bad', slug: 'product-91', status: 'publish', description: '', images: [], meta_data: [] }
|
|
576
|
+
]);
|
|
577
|
+
return mockFetchJson({});
|
|
578
|
+
});
|
|
579
|
+
const res = await call('wc_audit_product_seo', { min_score: 80 });
|
|
580
|
+
const d = parseResult(res);
|
|
581
|
+
// Only the low-score product should be in the filtered list
|
|
582
|
+
expect(d.products.every(p => p.score < 80)).toBe(true);
|
|
583
|
+
expect(d.summary.total_audited).toBe(2);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('detects RankMath Product schema', async () => {
|
|
587
|
+
fetch.mockImplementation((u) => {
|
|
588
|
+
if (u.includes('/wc/v3/products')) return mockFetchJson([{
|
|
589
|
+
id: 92, name: 'RankMath Product With Good Title Here', slug: 'rankmath-product', status: 'publish',
|
|
590
|
+
description: '<p>' + 'R'.repeat(200) + '</p>',
|
|
591
|
+
images: [{ src: 'img.jpg', alt: 'Good alt text here' }],
|
|
592
|
+
meta_data: [{ key: 'rank_math_schema', value: '{"@type":"Product","name":"Test"}' }]
|
|
593
|
+
}]);
|
|
594
|
+
return mockFetchJson({});
|
|
595
|
+
});
|
|
596
|
+
const res = await call('wc_audit_product_seo');
|
|
597
|
+
const d = parseResult(res);
|
|
598
|
+
expect(d.products[0].issues.some(i => i.field === 'schema')).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe('wc_get_product_performance — additional', () => {
|
|
603
|
+
it('includes refund rate', async () => {
|
|
604
|
+
fetch.mockImplementation((u) => {
|
|
605
|
+
if (u.includes('/wc/v3/products/45')) return mockFetchJson({
|
|
606
|
+
id: 45, name: 'Refunded Item', sku: 'REF', status: 'publish', stock_quantity: 10, price: '50'
|
|
607
|
+
});
|
|
608
|
+
if (u.includes('/wc/v3/orders') && u.includes('product=45') && !u.includes('before=')) return mockFetchJson([
|
|
609
|
+
{ status: 'completed', line_items: [{ product_id: 45, quantity: 5, total: '250' }] },
|
|
610
|
+
{ status: 'refunded', line_items: [{ product_id: 45, quantity: 1, total: '50' }] }
|
|
611
|
+
]);
|
|
612
|
+
if (u.includes('/wc/v3/orders') && u.includes('before=')) return mockFetchJson([]);
|
|
613
|
+
if (u.includes('/wc/v3/reports')) return mockFetchJson([]);
|
|
614
|
+
return mockFetchJson({});
|
|
615
|
+
});
|
|
616
|
+
const res = await call('wc_get_product_performance', { product_id: 45 });
|
|
617
|
+
const d = parseResult(res);
|
|
618
|
+
expect(d.metrics.refunds_count).toBe(1);
|
|
619
|
+
expect(d.metrics.refund_rate).toBeGreaterThan(0);
|
|
620
|
+
expect(d.ga4_note).toContain('GA4');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe('wc_find_abandoned_carts_pattern — additional', () => {
|
|
625
|
+
it('generates recommendations based on data', async () => {
|
|
626
|
+
fetch.mockImplementation((u) => {
|
|
627
|
+
if (u.includes('/wc-abandoned-carts')) return mockFetchJson({
|
|
628
|
+
available: true, source: 'wc_sessions',
|
|
629
|
+
carts: [
|
|
630
|
+
{ cart_value: 150, abandoned_at: '2026-03-01T14:00:00Z', products: [{ product_id: 1, name: 'ExpensiveItem', quantity: 1 }] }
|
|
631
|
+
]
|
|
632
|
+
});
|
|
633
|
+
if (u.includes('/wc/v3')) return mockFetchJson([]);
|
|
634
|
+
return mockFetchJson({});
|
|
635
|
+
});
|
|
636
|
+
const res = await call('wc_find_abandoned_carts_pattern', { days: 7 });
|
|
637
|
+
const d = parseResult(res);
|
|
638
|
+
expect(d.recommendations.length).toBeGreaterThan(0);
|
|
639
|
+
expect(d.estimated_revenue_loss).toBe(150);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe('wc_audit_checkout_friction — additional', () => {
|
|
644
|
+
it('adds friction for many required fields', async () => {
|
|
645
|
+
const inputs = '<input required>'.repeat(12);
|
|
646
|
+
fetch.mockImplementation((u) => {
|
|
647
|
+
if (u.includes('/wc/v3/settings/general')) return mockFetchJson([
|
|
648
|
+
{ id: 'woocommerce_enable_guest_checkout', value: 'yes' },
|
|
649
|
+
{ id: 'woocommerce_enable_coupons', value: 'yes' }
|
|
650
|
+
]);
|
|
651
|
+
if (u.includes('/wc/v3/settings/advanced')) return mockFetchJson([]);
|
|
652
|
+
if (u.includes('/checkout') || u.includes('page_id')) return mockFetchText(
|
|
653
|
+
`<html><body class="woocommerce-checkout"><form>${inputs}</form></body></html>`
|
|
654
|
+
);
|
|
655
|
+
return mockFetchJson({});
|
|
656
|
+
});
|
|
657
|
+
const res = await call('wc_audit_checkout_friction');
|
|
658
|
+
const d = parseResult(res);
|
|
659
|
+
const fieldCheck = d.checks.find(c => c.name === 'required_fields');
|
|
660
|
+
expect(fieldCheck.friction_points).toBe(1.5);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe('wc_audit_pricing_consistency — additional', () => {
|
|
665
|
+
it('detects CRITICAL: empty price on published product', async () => {
|
|
666
|
+
fetch.mockImplementation((u) => {
|
|
667
|
+
if (u.includes('/wc/v3/products') && !u.includes('variations')) return mockFetchJson([{
|
|
668
|
+
id: 85, name: 'No Price Product', type: 'simple', status: 'publish', price: '',
|
|
669
|
+
regular_price: '', sale_price: '', on_sale: false,
|
|
670
|
+
date_on_sale_to: null, variations: []
|
|
671
|
+
}]);
|
|
672
|
+
return mockFetchJson([]);
|
|
673
|
+
});
|
|
674
|
+
const res = await call('wc_audit_pricing_consistency');
|
|
675
|
+
const d = parseResult(res);
|
|
676
|
+
expect(d.inconsistencies.some(i => i.type === 'empty_price')).toBe(true);
|
|
677
|
+
expect(d.summary.critical).toBeGreaterThan(0);
|
|
678
|
+
});
|
|
679
|
+
});
|