@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.
@@ -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[].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
+ 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.variants?.[0]?.priceWithTax;
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.variants?.[0]?.priceWithTax;
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 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
-
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?.barcode || undefined;
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
- });