@tallyui/connector-vendure 1.0.0 → 2.0.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/LICENSE +21 -0
- package/dist/index.d.ts +77 -25
- package/dist/index.js +558 -143
- package/package.json +12 -3
- package/src/auth.ts +74 -0
- package/src/global-settings.ts +18 -0
- package/src/index.ts +67 -44
- package/src/reconcile/ids.ts +38 -0
- package/src/reconcile/prices.ts +58 -0
- package/src/reconcile/stock.ts +48 -0
- package/src/replication/products.ts +136 -62
- package/src/replication/variant-feed.ts +108 -0
- package/src/schemas/products.ts +17 -1
- package/src/store-settings.ts +78 -0
- package/src/sync/products.ts +9 -8
- package/src/traits/product.ts +115 -23
- package/src/__tests__/product-traits.test.ts +0 -248
- package/src/replication/products.test.ts +0 -204
package/src/traits/product.ts
CHANGED
|
@@ -1,34 +1,128 @@
|
|
|
1
|
-
import type { ProductTraits } from '@tallyui/core';
|
|
1
|
+
import type { ProductTraits, StockLevel } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
function productStock(variants: StockLevel[]): StockLevel {
|
|
4
|
+
if (!variants.length) return { status: 'unknown' };
|
|
5
|
+
const status = variants.some((stock) => stock.status === 'in_stock') ? 'in_stock'
|
|
6
|
+
: variants.some((stock) => stock.status === 'backorder') ? 'backorder'
|
|
7
|
+
: variants.every((stock) => stock.status === 'out_of_stock') ? 'out_of_stock' : 'unknown';
|
|
8
|
+
const quantity = variants.every((stock) => stock.quantity != null)
|
|
9
|
+
? variants.reduce((sum, stock) => sum + stock.quantity!, 0) : undefined;
|
|
10
|
+
return quantity === undefined ? { status } : { status, quantity };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per-variant stock (backlog 28, #45 stock review). Untracked (`trackInventory`
|
|
15
|
+
* `FALSE`, or `INHERIT` with the global setting off) is always sellable, with no
|
|
16
|
+
* quantity. A tracked variant's saleable units are on hand minus allocated minus
|
|
17
|
+
* the effective threshold (global unless `useGlobalOutOfStockThreshold` is
|
|
18
|
+
* `false`) -- Vendure's own rule (`@vendure/core` 3.7.3,
|
|
19
|
+
* `dist/service/services/product-variant.service.js:270-274`,
|
|
20
|
+
* `getSaleableStockLevel`: `stockOnHand - stockAllocated - effectiveOutOfStockThreshold`).
|
|
21
|
+
* `backorder` is saleable > 0 with on-hand-less-allocated <= 0 (a negative
|
|
22
|
+
* threshold). The Shop API's `stockLevel` string is a last-resort fallback
|
|
23
|
+
* when neither `stockLevels` nor `stockOnHand` is present.
|
|
24
|
+
*/
|
|
25
|
+
function variantStock(
|
|
26
|
+
variant: any,
|
|
27
|
+
stockLocationId: string | undefined,
|
|
28
|
+
globalTrackInventory: boolean,
|
|
29
|
+
globalOutOfStockThreshold: number,
|
|
30
|
+
): StockLevel {
|
|
31
|
+
if (!variant) return { status: 'unknown' };
|
|
32
|
+
const tracked = variant.trackInventory === 'TRUE'
|
|
33
|
+
|| (variant.trackInventory !== 'FALSE' && globalTrackInventory);
|
|
34
|
+
if (!tracked) return { status: 'in_stock' };
|
|
35
|
+
|
|
36
|
+
const threshold = variant.useGlobalOutOfStockThreshold !== false
|
|
37
|
+
? globalOutOfStockThreshold : (variant.outOfStockThreshold ?? 0);
|
|
38
|
+
|
|
39
|
+
let onHandLessAllocated: number | undefined;
|
|
40
|
+
if (variant.stockLevels != null) {
|
|
41
|
+
onHandLessAllocated = variant.stockLevels
|
|
42
|
+
.filter((level: any) => stockLocationId == null || String(level.stockLocationId) === stockLocationId)
|
|
43
|
+
.reduce((sum: number, level: any) => sum + level.stockOnHand - level.stockAllocated, 0);
|
|
44
|
+
} else if (variant.stockOnHand != null) {
|
|
45
|
+
onHandLessAllocated = variant.stockOnHand;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (onHandLessAllocated != null) {
|
|
49
|
+
const saleable = onHandLessAllocated - threshold;
|
|
50
|
+
const status = saleable <= 0 ? 'out_of_stock' : onHandLessAllocated > 0 ? 'in_stock' : 'backorder';
|
|
51
|
+
return { status, quantity: Math.max(saleable, 0) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Shop API: only the stockLevel string, so tracking/threshold cannot apply.
|
|
55
|
+
if (variant.stockLevel === 'IN_STOCK' || variant.stockLevel === 'LOW_STOCK') return { status: 'in_stock' };
|
|
56
|
+
if (variant.stockLevel === 'OUT_OF_STOCK') return { status: 'out_of_stock' };
|
|
57
|
+
return { status: 'unknown' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Vendure's variant `enabled` (replicated since #110): a disabled variant is
|
|
61
|
+
// not offered, priced or counted; a product with none left is not sellable
|
|
62
|
+
// (#104 hides it).
|
|
63
|
+
function liveVariants(doc: any): any[] {
|
|
64
|
+
return (doc.variants ?? []).filter((variant: any) => variant.enabled !== false);
|
|
65
|
+
}
|
|
2
66
|
|
|
3
67
|
/**
|
|
4
68
|
* Vendure product trait implementations.
|
|
5
69
|
*
|
|
6
70
|
* Key differences from other connectors:
|
|
7
71
|
* - Product name is `name` (same as WooCommerce, unlike Medusa's `title`)
|
|
8
|
-
* - Price is on `variants[].
|
|
72
|
+
* - Price is on `variants[].price` or `priceWithTax`, according to the channel (integer cents)
|
|
9
73
|
* - Images use `featuredAsset.preview` and `assets[].preview`
|
|
10
74
|
* - Stock status is a string from the Shop API: 'IN_STOCK', 'OUT_OF_STOCK', 'LOW_STOCK'
|
|
11
75
|
* - Categories are `collections[].name` (Vendure's equivalent of categories)
|
|
12
76
|
* - No native sale price — Vendure handles sales via promotions at checkout
|
|
13
77
|
* - No native barcode field — uses custom fields if configured
|
|
14
78
|
*/
|
|
15
|
-
export
|
|
79
|
+
export function createVendureProductTraits(
|
|
80
|
+
barcodeField?: string,
|
|
81
|
+
stockLocationId?: string,
|
|
82
|
+
pricesIncludeTax = false,
|
|
83
|
+
globalTrackInventory = true,
|
|
84
|
+
globalOutOfStockThreshold = 0,
|
|
85
|
+
): ProductTraits {
|
|
86
|
+
const priceField = pricesIncludeTax ? 'priceWithTax' : 'price';
|
|
87
|
+
const traits: ProductTraits = {
|
|
16
88
|
getId: (doc) => String(doc.id),
|
|
17
89
|
|
|
18
90
|
getName: (doc) => doc.name ?? '',
|
|
19
91
|
|
|
20
92
|
getSku: (doc) => doc.variants?.[0]?.sku || undefined,
|
|
21
93
|
|
|
94
|
+
getPrices: (doc, context) => {
|
|
95
|
+
const variant = liveVariants(doc)[0];
|
|
96
|
+
// Both price fields are already integers in minor units.
|
|
97
|
+
const amount = variant?.[priceField];
|
|
98
|
+
if (amount == null) return [];
|
|
99
|
+
const currency = String(variant.currencyCode ?? context?.currency ?? 'XXX').toUpperCase();
|
|
100
|
+
// Vendure applies sales as order-level promotions, so there is no sale entry.
|
|
101
|
+
return [{ amount, currency, kind: 'base' }];
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
getVariants: (doc, context) => liveVariants(doc).map((variant: any) => ({
|
|
105
|
+
id: String(variant.id),
|
|
106
|
+
title: variant.name || undefined,
|
|
107
|
+
sku: variant.sku || undefined,
|
|
108
|
+
barcode: barcodeField ? variant.customFields?.[barcodeField] || undefined : undefined,
|
|
109
|
+
prices: traits.getPrices({ variants: [variant] }, context),
|
|
110
|
+
stock: traits.getStock({ variants: [variant] }),
|
|
111
|
+
})),
|
|
112
|
+
|
|
113
|
+
getStock: (doc) => productStock(liveVariants(doc).map((variant: any) =>
|
|
114
|
+
variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
|
|
115
|
+
|
|
22
116
|
getPrice: (doc) => {
|
|
23
117
|
// Vendure stores prices as integers in smallest currency unit (cents)
|
|
24
|
-
const amount = doc
|
|
118
|
+
const amount = liveVariants(doc)[0]?.[priceField];
|
|
25
119
|
if (amount == null) return undefined;
|
|
26
120
|
return (amount / 100).toFixed(2);
|
|
27
121
|
},
|
|
28
122
|
|
|
29
123
|
getRegularPrice: (doc) => {
|
|
30
124
|
// Vendure has no separate regular/sale price — promotions happen at checkout
|
|
31
|
-
const amount = doc
|
|
125
|
+
const amount = liveVariants(doc)[0]?.[priceField];
|
|
32
126
|
if (amount == null) return undefined;
|
|
33
127
|
return (amount / 100).toFixed(2);
|
|
34
128
|
},
|
|
@@ -51,30 +145,24 @@ export const vendureProductTraits: ProductTraits = {
|
|
|
51
145
|
|
|
52
146
|
getDescription: (doc) => doc.description || undefined,
|
|
53
147
|
|
|
148
|
+
// Aggregated across every variant, like getStock (backlog 28), not variant 0.
|
|
54
149
|
getStockStatus: (doc) => {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
// Vendure Shop API returns stockLevel as a string
|
|
59
|
-
const level = variant.stockLevel;
|
|
60
|
-
if (level === 'IN_STOCK' || level === 'LOW_STOCK') return 'instock';
|
|
61
|
-
if (level === 'OUT_OF_STOCK') return 'outofstock';
|
|
62
|
-
|
|
63
|
-
// Fall back to Admin API stockOnHand if available
|
|
64
|
-
if (variant.stockOnHand != null) {
|
|
65
|
-
return variant.stockOnHand > 0 ? 'instock' : 'outofstock';
|
|
66
|
-
}
|
|
67
|
-
|
|
150
|
+
const status = traits.getStock(doc).status;
|
|
151
|
+
if (status === 'in_stock' || status === 'backorder') return 'instock';
|
|
152
|
+
if (status === 'out_of_stock') return 'outofstock';
|
|
68
153
|
return 'unknown';
|
|
69
154
|
},
|
|
70
155
|
|
|
71
|
-
getStockQuantity: (doc) =>
|
|
72
|
-
// stockOnHand is available from Admin API; Shop API only has the string stockLevel
|
|
73
|
-
return doc.variants?.[0]?.stockOnHand ?? null;
|
|
74
|
-
},
|
|
156
|
+
getStockQuantity: (doc) => traits.getStock(doc).quantity ?? null,
|
|
75
157
|
|
|
76
158
|
hasVariants: (doc) => (doc.variants?.length ?? 0) > 1,
|
|
77
159
|
|
|
160
|
+
// A product with no variants at all keeps the product-level-only answer.
|
|
161
|
+
isSellable: (doc) => doc.enabled !== false
|
|
162
|
+
&& ((doc.variants?.length ?? 0) === 0 || liveVariants(doc).length > 0),
|
|
163
|
+
|
|
164
|
+
getVariantCount: (doc) => doc.variants ? liveVariants(doc).length : 1,
|
|
165
|
+
|
|
78
166
|
getType: (doc) => {
|
|
79
167
|
// Vendure has no native product type field
|
|
80
168
|
if ((doc.variants?.length ?? 0) > 1) return 'variable';
|
|
@@ -83,9 +171,13 @@ export const vendureProductTraits: ProductTraits = {
|
|
|
83
171
|
|
|
84
172
|
getBarcode: (doc) => {
|
|
85
173
|
// Vendure has no native barcode — check custom fields
|
|
86
|
-
return doc.variants?.[0]?.customFields?.
|
|
174
|
+
return barcodeField ? doc.variants?.[0]?.customFields?.[barcodeField] || undefined : undefined;
|
|
87
175
|
},
|
|
88
176
|
|
|
89
177
|
getCategoryNames: (doc) =>
|
|
90
178
|
(doc.collections ?? []).map((c: any) => c.name).filter(Boolean),
|
|
91
179
|
};
|
|
180
|
+
return traits;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const vendureProductTraits = createVendureProductTraits();
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { vendureProductTraits } from '../traits/product';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Realistic Vendure product document, shaped like the Admin GraphQL API response.
|
|
6
|
-
*/
|
|
7
|
-
const fullProduct = {
|
|
8
|
-
id: '42',
|
|
9
|
-
name: 'Commercial Espresso Machine',
|
|
10
|
-
slug: 'commercial-espresso-machine',
|
|
11
|
-
description: '<p>High-end commercial espresso machine with dual boilers.</p>',
|
|
12
|
-
enabled: true,
|
|
13
|
-
featuredAsset: {
|
|
14
|
-
id: '89',
|
|
15
|
-
preview: 'https://cdn.example.com/assets/espresso-main__preview.jpg',
|
|
16
|
-
},
|
|
17
|
-
assets: [
|
|
18
|
-
{ id: '89', preview: 'https://cdn.example.com/assets/espresso-main__preview.jpg' },
|
|
19
|
-
{ id: '90', preview: 'https://cdn.example.com/assets/espresso-side__preview.jpg' },
|
|
20
|
-
{ id: '91', preview: 'https://cdn.example.com/assets/espresso-back__preview.jpg' },
|
|
21
|
-
],
|
|
22
|
-
collections: [
|
|
23
|
-
{ id: '5', name: 'Equipment', slug: 'equipment' },
|
|
24
|
-
{ id: '12', name: 'Coffee Machines', slug: 'coffee-machines' },
|
|
25
|
-
],
|
|
26
|
-
facetValues: [
|
|
27
|
-
{ id: '23', name: 'Espresso', code: 'espresso', facet: { id: '3', name: 'Category' } },
|
|
28
|
-
{ id: '31', name: 'La Marzocco', code: 'la-marzocco', facet: { id: '5', name: 'Brand' } },
|
|
29
|
-
],
|
|
30
|
-
variants: [
|
|
31
|
-
{
|
|
32
|
-
id: '101',
|
|
33
|
-
name: 'Commercial Espresso Machine 110V',
|
|
34
|
-
sku: 'VND-ESP-110',
|
|
35
|
-
price: 489900,
|
|
36
|
-
priceWithTax: 534191,
|
|
37
|
-
currencyCode: 'USD',
|
|
38
|
-
stockLevel: 'IN_STOCK',
|
|
39
|
-
stockOnHand: 12,
|
|
40
|
-
trackInventory: 'INHERIT',
|
|
41
|
-
featuredAsset: null,
|
|
42
|
-
options: [{ id: '14', name: '110V', code: '110v' }],
|
|
43
|
-
customFields: { barcode: '5901234123457' },
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: '102',
|
|
47
|
-
name: 'Commercial Espresso Machine 220V',
|
|
48
|
-
sku: 'VND-ESP-220',
|
|
49
|
-
price: 489900,
|
|
50
|
-
priceWithTax: 534191,
|
|
51
|
-
currencyCode: 'USD',
|
|
52
|
-
stockLevel: 'OUT_OF_STOCK',
|
|
53
|
-
stockOnHand: 0,
|
|
54
|
-
trackInventory: 'INHERIT',
|
|
55
|
-
featuredAsset: null,
|
|
56
|
-
options: [{ id: '15', name: '220V', code: '220v' }],
|
|
57
|
-
customFields: {},
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const simpleProduct = {
|
|
63
|
-
id: '43',
|
|
64
|
-
name: 'Coffee Mug',
|
|
65
|
-
slug: 'coffee-mug',
|
|
66
|
-
description: 'A simple ceramic mug.',
|
|
67
|
-
enabled: true,
|
|
68
|
-
featuredAsset: null,
|
|
69
|
-
assets: [],
|
|
70
|
-
collections: [],
|
|
71
|
-
facetValues: [],
|
|
72
|
-
variants: [
|
|
73
|
-
{
|
|
74
|
-
id: '201',
|
|
75
|
-
name: 'Coffee Mug',
|
|
76
|
-
sku: 'MUG-001',
|
|
77
|
-
price: 1299,
|
|
78
|
-
priceWithTax: 1499,
|
|
79
|
-
currencyCode: 'USD',
|
|
80
|
-
stockLevel: 'LOW_STOCK',
|
|
81
|
-
stockOnHand: 3,
|
|
82
|
-
trackInventory: 'INHERIT',
|
|
83
|
-
featuredAsset: null,
|
|
84
|
-
options: [],
|
|
85
|
-
customFields: {},
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const minimalProduct = {
|
|
91
|
-
id: '999',
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
describe('Vendure product traits', () => {
|
|
95
|
-
describe('getId', () => {
|
|
96
|
-
it('returns the product id as a string', () => {
|
|
97
|
-
expect(vendureProductTraits.getId(fullProduct)).toBe('42');
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('getName', () => {
|
|
102
|
-
it('returns the name', () => {
|
|
103
|
-
expect(vendureProductTraits.getName(fullProduct)).toBe('Commercial Espresso Machine');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('returns empty string for missing name', () => {
|
|
107
|
-
expect(vendureProductTraits.getName(minimalProduct)).toBe('');
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe('getSku', () => {
|
|
112
|
-
it('returns SKU from first variant', () => {
|
|
113
|
-
expect(vendureProductTraits.getSku(fullProduct)).toBe('VND-ESP-110');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('returns undefined when no variants', () => {
|
|
117
|
-
expect(vendureProductTraits.getSku(minimalProduct)).toBeUndefined();
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe('getPrice', () => {
|
|
122
|
-
it('converts priceWithTax cents to decimal string', () => {
|
|
123
|
-
expect(vendureProductTraits.getPrice(fullProduct)).toBe('5341.91');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('handles smaller prices', () => {
|
|
127
|
-
expect(vendureProductTraits.getPrice(simpleProduct)).toBe('14.99');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('returns undefined when no variants', () => {
|
|
131
|
-
expect(vendureProductTraits.getPrice(minimalProduct)).toBeUndefined();
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('getRegularPrice / getSalePrice / isOnSale', () => {
|
|
136
|
-
it('regular price matches price (Vendure uses promotions, not product-level sales)', () => {
|
|
137
|
-
expect(vendureProductTraits.getRegularPrice(fullProduct)).toBe('5341.91');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('sale price is always undefined', () => {
|
|
141
|
-
expect(vendureProductTraits.getSalePrice(fullProduct)).toBeUndefined();
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('isOnSale is always false', () => {
|
|
145
|
-
expect(vendureProductTraits.isOnSale(fullProduct)).toBe(false);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe('getImageUrl / getImageUrls', () => {
|
|
150
|
-
it('returns featuredAsset preview URL', () => {
|
|
151
|
-
expect(vendureProductTraits.getImageUrl(fullProduct)).toBe(
|
|
152
|
-
'https://cdn.example.com/assets/espresso-main__preview.jpg'
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('returns all asset preview URLs', () => {
|
|
157
|
-
expect(vendureProductTraits.getImageUrls(fullProduct)).toEqual([
|
|
158
|
-
'https://cdn.example.com/assets/espresso-main__preview.jpg',
|
|
159
|
-
'https://cdn.example.com/assets/espresso-side__preview.jpg',
|
|
160
|
-
'https://cdn.example.com/assets/espresso-back__preview.jpg',
|
|
161
|
-
]);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('returns undefined / empty when no assets', () => {
|
|
165
|
-
expect(vendureProductTraits.getImageUrl(simpleProduct)).toBeUndefined();
|
|
166
|
-
expect(vendureProductTraits.getImageUrls(simpleProduct)).toEqual([]);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
describe('getDescription', () => {
|
|
171
|
-
it('returns the description', () => {
|
|
172
|
-
expect(vendureProductTraits.getDescription(fullProduct)).toBe(
|
|
173
|
-
'<p>High-end commercial espresso machine with dual boilers.</p>'
|
|
174
|
-
);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('returns undefined when missing', () => {
|
|
178
|
-
expect(vendureProductTraits.getDescription(minimalProduct)).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('getStockStatus', () => {
|
|
183
|
-
it('maps IN_STOCK to instock', () => {
|
|
184
|
-
expect(vendureProductTraits.getStockStatus(fullProduct)).toBe('instock');
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('maps LOW_STOCK to instock', () => {
|
|
188
|
-
expect(vendureProductTraits.getStockStatus(simpleProduct)).toBe('instock');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('maps OUT_OF_STOCK to outofstock', () => {
|
|
192
|
-
const outOfStock = {
|
|
193
|
-
...fullProduct,
|
|
194
|
-
variants: [{ ...fullProduct.variants[1] }], // 220V variant is OUT_OF_STOCK
|
|
195
|
-
};
|
|
196
|
-
expect(vendureProductTraits.getStockStatus(outOfStock)).toBe('outofstock');
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('returns unknown when no variants', () => {
|
|
200
|
-
expect(vendureProductTraits.getStockStatus(minimalProduct)).toBe('unknown');
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe('getStockQuantity', () => {
|
|
205
|
-
it('returns stockOnHand from first variant', () => {
|
|
206
|
-
expect(vendureProductTraits.getStockQuantity(fullProduct)).toBe(12);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('returns null when no variants', () => {
|
|
210
|
-
expect(vendureProductTraits.getStockQuantity(minimalProduct)).toBeNull();
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe('hasVariants / getType', () => {
|
|
215
|
-
it('multi-variant product has variants', () => {
|
|
216
|
-
expect(vendureProductTraits.hasVariants(fullProduct)).toBe(true);
|
|
217
|
-
expect(vendureProductTraits.getType(fullProduct)).toBe('variable');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('single-variant product has no variants', () => {
|
|
221
|
-
expect(vendureProductTraits.hasVariants(simpleProduct)).toBe(false);
|
|
222
|
-
expect(vendureProductTraits.getType(simpleProduct)).toBe('simple');
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe('getBarcode', () => {
|
|
227
|
-
it('returns barcode from custom fields', () => {
|
|
228
|
-
expect(vendureProductTraits.getBarcode(fullProduct)).toBe('5901234123457');
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('returns undefined when no custom barcode', () => {
|
|
232
|
-
expect(vendureProductTraits.getBarcode(simpleProduct)).toBeUndefined();
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
describe('getCategoryNames', () => {
|
|
237
|
-
it('returns collection names', () => {
|
|
238
|
-
expect(vendureProductTraits.getCategoryNames(fullProduct)).toEqual([
|
|
239
|
-
'Equipment',
|
|
240
|
-
'Coffee Machines',
|
|
241
|
-
]);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('returns empty array when no collections', () => {
|
|
245
|
-
expect(vendureProductTraits.getCategoryNames(simpleProduct)).toEqual([]);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
});
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import type { SyncContext } from '@tallyui/core';
|
|
3
|
-
|
|
4
|
-
import { vendureProductReplication } from './products';
|
|
5
|
-
|
|
6
|
-
const context: SyncContext = {
|
|
7
|
-
connectorId: 'vendure',
|
|
8
|
-
baseUrl: 'https://my-vendure-server.com',
|
|
9
|
-
headers: { Authorization: 'Bearer test_token' },
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Helper to build a mock GraphQL response.
|
|
14
|
-
*/
|
|
15
|
-
function gqlResponse(data: any, errors?: any[]) {
|
|
16
|
-
return new Response(
|
|
17
|
-
JSON.stringify({ data, errors }),
|
|
18
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('vendureProductReplication.pull.handler', () => {
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
vi.restoreAllMocks();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('fetches products from initial checkpoint (undefined)', async () => {
|
|
28
|
-
const mockProducts = [
|
|
29
|
-
{ id: '1', name: 'Widget', updatedAt: '2026-01-01T00:00:00Z' },
|
|
30
|
-
{ id: '2', name: 'Gadget', updatedAt: '2026-01-02T00:00:00Z' },
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
34
|
-
gqlResponse({
|
|
35
|
-
products: { items: mockProducts, totalItems: 2 },
|
|
36
|
-
}),
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
const result = await vendureProductReplication.pull.handler(undefined, 100, context);
|
|
40
|
-
|
|
41
|
-
expect(result.documents).toHaveLength(2);
|
|
42
|
-
expect(result.documents[0]._deleted).toBe(false);
|
|
43
|
-
expect(result.checkpoint).toEqual({
|
|
44
|
-
skip: 0,
|
|
45
|
-
updatedAt: '2026-01-02T00:00:00Z',
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// Verify the GraphQL request
|
|
49
|
-
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
50
|
-
expect(url).toContain('/admin-api');
|
|
51
|
-
const body = JSON.parse(opts.body);
|
|
52
|
-
expect(body.variables.options.take).toBe(100);
|
|
53
|
-
expect(body.variables.options.skip).toBe(0);
|
|
54
|
-
expect(body.variables.options.filter).toBeUndefined();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('passes updatedAt filter and skip from checkpoint', async () => {
|
|
58
|
-
const mockProducts = [
|
|
59
|
-
{ id: '3', name: 'Thingamajig', updatedAt: '2026-02-01T00:00:00Z' },
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
63
|
-
gqlResponse({
|
|
64
|
-
products: { items: mockProducts, totalItems: 1 },
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const checkpoint = { skip: 50, updatedAt: '2026-01-15T00:00:00Z' };
|
|
69
|
-
const result = await vendureProductReplication.pull.handler(checkpoint, 100, context);
|
|
70
|
-
|
|
71
|
-
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
|
72
|
-
expect(body.variables.options.skip).toBe(50);
|
|
73
|
-
expect(body.variables.options.filter).toEqual({
|
|
74
|
-
updatedAt: { after: '2026-01-15T00:00:00Z' },
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// Batch smaller than batchSize resets skip to 0
|
|
78
|
-
expect(result.checkpoint.skip).toBe(0);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('advances skip when batch equals batchSize', async () => {
|
|
82
|
-
const mockProducts = Array.from({ length: 25 }, (_, i) => ({
|
|
83
|
-
id: String(i),
|
|
84
|
-
name: `Product ${i}`,
|
|
85
|
-
updatedAt: '2026-01-01T00:00:00Z',
|
|
86
|
-
}));
|
|
87
|
-
|
|
88
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
89
|
-
gqlResponse({
|
|
90
|
-
products: { items: mockProducts, totalItems: 100 },
|
|
91
|
-
}),
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const result = await vendureProductReplication.pull.handler(undefined, 25, context);
|
|
95
|
-
|
|
96
|
-
expect(result.checkpoint.skip).toBe(25);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('throws on non-OK HTTP response', async () => {
|
|
100
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
101
|
-
new Response('Internal Server Error', { status: 500 }),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
await expect(
|
|
105
|
-
vendureProductReplication.pull.handler(undefined, 100, context),
|
|
106
|
-
).rejects.toThrow('Vendure API error: 500');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('throws on GraphQL errors', async () => {
|
|
110
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
111
|
-
gqlResponse(null, [{ message: 'Something went wrong' }]),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
await expect(
|
|
115
|
-
vendureProductReplication.pull.handler(undefined, 100, context),
|
|
116
|
-
).rejects.toThrow('Vendure GraphQL error: Something went wrong');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('returns lastCheckpoint when no products are returned', async () => {
|
|
120
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
121
|
-
gqlResponse({
|
|
122
|
-
products: { items: [], totalItems: 0 },
|
|
123
|
-
}),
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
const checkpoint = { skip: 0, updatedAt: '2026-01-01T00:00:00Z' };
|
|
127
|
-
const result = await vendureProductReplication.pull.handler(checkpoint, 100, context);
|
|
128
|
-
|
|
129
|
-
expect(result.checkpoint).toEqual(checkpoint);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe('vendureProductReplication.push.handler', () => {
|
|
134
|
-
beforeEach(() => {
|
|
135
|
-
vi.restoreAllMocks();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('sends updateProduct mutation', async () => {
|
|
139
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
140
|
-
gqlResponse({
|
|
141
|
-
updateProduct: { id: '1', updatedAt: '2026-01-03T00:00:00Z' },
|
|
142
|
-
}),
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
const conflicts = await vendureProductReplication.push!.handler(
|
|
146
|
-
[{
|
|
147
|
-
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
148
|
-
assumedMasterState: { id: '1', name: 'Old', _deleted: false } as any,
|
|
149
|
-
}],
|
|
150
|
-
context,
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
expect(conflicts).toHaveLength(0);
|
|
154
|
-
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
|
155
|
-
expect(body.query).toContain('updateProduct');
|
|
156
|
-
expect(body.variables.input.name).toBe('Updated');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('returns conflicts on GraphQL error', async () => {
|
|
160
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
161
|
-
gqlResponse(null, [{ message: 'Conflict' }]),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
const conflicts = await vendureProductReplication.push!.handler(
|
|
165
|
-
[{
|
|
166
|
-
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
167
|
-
assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
|
|
168
|
-
}],
|
|
169
|
-
context,
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
expect(conflicts).toHaveLength(1);
|
|
173
|
-
expect(conflicts[0].name).toBe('Server Version');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('returns conflicts on network failure', async () => {
|
|
177
|
-
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
|
|
178
|
-
|
|
179
|
-
const conflicts = await vendureProductReplication.push!.handler(
|
|
180
|
-
[{
|
|
181
|
-
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
182
|
-
assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
|
|
183
|
-
}],
|
|
184
|
-
context,
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
expect(conflicts).toHaveLength(1);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('does not return conflict when assumedMasterState is absent', async () => {
|
|
191
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
192
|
-
gqlResponse(null, [{ message: 'Error' }]),
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
const conflicts = await vendureProductReplication.push!.handler(
|
|
196
|
-
[{
|
|
197
|
-
newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
|
|
198
|
-
}],
|
|
199
|
-
context,
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
expect(conflicts).toHaveLength(0);
|
|
203
|
-
});
|
|
204
|
-
});
|