@tallyui/connector-woocommerce 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 +3 -3
- package/dist/index.js +21 -32
- package/package.json +9 -3
- package/src/replication/products.ts +4 -44
- package/src/traits/product.ts +29 -1
- package/src/__tests__/product-traits.test.ts +0 -191
- package/src/replication/products.test.ts +0 -189
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Kilmurray and TallyUI contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
CHANGED
|
@@ -32,9 +32,9 @@ type WooProductCheckpoint = {
|
|
|
32
32
|
/**
|
|
33
33
|
* Replication adapter for WooCommerce products.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Pull-only (cursor-based pagination by date_modified_gmt) for RxDB's
|
|
36
|
+
* replicateRxCollection. Catalogue data is server-owned; the POS never
|
|
37
|
+
* writes products.
|
|
38
38
|
*/
|
|
39
39
|
declare const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint>;
|
|
40
40
|
|
package/dist/index.js
CHANGED
|
@@ -144,10 +144,29 @@ var wooProductSchema = {
|
|
|
144
144
|
};
|
|
145
145
|
|
|
146
146
|
// src/traits/product.ts
|
|
147
|
+
import { moneyFromMajor } from "@tallyui/core";
|
|
148
|
+
var WOO_STOCK_STATUS = {
|
|
149
|
+
instock: "in_stock",
|
|
150
|
+
outofstock: "out_of_stock",
|
|
151
|
+
onbackorder: "backorder"
|
|
152
|
+
};
|
|
147
153
|
var wooProductTraits = {
|
|
148
154
|
getId: (doc) => String(doc.id),
|
|
149
155
|
getName: (doc) => doc.name ?? "",
|
|
150
156
|
getSku: (doc) => doc.sku || void 0,
|
|
157
|
+
getPrices: (doc, context) => {
|
|
158
|
+
const currency = context?.currency ?? "XXX";
|
|
159
|
+
const prices = [];
|
|
160
|
+
const base = moneyFromMajor(doc.regular_price || doc.price, currency);
|
|
161
|
+
if (base) prices.push({ ...base, kind: "base" });
|
|
162
|
+
const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : void 0;
|
|
163
|
+
if (sale) prices.push({ ...sale, kind: "sale" });
|
|
164
|
+
return prices;
|
|
165
|
+
},
|
|
166
|
+
getStock: (doc) => ({
|
|
167
|
+
status: WOO_STOCK_STATUS[doc.stock_status] ?? "unknown",
|
|
168
|
+
quantity: doc.manage_stock === false ? void 0 : doc.stock_quantity ?? void 0
|
|
169
|
+
}),
|
|
151
170
|
getPrice: (doc) => doc.price || void 0,
|
|
152
171
|
getRegularPrice: (doc) => doc.regular_price || void 0,
|
|
153
172
|
getSalePrice: (doc) => doc.sale_price || void 0,
|
|
@@ -164,6 +183,8 @@ var wooProductTraits = {
|
|
|
164
183
|
},
|
|
165
184
|
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
166
185
|
hasVariants: (doc) => doc.type === "variable",
|
|
186
|
+
isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
|
|
187
|
+
getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
|
|
167
188
|
getType: (doc) => doc.type ?? "simple",
|
|
168
189
|
getBarcode: (doc) => doc.barcode || void 0,
|
|
169
190
|
getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
|
|
@@ -253,38 +274,6 @@ var wooProductReplication = {
|
|
|
253
274
|
} : lastCheckpoint ?? { id: "", modified: "" };
|
|
254
275
|
return { documents, checkpoint };
|
|
255
276
|
}
|
|
256
|
-
},
|
|
257
|
-
push: {
|
|
258
|
-
async handler(changeRows, context) {
|
|
259
|
-
const conflicts = [];
|
|
260
|
-
for (const row of changeRows) {
|
|
261
|
-
const doc = row.newDocumentState;
|
|
262
|
-
try {
|
|
263
|
-
const isCreate = !row.assumedMasterState;
|
|
264
|
-
const method = isCreate ? "POST" : "PUT";
|
|
265
|
-
const url = isCreate ? `${context.baseUrl}/products` : `${context.baseUrl}/products/${doc.id}`;
|
|
266
|
-
const response = await fetch(url, {
|
|
267
|
-
method,
|
|
268
|
-
headers: {
|
|
269
|
-
...context.headers,
|
|
270
|
-
"Content-Type": "application/json"
|
|
271
|
-
},
|
|
272
|
-
body: JSON.stringify(doc),
|
|
273
|
-
signal: context.signal
|
|
274
|
-
});
|
|
275
|
-
if (!response.ok) {
|
|
276
|
-
if (row.assumedMasterState) {
|
|
277
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
if (row.assumedMasterState) {
|
|
282
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return conflicts;
|
|
287
|
-
}
|
|
288
277
|
}
|
|
289
278
|
};
|
|
290
279
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-woocommerce",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WooCommerce connector for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,11 +15,17 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
-
"src"
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*.test.ts"
|
|
19
20
|
],
|
|
20
21
|
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/TallyUI/tallyui.git",
|
|
25
|
+
"directory": "connectors/woocommerce"
|
|
26
|
+
},
|
|
21
27
|
"peerDependencies": {
|
|
22
|
-
"@tallyui/core": "
|
|
28
|
+
"@tallyui/core": "2.0.0"
|
|
23
29
|
},
|
|
24
30
|
"devDependencies": {
|
|
25
31
|
"rxdb": "16.21.1"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReplicationAdapter
|
|
1
|
+
import type { ReplicationAdapter } from '@tallyui/core';
|
|
2
2
|
|
|
3
3
|
export type WooProductCheckpoint = {
|
|
4
4
|
id: string;
|
|
@@ -8,9 +8,9 @@ export type WooProductCheckpoint = {
|
|
|
8
8
|
/**
|
|
9
9
|
* Replication adapter for WooCommerce products.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Pull-only (cursor-based pagination by date_modified_gmt) for RxDB's
|
|
12
|
+
* replicateRxCollection. Catalogue data is server-owned; the POS never
|
|
13
|
+
* writes products.
|
|
14
14
|
*/
|
|
15
15
|
export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint> = {
|
|
16
16
|
pull: {
|
|
@@ -50,44 +50,4 @@ export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint
|
|
|
50
50
|
return { documents, checkpoint };
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
|
-
|
|
54
|
-
push: {
|
|
55
|
-
async handler(changeRows, context) {
|
|
56
|
-
const conflicts: any[] = [];
|
|
57
|
-
|
|
58
|
-
for (const row of changeRows) {
|
|
59
|
-
const doc = row.newDocumentState as any;
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const isCreate = !row.assumedMasterState;
|
|
63
|
-
const method = isCreate ? 'POST' : 'PUT';
|
|
64
|
-
const url = isCreate
|
|
65
|
-
? `${context.baseUrl}/products`
|
|
66
|
-
: `${context.baseUrl}/products/${doc.id}`;
|
|
67
|
-
|
|
68
|
-
const response = await fetch(url, {
|
|
69
|
-
method,
|
|
70
|
-
headers: {
|
|
71
|
-
...context.headers,
|
|
72
|
-
'Content-Type': 'application/json',
|
|
73
|
-
},
|
|
74
|
-
body: JSON.stringify(doc),
|
|
75
|
-
signal: context.signal,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (!response.ok) {
|
|
79
|
-
if (row.assumedMasterState) {
|
|
80
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
if (row.assumedMasterState) {
|
|
85
|
-
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return conflicts;
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
53
|
};
|
package/src/traits/product.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { moneyFromMajor } from '@tallyui/core';
|
|
2
|
+
import type { ProductPrice, ProductTraits, StockStatus } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
/** WooCommerce's stock_status strings, mapped to the neutral enum. */
|
|
5
|
+
const WOO_STOCK_STATUS: Record<string, StockStatus> = {
|
|
6
|
+
instock: 'in_stock',
|
|
7
|
+
outofstock: 'out_of_stock',
|
|
8
|
+
onbackorder: 'backorder',
|
|
9
|
+
};
|
|
2
10
|
|
|
3
11
|
/**
|
|
4
12
|
* WooCommerce product trait implementations.
|
|
@@ -13,6 +21,22 @@ export const wooProductTraits: ProductTraits = {
|
|
|
13
21
|
|
|
14
22
|
getSku: (doc) => doc.sku || undefined,
|
|
15
23
|
|
|
24
|
+
getPrices: (doc, context) => {
|
|
25
|
+
// Woo prices are decimal strings with no currency; the store supplies it.
|
|
26
|
+
const currency = context?.currency ?? 'XXX';
|
|
27
|
+
const prices: ProductPrice[] = [];
|
|
28
|
+
const base = moneyFromMajor(doc.regular_price || doc.price, currency);
|
|
29
|
+
if (base) prices.push({ ...base, kind: 'base' });
|
|
30
|
+
const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : undefined;
|
|
31
|
+
if (sale) prices.push({ ...sale, kind: 'sale' });
|
|
32
|
+
return prices;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
getStock: (doc) => ({
|
|
36
|
+
status: WOO_STOCK_STATUS[doc.stock_status] ?? 'unknown',
|
|
37
|
+
quantity: doc.manage_stock === false ? undefined : doc.stock_quantity ?? undefined,
|
|
38
|
+
}),
|
|
39
|
+
|
|
16
40
|
getPrice: (doc) => doc.price || undefined,
|
|
17
41
|
|
|
18
42
|
getRegularPrice: (doc) => doc.regular_price || undefined,
|
|
@@ -39,6 +63,10 @@ export const wooProductTraits: ProductTraits = {
|
|
|
39
63
|
|
|
40
64
|
hasVariants: (doc) => doc.type === 'variable',
|
|
41
65
|
|
|
66
|
+
isSellable: (doc) => doc.status === undefined || doc.status === 'publish',
|
|
67
|
+
|
|
68
|
+
getVariantCount: (doc) => doc.type === 'variable' ? doc.variations?.length ?? 0 : 1,
|
|
69
|
+
|
|
42
70
|
getType: (doc) => doc.type ?? 'simple',
|
|
43
71
|
|
|
44
72
|
getBarcode: (doc) => doc.barcode || undefined,
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { wooProductTraits } from '../traits/product';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Realistic WooCommerce product document, shaped like the REST API v3 response.
|
|
6
|
-
*/
|
|
7
|
-
const fullProduct = {
|
|
8
|
-
id: 42,
|
|
9
|
-
name: 'Espresso Machine Pro',
|
|
10
|
-
slug: 'espresso-machine-pro',
|
|
11
|
-
type: 'simple',
|
|
12
|
-
status: 'publish',
|
|
13
|
-
sku: 'ESP-001',
|
|
14
|
-
price: '599.99',
|
|
15
|
-
regular_price: '599.99',
|
|
16
|
-
sale_price: '',
|
|
17
|
-
on_sale: false,
|
|
18
|
-
stock_status: 'instock',
|
|
19
|
-
stock_quantity: 15,
|
|
20
|
-
barcode: '1234567890123',
|
|
21
|
-
short_description: 'High-end espresso machine.',
|
|
22
|
-
description: 'Full description here.',
|
|
23
|
-
images: [
|
|
24
|
-
{ id: 1, src: 'https://store.example/espresso.jpg', alt: 'Espresso Machine' },
|
|
25
|
-
{ id: 2, src: 'https://store.example/espresso-side.jpg', alt: '' },
|
|
26
|
-
],
|
|
27
|
-
categories: [
|
|
28
|
-
{ id: 1, name: 'Equipment', slug: 'equipment' },
|
|
29
|
-
{ id: 2, name: 'Coffee', slug: 'coffee' },
|
|
30
|
-
],
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const saleProduct = {
|
|
34
|
-
...fullProduct,
|
|
35
|
-
id: 43,
|
|
36
|
-
price: '499.99',
|
|
37
|
-
sale_price: '499.99',
|
|
38
|
-
on_sale: true,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const variableProduct = {
|
|
42
|
-
...fullProduct,
|
|
43
|
-
id: 44,
|
|
44
|
-
type: 'variable',
|
|
45
|
-
variations: [101, 102],
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const minimalProduct = {
|
|
49
|
-
id: 99,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
describe('WooCommerce product traits', () => {
|
|
53
|
-
describe('getId', () => {
|
|
54
|
-
it('returns the product id as a string', () => {
|
|
55
|
-
expect(wooProductTraits.getId(fullProduct)).toBe('42');
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('getName', () => {
|
|
60
|
-
it('returns the product name', () => {
|
|
61
|
-
expect(wooProductTraits.getName(fullProduct)).toBe('Espresso Machine Pro');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('returns empty string for missing name', () => {
|
|
65
|
-
expect(wooProductTraits.getName(minimalProduct)).toBe('');
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe('getSku', () => {
|
|
70
|
-
it('returns the SKU', () => {
|
|
71
|
-
expect(wooProductTraits.getSku(fullProduct)).toBe('ESP-001');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('returns undefined when no SKU', () => {
|
|
75
|
-
expect(wooProductTraits.getSku(minimalProduct)).toBeUndefined();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('getPrice', () => {
|
|
80
|
-
it('returns the price string', () => {
|
|
81
|
-
expect(wooProductTraits.getPrice(fullProduct)).toBe('599.99');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('returns undefined for missing price', () => {
|
|
85
|
-
expect(wooProductTraits.getPrice(minimalProduct)).toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('getRegularPrice / getSalePrice / isOnSale', () => {
|
|
90
|
-
it('returns regular price', () => {
|
|
91
|
-
expect(wooProductTraits.getRegularPrice(fullProduct)).toBe('599.99');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('detects non-sale product', () => {
|
|
95
|
-
expect(wooProductTraits.isOnSale(fullProduct)).toBe(false);
|
|
96
|
-
expect(wooProductTraits.getSalePrice(fullProduct)).toBeUndefined();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('detects sale product', () => {
|
|
100
|
-
expect(wooProductTraits.isOnSale(saleProduct)).toBe(true);
|
|
101
|
-
expect(wooProductTraits.getSalePrice(saleProduct)).toBe('499.99');
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('getImageUrl / getImageUrls', () => {
|
|
106
|
-
it('returns the first image src', () => {
|
|
107
|
-
expect(wooProductTraits.getImageUrl(fullProduct)).toBe('https://store.example/espresso.jpg');
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('returns all image URLs', () => {
|
|
111
|
-
expect(wooProductTraits.getImageUrls(fullProduct)).toEqual([
|
|
112
|
-
'https://store.example/espresso.jpg',
|
|
113
|
-
'https://store.example/espresso-side.jpg',
|
|
114
|
-
]);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('returns undefined / empty for missing images', () => {
|
|
118
|
-
expect(wooProductTraits.getImageUrl(minimalProduct)).toBeUndefined();
|
|
119
|
-
expect(wooProductTraits.getImageUrls(minimalProduct)).toEqual([]);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe('getDescription', () => {
|
|
124
|
-
it('prefers short_description', () => {
|
|
125
|
-
expect(wooProductTraits.getDescription(fullProduct)).toBe('High-end espresso machine.');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('falls back to description', () => {
|
|
129
|
-
const doc = { ...fullProduct, short_description: '' };
|
|
130
|
-
expect(wooProductTraits.getDescription(doc)).toBe('Full description here.');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('returns undefined when neither exists', () => {
|
|
134
|
-
expect(wooProductTraits.getDescription(minimalProduct)).toBeUndefined();
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe('getStockStatus / getStockQuantity', () => {
|
|
139
|
-
it('returns instock status', () => {
|
|
140
|
-
expect(wooProductTraits.getStockStatus(fullProduct)).toBe('instock');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('returns stock quantity', () => {
|
|
144
|
-
expect(wooProductTraits.getStockQuantity(fullProduct)).toBe(15);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('returns unknown for unrecognised status', () => {
|
|
148
|
-
expect(wooProductTraits.getStockStatus({ ...fullProduct, stock_status: 'weird' })).toBe('unknown');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('returns null quantity when missing', () => {
|
|
152
|
-
expect(wooProductTraits.getStockQuantity(minimalProduct)).toBeNull();
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe('hasVariants / getType', () => {
|
|
157
|
-
it('simple product has no variants', () => {
|
|
158
|
-
expect(wooProductTraits.hasVariants(fullProduct)).toBe(false);
|
|
159
|
-
expect(wooProductTraits.getType(fullProduct)).toBe('simple');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('variable product has variants', () => {
|
|
163
|
-
expect(wooProductTraits.hasVariants(variableProduct)).toBe(true);
|
|
164
|
-
expect(wooProductTraits.getType(variableProduct)).toBe('variable');
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('defaults to simple for missing type', () => {
|
|
168
|
-
expect(wooProductTraits.getType(minimalProduct)).toBe('simple');
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('getBarcode', () => {
|
|
173
|
-
it('returns barcode', () => {
|
|
174
|
-
expect(wooProductTraits.getBarcode(fullProduct)).toBe('1234567890123');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('returns undefined when missing', () => {
|
|
178
|
-
expect(wooProductTraits.getBarcode(minimalProduct)).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('getCategoryNames', () => {
|
|
183
|
-
it('returns category name strings', () => {
|
|
184
|
-
expect(wooProductTraits.getCategoryNames(fullProduct)).toEqual(['Equipment', 'Coffee']);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('returns empty array when no categories', () => {
|
|
188
|
-
expect(wooProductTraits.getCategoryNames(minimalProduct)).toEqual([]);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
});
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import type { SyncContext } from '@tallyui/core';
|
|
3
|
-
|
|
4
|
-
import { wooProductReplication } from './products';
|
|
5
|
-
|
|
6
|
-
const context: SyncContext = {
|
|
7
|
-
connectorId: 'woocommerce',
|
|
8
|
-
baseUrl: 'https://example.com/wp-json/wc/v3',
|
|
9
|
-
headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
describe('wooProductReplication.pull.handler', () => {
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
vi.restoreAllMocks();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('fetches products from initial checkpoint (undefined)', async () => {
|
|
18
|
-
const mockProducts = [
|
|
19
|
-
{ id: 1, uuid: 'abc', name: 'Widget', date_modified_gmt: '2026-01-01T00:00:00' },
|
|
20
|
-
{ id: 2, uuid: 'def', name: 'Gadget', date_modified_gmt: '2026-01-02T00:00:00' },
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
24
|
-
new Response(JSON.stringify(mockProducts), { status: 200 }),
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
const result = await wooProductReplication.pull.handler(undefined, 100, context);
|
|
28
|
-
|
|
29
|
-
expect(result.documents).toHaveLength(2);
|
|
30
|
-
expect(result.documents[0]._deleted).toBe(false);
|
|
31
|
-
expect(result.checkpoint).toEqual({
|
|
32
|
-
id: 'def',
|
|
33
|
-
modified: '2026-01-02T00:00:00',
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('fetches products after a checkpoint', async () => {
|
|
38
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
39
|
-
new Response(JSON.stringify([]), { status: 200 }),
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const checkpoint = { id: 'abc', modified: '2026-01-01T00:00:00' };
|
|
43
|
-
const result = await wooProductReplication.pull.handler(checkpoint, 100, context);
|
|
44
|
-
|
|
45
|
-
expect(result.documents).toHaveLength(0);
|
|
46
|
-
|
|
47
|
-
const calledUrl = (globalThis.fetch as any).mock.calls[0][0];
|
|
48
|
-
expect(calledUrl).toContain('modified_after=2026-01-01T00%3A00%3A00');
|
|
49
|
-
expect(calledUrl).toContain('orderby=modified');
|
|
50
|
-
expect(calledUrl).toContain('order=asc');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('uses batchSize as per_page', async () => {
|
|
54
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
55
|
-
new Response(JSON.stringify([]), { status: 200 }),
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
await wooProductReplication.pull.handler(undefined, 25, context);
|
|
59
|
-
|
|
60
|
-
const calledUrl = (globalThis.fetch as any).mock.calls[0][0];
|
|
61
|
-
expect(calledUrl).toContain('per_page=25');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('throws on non-OK response', async () => {
|
|
65
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
66
|
-
new Response('Internal Server Error', { status: 500 }),
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
await expect(
|
|
70
|
-
wooProductReplication.pull.handler(undefined, 100, context),
|
|
71
|
-
).rejects.toThrow('WooCommerce API error: 500');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('returns lastCheckpoint when no products are returned', async () => {
|
|
75
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
76
|
-
new Response(JSON.stringify([]), { status: 200 }),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const checkpoint = { id: 'abc', modified: '2026-01-01T00:00:00' };
|
|
80
|
-
const result = await wooProductReplication.pull.handler(checkpoint, 100, context);
|
|
81
|
-
|
|
82
|
-
expect(result.checkpoint).toEqual(checkpoint);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('falls back to id when uuid is missing', async () => {
|
|
86
|
-
const mockProducts = [
|
|
87
|
-
{ id: 42, name: 'No UUID', date_modified_gmt: '2026-03-01T00:00:00' },
|
|
88
|
-
];
|
|
89
|
-
|
|
90
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
91
|
-
new Response(JSON.stringify(mockProducts), { status: 200 }),
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const result = await wooProductReplication.pull.handler(undefined, 100, context);
|
|
95
|
-
|
|
96
|
-
expect(result.checkpoint.id).toBe('42');
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('wooProductReplication.push.handler', () => {
|
|
101
|
-
beforeEach(() => {
|
|
102
|
-
vi.restoreAllMocks();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('sends PUT for updates', async () => {
|
|
106
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
107
|
-
new Response(JSON.stringify({}), { status: 200 }),
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const conflicts = await wooProductReplication.push!.handler(
|
|
111
|
-
[{
|
|
112
|
-
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
113
|
-
assumedMasterState: { id: '1', name: 'Old', _deleted: false } as any,
|
|
114
|
-
}],
|
|
115
|
-
context,
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
expect(conflicts).toHaveLength(0);
|
|
119
|
-
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
120
|
-
expect(url).toContain('/products/1');
|
|
121
|
-
expect(opts.method).toBe('PUT');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('sends POST for creates', async () => {
|
|
125
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
126
|
-
new Response(JSON.stringify({}), { status: 200 }),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
const conflicts = await wooProductReplication.push!.handler(
|
|
130
|
-
[{
|
|
131
|
-
newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
|
|
132
|
-
// No assumedMasterState = create
|
|
133
|
-
}],
|
|
134
|
-
context,
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
expect(conflicts).toHaveLength(0);
|
|
138
|
-
const [, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
139
|
-
expect(opts.method).toBe('POST');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('returns conflicts on server error', async () => {
|
|
143
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
144
|
-
new Response('conflict', { status: 409 }),
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
const conflicts = await wooProductReplication.push!.handler(
|
|
148
|
-
[{
|
|
149
|
-
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
150
|
-
assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
|
|
151
|
-
}],
|
|
152
|
-
context,
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
expect(conflicts).toHaveLength(1);
|
|
156
|
-
expect(conflicts[0].name).toBe('Server Version');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('returns conflicts on network failure', async () => {
|
|
160
|
-
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
|
|
161
|
-
|
|
162
|
-
const conflicts = await wooProductReplication.push!.handler(
|
|
163
|
-
[{
|
|
164
|
-
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
165
|
-
assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
|
|
166
|
-
}],
|
|
167
|
-
context,
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
expect(conflicts).toHaveLength(1);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('does not return conflict for creates that fail', async () => {
|
|
174
|
-
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
175
|
-
new Response('error', { status: 500 }),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
const conflicts = await wooProductReplication.push!.handler(
|
|
179
|
-
[{
|
|
180
|
-
newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
|
|
181
|
-
// No assumedMasterState = create, so no conflict to report
|
|
182
|
-
}],
|
|
183
|
-
context,
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
// Creates without assumedMasterState don't produce conflicts
|
|
187
|
-
expect(conflicts).toHaveLength(0);
|
|
188
|
-
});
|
|
189
|
-
});
|