Skip to content

Commit 70f315c

Browse files
Add E2E tests for bundle pricing and fix feed token handling
Adds bundle-pricing.spec.ts covering 10 scenarios: both tax configs, all Channable tax settings, fixed bundles, and OOS stock impact. Stops feed tests from overwriting Channable token in config (broke order tests). Sets default tax destination to NL in CI setup for correct getTaxPrice() behavior in feed context.
1 parent 76cd281 commit 70f315c

5 files changed

Lines changed: 764 additions & 0 deletions

File tree

.github/workflows/templates/magento/configure-channable.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ bin/magento config:set tax/calculation/cross_border_trade_enabled 0
2525
bin/magento config:set tax/calculation/based_on shipping
2626
bin/magento config:set tax/calculation/algorithm TOTAL_BASE_CALCULATION
2727

28+
# Default tax destination — must match shipping origin so getTaxPrice() resolves
29+
# the correct rate when no customer is logged in (e.g. feed generation context)
30+
bin/magento config:set tax/defaults/country NL
31+
bin/magento config:set tax/defaults/region 0
32+
bin/magento config:set tax/defaults/postcode '1000 AA'
33+
2834
# Tax display settings
2935
bin/magento config:set tax/display/type 2
3036
bin/magento config:set tax/display/shipping 2

E2E-TESTS.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,36 @@ Channable periodically polls Magento for order status updates and shipment infor
5050
| Shipments — recent order | Returns shipment array for known orders |
5151
| Shipments — LVB with tracking | Shipped order includes tracking information |
5252

53+
## Bundle Pricing
54+
55+
Dynamic bundle products derive their prices from their children rather than having a fixed price. This test suite validates that the feed correctly reports bundle prices across different tax configurations. The fix uses indexed prices (`$product->getData('min_price')`) instead of `getBaseAmount()`, matching how configurables are handled and ensuring correct behavior with Magento's `getTaxPrice()`.
56+
57+
Test products are created via REST API: two simple children (€20 + €30, tax class 2), two bundles (one dynamic with two required options, one fixed at €100), and a single-option dynamic bundle for OOS testing. NL 21% tax rules are used.
58+
59+
**Group A: Catalog prices including tax** (stored €50 = incl tax)
60+
61+
| Test | Channable Config | Assert |
62+
|------|------------------|--------|
63+
| Dynamic, add tax on | `tax=1` | price = 50.00, min_price = 50.00 |
64+
| Dynamic, add tax off | `tax=0` | price = 50.00, min_price = 50.00 |
65+
| Dynamic, include both | `tax=1, tax_include_both=1` | price_incl = 50.00, price_excl ≈ 41.32 |
66+
| Fixed, add tax on | `tax=1` | price = 100.00 |
67+
68+
**Group B: Catalog prices excluding tax** (stored €50 = excl tax, incl = €60.50)
69+
70+
| Test | Channable Config | Assert |
71+
|------|------------------|--------|
72+
| Dynamic, add tax on | `tax=1` | price ≈ 60.50, min_price ≈ 60.50 |
73+
| Dynamic, add tax off | `tax=0` | price = 50.00, min_price = 50.00 |
74+
| Dynamic, include both | `tax=1, tax_include_both=1` | price_incl ≈ 60.50, price_excl = 50.00 |
75+
| Fixed, add tax on | `tax=1` | price ≈ 121.00 |
76+
77+
**Group C: Stock scenarios** (prices incl tax)
78+
79+
| Test | Setup | Assert |
80+
|------|-------|--------|
81+
| Dynamic (single option), OOS child | child-b set OOS + reindex | min_price = 20.00 (only in-stock child) |
82+
5383
## Upcoming: Feed Generation
5484

5585
The next suite of E2E tests will cover feed generation — validating that product data is correctly exported to Channable based on attribute mapping, category filters, and feed configuration. This will include tests for price rendering, image URLs, stock status, and custom attribute handling.
@@ -74,4 +104,5 @@ Run a specific suite:
74104
npx playwright test tests/order/cross-border-tax.spec.ts
75105
npx playwright test tests/order/order-import.spec.ts
76106
npx playwright test tests/order/webhooks.spec.ts
107+
npx playwright test tests/feed/bundle-pricing.spec.ts
77108
```

Test/End-2-end/support/services/ChannableApi.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import * as fs from 'fs';
77
import * as path from 'path';
8+
import { execSync } from 'child_process';
89
import BaseApi from './BaseApi';
910

1011
export default class ChannableApi extends BaseApi {
@@ -191,4 +192,169 @@ export default class ChannableApi extends BaseApi {
191192

192193
this.flushAllCaches();
193194
}
195+
196+
/**
197+
* Create a product via the Magento REST API.
198+
*/
199+
async createProduct(baseURL: string, payload: any): Promise<any> {
200+
const token = process.env.admin_token;
201+
const url = `${baseURL}rest/all/V1/products`;
202+
203+
const response = await fetch(url, {
204+
method: 'POST',
205+
headers: {
206+
'Content-Type': 'application/json',
207+
'Authorization': `Bearer ${token}`,
208+
},
209+
body: JSON.stringify({ product: payload }),
210+
});
211+
212+
if (!response.ok) {
213+
const text = await response.text();
214+
throw new Error(`Failed to create product ${payload.sku}: ${response.status} - ${text}`);
215+
}
216+
217+
return response.json();
218+
}
219+
220+
/**
221+
* Delete a product by SKU via the Magento REST API.
222+
*/
223+
async deleteProduct(baseURL: string, sku: string): Promise<void> {
224+
const token = process.env.admin_token;
225+
const url = `${baseURL}rest/all/V1/products/${encodeURIComponent(sku)}`;
226+
227+
const response = await fetch(url, {
228+
method: 'DELETE',
229+
headers: { 'Authorization': `Bearer ${token}` },
230+
});
231+
232+
if (!response.ok && response.status !== 404) {
233+
const text = await response.text();
234+
throw new Error(`Failed to delete product ${sku}: ${response.status} - ${text}`);
235+
}
236+
}
237+
238+
/**
239+
* Set stock status and quantity for a product by SKU.
240+
*/
241+
async setStockStatus(baseURL: string, sku: string, qty: number, inStock: boolean): Promise<void> {
242+
const token = process.env.admin_token;
243+
const url = `${baseURL}rest/all/V1/products/${encodeURIComponent(sku)}`;
244+
245+
const response = await fetch(url, {
246+
method: 'PUT',
247+
headers: {
248+
'Content-Type': 'application/json',
249+
'Authorization': `Bearer ${token}`,
250+
},
251+
body: JSON.stringify({
252+
product: {
253+
sku,
254+
extension_attributes: {
255+
stock_item: {
256+
qty,
257+
is_in_stock: inStock,
258+
},
259+
},
260+
},
261+
}),
262+
});
263+
264+
if (!response.ok) {
265+
const text = await response.text();
266+
throw new Error(`Failed to set stock for ${sku}: ${response.status} - ${text}`);
267+
}
268+
}
269+
270+
/**
271+
* Reindex catalog prices via docker exec.
272+
*/
273+
reindexPrices(): void {
274+
if (!this.container) {
275+
throw new Error('MAGENTO_CONTAINER env var is required for reindexing');
276+
}
277+
278+
execSync(
279+
`docker exec ${this.container} bin/magento indexer:reindex catalog_product_price cataloginventory_stock`,
280+
{ stdio: 'pipe', timeout: 120000 }
281+
);
282+
console.log('Price index rebuilt.');
283+
}
284+
285+
/**
286+
* Full reindex of all indexers + cache flush via docker exec.
287+
*/
288+
reindexAll(): void {
289+
if (!this.container) {
290+
throw new Error('MAGENTO_CONTAINER env var is required for reindexing');
291+
}
292+
293+
execSync(
294+
`docker exec ${this.container} bin/magento indexer:reindex`,
295+
{ stdio: 'pipe', timeout: 120000 }
296+
);
297+
this.flushAllCaches();
298+
console.log('Full reindex + cache flush done.');
299+
}
300+
301+
/**
302+
* Fetch a single product from the Channable feed by product ID.
303+
*/
304+
async getFeedProduct(baseURL: string, pid: number, storeId: number = 1): Promise<any> {
305+
const token = process.env.CHANNABLE_TOKEN || 'e2e-test-token';
306+
const url = `${baseURL}channable/feed/json?id=${storeId}&token=${token}&pid=${pid}`;
307+
308+
const response = await fetch(url, {
309+
headers: { 'Accept': 'application/json' },
310+
});
311+
312+
if (!response.ok) {
313+
throw new Error(`Feed request failed: ${response.status}`);
314+
}
315+
316+
const body = await response.json() as any;
317+
318+
// ?pid= returns {"products": {"product": {...}, "feed": {...}}}
319+
// ?page= returns {"products": [...]}
320+
const productsNode = body.products;
321+
322+
if (!productsNode) {
323+
throw new Error(`Product ${pid} not found in feed (no products key)`);
324+
}
325+
326+
// Single product via ?pid= — return the processed feed object
327+
if (productsNode.feed) {
328+
return productsNode.feed;
329+
}
330+
331+
// Array from ?page=
332+
if (Array.isArray(productsNode) && productsNode.length > 0) {
333+
return productsNode[0];
334+
}
335+
336+
throw new Error(`Product ${pid} not found in feed`);
337+
}
338+
339+
/**
340+
* Get the Magento entity ID for a product by SKU (via REST API).
341+
*/
342+
async getProductId(baseURL: string, sku: string): Promise<number> {
343+
const token = process.env.admin_token;
344+
const url = `${baseURL}rest/all/V1/products/${encodeURIComponent(sku)}`;
345+
346+
const response = await fetch(url, {
347+
headers: {
348+
'Authorization': `Bearer ${token}`,
349+
'Accept': 'application/json',
350+
},
351+
});
352+
353+
if (!response.ok) {
354+
throw new Error(`Product ${sku} not found: ${response.status}`);
355+
}
356+
357+
const data = await response.json() as any;
358+
return data.id;
359+
}
194360
}

0 commit comments

Comments
 (0)