@tallyui/connector-vendure 1.0.0 → 3.0.0-next.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.
@@ -1,34 +1,136 @@
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[].priceWithTax` (integer cents, like Medusa)
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 const vendureProductTraits: ProductTraits = {
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
+ // The variant's tax category id: store-settings.ts keys taxRatesPpm by the same id. No variant id: the default
114
+ // (first live) variant, as getPrices.
115
+ getTaxClass: (doc, variantId) => {
116
+ const variant = variantId === undefined ? liveVariants(doc)[0]
117
+ : (doc.variants ?? []).find((v: any) => String(v.id) === variantId);
118
+ return variant?.taxCategory?.id == null ? undefined : String(variant.taxCategory.id);
119
+ },
120
+
121
+ getStock: (doc) => productStock(liveVariants(doc).map((variant: any) =>
122
+ variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
123
+
22
124
  getPrice: (doc) => {
23
125
  // Vendure stores prices as integers in smallest currency unit (cents)
24
- const amount = doc.variants?.[0]?.priceWithTax;
126
+ const amount = liveVariants(doc)[0]?.[priceField];
25
127
  if (amount == null) return undefined;
26
128
  return (amount / 100).toFixed(2);
27
129
  },
28
130
 
29
131
  getRegularPrice: (doc) => {
30
132
  // Vendure has no separate regular/sale price — promotions happen at checkout
31
- const amount = doc.variants?.[0]?.priceWithTax;
133
+ const amount = liveVariants(doc)[0]?.[priceField];
32
134
  if (amount == null) return undefined;
33
135
  return (amount / 100).toFixed(2);
34
136
  },
@@ -51,30 +153,24 @@ export const vendureProductTraits: ProductTraits = {
51
153
 
52
154
  getDescription: (doc) => doc.description || undefined,
53
155
 
156
+ // Aggregated across every variant, like getStock (backlog 28), not variant 0.
54
157
  getStockStatus: (doc) => {
55
- const variant = doc.variants?.[0];
56
- if (!variant) return 'unknown';
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
-
158
+ const status = traits.getStock(doc).status;
159
+ if (status === 'in_stock' || status === 'backorder') return 'instock';
160
+ if (status === 'out_of_stock') return 'outofstock';
68
161
  return 'unknown';
69
162
  },
70
163
 
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
- },
164
+ getStockQuantity: (doc) => traits.getStock(doc).quantity ?? null,
75
165
 
76
166
  hasVariants: (doc) => (doc.variants?.length ?? 0) > 1,
77
167
 
168
+ // A product with no variants at all keeps the product-level-only answer.
169
+ isSellable: (doc) => doc.enabled !== false
170
+ && ((doc.variants?.length ?? 0) === 0 || liveVariants(doc).length > 0),
171
+
172
+ getVariantCount: (doc) => doc.variants ? liveVariants(doc).length : 1,
173
+
78
174
  getType: (doc) => {
79
175
  // Vendure has no native product type field
80
176
  if ((doc.variants?.length ?? 0) > 1) return 'variable';
@@ -83,9 +179,13 @@ export const vendureProductTraits: ProductTraits = {
83
179
 
84
180
  getBarcode: (doc) => {
85
181
  // Vendure has no native barcode — check custom fields
86
- return doc.variants?.[0]?.customFields?.barcode || undefined;
182
+ return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || undefined : undefined;
87
183
  },
88
184
 
89
185
  getCategoryNames: (doc) =>
90
186
  (doc.collections ?? []).map((c: any) => c.name).filter(Boolean),
91
187
  };
188
+ return traits;
189
+ }
190
+
191
+ 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
- });