@86d-app/products 0.0.3
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/AGENTS.md +65 -0
- package/COMPONENTS.md +231 -0
- package/README.md +201 -0
- package/package.json +46 -0
- package/src/__tests__/controllers.test.ts +2227 -0
- package/src/__tests__/state.test.ts +138 -0
- package/src/admin/components/categories-admin.mdx +3 -0
- package/src/admin/components/categories-admin.tsx +449 -0
- package/src/admin/components/category-form.mdx +9 -0
- package/src/admin/components/category-form.tsx +490 -0
- package/src/admin/components/category-list.mdx +75 -0
- package/src/admin/components/category-list.tsx +168 -0
- package/src/admin/components/collections-admin.mdx +3 -0
- package/src/admin/components/collections-admin.tsx +771 -0
- package/src/admin/components/index.tsx +8 -0
- package/src/admin/components/product-detail.mdx +12 -0
- package/src/admin/components/product-detail.tsx +790 -0
- package/src/admin/components/product-edit.tsx +60 -0
- package/src/admin/components/product-form.tsx +793 -0
- package/src/admin/components/product-list.mdx +3 -0
- package/src/admin/components/product-list.tsx +1125 -0
- package/src/admin/components/product-new.tsx +38 -0
- package/src/admin/endpoints/add-collection-product.ts +17 -0
- package/src/admin/endpoints/bulk-action.ts +43 -0
- package/src/admin/endpoints/create-category.ts +52 -0
- package/src/admin/endpoints/create-collection.ts +35 -0
- package/src/admin/endpoints/create-product.ts +50 -0
- package/src/admin/endpoints/create-variant.ts +45 -0
- package/src/admin/endpoints/delete-category.ts +27 -0
- package/src/admin/endpoints/delete-collection.ts +12 -0
- package/src/admin/endpoints/delete-product.ts +27 -0
- package/src/admin/endpoints/delete-variant.ts +27 -0
- package/src/admin/endpoints/get-product.ts +23 -0
- package/src/admin/endpoints/import-products.ts +47 -0
- package/src/admin/endpoints/index.ts +43 -0
- package/src/admin/endpoints/list-categories.ts +21 -0
- package/src/admin/endpoints/list-collections.ts +20 -0
- package/src/admin/endpoints/list-products.ts +25 -0
- package/src/admin/endpoints/remove-collection-product.ts +15 -0
- package/src/admin/endpoints/update-category.ts +82 -0
- package/src/admin/endpoints/update-collection.ts +22 -0
- package/src/admin/endpoints/update-product.ts +67 -0
- package/src/admin/endpoints/update-variant.ts +41 -0
- package/src/controllers.ts +1410 -0
- package/src/index.ts +120 -0
- package/src/markdown.ts +150 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +352 -0
- package/src/state.ts +84 -0
- package/src/store/components/_hooks.ts +78 -0
- package/src/store/components/_types.ts +73 -0
- package/src/store/components/_utils.ts +14 -0
- package/src/store/components/back-in-stock-notify.tsx +97 -0
- package/src/store/components/collection-card.mdx +42 -0
- package/src/store/components/collection-card.tsx +12 -0
- package/src/store/components/collection-detail.mdx +12 -0
- package/src/store/components/collection-detail.tsx +149 -0
- package/src/store/components/collection-grid.mdx +9 -0
- package/src/store/components/collection-grid.tsx +80 -0
- package/src/store/components/featured-products.mdx +9 -0
- package/src/store/components/featured-products.tsx +75 -0
- package/src/store/components/filter-chip.mdx +25 -0
- package/src/store/components/filter-chip.tsx +12 -0
- package/src/store/components/index.tsx +39 -0
- package/src/store/components/product-card.mdx +69 -0
- package/src/store/components/product-card.tsx +71 -0
- package/src/store/components/product-detail.mdx +30 -0
- package/src/store/components/product-detail.tsx +488 -0
- package/src/store/components/product-listing.mdx +7 -0
- package/src/store/components/product-listing.tsx +423 -0
- package/src/store/components/product-reviews-section.mdx +21 -0
- package/src/store/components/product-reviews-section.tsx +372 -0
- package/src/store/components/recently-viewed.tsx +100 -0
- package/src/store/components/related-products.mdx +6 -0
- package/src/store/components/related-products.tsx +62 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +27 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +21 -0
- package/src/store/components/stock-badge.mdx +12 -0
- package/src/store/components/stock-badge.tsx +19 -0
- package/src/store/endpoints/get-category.ts +61 -0
- package/src/store/endpoints/get-collection.ts +46 -0
- package/src/store/endpoints/get-featured.ts +18 -0
- package/src/store/endpoints/get-product.ts +52 -0
- package/src/store/endpoints/get-related.ts +20 -0
- package/src/store/endpoints/index.ts +23 -0
- package/src/store/endpoints/list-categories.ts +13 -0
- package/src/store/endpoints/list-collections.ts +22 -0
- package/src/store/endpoints/list-products.ts +28 -0
- package/src/store/endpoints/search-products.ts +18 -0
- package/src/store/endpoints/store-search.ts +111 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,2227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMockDataService,
|
|
3
|
+
makeControllerCtx,
|
|
4
|
+
} from "@86d-app/core/test-utils";
|
|
5
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type {
|
|
7
|
+
Category,
|
|
8
|
+
Collection,
|
|
9
|
+
CollectionProduct,
|
|
10
|
+
CollectionWithProducts,
|
|
11
|
+
ImportResult,
|
|
12
|
+
Product,
|
|
13
|
+
ProductVariant,
|
|
14
|
+
} from "../controllers";
|
|
15
|
+
import { controllers } from "../controllers";
|
|
16
|
+
|
|
17
|
+
// ── Sample data ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makeProduct(overrides: Partial<Product> = {}): Product {
|
|
20
|
+
const now = new Date();
|
|
21
|
+
return {
|
|
22
|
+
id: "prod_1",
|
|
23
|
+
name: "Test Product",
|
|
24
|
+
slug: "test-product",
|
|
25
|
+
price: 2999,
|
|
26
|
+
inventory: 10,
|
|
27
|
+
trackInventory: true,
|
|
28
|
+
allowBackorder: false,
|
|
29
|
+
status: "active",
|
|
30
|
+
images: [],
|
|
31
|
+
tags: ["test"],
|
|
32
|
+
isFeatured: false,
|
|
33
|
+
createdAt: now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeVariant(overrides: Partial<ProductVariant> = {}): ProductVariant {
|
|
40
|
+
const now = new Date();
|
|
41
|
+
return {
|
|
42
|
+
id: "var_1",
|
|
43
|
+
productId: "prod_1",
|
|
44
|
+
name: "Default",
|
|
45
|
+
price: 2999,
|
|
46
|
+
inventory: 5,
|
|
47
|
+
options: { size: "M" },
|
|
48
|
+
images: [],
|
|
49
|
+
position: 0,
|
|
50
|
+
createdAt: now,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeCategory(overrides: Partial<Category> = {}): Category {
|
|
57
|
+
const now = new Date();
|
|
58
|
+
return {
|
|
59
|
+
id: "cat_1",
|
|
60
|
+
name: "Electronics",
|
|
61
|
+
slug: "electronics",
|
|
62
|
+
position: 0,
|
|
63
|
+
isVisible: true,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("product controllers", () => {
|
|
73
|
+
let data: ReturnType<typeof createMockDataService>;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
data = createMockDataService();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("getById", () => {
|
|
80
|
+
it("returns product when found", async () => {
|
|
81
|
+
const product = makeProduct();
|
|
82
|
+
await data.upsert("product", product.id, product);
|
|
83
|
+
|
|
84
|
+
const result = await controllers.product.getById(
|
|
85
|
+
makeControllerCtx(data, { params: { id: "prod_1" } }),
|
|
86
|
+
);
|
|
87
|
+
expect(result).toMatchObject({ id: "prod_1", name: "Test Product" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns null when not found", async () => {
|
|
91
|
+
const result = await controllers.product.getById(
|
|
92
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
93
|
+
);
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getBySlug", () => {
|
|
99
|
+
it("returns product matching slug", async () => {
|
|
100
|
+
const product = makeProduct();
|
|
101
|
+
await data.upsert("product", product.id, product);
|
|
102
|
+
|
|
103
|
+
const result = await controllers.product.getBySlug(
|
|
104
|
+
makeControllerCtx(data, { query: { slug: "test-product" } }),
|
|
105
|
+
);
|
|
106
|
+
expect(result).toMatchObject({ slug: "test-product" });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null when slug not found", async () => {
|
|
110
|
+
const result = await controllers.product.getBySlug(
|
|
111
|
+
makeControllerCtx(data, { query: { slug: "no-match" } }),
|
|
112
|
+
);
|
|
113
|
+
expect(result).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("getWithVariants", () => {
|
|
118
|
+
it("returns product with empty variants array when none exist", async () => {
|
|
119
|
+
const product = makeProduct();
|
|
120
|
+
await data.upsert("product", product.id, product);
|
|
121
|
+
|
|
122
|
+
const result = (await controllers.product.getWithVariants(
|
|
123
|
+
makeControllerCtx(data, { params: { id: "prod_1" } }),
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
125
|
+
)) as any;
|
|
126
|
+
expect(result).toMatchObject({ id: "prod_1" });
|
|
127
|
+
expect(result.variants).toEqual([]);
|
|
128
|
+
expect(result.category).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns product with variants", async () => {
|
|
132
|
+
const product = makeProduct();
|
|
133
|
+
const variant = makeVariant();
|
|
134
|
+
await data.upsert("product", product.id, product);
|
|
135
|
+
await data.upsert("productVariant", variant.id, variant);
|
|
136
|
+
|
|
137
|
+
const result = (await controllers.product.getWithVariants(
|
|
138
|
+
makeControllerCtx(data, { params: { id: "prod_1" } }),
|
|
139
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
140
|
+
)) as any;
|
|
141
|
+
expect(result.variants).toHaveLength(1);
|
|
142
|
+
expect(result.variants[0]).toMatchObject({ id: "var_1" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns product with category when categoryId is set", async () => {
|
|
146
|
+
const category = makeCategory();
|
|
147
|
+
const product = makeProduct({ categoryId: "cat_1" });
|
|
148
|
+
await data.upsert("category", category.id, category);
|
|
149
|
+
await data.upsert("product", product.id, product);
|
|
150
|
+
|
|
151
|
+
const result = (await controllers.product.getWithVariants(
|
|
152
|
+
makeControllerCtx(data, { params: { id: "prod_1" } }),
|
|
153
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
154
|
+
)) as any;
|
|
155
|
+
expect(result.category).toMatchObject({
|
|
156
|
+
id: "cat_1",
|
|
157
|
+
name: "Electronics",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns null when product not found", async () => {
|
|
162
|
+
const result = await controllers.product.getWithVariants(
|
|
163
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
164
|
+
);
|
|
165
|
+
expect(result).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("list", () => {
|
|
170
|
+
it("returns products with default pagination", async () => {
|
|
171
|
+
const product = makeProduct();
|
|
172
|
+
await data.upsert("product", product.id, product);
|
|
173
|
+
|
|
174
|
+
const result = (await controllers.product.list(
|
|
175
|
+
makeControllerCtx(data),
|
|
176
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
177
|
+
)) as any;
|
|
178
|
+
expect(result.products).toHaveLength(1);
|
|
179
|
+
expect(result.page).toBe(1);
|
|
180
|
+
expect(result.limit).toBe(20);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("filters by status", async () => {
|
|
184
|
+
await data.upsert(
|
|
185
|
+
"product",
|
|
186
|
+
"p1",
|
|
187
|
+
makeProduct({ id: "p1", status: "active" }),
|
|
188
|
+
);
|
|
189
|
+
await data.upsert(
|
|
190
|
+
"product",
|
|
191
|
+
"p2",
|
|
192
|
+
makeProduct({ id: "p2", slug: "draft", status: "draft" }),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const result = (await controllers.product.list(
|
|
196
|
+
makeControllerCtx(data, { query: { status: "draft" } }),
|
|
197
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
198
|
+
)) as any;
|
|
199
|
+
expect(result.products).toHaveLength(1);
|
|
200
|
+
expect(result.products[0].status).toBe("draft");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("filters by featured", async () => {
|
|
204
|
+
await data.upsert(
|
|
205
|
+
"product",
|
|
206
|
+
"p1",
|
|
207
|
+
makeProduct({ id: "p1", isFeatured: true }),
|
|
208
|
+
);
|
|
209
|
+
await data.upsert(
|
|
210
|
+
"product",
|
|
211
|
+
"p2",
|
|
212
|
+
makeProduct({ id: "p2", slug: "p2", isFeatured: false }),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const result = (await controllers.product.list(
|
|
216
|
+
makeControllerCtx(data, { query: { featured: "true" } }),
|
|
217
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
218
|
+
)) as any;
|
|
219
|
+
expect(result.products).toHaveLength(1);
|
|
220
|
+
expect(result.products[0].isFeatured).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns total count of matching products", async () => {
|
|
224
|
+
for (let i = 1; i <= 5; i++) {
|
|
225
|
+
await data.upsert(
|
|
226
|
+
"product",
|
|
227
|
+
`p${i}`,
|
|
228
|
+
makeProduct({
|
|
229
|
+
id: `p${i}`,
|
|
230
|
+
slug: `product-${i}`,
|
|
231
|
+
name: `Product ${i}`,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = (await controllers.product.list(
|
|
237
|
+
makeControllerCtx(data, { query: { limit: "2" } }),
|
|
238
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
239
|
+
)) as any;
|
|
240
|
+
expect(result.products).toHaveLength(2);
|
|
241
|
+
expect(result.total).toBe(5);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("filters by minPrice", async () => {
|
|
245
|
+
await data.upsert(
|
|
246
|
+
"product",
|
|
247
|
+
"p1",
|
|
248
|
+
makeProduct({ id: "p1", slug: "cheap", price: 1000 }),
|
|
249
|
+
);
|
|
250
|
+
await data.upsert(
|
|
251
|
+
"product",
|
|
252
|
+
"p2",
|
|
253
|
+
makeProduct({ id: "p2", slug: "expensive", price: 5000 }),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const result = (await controllers.product.list(
|
|
257
|
+
makeControllerCtx(data, { query: { minPrice: "3000" } }),
|
|
258
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
259
|
+
)) as any;
|
|
260
|
+
expect(result.products).toHaveLength(1);
|
|
261
|
+
expect(result.products[0].price).toBe(5000);
|
|
262
|
+
expect(result.total).toBe(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("filters by maxPrice", async () => {
|
|
266
|
+
await data.upsert(
|
|
267
|
+
"product",
|
|
268
|
+
"p1",
|
|
269
|
+
makeProduct({ id: "p1", slug: "cheap", price: 1000 }),
|
|
270
|
+
);
|
|
271
|
+
await data.upsert(
|
|
272
|
+
"product",
|
|
273
|
+
"p2",
|
|
274
|
+
makeProduct({ id: "p2", slug: "expensive", price: 5000 }),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const result = (await controllers.product.list(
|
|
278
|
+
makeControllerCtx(data, { query: { maxPrice: "2000" } }),
|
|
279
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
280
|
+
)) as any;
|
|
281
|
+
expect(result.products).toHaveLength(1);
|
|
282
|
+
expect(result.products[0].price).toBe(1000);
|
|
283
|
+
expect(result.total).toBe(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("filters by price range (min and max)", async () => {
|
|
287
|
+
await data.upsert(
|
|
288
|
+
"product",
|
|
289
|
+
"p1",
|
|
290
|
+
makeProduct({ id: "p1", slug: "cheap", price: 500 }),
|
|
291
|
+
);
|
|
292
|
+
await data.upsert(
|
|
293
|
+
"product",
|
|
294
|
+
"p2",
|
|
295
|
+
makeProduct({ id: "p2", slug: "mid", price: 2500 }),
|
|
296
|
+
);
|
|
297
|
+
await data.upsert(
|
|
298
|
+
"product",
|
|
299
|
+
"p3",
|
|
300
|
+
makeProduct({ id: "p3", slug: "expensive", price: 9999 }),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const result = (await controllers.product.list(
|
|
304
|
+
makeControllerCtx(data, {
|
|
305
|
+
query: { minPrice: "1000", maxPrice: "5000" },
|
|
306
|
+
}),
|
|
307
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
308
|
+
)) as any;
|
|
309
|
+
expect(result.products).toHaveLength(1);
|
|
310
|
+
expect(result.products[0].id).toBe("p2");
|
|
311
|
+
expect(result.total).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("filters by inStock", async () => {
|
|
315
|
+
await data.upsert(
|
|
316
|
+
"product",
|
|
317
|
+
"p1",
|
|
318
|
+
makeProduct({ id: "p1", slug: "in-stock", inventory: 10 }),
|
|
319
|
+
);
|
|
320
|
+
await data.upsert(
|
|
321
|
+
"product",
|
|
322
|
+
"p2",
|
|
323
|
+
makeProduct({ id: "p2", slug: "out-of-stock", inventory: 0 }),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const result = (await controllers.product.list(
|
|
327
|
+
makeControllerCtx(data, { query: { inStock: "true" } }),
|
|
328
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
329
|
+
)) as any;
|
|
330
|
+
expect(result.products).toHaveLength(1);
|
|
331
|
+
expect(result.products[0].inventory).toBeGreaterThan(0);
|
|
332
|
+
expect(result.total).toBe(1);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("filters by tag", async () => {
|
|
336
|
+
await data.upsert(
|
|
337
|
+
"product",
|
|
338
|
+
"p1",
|
|
339
|
+
makeProduct({ id: "p1", slug: "sale-item", tags: ["sale", "new"] }),
|
|
340
|
+
);
|
|
341
|
+
await data.upsert(
|
|
342
|
+
"product",
|
|
343
|
+
"p2",
|
|
344
|
+
makeProduct({
|
|
345
|
+
id: "p2",
|
|
346
|
+
slug: "regular-item",
|
|
347
|
+
tags: ["featured"],
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const result = (await controllers.product.list(
|
|
352
|
+
makeControllerCtx(data, { query: { tag: "sale" } }),
|
|
353
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
354
|
+
)) as any;
|
|
355
|
+
expect(result.products).toHaveLength(1);
|
|
356
|
+
expect(result.products[0].id).toBe("p1");
|
|
357
|
+
expect(result.total).toBe(1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("tag filter is case-insensitive", async () => {
|
|
361
|
+
await data.upsert(
|
|
362
|
+
"product",
|
|
363
|
+
"p1",
|
|
364
|
+
makeProduct({ id: "p1", slug: "item", tags: ["Sale"] }),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const result = (await controllers.product.list(
|
|
368
|
+
makeControllerCtx(data, { query: { tag: "sale" } }),
|
|
369
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
370
|
+
)) as any;
|
|
371
|
+
expect(result.products).toHaveLength(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("filters by search text in name and description", async () => {
|
|
375
|
+
await data.upsert(
|
|
376
|
+
"product",
|
|
377
|
+
"p1",
|
|
378
|
+
makeProduct({
|
|
379
|
+
id: "p1",
|
|
380
|
+
slug: "p1",
|
|
381
|
+
name: "Blue Widget",
|
|
382
|
+
status: "active",
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
await data.upsert(
|
|
386
|
+
"product",
|
|
387
|
+
"p2",
|
|
388
|
+
makeProduct({
|
|
389
|
+
id: "p2",
|
|
390
|
+
slug: "p2",
|
|
391
|
+
name: "Red Gadget",
|
|
392
|
+
description: "A blue-tinted gadget",
|
|
393
|
+
status: "active",
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
await data.upsert(
|
|
397
|
+
"product",
|
|
398
|
+
"p3",
|
|
399
|
+
makeProduct({
|
|
400
|
+
id: "p3",
|
|
401
|
+
slug: "p3",
|
|
402
|
+
name: "Green Thing",
|
|
403
|
+
status: "active",
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const result = (await controllers.product.list(
|
|
408
|
+
makeControllerCtx(data, { query: { search: "blue" } }),
|
|
409
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
410
|
+
)) as any;
|
|
411
|
+
expect(result.products).toHaveLength(2);
|
|
412
|
+
expect(result.total).toBe(2);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("combines multiple filters", async () => {
|
|
416
|
+
await data.upsert(
|
|
417
|
+
"product",
|
|
418
|
+
"p1",
|
|
419
|
+
makeProduct({
|
|
420
|
+
id: "p1",
|
|
421
|
+
slug: "cheap-in-stock",
|
|
422
|
+
price: 1000,
|
|
423
|
+
inventory: 5,
|
|
424
|
+
tags: ["sale"],
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
await data.upsert(
|
|
428
|
+
"product",
|
|
429
|
+
"p2",
|
|
430
|
+
makeProduct({
|
|
431
|
+
id: "p2",
|
|
432
|
+
slug: "expensive-in-stock",
|
|
433
|
+
price: 9000,
|
|
434
|
+
inventory: 3,
|
|
435
|
+
tags: ["sale"],
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
await data.upsert(
|
|
439
|
+
"product",
|
|
440
|
+
"p3",
|
|
441
|
+
makeProduct({
|
|
442
|
+
id: "p3",
|
|
443
|
+
slug: "cheap-out-of-stock",
|
|
444
|
+
price: 1000,
|
|
445
|
+
inventory: 0,
|
|
446
|
+
tags: ["sale"],
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const result = (await controllers.product.list(
|
|
451
|
+
makeControllerCtx(data, {
|
|
452
|
+
query: { maxPrice: "5000", inStock: "true", tag: "sale" },
|
|
453
|
+
}),
|
|
454
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
455
|
+
)) as any;
|
|
456
|
+
expect(result.products).toHaveLength(1);
|
|
457
|
+
expect(result.products[0].id).toBe("p1");
|
|
458
|
+
expect(result.total).toBe(1);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe("search", () => {
|
|
463
|
+
it("matches by name", async () => {
|
|
464
|
+
await data.upsert(
|
|
465
|
+
"product",
|
|
466
|
+
"p1",
|
|
467
|
+
makeProduct({ id: "p1", name: "Blue Widget", status: "active" }),
|
|
468
|
+
);
|
|
469
|
+
await data.upsert(
|
|
470
|
+
"product",
|
|
471
|
+
"p2",
|
|
472
|
+
makeProduct({
|
|
473
|
+
id: "p2",
|
|
474
|
+
slug: "p2",
|
|
475
|
+
name: "Red Gadget",
|
|
476
|
+
status: "active",
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const result = (await controllers.product.search(
|
|
481
|
+
makeControllerCtx(data, { query: { q: "blue" } }),
|
|
482
|
+
)) as Product[];
|
|
483
|
+
expect(result).toHaveLength(1);
|
|
484
|
+
expect(result[0].name).toBe("Blue Widget");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("matches by tag", async () => {
|
|
488
|
+
await data.upsert(
|
|
489
|
+
"product",
|
|
490
|
+
"p1",
|
|
491
|
+
makeProduct({ id: "p1", tags: ["sale", "featured"], status: "active" }),
|
|
492
|
+
);
|
|
493
|
+
await data.upsert(
|
|
494
|
+
"product",
|
|
495
|
+
"p2",
|
|
496
|
+
makeProduct({ id: "p2", slug: "p2", tags: [], status: "active" }),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const result = (await controllers.product.search(
|
|
500
|
+
makeControllerCtx(data, { query: { q: "sale" } }),
|
|
501
|
+
)) as Product[];
|
|
502
|
+
expect(result).toHaveLength(1);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("limits results", async () => {
|
|
506
|
+
for (let i = 1; i <= 5; i++) {
|
|
507
|
+
await data.upsert(
|
|
508
|
+
"product",
|
|
509
|
+
`p${i}`,
|
|
510
|
+
makeProduct({
|
|
511
|
+
id: `p${i}`,
|
|
512
|
+
slug: `widget-${i}`,
|
|
513
|
+
name: `Widget ${i}`,
|
|
514
|
+
status: "active",
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const result = (await controllers.product.search(
|
|
520
|
+
makeControllerCtx(data, { query: { q: "widget", limit: "3" } }),
|
|
521
|
+
)) as Product[];
|
|
522
|
+
expect(result).toHaveLength(3);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe("getFeatured", () => {
|
|
527
|
+
it("returns only featured active products", async () => {
|
|
528
|
+
await data.upsert(
|
|
529
|
+
"product",
|
|
530
|
+
"p1",
|
|
531
|
+
makeProduct({ id: "p1", isFeatured: true, status: "active" }),
|
|
532
|
+
);
|
|
533
|
+
await data.upsert(
|
|
534
|
+
"product",
|
|
535
|
+
"p2",
|
|
536
|
+
makeProduct({
|
|
537
|
+
id: "p2",
|
|
538
|
+
slug: "p2",
|
|
539
|
+
isFeatured: false,
|
|
540
|
+
status: "active",
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
await data.upsert(
|
|
544
|
+
"product",
|
|
545
|
+
"p3",
|
|
546
|
+
makeProduct({
|
|
547
|
+
id: "p3",
|
|
548
|
+
slug: "p3",
|
|
549
|
+
isFeatured: true,
|
|
550
|
+
status: "draft",
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const result = (await controllers.product.getFeatured(
|
|
555
|
+
makeControllerCtx(data),
|
|
556
|
+
)) as Product[];
|
|
557
|
+
expect(result).toHaveLength(1);
|
|
558
|
+
expect(result[0].id).toBe("p1");
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe("getRelated", () => {
|
|
563
|
+
it("returns products from the same category first", async () => {
|
|
564
|
+
await data.upsert(
|
|
565
|
+
"product",
|
|
566
|
+
"target",
|
|
567
|
+
makeProduct({
|
|
568
|
+
id: "target",
|
|
569
|
+
slug: "target",
|
|
570
|
+
categoryId: "cat_1",
|
|
571
|
+
tags: [],
|
|
572
|
+
status: "active",
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
await data.upsert(
|
|
576
|
+
"product",
|
|
577
|
+
"same-cat",
|
|
578
|
+
makeProduct({
|
|
579
|
+
id: "same-cat",
|
|
580
|
+
slug: "same-cat",
|
|
581
|
+
categoryId: "cat_1",
|
|
582
|
+
tags: [],
|
|
583
|
+
status: "active",
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
await data.upsert(
|
|
587
|
+
"product",
|
|
588
|
+
"diff-cat",
|
|
589
|
+
makeProduct({
|
|
590
|
+
id: "diff-cat",
|
|
591
|
+
slug: "diff-cat",
|
|
592
|
+
categoryId: "cat_2",
|
|
593
|
+
tags: [],
|
|
594
|
+
status: "active",
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const result = (await controllers.product.getRelated(
|
|
599
|
+
makeControllerCtx(data, { params: { id: "target" } }),
|
|
600
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
601
|
+
)) as any;
|
|
602
|
+
expect(result.products).toHaveLength(2);
|
|
603
|
+
expect(result.products[0].id).toBe("same-cat");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("returns products with shared tags", async () => {
|
|
607
|
+
await data.upsert(
|
|
608
|
+
"product",
|
|
609
|
+
"target",
|
|
610
|
+
makeProduct({
|
|
611
|
+
id: "target",
|
|
612
|
+
slug: "target",
|
|
613
|
+
tags: ["electronics", "sale"],
|
|
614
|
+
status: "active",
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
await data.upsert(
|
|
618
|
+
"product",
|
|
619
|
+
"shared-2",
|
|
620
|
+
makeProduct({
|
|
621
|
+
id: "shared-2",
|
|
622
|
+
slug: "shared-2",
|
|
623
|
+
tags: ["electronics", "sale"],
|
|
624
|
+
status: "active",
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
await data.upsert(
|
|
628
|
+
"product",
|
|
629
|
+
"shared-1",
|
|
630
|
+
makeProduct({
|
|
631
|
+
id: "shared-1",
|
|
632
|
+
slug: "shared-1",
|
|
633
|
+
tags: ["electronics"],
|
|
634
|
+
status: "active",
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
await data.upsert(
|
|
638
|
+
"product",
|
|
639
|
+
"no-tags",
|
|
640
|
+
makeProduct({
|
|
641
|
+
id: "no-tags",
|
|
642
|
+
slug: "no-tags",
|
|
643
|
+
tags: [],
|
|
644
|
+
status: "active",
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const result = (await controllers.product.getRelated(
|
|
649
|
+
makeControllerCtx(data, { params: { id: "target" } }),
|
|
650
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
651
|
+
)) as any;
|
|
652
|
+
expect(result.products).toHaveLength(3);
|
|
653
|
+
// shared-2 has 2 shared tags, shared-1 has 1
|
|
654
|
+
expect(result.products[0].id).toBe("shared-2");
|
|
655
|
+
expect(result.products[1].id).toBe("shared-1");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("excludes the current product from results", async () => {
|
|
659
|
+
await data.upsert(
|
|
660
|
+
"product",
|
|
661
|
+
"target",
|
|
662
|
+
makeProduct({
|
|
663
|
+
id: "target",
|
|
664
|
+
slug: "target",
|
|
665
|
+
tags: ["sale"],
|
|
666
|
+
status: "active",
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const result = (await controllers.product.getRelated(
|
|
671
|
+
makeControllerCtx(data, { params: { id: "target" } }),
|
|
672
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
673
|
+
)) as any;
|
|
674
|
+
expect(result.products).toHaveLength(0);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("respects the limit parameter", async () => {
|
|
678
|
+
await data.upsert(
|
|
679
|
+
"product",
|
|
680
|
+
"target",
|
|
681
|
+
makeProduct({
|
|
682
|
+
id: "target",
|
|
683
|
+
slug: "target",
|
|
684
|
+
categoryId: "cat_1",
|
|
685
|
+
status: "active",
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
for (let i = 1; i <= 6; i++) {
|
|
689
|
+
await data.upsert(
|
|
690
|
+
"product",
|
|
691
|
+
`rel${i}`,
|
|
692
|
+
makeProduct({
|
|
693
|
+
id: `rel${i}`,
|
|
694
|
+
slug: `rel-${i}`,
|
|
695
|
+
categoryId: "cat_1",
|
|
696
|
+
status: "active",
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const result = (await controllers.product.getRelated(
|
|
702
|
+
makeControllerCtx(data, {
|
|
703
|
+
params: { id: "target" },
|
|
704
|
+
query: { limit: "2" },
|
|
705
|
+
}),
|
|
706
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
707
|
+
)) as any;
|
|
708
|
+
expect(result.products).toHaveLength(2);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("returns empty products when product not found", async () => {
|
|
712
|
+
const result = (await controllers.product.getRelated(
|
|
713
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
714
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
715
|
+
)) as any;
|
|
716
|
+
expect(result.products).toEqual([]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("only returns active products", async () => {
|
|
720
|
+
await data.upsert(
|
|
721
|
+
"product",
|
|
722
|
+
"target",
|
|
723
|
+
makeProduct({
|
|
724
|
+
id: "target",
|
|
725
|
+
slug: "target",
|
|
726
|
+
categoryId: "cat_1",
|
|
727
|
+
status: "active",
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
await data.upsert(
|
|
731
|
+
"product",
|
|
732
|
+
"draft",
|
|
733
|
+
makeProduct({
|
|
734
|
+
id: "draft",
|
|
735
|
+
slug: "draft",
|
|
736
|
+
categoryId: "cat_1",
|
|
737
|
+
status: "draft",
|
|
738
|
+
}),
|
|
739
|
+
);
|
|
740
|
+
await data.upsert(
|
|
741
|
+
"product",
|
|
742
|
+
"active",
|
|
743
|
+
makeProduct({
|
|
744
|
+
id: "active",
|
|
745
|
+
slug: "active",
|
|
746
|
+
categoryId: "cat_1",
|
|
747
|
+
status: "active",
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const result = (await controllers.product.getRelated(
|
|
752
|
+
makeControllerCtx(data, { params: { id: "target" } }),
|
|
753
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
754
|
+
)) as any;
|
|
755
|
+
expect(result.products).toHaveLength(1);
|
|
756
|
+
expect(result.products[0].id).toBe("active");
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe("create", () => {
|
|
761
|
+
it("creates a new product with defaults", async () => {
|
|
762
|
+
const result = (await controllers.product.create(
|
|
763
|
+
makeControllerCtx(data, {
|
|
764
|
+
body: { name: "New Product", slug: "new-product", price: 1999 },
|
|
765
|
+
}),
|
|
766
|
+
)) as Product;
|
|
767
|
+
|
|
768
|
+
expect(result.name).toBe("New Product");
|
|
769
|
+
expect(result.slug).toBe("new-product");
|
|
770
|
+
expect(result.price).toBe(1999);
|
|
771
|
+
expect(result.status).toBe("draft");
|
|
772
|
+
expect(result.inventory).toBe(0);
|
|
773
|
+
expect(result.isFeatured).toBe(false);
|
|
774
|
+
expect(result.images).toEqual([]);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("stores the product in the data service", async () => {
|
|
778
|
+
const result = (await controllers.product.create(
|
|
779
|
+
makeControllerCtx(data, {
|
|
780
|
+
body: { name: "Stored Product", slug: "stored", price: 999 },
|
|
781
|
+
}),
|
|
782
|
+
)) as Product;
|
|
783
|
+
|
|
784
|
+
const stored = await data.get("product", result.id);
|
|
785
|
+
expect(stored).toMatchObject({ name: "Stored Product" });
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
describe("update", () => {
|
|
790
|
+
it("updates an existing product", async () => {
|
|
791
|
+
const product = makeProduct();
|
|
792
|
+
await data.upsert("product", product.id, product);
|
|
793
|
+
|
|
794
|
+
const result = (await controllers.product.update(
|
|
795
|
+
makeControllerCtx(data, {
|
|
796
|
+
params: { id: "prod_1" },
|
|
797
|
+
body: { price: 3999, status: "active" },
|
|
798
|
+
}),
|
|
799
|
+
)) as Product;
|
|
800
|
+
|
|
801
|
+
expect(result.price).toBe(3999);
|
|
802
|
+
expect(result.status).toBe("active");
|
|
803
|
+
expect(result.name).toBe("Test Product"); // unchanged
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("throws when product not found", async () => {
|
|
807
|
+
await expect(
|
|
808
|
+
controllers.product.update(
|
|
809
|
+
makeControllerCtx(data, {
|
|
810
|
+
params: { id: "missing" },
|
|
811
|
+
body: { price: 100 },
|
|
812
|
+
}),
|
|
813
|
+
),
|
|
814
|
+
).rejects.toThrow("Product missing not found");
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
describe("delete", () => {
|
|
819
|
+
it("deletes product and its variants", async () => {
|
|
820
|
+
const product = makeProduct();
|
|
821
|
+
const variant = makeVariant();
|
|
822
|
+
await data.upsert("product", product.id, product);
|
|
823
|
+
await data.upsert("productVariant", variant.id, variant);
|
|
824
|
+
|
|
825
|
+
const result = (await controllers.product.delete(
|
|
826
|
+
makeControllerCtx(data, { params: { id: "prod_1" } }),
|
|
827
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
828
|
+
)) as any;
|
|
829
|
+
expect(result.success).toBe(true);
|
|
830
|
+
expect(await data.get("product", "prod_1")).toBeNull();
|
|
831
|
+
expect(await data.get("productVariant", "var_1")).toBeNull();
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
describe("checkAvailability", () => {
|
|
836
|
+
it("returns available when inventory is sufficient", async () => {
|
|
837
|
+
await data.upsert(
|
|
838
|
+
"product",
|
|
839
|
+
"p1",
|
|
840
|
+
makeProduct({ id: "p1", inventory: 10, trackInventory: true }),
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const result = (await controllers.product.checkAvailability(
|
|
844
|
+
makeControllerCtx(data, {
|
|
845
|
+
query: { productId: "p1", quantity: "3" },
|
|
846
|
+
}),
|
|
847
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
848
|
+
)) as any;
|
|
849
|
+
expect(result.available).toBe(true);
|
|
850
|
+
expect(result.inventory).toBe(10);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("returns not available when inventory is too low", async () => {
|
|
854
|
+
await data.upsert(
|
|
855
|
+
"product",
|
|
856
|
+
"p1",
|
|
857
|
+
makeProduct({ id: "p1", inventory: 2, trackInventory: true }),
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
const result = (await controllers.product.checkAvailability(
|
|
861
|
+
makeControllerCtx(data, {
|
|
862
|
+
query: { productId: "p1", quantity: "5" },
|
|
863
|
+
}),
|
|
864
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
865
|
+
)) as any;
|
|
866
|
+
expect(result.available).toBe(false);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("always available when trackInventory is false", async () => {
|
|
870
|
+
await data.upsert(
|
|
871
|
+
"product",
|
|
872
|
+
"p1",
|
|
873
|
+
makeProduct({ id: "p1", inventory: 0, trackInventory: false }),
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
const result = (await controllers.product.checkAvailability(
|
|
877
|
+
makeControllerCtx(data, {
|
|
878
|
+
query: { productId: "p1", quantity: "100" },
|
|
879
|
+
}),
|
|
880
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
881
|
+
)) as any;
|
|
882
|
+
expect(result.available).toBe(true);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("available when backorder is allowed", async () => {
|
|
886
|
+
await data.upsert(
|
|
887
|
+
"product",
|
|
888
|
+
"p1",
|
|
889
|
+
makeProduct({
|
|
890
|
+
id: "p1",
|
|
891
|
+
inventory: 0,
|
|
892
|
+
trackInventory: true,
|
|
893
|
+
allowBackorder: true,
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
const result = (await controllers.product.checkAvailability(
|
|
898
|
+
makeControllerCtx(data, {
|
|
899
|
+
query: { productId: "p1", quantity: "5" },
|
|
900
|
+
}),
|
|
901
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
902
|
+
)) as any;
|
|
903
|
+
expect(result.available).toBe(true);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("checks variant inventory", async () => {
|
|
907
|
+
await data.upsert("product", "p1", makeProduct({ id: "p1" }));
|
|
908
|
+
await data.upsert(
|
|
909
|
+
"productVariant",
|
|
910
|
+
"var_1",
|
|
911
|
+
makeVariant({ id: "var_1", inventory: 3 }),
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
const result = (await controllers.product.checkAvailability(
|
|
915
|
+
makeControllerCtx(data, {
|
|
916
|
+
query: { productId: "p1", variantId: "var_1", quantity: "2" },
|
|
917
|
+
}),
|
|
918
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
919
|
+
)) as any;
|
|
920
|
+
expect(result.available).toBe(true);
|
|
921
|
+
expect(result.inventory).toBe(3);
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
describe("decrementInventory", () => {
|
|
926
|
+
it("decrements product inventory", async () => {
|
|
927
|
+
await data.upsert(
|
|
928
|
+
"product",
|
|
929
|
+
"p1",
|
|
930
|
+
makeProduct({ id: "p1", inventory: 10 }),
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
await controllers.product.decrementInventory(
|
|
934
|
+
makeControllerCtx(data, {
|
|
935
|
+
params: { productId: "p1" },
|
|
936
|
+
body: { quantity: 3 },
|
|
937
|
+
}),
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const updated = (await data.get("product", "p1")) as Product;
|
|
941
|
+
expect(updated.inventory).toBe(7);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("decrements variant inventory", async () => {
|
|
945
|
+
await data.upsert("product", "p1", makeProduct({ id: "p1" }));
|
|
946
|
+
await data.upsert(
|
|
947
|
+
"productVariant",
|
|
948
|
+
"var_1",
|
|
949
|
+
makeVariant({ id: "var_1", inventory: 8 }),
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
await controllers.product.decrementInventory(
|
|
953
|
+
makeControllerCtx(data, {
|
|
954
|
+
params: { productId: "p1", variantId: "var_1" },
|
|
955
|
+
body: { quantity: 2 },
|
|
956
|
+
}),
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
const updated = (await data.get(
|
|
960
|
+
"productVariant",
|
|
961
|
+
"var_1",
|
|
962
|
+
)) as ProductVariant;
|
|
963
|
+
expect(updated.inventory).toBe(6);
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
describe("incrementInventory", () => {
|
|
968
|
+
it("increments product inventory", async () => {
|
|
969
|
+
await data.upsert(
|
|
970
|
+
"product",
|
|
971
|
+
"p1",
|
|
972
|
+
makeProduct({ id: "p1", inventory: 5 }),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
await controllers.product.incrementInventory(
|
|
976
|
+
makeControllerCtx(data, {
|
|
977
|
+
params: { productId: "p1" },
|
|
978
|
+
body: { quantity: 10 },
|
|
979
|
+
}),
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
const updated = (await data.get("product", "p1")) as Product;
|
|
983
|
+
expect(updated.inventory).toBe(15);
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// ── Variant controllers ────────────────────────────────────────────────────
|
|
989
|
+
|
|
990
|
+
describe("variant controllers", () => {
|
|
991
|
+
let data: ReturnType<typeof createMockDataService>;
|
|
992
|
+
|
|
993
|
+
beforeEach(() => {
|
|
994
|
+
data = createMockDataService();
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
describe("getById", () => {
|
|
998
|
+
it("returns variant when found", async () => {
|
|
999
|
+
const variant = makeVariant();
|
|
1000
|
+
await data.upsert("productVariant", variant.id, variant);
|
|
1001
|
+
|
|
1002
|
+
const result = await controllers.variant.getById(
|
|
1003
|
+
makeControllerCtx(data, { params: { id: "var_1" } }),
|
|
1004
|
+
);
|
|
1005
|
+
expect(result).toMatchObject({ id: "var_1" });
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("returns null when not found", async () => {
|
|
1009
|
+
const result = await controllers.variant.getById(
|
|
1010
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
1011
|
+
);
|
|
1012
|
+
expect(result).toBeNull();
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
describe("getByProduct", () => {
|
|
1017
|
+
it("returns variants sorted by position", async () => {
|
|
1018
|
+
await data.upsert(
|
|
1019
|
+
"productVariant",
|
|
1020
|
+
"v1",
|
|
1021
|
+
makeVariant({ id: "v1", position: 2 }),
|
|
1022
|
+
);
|
|
1023
|
+
await data.upsert(
|
|
1024
|
+
"productVariant",
|
|
1025
|
+
"v2",
|
|
1026
|
+
makeVariant({ id: "v2", position: 0 }),
|
|
1027
|
+
);
|
|
1028
|
+
await data.upsert(
|
|
1029
|
+
"productVariant",
|
|
1030
|
+
"v3",
|
|
1031
|
+
makeVariant({ id: "v3", position: 1 }),
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
const result = (await controllers.variant.getByProduct(
|
|
1035
|
+
makeControllerCtx(data, { params: { productId: "prod_1" } }),
|
|
1036
|
+
)) as ProductVariant[];
|
|
1037
|
+
expect(result.map((v) => v.position)).toEqual([0, 1, 2]);
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
describe("create", () => {
|
|
1042
|
+
it("creates a variant with defaults", async () => {
|
|
1043
|
+
const product = makeProduct();
|
|
1044
|
+
await data.upsert("product", product.id, product);
|
|
1045
|
+
|
|
1046
|
+
const result = (await controllers.variant.create(
|
|
1047
|
+
makeControllerCtx(data, {
|
|
1048
|
+
params: { productId: "prod_1" },
|
|
1049
|
+
body: { name: "Large", price: 3499, options: { size: "L" } },
|
|
1050
|
+
}),
|
|
1051
|
+
)) as ProductVariant;
|
|
1052
|
+
|
|
1053
|
+
expect(result.productId).toBe("prod_1");
|
|
1054
|
+
expect(result.name).toBe("Large");
|
|
1055
|
+
expect(result.inventory).toBe(0);
|
|
1056
|
+
expect(result.position).toBe(0);
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
describe("update", () => {
|
|
1061
|
+
it("updates variant and product timestamp", async () => {
|
|
1062
|
+
const product = makeProduct();
|
|
1063
|
+
const variant = makeVariant();
|
|
1064
|
+
await data.upsert("product", product.id, product);
|
|
1065
|
+
await data.upsert("productVariant", variant.id, variant);
|
|
1066
|
+
|
|
1067
|
+
const result = (await controllers.variant.update(
|
|
1068
|
+
makeControllerCtx(data, {
|
|
1069
|
+
params: { id: "var_1" },
|
|
1070
|
+
body: { price: 5999 },
|
|
1071
|
+
}),
|
|
1072
|
+
)) as ProductVariant;
|
|
1073
|
+
|
|
1074
|
+
expect(result.price).toBe(5999);
|
|
1075
|
+
expect(result.name).toBe("Default"); // unchanged
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("throws when variant not found", async () => {
|
|
1079
|
+
await expect(
|
|
1080
|
+
controllers.variant.update(
|
|
1081
|
+
makeControllerCtx(data, {
|
|
1082
|
+
params: { id: "missing" },
|
|
1083
|
+
body: { price: 100 },
|
|
1084
|
+
}),
|
|
1085
|
+
),
|
|
1086
|
+
).rejects.toThrow("Variant missing not found");
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
describe("delete", () => {
|
|
1091
|
+
it("deletes variant and updates product timestamp", async () => {
|
|
1092
|
+
const product = makeProduct();
|
|
1093
|
+
const variant = makeVariant();
|
|
1094
|
+
await data.upsert("product", product.id, product);
|
|
1095
|
+
await data.upsert("productVariant", variant.id, variant);
|
|
1096
|
+
|
|
1097
|
+
const result = (await controllers.variant.delete(
|
|
1098
|
+
makeControllerCtx(data, { params: { id: "var_1" } }),
|
|
1099
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1100
|
+
)) as any;
|
|
1101
|
+
expect(result.success).toBe(true);
|
|
1102
|
+
expect(await data.get("productVariant", "var_1")).toBeNull();
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// ── Category controllers ───────────────────────────────────────────────────
|
|
1108
|
+
|
|
1109
|
+
describe("category controllers", () => {
|
|
1110
|
+
let data: ReturnType<typeof createMockDataService>;
|
|
1111
|
+
|
|
1112
|
+
beforeEach(() => {
|
|
1113
|
+
data = createMockDataService();
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe("getById", () => {
|
|
1117
|
+
it("returns category when found", async () => {
|
|
1118
|
+
const category = makeCategory();
|
|
1119
|
+
await data.upsert("category", category.id, category);
|
|
1120
|
+
|
|
1121
|
+
const result = await controllers.category.getById(
|
|
1122
|
+
makeControllerCtx(data, { params: { id: "cat_1" } }),
|
|
1123
|
+
);
|
|
1124
|
+
expect(result).toMatchObject({ id: "cat_1", name: "Electronics" });
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
describe("getBySlug", () => {
|
|
1129
|
+
it("returns category matching slug", async () => {
|
|
1130
|
+
const category = makeCategory();
|
|
1131
|
+
await data.upsert("category", category.id, category);
|
|
1132
|
+
|
|
1133
|
+
const result = await controllers.category.getBySlug(
|
|
1134
|
+
makeControllerCtx(data, { query: { slug: "electronics" } }),
|
|
1135
|
+
);
|
|
1136
|
+
expect(result).toMatchObject({ slug: "electronics" });
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("returns null when not found", async () => {
|
|
1140
|
+
const result = await controllers.category.getBySlug(
|
|
1141
|
+
makeControllerCtx(data, { query: { slug: "missing" } }),
|
|
1142
|
+
);
|
|
1143
|
+
expect(result).toBeNull();
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
describe("list", () => {
|
|
1148
|
+
it("returns categories sorted by position", async () => {
|
|
1149
|
+
await data.upsert(
|
|
1150
|
+
"category",
|
|
1151
|
+
"c1",
|
|
1152
|
+
makeCategory({ id: "c1", position: 2 }),
|
|
1153
|
+
);
|
|
1154
|
+
await data.upsert(
|
|
1155
|
+
"category",
|
|
1156
|
+
"c2",
|
|
1157
|
+
makeCategory({ id: "c2", slug: "c2", position: 0 }),
|
|
1158
|
+
);
|
|
1159
|
+
await data.upsert(
|
|
1160
|
+
"category",
|
|
1161
|
+
"c3",
|
|
1162
|
+
makeCategory({ id: "c3", slug: "c3", position: 1 }),
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
const result = (await controllers.category.list(
|
|
1166
|
+
makeControllerCtx(data),
|
|
1167
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1168
|
+
)) as any;
|
|
1169
|
+
expect(result.categories.map((c: Category) => c.position)).toEqual([
|
|
1170
|
+
0, 1, 2,
|
|
1171
|
+
]);
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
describe("getTree", () => {
|
|
1176
|
+
it("builds tree structure from flat categories", async () => {
|
|
1177
|
+
await data.upsert(
|
|
1178
|
+
"category",
|
|
1179
|
+
"root",
|
|
1180
|
+
makeCategory({ id: "root", isVisible: true }),
|
|
1181
|
+
);
|
|
1182
|
+
await data.upsert(
|
|
1183
|
+
"category",
|
|
1184
|
+
"child",
|
|
1185
|
+
makeCategory({
|
|
1186
|
+
id: "child",
|
|
1187
|
+
slug: "child",
|
|
1188
|
+
parentId: "root",
|
|
1189
|
+
isVisible: true,
|
|
1190
|
+
}),
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
const tree = (await controllers.category.getTree(
|
|
1194
|
+
makeControllerCtx(data),
|
|
1195
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1196
|
+
)) as any[];
|
|
1197
|
+
expect(tree).toHaveLength(1);
|
|
1198
|
+
expect(tree[0].id).toBe("root");
|
|
1199
|
+
expect(tree[0].children).toHaveLength(1);
|
|
1200
|
+
expect(tree[0].children[0].id).toBe("child");
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it("only includes visible categories", async () => {
|
|
1204
|
+
await data.upsert(
|
|
1205
|
+
"category",
|
|
1206
|
+
"c1",
|
|
1207
|
+
makeCategory({ id: "c1", isVisible: true }),
|
|
1208
|
+
);
|
|
1209
|
+
await data.upsert(
|
|
1210
|
+
"category",
|
|
1211
|
+
"c2",
|
|
1212
|
+
makeCategory({ id: "c2", slug: "c2", isVisible: false }),
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
const tree = (await controllers.category.getTree(
|
|
1216
|
+
makeControllerCtx(data),
|
|
1217
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1218
|
+
)) as any[];
|
|
1219
|
+
expect(tree).toHaveLength(1);
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
describe("create", () => {
|
|
1224
|
+
it("creates a category with defaults", async () => {
|
|
1225
|
+
const result = (await controllers.category.create(
|
|
1226
|
+
makeControllerCtx(data, {
|
|
1227
|
+
body: { name: "New Category", slug: "new-cat" },
|
|
1228
|
+
}),
|
|
1229
|
+
)) as Category;
|
|
1230
|
+
|
|
1231
|
+
expect(result.name).toBe("New Category");
|
|
1232
|
+
expect(result.slug).toBe("new-cat");
|
|
1233
|
+
expect(result.position).toBe(0);
|
|
1234
|
+
expect(result.isVisible).toBe(true);
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
describe("update", () => {
|
|
1239
|
+
it("updates a category", async () => {
|
|
1240
|
+
const category = makeCategory();
|
|
1241
|
+
await data.upsert("category", category.id, category);
|
|
1242
|
+
|
|
1243
|
+
const result = (await controllers.category.update(
|
|
1244
|
+
makeControllerCtx(data, {
|
|
1245
|
+
params: { id: "cat_1" },
|
|
1246
|
+
body: { name: "Updated Name" },
|
|
1247
|
+
}),
|
|
1248
|
+
)) as Category;
|
|
1249
|
+
|
|
1250
|
+
expect(result.name).toBe("Updated Name");
|
|
1251
|
+
expect(result.slug).toBe("electronics"); // unchanged
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it("throws when category not found", async () => {
|
|
1255
|
+
await expect(
|
|
1256
|
+
controllers.category.update(
|
|
1257
|
+
makeControllerCtx(data, {
|
|
1258
|
+
params: { id: "missing" },
|
|
1259
|
+
body: { name: "X" },
|
|
1260
|
+
}),
|
|
1261
|
+
),
|
|
1262
|
+
).rejects.toThrow("Category missing not found");
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
describe("delete", () => {
|
|
1267
|
+
it("unlinks products before deleting category", async () => {
|
|
1268
|
+
const category = makeCategory();
|
|
1269
|
+
const product = makeProduct({ categoryId: "cat_1" });
|
|
1270
|
+
await data.upsert("category", category.id, category);
|
|
1271
|
+
await data.upsert("product", product.id, product);
|
|
1272
|
+
|
|
1273
|
+
const result = (await controllers.category.delete(
|
|
1274
|
+
makeControllerCtx(data, { params: { id: "cat_1" } }),
|
|
1275
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1276
|
+
)) as any;
|
|
1277
|
+
expect(result.success).toBe(true);
|
|
1278
|
+
|
|
1279
|
+
const updatedProduct = (await data.get("product", "prod_1")) as Product;
|
|
1280
|
+
expect(updatedProduct.categoryId).toBeUndefined();
|
|
1281
|
+
expect(await data.get("category", "cat_1")).toBeNull();
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
it("unlinks subcategories before deleting", async () => {
|
|
1285
|
+
const parent = makeCategory({ id: "parent" });
|
|
1286
|
+
const child = makeCategory({
|
|
1287
|
+
id: "child",
|
|
1288
|
+
slug: "child",
|
|
1289
|
+
parentId: "parent",
|
|
1290
|
+
});
|
|
1291
|
+
await data.upsert("category", parent.id, parent);
|
|
1292
|
+
await data.upsert("category", child.id, child);
|
|
1293
|
+
|
|
1294
|
+
await controllers.category.delete(
|
|
1295
|
+
makeControllerCtx(data, { params: { id: "parent" } }),
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
const updatedChild = (await data.get("category", "child")) as Category;
|
|
1299
|
+
expect(updatedChild.parentId).toBeUndefined();
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// ── Collection controllers ──────────────────────────────────────────────
|
|
1305
|
+
|
|
1306
|
+
function makeCollection(overrides: Partial<Collection> = {}): Collection {
|
|
1307
|
+
const now = new Date();
|
|
1308
|
+
return {
|
|
1309
|
+
id: "col_1",
|
|
1310
|
+
name: "Summer Sale",
|
|
1311
|
+
slug: "summer-sale",
|
|
1312
|
+
isFeatured: false,
|
|
1313
|
+
isVisible: true,
|
|
1314
|
+
position: 0,
|
|
1315
|
+
createdAt: now,
|
|
1316
|
+
updatedAt: now,
|
|
1317
|
+
...overrides,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
describe("collection controllers", () => {
|
|
1322
|
+
let data: ReturnType<typeof createMockDataService>;
|
|
1323
|
+
|
|
1324
|
+
beforeEach(() => {
|
|
1325
|
+
data = createMockDataService();
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
describe("getById", () => {
|
|
1329
|
+
it("returns collection when found", async () => {
|
|
1330
|
+
const col = makeCollection();
|
|
1331
|
+
await data.upsert("collection", col.id, col);
|
|
1332
|
+
|
|
1333
|
+
const result = await controllers.collection.getById(
|
|
1334
|
+
makeControllerCtx(data, { params: { id: "col_1" } }),
|
|
1335
|
+
);
|
|
1336
|
+
expect(result).toMatchObject({ id: "col_1", name: "Summer Sale" });
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("returns null when not found", async () => {
|
|
1340
|
+
const result = await controllers.collection.getById(
|
|
1341
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
1342
|
+
);
|
|
1343
|
+
expect(result).toBeNull();
|
|
1344
|
+
});
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
describe("getBySlug", () => {
|
|
1348
|
+
it("returns collection matching slug", async () => {
|
|
1349
|
+
const col = makeCollection();
|
|
1350
|
+
await data.upsert("collection", col.id, col);
|
|
1351
|
+
|
|
1352
|
+
const result = await controllers.collection.getBySlug(
|
|
1353
|
+
makeControllerCtx(data, { query: { slug: "summer-sale" } }),
|
|
1354
|
+
);
|
|
1355
|
+
expect(result).toMatchObject({ slug: "summer-sale" });
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it("returns null when slug not found", async () => {
|
|
1359
|
+
const result = await controllers.collection.getBySlug(
|
|
1360
|
+
makeControllerCtx(data, { query: { slug: "missing" } }),
|
|
1361
|
+
);
|
|
1362
|
+
expect(result).toBeNull();
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
describe("list", () => {
|
|
1367
|
+
it("returns collections sorted by position", async () => {
|
|
1368
|
+
await data.upsert(
|
|
1369
|
+
"collection",
|
|
1370
|
+
"c1",
|
|
1371
|
+
makeCollection({ id: "c1", position: 2 }),
|
|
1372
|
+
);
|
|
1373
|
+
await data.upsert(
|
|
1374
|
+
"collection",
|
|
1375
|
+
"c2",
|
|
1376
|
+
makeCollection({ id: "c2", slug: "c2", position: 0 }),
|
|
1377
|
+
);
|
|
1378
|
+
await data.upsert(
|
|
1379
|
+
"collection",
|
|
1380
|
+
"c3",
|
|
1381
|
+
makeCollection({ id: "c3", slug: "c3", position: 1 }),
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
const result = (await controllers.collection.list(
|
|
1385
|
+
makeControllerCtx(data),
|
|
1386
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1387
|
+
)) as any;
|
|
1388
|
+
expect(result.collections).toHaveLength(3);
|
|
1389
|
+
expect(result.collections.map((c: Collection) => c.position)).toEqual([
|
|
1390
|
+
0, 1, 2,
|
|
1391
|
+
]);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it("filters by featured", async () => {
|
|
1395
|
+
await data.upsert(
|
|
1396
|
+
"collection",
|
|
1397
|
+
"c1",
|
|
1398
|
+
makeCollection({ id: "c1", isFeatured: true }),
|
|
1399
|
+
);
|
|
1400
|
+
await data.upsert(
|
|
1401
|
+
"collection",
|
|
1402
|
+
"c2",
|
|
1403
|
+
makeCollection({ id: "c2", slug: "c2", isFeatured: false }),
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
const result = (await controllers.collection.list(
|
|
1407
|
+
makeControllerCtx(data, { query: { featured: "true" } }),
|
|
1408
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1409
|
+
)) as any;
|
|
1410
|
+
expect(result.collections).toHaveLength(1);
|
|
1411
|
+
expect(result.collections[0].isFeatured).toBe(true);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it("filters by visible", async () => {
|
|
1415
|
+
await data.upsert(
|
|
1416
|
+
"collection",
|
|
1417
|
+
"c1",
|
|
1418
|
+
makeCollection({ id: "c1", isVisible: true }),
|
|
1419
|
+
);
|
|
1420
|
+
await data.upsert(
|
|
1421
|
+
"collection",
|
|
1422
|
+
"c2",
|
|
1423
|
+
makeCollection({ id: "c2", slug: "c2", isVisible: false }),
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
const result = (await controllers.collection.list(
|
|
1427
|
+
makeControllerCtx(data, { query: { visible: "true" } }),
|
|
1428
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1429
|
+
)) as any;
|
|
1430
|
+
expect(result.collections).toHaveLength(1);
|
|
1431
|
+
expect(result.collections[0].isVisible).toBe(true);
|
|
1432
|
+
});
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
describe("getWithProducts", () => {
|
|
1436
|
+
it("returns collection with linked active products", async () => {
|
|
1437
|
+
const col = makeCollection();
|
|
1438
|
+
await data.upsert("collection", col.id, col);
|
|
1439
|
+
|
|
1440
|
+
const p1 = makeProduct({ id: "p1", status: "active" });
|
|
1441
|
+
const p2 = makeProduct({
|
|
1442
|
+
id: "p2",
|
|
1443
|
+
slug: "p2",
|
|
1444
|
+
status: "active",
|
|
1445
|
+
});
|
|
1446
|
+
await data.upsert("product", p1.id, p1);
|
|
1447
|
+
await data.upsert("product", p2.id, p2);
|
|
1448
|
+
|
|
1449
|
+
const link1: CollectionProduct = {
|
|
1450
|
+
id: "cp_1",
|
|
1451
|
+
collectionId: "col_1",
|
|
1452
|
+
productId: "p1",
|
|
1453
|
+
position: 1,
|
|
1454
|
+
createdAt: new Date(),
|
|
1455
|
+
};
|
|
1456
|
+
const link2: CollectionProduct = {
|
|
1457
|
+
id: "cp_2",
|
|
1458
|
+
collectionId: "col_1",
|
|
1459
|
+
productId: "p2",
|
|
1460
|
+
position: 0,
|
|
1461
|
+
createdAt: new Date(),
|
|
1462
|
+
};
|
|
1463
|
+
await data.upsert("collectionProduct", link1.id, link1);
|
|
1464
|
+
await data.upsert("collectionProduct", link2.id, link2);
|
|
1465
|
+
|
|
1466
|
+
const result = (await controllers.collection.getWithProducts(
|
|
1467
|
+
makeControllerCtx(data, { params: { id: "col_1" } }),
|
|
1468
|
+
)) as CollectionWithProducts;
|
|
1469
|
+
|
|
1470
|
+
expect(result.name).toBe("Summer Sale");
|
|
1471
|
+
expect(result.products).toHaveLength(2);
|
|
1472
|
+
// Sorted by link position: p2 (pos 0) before p1 (pos 1)
|
|
1473
|
+
expect(result.products[0].id).toBe("p2");
|
|
1474
|
+
expect(result.products[1].id).toBe("p1");
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
it("excludes non-active products", async () => {
|
|
1478
|
+
const col = makeCollection();
|
|
1479
|
+
await data.upsert("collection", col.id, col);
|
|
1480
|
+
|
|
1481
|
+
const p1 = makeProduct({ id: "p1", status: "active" });
|
|
1482
|
+
const p2 = makeProduct({ id: "p2", slug: "p2", status: "draft" });
|
|
1483
|
+
await data.upsert("product", p1.id, p1);
|
|
1484
|
+
await data.upsert("product", p2.id, p2);
|
|
1485
|
+
|
|
1486
|
+
await data.upsert("collectionProduct", "cp_1", {
|
|
1487
|
+
id: "cp_1",
|
|
1488
|
+
collectionId: "col_1",
|
|
1489
|
+
productId: "p1",
|
|
1490
|
+
position: 0,
|
|
1491
|
+
createdAt: new Date(),
|
|
1492
|
+
});
|
|
1493
|
+
await data.upsert("collectionProduct", "cp_2", {
|
|
1494
|
+
id: "cp_2",
|
|
1495
|
+
collectionId: "col_1",
|
|
1496
|
+
productId: "p2",
|
|
1497
|
+
position: 1,
|
|
1498
|
+
createdAt: new Date(),
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
const result = (await controllers.collection.getWithProducts(
|
|
1502
|
+
makeControllerCtx(data, { params: { id: "col_1" } }),
|
|
1503
|
+
)) as CollectionWithProducts;
|
|
1504
|
+
expect(result.products).toHaveLength(1);
|
|
1505
|
+
expect(result.products[0].id).toBe("p1");
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
it("returns null when collection not found", async () => {
|
|
1509
|
+
const result = await controllers.collection.getWithProducts(
|
|
1510
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
1511
|
+
);
|
|
1512
|
+
expect(result).toBeNull();
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
describe("create", () => {
|
|
1517
|
+
it("creates a collection with defaults", async () => {
|
|
1518
|
+
const result = (await controllers.collection.create(
|
|
1519
|
+
makeControllerCtx(data, {
|
|
1520
|
+
body: { name: "New Arrivals", slug: "new-arrivals" },
|
|
1521
|
+
}),
|
|
1522
|
+
)) as Collection;
|
|
1523
|
+
|
|
1524
|
+
expect(result.name).toBe("New Arrivals");
|
|
1525
|
+
expect(result.slug).toBe("new-arrivals");
|
|
1526
|
+
expect(result.isFeatured).toBe(false);
|
|
1527
|
+
expect(result.isVisible).toBe(true);
|
|
1528
|
+
expect(result.position).toBe(0);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("stores the collection in the data service", async () => {
|
|
1532
|
+
const result = (await controllers.collection.create(
|
|
1533
|
+
makeControllerCtx(data, {
|
|
1534
|
+
body: { name: "Stored", slug: "stored" },
|
|
1535
|
+
}),
|
|
1536
|
+
)) as Collection;
|
|
1537
|
+
|
|
1538
|
+
const stored = await data.get("collection", result.id);
|
|
1539
|
+
expect(stored).toMatchObject({ name: "Stored" });
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
describe("update", () => {
|
|
1544
|
+
it("updates an existing collection", async () => {
|
|
1545
|
+
const col = makeCollection();
|
|
1546
|
+
await data.upsert("collection", col.id, col);
|
|
1547
|
+
|
|
1548
|
+
const result = (await controllers.collection.update(
|
|
1549
|
+
makeControllerCtx(data, {
|
|
1550
|
+
params: { id: "col_1" },
|
|
1551
|
+
body: { name: "Winter Sale", isFeatured: true },
|
|
1552
|
+
}),
|
|
1553
|
+
)) as Collection;
|
|
1554
|
+
|
|
1555
|
+
expect(result.name).toBe("Winter Sale");
|
|
1556
|
+
expect(result.isFeatured).toBe(true);
|
|
1557
|
+
expect(result.slug).toBe("summer-sale"); // unchanged
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it("throws when collection not found", async () => {
|
|
1561
|
+
await expect(
|
|
1562
|
+
controllers.collection.update(
|
|
1563
|
+
makeControllerCtx(data, {
|
|
1564
|
+
params: { id: "missing" },
|
|
1565
|
+
body: { name: "X" },
|
|
1566
|
+
}),
|
|
1567
|
+
),
|
|
1568
|
+
).rejects.toThrow("Collection missing not found");
|
|
1569
|
+
});
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
describe("delete", () => {
|
|
1573
|
+
it("deletes collection and its product links", async () => {
|
|
1574
|
+
const col = makeCollection();
|
|
1575
|
+
await data.upsert("collection", col.id, col);
|
|
1576
|
+
|
|
1577
|
+
await data.upsert("collectionProduct", "cp_1", {
|
|
1578
|
+
id: "cp_1",
|
|
1579
|
+
collectionId: "col_1",
|
|
1580
|
+
productId: "p1",
|
|
1581
|
+
position: 0,
|
|
1582
|
+
createdAt: new Date(),
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
const result = (await controllers.collection.delete(
|
|
1586
|
+
makeControllerCtx(data, { params: { id: "col_1" } }),
|
|
1587
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1588
|
+
)) as any;
|
|
1589
|
+
expect(result.success).toBe(true);
|
|
1590
|
+
expect(await data.get("collection", "col_1")).toBeNull();
|
|
1591
|
+
expect(await data.get("collectionProduct", "cp_1")).toBeNull();
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
describe("addProduct", () => {
|
|
1596
|
+
it("adds a product to a collection", async () => {
|
|
1597
|
+
const col = makeCollection();
|
|
1598
|
+
await data.upsert("collection", col.id, col);
|
|
1599
|
+
|
|
1600
|
+
const link = (await controllers.collection.addProduct(
|
|
1601
|
+
makeControllerCtx(data, {
|
|
1602
|
+
params: { id: "col_1" },
|
|
1603
|
+
body: { productId: "p1", position: 5 },
|
|
1604
|
+
}),
|
|
1605
|
+
)) as CollectionProduct;
|
|
1606
|
+
|
|
1607
|
+
expect(link.collectionId).toBe("col_1");
|
|
1608
|
+
expect(link.productId).toBe("p1");
|
|
1609
|
+
expect(link.position).toBe(5);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it("returns existing link if product already in collection", async () => {
|
|
1613
|
+
const col = makeCollection();
|
|
1614
|
+
await data.upsert("collection", col.id, col);
|
|
1615
|
+
|
|
1616
|
+
await data.upsert("collectionProduct", "cp_1", {
|
|
1617
|
+
id: "cp_1",
|
|
1618
|
+
collectionId: "col_1",
|
|
1619
|
+
productId: "p1",
|
|
1620
|
+
position: 0,
|
|
1621
|
+
createdAt: new Date(),
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
const result = (await controllers.collection.addProduct(
|
|
1625
|
+
makeControllerCtx(data, {
|
|
1626
|
+
params: { id: "col_1" },
|
|
1627
|
+
body: { productId: "p1" },
|
|
1628
|
+
}),
|
|
1629
|
+
)) as CollectionProduct;
|
|
1630
|
+
|
|
1631
|
+
expect(result.id).toBe("cp_1");
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
it("throws when collection not found", async () => {
|
|
1635
|
+
await expect(
|
|
1636
|
+
controllers.collection.addProduct(
|
|
1637
|
+
makeControllerCtx(data, {
|
|
1638
|
+
params: { id: "missing" },
|
|
1639
|
+
body: { productId: "p1" },
|
|
1640
|
+
}),
|
|
1641
|
+
),
|
|
1642
|
+
).rejects.toThrow("Collection missing not found");
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
describe("removeProduct", () => {
|
|
1647
|
+
it("removes a product from a collection", async () => {
|
|
1648
|
+
const col = makeCollection();
|
|
1649
|
+
await data.upsert("collection", col.id, col);
|
|
1650
|
+
|
|
1651
|
+
await data.upsert("collectionProduct", "cp_1", {
|
|
1652
|
+
id: "cp_1",
|
|
1653
|
+
collectionId: "col_1",
|
|
1654
|
+
productId: "p1",
|
|
1655
|
+
position: 0,
|
|
1656
|
+
createdAt: new Date(),
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
const result = (await controllers.collection.removeProduct(
|
|
1660
|
+
makeControllerCtx(data, {
|
|
1661
|
+
params: { id: "col_1", productId: "p1" },
|
|
1662
|
+
}),
|
|
1663
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1664
|
+
)) as any;
|
|
1665
|
+
expect(result.success).toBe(true);
|
|
1666
|
+
expect(await data.get("collectionProduct", "cp_1")).toBeNull();
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
describe("listProducts", () => {
|
|
1671
|
+
it("returns products in a collection sorted by position", async () => {
|
|
1672
|
+
const col = makeCollection();
|
|
1673
|
+
await data.upsert("collection", col.id, col);
|
|
1674
|
+
|
|
1675
|
+
const p1 = makeProduct({ id: "p1" });
|
|
1676
|
+
const p2 = makeProduct({ id: "p2", slug: "p2" });
|
|
1677
|
+
await data.upsert("product", p1.id, p1);
|
|
1678
|
+
await data.upsert("product", p2.id, p2);
|
|
1679
|
+
|
|
1680
|
+
await data.upsert("collectionProduct", "cp_1", {
|
|
1681
|
+
id: "cp_1",
|
|
1682
|
+
collectionId: "col_1",
|
|
1683
|
+
productId: "p1",
|
|
1684
|
+
position: 1,
|
|
1685
|
+
createdAt: new Date(),
|
|
1686
|
+
});
|
|
1687
|
+
await data.upsert("collectionProduct", "cp_2", {
|
|
1688
|
+
id: "cp_2",
|
|
1689
|
+
collectionId: "col_1",
|
|
1690
|
+
productId: "p2",
|
|
1691
|
+
position: 0,
|
|
1692
|
+
createdAt: new Date(),
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
const result = (await controllers.collection.listProducts(
|
|
1696
|
+
makeControllerCtx(data, { params: { id: "col_1" } }),
|
|
1697
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1698
|
+
)) as any;
|
|
1699
|
+
expect(result.products).toHaveLength(2);
|
|
1700
|
+
expect(result.products[0].id).toBe("p2");
|
|
1701
|
+
expect(result.products[1].id).toBe("p1");
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
it("returns empty products when collection not found", async () => {
|
|
1705
|
+
const result = (await controllers.collection.listProducts(
|
|
1706
|
+
makeControllerCtx(data, { params: { id: "missing" } }),
|
|
1707
|
+
// biome-ignore lint/suspicious/noExplicitAny: controller result cast in test
|
|
1708
|
+
)) as any;
|
|
1709
|
+
expect(result.products).toEqual([]);
|
|
1710
|
+
});
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
// ── Import controllers ────────────────────────────────────────────────────
|
|
1715
|
+
|
|
1716
|
+
describe("import controllers", () => {
|
|
1717
|
+
let data: ReturnType<typeof createMockDataService>;
|
|
1718
|
+
|
|
1719
|
+
beforeEach(() => {
|
|
1720
|
+
data = createMockDataService();
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
describe("importProducts", () => {
|
|
1724
|
+
it("creates products from valid import rows", async () => {
|
|
1725
|
+
const result = (await controllers.import.importProducts(
|
|
1726
|
+
makeControllerCtx(data, {
|
|
1727
|
+
body: {
|
|
1728
|
+
products: [
|
|
1729
|
+
{ name: "Widget A", price: "19.99" },
|
|
1730
|
+
{ name: "Widget B", price: "29.99", sku: "WB-001" },
|
|
1731
|
+
],
|
|
1732
|
+
},
|
|
1733
|
+
}),
|
|
1734
|
+
)) as ImportResult;
|
|
1735
|
+
|
|
1736
|
+
expect(result.created).toBe(2);
|
|
1737
|
+
expect(result.updated).toBe(0);
|
|
1738
|
+
expect(result.errors).toHaveLength(0);
|
|
1739
|
+
|
|
1740
|
+
// Verify products are actually stored
|
|
1741
|
+
const stored = await data.findMany("product", { where: {} });
|
|
1742
|
+
expect(stored).toHaveLength(2);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it("converts price from dollars to cents", async () => {
|
|
1746
|
+
const result = (await controllers.import.importProducts(
|
|
1747
|
+
makeControllerCtx(data, {
|
|
1748
|
+
body: {
|
|
1749
|
+
products: [{ name: "Dollar Test", price: "49.99" }],
|
|
1750
|
+
},
|
|
1751
|
+
}),
|
|
1752
|
+
)) as ImportResult;
|
|
1753
|
+
|
|
1754
|
+
expect(result.created).toBe(1);
|
|
1755
|
+
|
|
1756
|
+
const stored = (await data.findMany("product", {
|
|
1757
|
+
where: {},
|
|
1758
|
+
})) as Product[];
|
|
1759
|
+
expect(stored[0].price).toBe(4999);
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
it("generates slug from product name", async () => {
|
|
1763
|
+
const result = (await controllers.import.importProducts(
|
|
1764
|
+
makeControllerCtx(data, {
|
|
1765
|
+
body: {
|
|
1766
|
+
products: [{ name: "My Great Product!", price: "10" }],
|
|
1767
|
+
},
|
|
1768
|
+
}),
|
|
1769
|
+
)) as ImportResult;
|
|
1770
|
+
|
|
1771
|
+
expect(result.created).toBe(1);
|
|
1772
|
+
|
|
1773
|
+
const stored = (await data.findMany("product", {
|
|
1774
|
+
where: {},
|
|
1775
|
+
})) as Product[];
|
|
1776
|
+
expect(stored[0].slug).toBe("my-great-product");
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it("uses provided slug when available", async () => {
|
|
1780
|
+
const result = (await controllers.import.importProducts(
|
|
1781
|
+
makeControllerCtx(data, {
|
|
1782
|
+
body: {
|
|
1783
|
+
products: [
|
|
1784
|
+
{ name: "My Product", slug: "custom-slug", price: "10" },
|
|
1785
|
+
],
|
|
1786
|
+
},
|
|
1787
|
+
}),
|
|
1788
|
+
)) as ImportResult;
|
|
1789
|
+
|
|
1790
|
+
expect(result.created).toBe(1);
|
|
1791
|
+
|
|
1792
|
+
const stored = (await data.findMany("product", {
|
|
1793
|
+
where: {},
|
|
1794
|
+
})) as Product[];
|
|
1795
|
+
expect(stored[0].slug).toBe("custom-slug");
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("deduplicates slugs within a batch", async () => {
|
|
1799
|
+
const result = (await controllers.import.importProducts(
|
|
1800
|
+
makeControllerCtx(data, {
|
|
1801
|
+
body: {
|
|
1802
|
+
products: [
|
|
1803
|
+
{ name: "Same Name", price: "10" },
|
|
1804
|
+
{ name: "Same Name", price: "20" },
|
|
1805
|
+
],
|
|
1806
|
+
},
|
|
1807
|
+
}),
|
|
1808
|
+
)) as ImportResult;
|
|
1809
|
+
|
|
1810
|
+
expect(result.created).toBe(2);
|
|
1811
|
+
|
|
1812
|
+
const stored = (await data.findMany("product", {
|
|
1813
|
+
where: {},
|
|
1814
|
+
})) as Product[];
|
|
1815
|
+
const slugs = stored.map((p) => p.slug).sort();
|
|
1816
|
+
expect(slugs).toContain("same-name");
|
|
1817
|
+
expect(slugs).toContain("same-name-1");
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
it("deduplicates slugs against existing products", async () => {
|
|
1821
|
+
const existing = makeProduct({ id: "exist_1", slug: "widget" });
|
|
1822
|
+
await data.upsert("product", existing.id, existing);
|
|
1823
|
+
|
|
1824
|
+
const result = (await controllers.import.importProducts(
|
|
1825
|
+
makeControllerCtx(data, {
|
|
1826
|
+
body: {
|
|
1827
|
+
products: [{ name: "Widget", price: "10" }],
|
|
1828
|
+
},
|
|
1829
|
+
}),
|
|
1830
|
+
)) as ImportResult;
|
|
1831
|
+
|
|
1832
|
+
expect(result.created).toBe(1);
|
|
1833
|
+
|
|
1834
|
+
const stored = (await data.findMany("product", {
|
|
1835
|
+
where: {},
|
|
1836
|
+
})) as Product[];
|
|
1837
|
+
const newProduct = stored.find((p) => p.id !== "exist_1");
|
|
1838
|
+
expect(newProduct?.slug).toBe("widget-1");
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
it("updates existing products when SKU matches", async () => {
|
|
1842
|
+
const existing = makeProduct({
|
|
1843
|
+
id: "exist_1",
|
|
1844
|
+
name: "Old Name",
|
|
1845
|
+
sku: "SKU-001",
|
|
1846
|
+
price: 1000,
|
|
1847
|
+
});
|
|
1848
|
+
await data.upsert("product", existing.id, existing);
|
|
1849
|
+
|
|
1850
|
+
const result = (await controllers.import.importProducts(
|
|
1851
|
+
makeControllerCtx(data, {
|
|
1852
|
+
body: {
|
|
1853
|
+
products: [
|
|
1854
|
+
{
|
|
1855
|
+
name: "New Name",
|
|
1856
|
+
price: "25.99",
|
|
1857
|
+
sku: "SKU-001",
|
|
1858
|
+
status: "active",
|
|
1859
|
+
},
|
|
1860
|
+
],
|
|
1861
|
+
},
|
|
1862
|
+
}),
|
|
1863
|
+
)) as ImportResult;
|
|
1864
|
+
|
|
1865
|
+
expect(result.created).toBe(0);
|
|
1866
|
+
expect(result.updated).toBe(1);
|
|
1867
|
+
|
|
1868
|
+
const stored = (await data.get("product", "exist_1")) as Product;
|
|
1869
|
+
expect(stored.name).toBe("New Name");
|
|
1870
|
+
expect(stored.price).toBe(2599);
|
|
1871
|
+
expect(stored.status).toBe("active");
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
it("resolves category name to category ID", async () => {
|
|
1875
|
+
const cat = makeCategory({ id: "cat_elec", name: "Electronics" });
|
|
1876
|
+
await data.upsert("category", cat.id, cat);
|
|
1877
|
+
|
|
1878
|
+
const result = (await controllers.import.importProducts(
|
|
1879
|
+
makeControllerCtx(data, {
|
|
1880
|
+
body: {
|
|
1881
|
+
products: [
|
|
1882
|
+
{ name: "Gadget", price: "50", category: "Electronics" },
|
|
1883
|
+
],
|
|
1884
|
+
},
|
|
1885
|
+
}),
|
|
1886
|
+
)) as ImportResult;
|
|
1887
|
+
|
|
1888
|
+
expect(result.created).toBe(1);
|
|
1889
|
+
|
|
1890
|
+
const stored = (await data.findMany("product", {
|
|
1891
|
+
where: {},
|
|
1892
|
+
})) as Product[];
|
|
1893
|
+
expect(stored[0].categoryId).toBe("cat_elec");
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
it("handles case-insensitive category matching", async () => {
|
|
1897
|
+
const cat = makeCategory({ id: "cat_1", name: "Clothing" });
|
|
1898
|
+
await data.upsert("category", cat.id, cat);
|
|
1899
|
+
|
|
1900
|
+
const result = (await controllers.import.importProducts(
|
|
1901
|
+
makeControllerCtx(data, {
|
|
1902
|
+
body: {
|
|
1903
|
+
products: [{ name: "Shirt", price: "30", category: "clothing" }],
|
|
1904
|
+
},
|
|
1905
|
+
}),
|
|
1906
|
+
)) as ImportResult;
|
|
1907
|
+
|
|
1908
|
+
expect(result.created).toBe(1);
|
|
1909
|
+
|
|
1910
|
+
const stored = (await data.findMany("product", {
|
|
1911
|
+
where: {},
|
|
1912
|
+
})) as Product[];
|
|
1913
|
+
expect(stored[0].categoryId).toBe("cat_1");
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
it("sets default values for optional fields", async () => {
|
|
1917
|
+
const result = (await controllers.import.importProducts(
|
|
1918
|
+
makeControllerCtx(data, {
|
|
1919
|
+
body: {
|
|
1920
|
+
products: [{ name: "Basic Product", price: "15" }],
|
|
1921
|
+
},
|
|
1922
|
+
}),
|
|
1923
|
+
)) as ImportResult;
|
|
1924
|
+
|
|
1925
|
+
expect(result.created).toBe(1);
|
|
1926
|
+
|
|
1927
|
+
const stored = (await data.findMany("product", {
|
|
1928
|
+
where: {},
|
|
1929
|
+
})) as Product[];
|
|
1930
|
+
expect(stored[0].status).toBe("draft");
|
|
1931
|
+
expect(stored[0].inventory).toBe(0);
|
|
1932
|
+
expect(stored[0].trackInventory).toBe(true);
|
|
1933
|
+
expect(stored[0].allowBackorder).toBe(false);
|
|
1934
|
+
expect(stored[0].isFeatured).toBe(false);
|
|
1935
|
+
expect(stored[0].images).toEqual([]);
|
|
1936
|
+
expect(stored[0].tags).toEqual([]);
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
it("imports optional fields when provided", async () => {
|
|
1940
|
+
const result = (await controllers.import.importProducts(
|
|
1941
|
+
makeControllerCtx(data, {
|
|
1942
|
+
body: {
|
|
1943
|
+
products: [
|
|
1944
|
+
{
|
|
1945
|
+
name: "Full Product",
|
|
1946
|
+
price: "99.99",
|
|
1947
|
+
sku: "FP-001",
|
|
1948
|
+
barcode: "123456789",
|
|
1949
|
+
description: "A detailed description",
|
|
1950
|
+
shortDescription: "Short desc",
|
|
1951
|
+
compareAtPrice: "149.99",
|
|
1952
|
+
costPrice: "50",
|
|
1953
|
+
inventory: "25",
|
|
1954
|
+
status: "active",
|
|
1955
|
+
tags: ["premium", "sale"],
|
|
1956
|
+
weight: "2.5",
|
|
1957
|
+
weightUnit: "kg",
|
|
1958
|
+
featured: true,
|
|
1959
|
+
trackInventory: true,
|
|
1960
|
+
allowBackorder: true,
|
|
1961
|
+
},
|
|
1962
|
+
],
|
|
1963
|
+
},
|
|
1964
|
+
}),
|
|
1965
|
+
)) as ImportResult;
|
|
1966
|
+
|
|
1967
|
+
expect(result.created).toBe(1);
|
|
1968
|
+
|
|
1969
|
+
const stored = (await data.findMany("product", {
|
|
1970
|
+
where: {},
|
|
1971
|
+
})) as Product[];
|
|
1972
|
+
const p = stored[0];
|
|
1973
|
+
expect(p.sku).toBe("FP-001");
|
|
1974
|
+
expect(p.barcode).toBe("123456789");
|
|
1975
|
+
expect(p.description).toBe("A detailed description");
|
|
1976
|
+
expect(p.shortDescription).toBe("Short desc");
|
|
1977
|
+
expect(p.compareAtPrice).toBe(14999);
|
|
1978
|
+
expect(p.costPrice).toBe(5000);
|
|
1979
|
+
expect(p.inventory).toBe(25);
|
|
1980
|
+
expect(p.status).toBe("active");
|
|
1981
|
+
expect(p.tags).toEqual(["premium", "sale"]);
|
|
1982
|
+
expect(p.weight).toBe(2.5);
|
|
1983
|
+
expect(p.weightUnit).toBe("kg");
|
|
1984
|
+
expect(p.isFeatured).toBe(true);
|
|
1985
|
+
expect(p.trackInventory).toBe(true);
|
|
1986
|
+
expect(p.allowBackorder).toBe(true);
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
it("returns errors for rows missing name", async () => {
|
|
1990
|
+
const result = (await controllers.import.importProducts(
|
|
1991
|
+
makeControllerCtx(data, {
|
|
1992
|
+
body: {
|
|
1993
|
+
products: [
|
|
1994
|
+
{ name: "", price: "10" },
|
|
1995
|
+
{ name: "Valid", price: "20" },
|
|
1996
|
+
],
|
|
1997
|
+
},
|
|
1998
|
+
}),
|
|
1999
|
+
)) as ImportResult;
|
|
2000
|
+
|
|
2001
|
+
expect(result.created).toBe(1);
|
|
2002
|
+
expect(result.errors).toHaveLength(1);
|
|
2003
|
+
expect(result.errors[0].row).toBe(1);
|
|
2004
|
+
expect(result.errors[0].field).toBe("name");
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
it("returns errors for rows missing price", async () => {
|
|
2008
|
+
const result = (await controllers.import.importProducts(
|
|
2009
|
+
makeControllerCtx(data, {
|
|
2010
|
+
body: {
|
|
2011
|
+
products: [
|
|
2012
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing missing field
|
|
2013
|
+
{ name: "No Price" } as any,
|
|
2014
|
+
],
|
|
2015
|
+
},
|
|
2016
|
+
}),
|
|
2017
|
+
)) as ImportResult;
|
|
2018
|
+
|
|
2019
|
+
expect(result.created).toBe(0);
|
|
2020
|
+
expect(result.errors).toHaveLength(1);
|
|
2021
|
+
expect(result.errors[0].field).toBe("price");
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
it("returns errors for invalid price values", async () => {
|
|
2025
|
+
const result = (await controllers.import.importProducts(
|
|
2026
|
+
makeControllerCtx(data, {
|
|
2027
|
+
body: {
|
|
2028
|
+
products: [
|
|
2029
|
+
{ name: "Bad Price", price: "abc" },
|
|
2030
|
+
{ name: "Negative Price", price: "-5" },
|
|
2031
|
+
],
|
|
2032
|
+
},
|
|
2033
|
+
}),
|
|
2034
|
+
)) as ImportResult;
|
|
2035
|
+
|
|
2036
|
+
expect(result.created).toBe(0);
|
|
2037
|
+
expect(result.errors).toHaveLength(2);
|
|
2038
|
+
expect(result.errors[0].field).toBe("price");
|
|
2039
|
+
expect(result.errors[1].field).toBe("price");
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
it("handles mixed valid and invalid rows", async () => {
|
|
2043
|
+
const result = (await controllers.import.importProducts(
|
|
2044
|
+
makeControllerCtx(data, {
|
|
2045
|
+
body: {
|
|
2046
|
+
products: [
|
|
2047
|
+
{ name: "Good One", price: "10" },
|
|
2048
|
+
{ name: "", price: "20" },
|
|
2049
|
+
{ name: "Good Two", price: "30" },
|
|
2050
|
+
{ name: "Bad Price", price: "nope" },
|
|
2051
|
+
{ name: "Good Three", price: "40" },
|
|
2052
|
+
],
|
|
2053
|
+
},
|
|
2054
|
+
}),
|
|
2055
|
+
)) as ImportResult;
|
|
2056
|
+
|
|
2057
|
+
expect(result.created).toBe(3);
|
|
2058
|
+
expect(result.errors).toHaveLength(2);
|
|
2059
|
+
expect(result.errors[0].row).toBe(2);
|
|
2060
|
+
expect(result.errors[1].row).toBe(4);
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
it("handles empty products array gracefully", async () => {
|
|
2064
|
+
const result = (await controllers.import.importProducts(
|
|
2065
|
+
makeControllerCtx(data, {
|
|
2066
|
+
body: {
|
|
2067
|
+
products: [],
|
|
2068
|
+
},
|
|
2069
|
+
}),
|
|
2070
|
+
)) as ImportResult;
|
|
2071
|
+
|
|
2072
|
+
expect(result.created).toBe(0);
|
|
2073
|
+
expect(result.updated).toBe(0);
|
|
2074
|
+
expect(result.errors).toHaveLength(0);
|
|
2075
|
+
});
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
describe("bulk.updateStatus", () => {
|
|
2079
|
+
it("updates status for multiple products", async () => {
|
|
2080
|
+
const p1 = makeProduct({ id: "prod_1", status: "draft" });
|
|
2081
|
+
const p2 = makeProduct({ id: "prod_2", status: "draft" });
|
|
2082
|
+
const p3 = makeProduct({ id: "prod_3", status: "active" });
|
|
2083
|
+
data._store.set("product:prod_1", p1);
|
|
2084
|
+
data._store.set("product:prod_2", p2);
|
|
2085
|
+
data._store.set("product:prod_3", p3);
|
|
2086
|
+
|
|
2087
|
+
const result = (await controllers.bulk.updateStatus(
|
|
2088
|
+
makeControllerCtx(data, {
|
|
2089
|
+
body: { ids: ["prod_1", "prod_2", "prod_3"], status: "active" },
|
|
2090
|
+
}),
|
|
2091
|
+
)) as { updated: number };
|
|
2092
|
+
|
|
2093
|
+
expect(result.updated).toBe(3);
|
|
2094
|
+
|
|
2095
|
+
const updated1 = data._store.get("product:prod_1") as Product;
|
|
2096
|
+
const updated2 = data._store.get("product:prod_2") as Product;
|
|
2097
|
+
const updated3 = data._store.get("product:prod_3") as Product;
|
|
2098
|
+
expect(updated1.status).toBe("active");
|
|
2099
|
+
expect(updated2.status).toBe("active");
|
|
2100
|
+
expect(updated3.status).toBe("active");
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
it("skips non-existent products", async () => {
|
|
2104
|
+
const p1 = makeProduct({ id: "prod_1", status: "draft" });
|
|
2105
|
+
data._store.set("product:prod_1", p1);
|
|
2106
|
+
|
|
2107
|
+
const result = (await controllers.bulk.updateStatus(
|
|
2108
|
+
makeControllerCtx(data, {
|
|
2109
|
+
body: {
|
|
2110
|
+
ids: ["prod_1", "prod_nonexistent"],
|
|
2111
|
+
status: "archived",
|
|
2112
|
+
},
|
|
2113
|
+
}),
|
|
2114
|
+
)) as { updated: number };
|
|
2115
|
+
|
|
2116
|
+
expect(result.updated).toBe(1);
|
|
2117
|
+
const updated = data._store.get("product:prod_1") as Product;
|
|
2118
|
+
expect(updated.status).toBe("archived");
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
it("returns zero for empty ids array", async () => {
|
|
2122
|
+
const result = (await controllers.bulk.updateStatus(
|
|
2123
|
+
makeControllerCtx(data, {
|
|
2124
|
+
body: { ids: [], status: "active" },
|
|
2125
|
+
}),
|
|
2126
|
+
)) as { updated: number };
|
|
2127
|
+
|
|
2128
|
+
expect(result.updated).toBe(0);
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
it("sets updatedAt to current time", async () => {
|
|
2132
|
+
const oldDate = new Date("2020-01-01");
|
|
2133
|
+
const p1 = makeProduct({ id: "prod_1", updatedAt: oldDate });
|
|
2134
|
+
data._store.set("product:prod_1", p1);
|
|
2135
|
+
|
|
2136
|
+
await controllers.bulk.updateStatus(
|
|
2137
|
+
makeControllerCtx(data, {
|
|
2138
|
+
body: { ids: ["prod_1"], status: "draft" },
|
|
2139
|
+
}),
|
|
2140
|
+
);
|
|
2141
|
+
|
|
2142
|
+
const updated = data._store.get("product:prod_1") as Product;
|
|
2143
|
+
expect(updated.updatedAt.getTime()).toBeGreaterThan(oldDate.getTime());
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
describe("bulk.deleteMany", () => {
|
|
2148
|
+
it("deletes multiple products", async () => {
|
|
2149
|
+
const p1 = makeProduct({ id: "prod_1" });
|
|
2150
|
+
const p2 = makeProduct({ id: "prod_2" });
|
|
2151
|
+
data._store.set("product:prod_1", p1);
|
|
2152
|
+
data._store.set("product:prod_2", p2);
|
|
2153
|
+
|
|
2154
|
+
const result = (await controllers.bulk.deleteMany(
|
|
2155
|
+
makeControllerCtx(data, {
|
|
2156
|
+
body: { ids: ["prod_1", "prod_2"] },
|
|
2157
|
+
}),
|
|
2158
|
+
)) as { deleted: number };
|
|
2159
|
+
|
|
2160
|
+
expect(result.deleted).toBe(2);
|
|
2161
|
+
expect(data._store.has("product:prod_1")).toBe(false);
|
|
2162
|
+
expect(data._store.has("product:prod_2")).toBe(false);
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
it("also deletes associated variants", async () => {
|
|
2166
|
+
const p1 = makeProduct({ id: "prod_1" });
|
|
2167
|
+
const v1 = makeVariant({ id: "var_1", productId: "prod_1" });
|
|
2168
|
+
const v2 = makeVariant({ id: "var_2", productId: "prod_1" });
|
|
2169
|
+
data._store.set("product:prod_1", p1);
|
|
2170
|
+
data._store.set("productVariant:var_1", v1);
|
|
2171
|
+
data._store.set("productVariant:var_2", v2);
|
|
2172
|
+
|
|
2173
|
+
const result = (await controllers.bulk.deleteMany(
|
|
2174
|
+
makeControllerCtx(data, {
|
|
2175
|
+
body: { ids: ["prod_1"] },
|
|
2176
|
+
}),
|
|
2177
|
+
)) as { deleted: number };
|
|
2178
|
+
|
|
2179
|
+
expect(result.deleted).toBe(1);
|
|
2180
|
+
expect(data._store.has("product:prod_1")).toBe(false);
|
|
2181
|
+
expect(data._store.has("productVariant:var_1")).toBe(false);
|
|
2182
|
+
expect(data._store.has("productVariant:var_2")).toBe(false);
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
it("skips non-existent products", async () => {
|
|
2186
|
+
const p1 = makeProduct({ id: "prod_1" });
|
|
2187
|
+
data._store.set("product:prod_1", p1);
|
|
2188
|
+
|
|
2189
|
+
const result = (await controllers.bulk.deleteMany(
|
|
2190
|
+
makeControllerCtx(data, {
|
|
2191
|
+
body: { ids: ["prod_1", "prod_nonexistent"] },
|
|
2192
|
+
}),
|
|
2193
|
+
)) as { deleted: number };
|
|
2194
|
+
|
|
2195
|
+
expect(result.deleted).toBe(1);
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
it("returns zero for empty ids array", async () => {
|
|
2199
|
+
const result = (await controllers.bulk.deleteMany(
|
|
2200
|
+
makeControllerCtx(data, {
|
|
2201
|
+
body: { ids: [] },
|
|
2202
|
+
}),
|
|
2203
|
+
)) as { deleted: number };
|
|
2204
|
+
|
|
2205
|
+
expect(result.deleted).toBe(0);
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
it("does not affect other products", async () => {
|
|
2209
|
+
const p1 = makeProduct({ id: "prod_1" });
|
|
2210
|
+
const p2 = makeProduct({ id: "prod_2" });
|
|
2211
|
+
const p3 = makeProduct({ id: "prod_3" });
|
|
2212
|
+
data._store.set("product:prod_1", p1);
|
|
2213
|
+
data._store.set("product:prod_2", p2);
|
|
2214
|
+
data._store.set("product:prod_3", p3);
|
|
2215
|
+
|
|
2216
|
+
await controllers.bulk.deleteMany(
|
|
2217
|
+
makeControllerCtx(data, {
|
|
2218
|
+
body: { ids: ["prod_1", "prod_3"] },
|
|
2219
|
+
}),
|
|
2220
|
+
);
|
|
2221
|
+
|
|
2222
|
+
expect(data._store.has("product:prod_1")).toBe(false);
|
|
2223
|
+
expect(data._store.has("product:prod_2")).toBe(true);
|
|
2224
|
+
expect(data._store.has("product:prod_3")).toBe(false);
|
|
2225
|
+
});
|
|
2226
|
+
});
|
|
2227
|
+
});
|