@86d-app/products 0.0.4 → 0.0.6
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +41 -41
- package/README.md +266 -5
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/__tests__/state.test.d.ts +2 -0
- package/dist/__tests__/state.test.d.ts.map +1 -0
- package/dist/admin/components/categories-admin.d.ts +2 -0
- package/dist/admin/components/categories-admin.d.ts.map +1 -0
- package/dist/admin/components/category-form.d.ts +7 -0
- package/dist/admin/components/category-form.d.ts.map +1 -0
- package/dist/admin/components/category-list.d.ts +7 -0
- package/dist/admin/components/category-list.d.ts.map +1 -0
- package/dist/admin/components/collections-admin.d.ts +2 -0
- package/dist/admin/components/collections-admin.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +9 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/product-detail.d.ts +7 -0
- package/dist/admin/components/product-detail.d.ts.map +1 -0
- package/dist/admin/components/product-edit.d.ts +6 -0
- package/dist/admin/components/product-edit.d.ts.map +1 -0
- package/dist/admin/components/product-form.d.ts +7 -0
- package/dist/admin/components/product-form.d.ts.map +1 -0
- package/dist/admin/components/product-list.d.ts +2 -0
- package/dist/admin/components/product-list.d.ts.map +1 -0
- package/dist/admin/components/product-new.d.ts +2 -0
- package/dist/admin/components/product-new.d.ts.map +1 -0
- package/dist/admin/endpoints/add-collection-product.d.ts +15 -0
- package/dist/admin/endpoints/add-collection-product.d.ts.map +1 -0
- package/dist/admin/endpoints/bulk-action.d.ts +17 -0
- package/dist/admin/endpoints/bulk-action.d.ts.map +1 -0
- package/dist/admin/endpoints/create-category.d.ts +23 -0
- package/dist/admin/endpoints/create-category.d.ts.map +1 -0
- package/dist/admin/endpoints/create-collection.d.ts +22 -0
- package/dist/admin/endpoints/create-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/create-product.d.ts +44 -0
- package/dist/admin/endpoints/create-product.d.ts.map +1 -0
- package/dist/admin/endpoints/create-variant.d.ts +35 -0
- package/dist/admin/endpoints/create-variant.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-category.d.ts +18 -0
- package/dist/admin/endpoints/delete-category.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-collection.d.ts +8 -0
- package/dist/admin/endpoints/delete-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-product.d.ts +18 -0
- package/dist/admin/endpoints/delete-product.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-variant.d.ts +18 -0
- package/dist/admin/endpoints/delete-variant.d.ts.map +1 -0
- package/dist/admin/endpoints/get-product.d.ts +16 -0
- package/dist/admin/endpoints/get-product.d.ts.map +1 -0
- package/dist/admin/endpoints/import-products.d.ts +36 -0
- package/dist/admin/endpoints/import-products.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +418 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-categories.d.ts +11 -0
- package/dist/admin/endpoints/list-categories.d.ts.map +1 -0
- package/dist/admin/endpoints/list-collections.d.ts +11 -0
- package/dist/admin/endpoints/list-collections.d.ts.map +1 -0
- package/dist/admin/endpoints/list-products.d.ts +27 -0
- package/dist/admin/endpoints/list-products.d.ts.map +1 -0
- package/dist/admin/endpoints/remove-collection-product.d.ts +9 -0
- package/dist/admin/endpoints/remove-collection-product.d.ts.map +1 -0
- package/dist/admin/endpoints/update-category.d.ts +26 -0
- package/dist/admin/endpoints/update-category.d.ts.map +1 -0
- package/dist/admin/endpoints/update-collection.d.ts +19 -0
- package/dist/admin/endpoints/update-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/update-product.d.ts +47 -0
- package/dist/admin/endpoints/update-product.d.ts.map +1 -0
- package/dist/admin/endpoints/update-variant.d.ts +35 -0
- package/dist/admin/endpoints/update-variant.d.ts.map +1 -0
- package/dist/controllers.d.ts +130 -0
- package/dist/controllers.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/markdown.d.ts +6 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/schema.d.ts +351 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +4 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +280 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/state.d.ts +38 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +88 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/_types.d.ts +70 -0
- package/dist/store/components/_types.d.ts.map +1 -0
- package/dist/store/components/_utils.d.ts +5 -0
- package/dist/store/components/_utils.d.ts.map +1 -0
- package/dist/store/components/back-in-stock-notify.d.ts +8 -0
- package/dist/store/components/back-in-stock-notify.d.ts.map +1 -0
- package/dist/store/components/collection-card.d.ts +6 -0
- package/dist/store/components/collection-card.d.ts.map +1 -0
- package/dist/store/components/collection-detail.d.ts +6 -0
- package/dist/store/components/collection-detail.d.ts.map +1 -0
- package/dist/store/components/collection-grid.d.ts +6 -0
- package/dist/store/components/collection-grid.d.ts.map +1 -0
- package/dist/store/components/featured-products.d.ts +6 -0
- package/dist/store/components/featured-products.d.ts.map +1 -0
- package/dist/store/components/filter-chip.d.ts +6 -0
- package/dist/store/components/filter-chip.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +37 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/product-card.d.ts +7 -0
- package/dist/store/components/product-card.d.ts.map +1 -0
- package/dist/store/components/product-detail.d.ts +6 -0
- package/dist/store/components/product-detail.d.ts.map +1 -0
- package/dist/store/components/product-listing.d.ts +7 -0
- package/dist/store/components/product-listing.d.ts.map +1 -0
- package/dist/store/components/product-qa-section.d.ts +5 -0
- package/dist/store/components/product-qa-section.d.ts.map +1 -0
- package/dist/store/components/product-reviews-section.d.ts +5 -0
- package/dist/store/components/product-reviews-section.d.ts.map +1 -0
- package/dist/store/components/recently-viewed.d.ts +12 -0
- package/dist/store/components/recently-viewed.d.ts.map +1 -0
- package/dist/store/components/recommended-products.d.ts +7 -0
- package/dist/store/components/recommended-products.d.ts.map +1 -0
- package/dist/store/components/related-products.d.ts +7 -0
- package/dist/store/components/related-products.d.ts.map +1 -0
- package/dist/store/components/star-display.d.ts +6 -0
- package/dist/store/components/star-display.d.ts.map +1 -0
- package/dist/store/components/star-picker.d.ts +6 -0
- package/dist/store/components/star-picker.d.ts.map +1 -0
- package/dist/store/components/stock-badge.d.ts +5 -0
- package/dist/store/components/stock-badge.d.ts.map +1 -0
- package/dist/store/endpoints/get-category.d.ts +22 -0
- package/dist/store/endpoints/get-category.d.ts.map +1 -0
- package/dist/store/endpoints/get-collection.d.ts +17 -0
- package/dist/store/endpoints/get-collection.d.ts.map +1 -0
- package/dist/store/endpoints/get-featured.d.ts +10 -0
- package/dist/store/endpoints/get-featured.d.ts.map +1 -0
- package/dist/store/endpoints/get-product.d.ts +17 -0
- package/dist/store/endpoints/get-product.d.ts.map +1 -0
- package/dist/store/endpoints/get-related.d.ts +11 -0
- package/dist/store/endpoints/get-related.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +130 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-categories.d.ts +6 -0
- package/dist/store/endpoints/list-categories.d.ts.map +1 -0
- package/dist/store/endpoints/list-collections.d.ts +10 -0
- package/dist/store/endpoints/list-collections.d.ts.map +1 -0
- package/dist/store/endpoints/list-products.d.ts +26 -0
- package/dist/store/endpoints/list-products.d.ts.map +1 -0
- package/dist/store/endpoints/search-products.d.ts +12 -0
- package/dist/store/endpoints/search-products.d.ts.map +1 -0
- package/dist/store/endpoints/store-search.d.ts +18 -0
- package/dist/store/endpoints/store-search.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/endpoint-security.test.ts +457 -0
- package/src/__tests__/service-impl.test.ts +1745 -0
- package/src/admin/endpoints/create-category.ts +5 -2
- package/src/admin/endpoints/create-collection.ts +1 -1
- package/src/admin/endpoints/create-product.ts +5 -2
- package/src/admin/endpoints/delete-category.ts +1 -1
- package/src/admin/endpoints/delete-collection.ts +1 -1
- package/src/admin/endpoints/delete-product.ts +1 -1
- package/src/admin/endpoints/delete-variant.ts +1 -1
- package/src/admin/endpoints/list-categories.ts +1 -1
- package/src/admin/endpoints/list-collections.ts +1 -1
- package/src/admin/endpoints/list-products.ts +1 -1
- package/src/admin/endpoints/remove-collection-product.ts +1 -1
- package/src/admin/endpoints/update-category.ts +5 -2
- package/src/admin/endpoints/update-collection.ts +1 -1
- package/src/admin/endpoints/update-product.ts +5 -2
- package/src/admin/endpoints/update-variant.ts +1 -1
- package/src/service-impl.ts +1139 -0
- package/src/service.ts +312 -0
- package/src/store/components/_hooks.ts +81 -0
- package/src/store/components/_utils.ts +8 -0
- package/src/store/components/collection-detail.tsx +21 -1
- package/src/store/components/collection-grid.tsx +5 -1
- package/src/store/components/featured-products.tsx +5 -1
- package/src/store/components/index.tsx +2 -0
- package/src/store/components/product-card.mdx +1 -1
- package/src/store/components/product-card.tsx +25 -5
- package/src/store/components/product-detail.mdx +2 -0
- package/src/store/components/product-detail.tsx +55 -8
- package/src/store/components/product-listing.tsx +25 -4
- package/src/store/components/product-qa-section.mdx +21 -0
- package/src/store/components/product-qa-section.tsx +503 -0
- package/src/store/components/recommended-products.mdx +6 -0
- package/src/store/components/recommended-products.tsx +119 -0
- package/src/store/endpoints/get-category.ts +2 -2
- package/src/store/endpoints/get-collection.ts +1 -1
- package/src/store/endpoints/get-featured.ts +1 -1
- package/src/store/endpoints/get-product.ts +1 -1
- package/src/store/endpoints/get-related.ts +2 -2
- package/src/store/endpoints/list-collections.ts +3 -3
- package/src/store/endpoints/list-products.ts +9 -9
- package/src/store/endpoints/store-search.ts +1 -1
- package/COMPONENTS.md +0 -231
|
@@ -0,0 +1,1745 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createProductController } from "../service-impl";
|
|
4
|
+
|
|
5
|
+
describe("createProductController", () => {
|
|
6
|
+
let mockData: ReturnType<typeof createMockDataService>;
|
|
7
|
+
let controller: ReturnType<typeof createProductController>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockData = createMockDataService();
|
|
11
|
+
controller = createProductController(mockData);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// ── Helpers ──
|
|
15
|
+
|
|
16
|
+
async function createTestProduct(
|
|
17
|
+
overrides: Partial<Parameters<typeof controller.createProduct>[0]> = {},
|
|
18
|
+
) {
|
|
19
|
+
return controller.createProduct({
|
|
20
|
+
name: "Test Product",
|
|
21
|
+
slug: "test-product",
|
|
22
|
+
price: 2999,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function createTestCategory(
|
|
28
|
+
overrides: Partial<Parameters<typeof controller.createCategory>[0]> = {},
|
|
29
|
+
) {
|
|
30
|
+
return controller.createCategory({
|
|
31
|
+
name: "Test Category",
|
|
32
|
+
slug: "test-category",
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function createTestCollection(
|
|
38
|
+
overrides: Partial<Parameters<typeof controller.createCollection>[0]> = {},
|
|
39
|
+
) {
|
|
40
|
+
return controller.createCollection({
|
|
41
|
+
name: "Test Collection",
|
|
42
|
+
slug: "test-collection",
|
|
43
|
+
...overrides,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── createProduct ──
|
|
48
|
+
|
|
49
|
+
describe("createProduct", () => {
|
|
50
|
+
it("creates a product with required fields", async () => {
|
|
51
|
+
const product = await createTestProduct();
|
|
52
|
+
expect(product.id).toBeDefined();
|
|
53
|
+
expect(product.name).toBe("Test Product");
|
|
54
|
+
expect(product.slug).toBe("test-product");
|
|
55
|
+
expect(product.price).toBe(2999);
|
|
56
|
+
expect(product.createdAt).toBeInstanceOf(Date);
|
|
57
|
+
expect(product.updatedAt).toBeInstanceOf(Date);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("applies default values", async () => {
|
|
61
|
+
const product = await createTestProduct();
|
|
62
|
+
expect(product.inventory).toBe(0);
|
|
63
|
+
expect(product.trackInventory).toBe(true);
|
|
64
|
+
expect(product.allowBackorder).toBe(false);
|
|
65
|
+
expect(product.status).toBe("draft");
|
|
66
|
+
expect(product.images).toEqual([]);
|
|
67
|
+
expect(product.tags).toEqual([]);
|
|
68
|
+
expect(product.isFeatured).toBe(false);
|
|
69
|
+
expect(product.weightUnit).toBe("kg");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("creates a product with all optional fields", async () => {
|
|
73
|
+
const product = await createTestProduct({
|
|
74
|
+
description: "A great product",
|
|
75
|
+
shortDescription: "Great",
|
|
76
|
+
compareAtPrice: 3999,
|
|
77
|
+
costPrice: 1500,
|
|
78
|
+
sku: "SKU-001",
|
|
79
|
+
barcode: "1234567890",
|
|
80
|
+
inventory: 50,
|
|
81
|
+
trackInventory: false,
|
|
82
|
+
allowBackorder: true,
|
|
83
|
+
status: "active",
|
|
84
|
+
categoryId: "cat_123",
|
|
85
|
+
images: ["img1.jpg", "img2.jpg"],
|
|
86
|
+
tags: ["sale", "new"],
|
|
87
|
+
metadata: { color: "red" },
|
|
88
|
+
weight: 1.5,
|
|
89
|
+
weightUnit: "lb",
|
|
90
|
+
isFeatured: true,
|
|
91
|
+
});
|
|
92
|
+
expect(product.description).toBe("A great product");
|
|
93
|
+
expect(product.shortDescription).toBe("Great");
|
|
94
|
+
expect(product.compareAtPrice).toBe(3999);
|
|
95
|
+
expect(product.costPrice).toBe(1500);
|
|
96
|
+
expect(product.sku).toBe("SKU-001");
|
|
97
|
+
expect(product.barcode).toBe("1234567890");
|
|
98
|
+
expect(product.inventory).toBe(50);
|
|
99
|
+
expect(product.trackInventory).toBe(false);
|
|
100
|
+
expect(product.allowBackorder).toBe(true);
|
|
101
|
+
expect(product.status).toBe("active");
|
|
102
|
+
expect(product.categoryId).toBe("cat_123");
|
|
103
|
+
expect(product.images).toEqual(["img1.jpg", "img2.jpg"]);
|
|
104
|
+
expect(product.tags).toEqual(["sale", "new"]);
|
|
105
|
+
expect(product.metadata).toEqual({ color: "red" });
|
|
106
|
+
expect(product.weight).toBe(1.5);
|
|
107
|
+
expect(product.weightUnit).toBe("lb");
|
|
108
|
+
expect(product.isFeatured).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("generates unique IDs", async () => {
|
|
112
|
+
const p1 = await createTestProduct({ slug: "product-1" });
|
|
113
|
+
const p2 = await createTestProduct({ slug: "product-2" });
|
|
114
|
+
expect(p1.id).not.toBe(p2.id);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("persists product to data store", async () => {
|
|
118
|
+
const product = await createTestProduct();
|
|
119
|
+
const stored = await mockData.get("product", product.id);
|
|
120
|
+
expect(stored).not.toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── getProduct ──
|
|
125
|
+
|
|
126
|
+
describe("getProduct", () => {
|
|
127
|
+
it("returns product by ID", async () => {
|
|
128
|
+
const created = await createTestProduct();
|
|
129
|
+
const found = await controller.getProduct(created.id);
|
|
130
|
+
expect(found?.name).toBe("Test Product");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns null for non-existent ID", async () => {
|
|
134
|
+
const found = await controller.getProduct("non-existent");
|
|
135
|
+
expect(found).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── getProductBySlug ──
|
|
140
|
+
|
|
141
|
+
describe("getProductBySlug", () => {
|
|
142
|
+
it("returns product by slug", async () => {
|
|
143
|
+
await createTestProduct();
|
|
144
|
+
const found = await controller.getProductBySlug("test-product");
|
|
145
|
+
expect(found?.name).toBe("Test Product");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns null for non-existent slug", async () => {
|
|
149
|
+
const found = await controller.getProductBySlug("non-existent");
|
|
150
|
+
expect(found).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── getProductWithVariants ──
|
|
155
|
+
|
|
156
|
+
describe("getProductWithVariants", () => {
|
|
157
|
+
it("returns product with empty variants when none exist", async () => {
|
|
158
|
+
const product = await createTestProduct();
|
|
159
|
+
const result = await controller.getProductWithVariants(product.id);
|
|
160
|
+
expect(result?.id).toBe(product.id);
|
|
161
|
+
expect(result?.variants).toEqual([]);
|
|
162
|
+
expect(result?.category).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("includes variants", async () => {
|
|
166
|
+
const product = await createTestProduct();
|
|
167
|
+
await controller.createVariant({
|
|
168
|
+
productId: product.id,
|
|
169
|
+
name: "Small",
|
|
170
|
+
price: 2999,
|
|
171
|
+
options: { size: "S" },
|
|
172
|
+
});
|
|
173
|
+
await controller.createVariant({
|
|
174
|
+
productId: product.id,
|
|
175
|
+
name: "Large",
|
|
176
|
+
price: 3499,
|
|
177
|
+
options: { size: "L" },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await controller.getProductWithVariants(product.id);
|
|
181
|
+
expect(result?.variants).toHaveLength(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("includes category when assigned", async () => {
|
|
185
|
+
const category = await createTestCategory();
|
|
186
|
+
const product = await createTestProduct({ categoryId: category.id });
|
|
187
|
+
const result = await controller.getProductWithVariants(product.id);
|
|
188
|
+
expect(result?.category?.name).toBe("Test Category");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns null for non-existent product", async () => {
|
|
192
|
+
const result = await controller.getProductWithVariants("missing");
|
|
193
|
+
expect(result).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── updateProduct ──
|
|
198
|
+
|
|
199
|
+
describe("updateProduct", () => {
|
|
200
|
+
it("updates basic fields", async () => {
|
|
201
|
+
const created = await createTestProduct();
|
|
202
|
+
const updated = await controller.updateProduct(created.id, {
|
|
203
|
+
name: "Updated Product",
|
|
204
|
+
price: 3999,
|
|
205
|
+
});
|
|
206
|
+
expect(updated.name).toBe("Updated Product");
|
|
207
|
+
expect(updated.price).toBe(3999);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("preserves fields not in update", async () => {
|
|
211
|
+
const created = await createTestProduct({
|
|
212
|
+
description: "Original",
|
|
213
|
+
tags: ["tag1"],
|
|
214
|
+
});
|
|
215
|
+
const updated = await controller.updateProduct(created.id, {
|
|
216
|
+
name: "New Name",
|
|
217
|
+
});
|
|
218
|
+
expect(updated.description).toBe("Original");
|
|
219
|
+
expect(updated.tags).toEqual(["tag1"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("updates the updatedAt timestamp", async () => {
|
|
223
|
+
const created = await createTestProduct();
|
|
224
|
+
const updated = await controller.updateProduct(created.id, {
|
|
225
|
+
name: "Updated",
|
|
226
|
+
});
|
|
227
|
+
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
228
|
+
created.updatedAt.getTime(),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("throws for non-existent product", async () => {
|
|
233
|
+
await expect(
|
|
234
|
+
controller.updateProduct("missing", { name: "X" }),
|
|
235
|
+
).rejects.toThrow("Product missing not found");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("preserves createdAt", async () => {
|
|
239
|
+
const created = await createTestProduct();
|
|
240
|
+
const updated = await controller.updateProduct(created.id, {
|
|
241
|
+
name: "Updated",
|
|
242
|
+
});
|
|
243
|
+
expect(updated.createdAt.getTime()).toBe(created.createdAt.getTime());
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── deleteProduct ──
|
|
248
|
+
|
|
249
|
+
describe("deleteProduct", () => {
|
|
250
|
+
it("deletes a product", async () => {
|
|
251
|
+
const product = await createTestProduct();
|
|
252
|
+
await controller.deleteProduct(product.id);
|
|
253
|
+
const found = await controller.getProduct(product.id);
|
|
254
|
+
expect(found).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("cascade-deletes all variants", async () => {
|
|
258
|
+
const product = await createTestProduct();
|
|
259
|
+
await controller.createVariant({
|
|
260
|
+
productId: product.id,
|
|
261
|
+
name: "V1",
|
|
262
|
+
price: 100,
|
|
263
|
+
options: { size: "S" },
|
|
264
|
+
});
|
|
265
|
+
await controller.createVariant({
|
|
266
|
+
productId: product.id,
|
|
267
|
+
name: "V2",
|
|
268
|
+
price: 200,
|
|
269
|
+
options: { size: "M" },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await controller.deleteProduct(product.id);
|
|
273
|
+
|
|
274
|
+
const variants = await controller.getVariantsByProduct(product.id);
|
|
275
|
+
expect(variants).toHaveLength(0);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── listProducts ──
|
|
280
|
+
|
|
281
|
+
describe("listProducts", () => {
|
|
282
|
+
it("returns empty list when no products exist", async () => {
|
|
283
|
+
const result = await controller.listProducts();
|
|
284
|
+
expect(result.products).toHaveLength(0);
|
|
285
|
+
expect(result.total).toBe(0);
|
|
286
|
+
expect(result.page).toBe(1);
|
|
287
|
+
expect(result.limit).toBe(20);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("paginates results", async () => {
|
|
291
|
+
for (let i = 0; i < 5; i++) {
|
|
292
|
+
await createTestProduct({ slug: `product-${i}` });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const page1 = await controller.listProducts({ page: 1, limit: 2 });
|
|
296
|
+
expect(page1.products).toHaveLength(2);
|
|
297
|
+
expect(page1.total).toBe(5);
|
|
298
|
+
|
|
299
|
+
const page3 = await controller.listProducts({ page: 3, limit: 2 });
|
|
300
|
+
expect(page3.products).toHaveLength(1);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("filters by status", async () => {
|
|
304
|
+
await createTestProduct({ slug: "active-1", status: "active" });
|
|
305
|
+
await createTestProduct({ slug: "draft-1", status: "draft" });
|
|
306
|
+
|
|
307
|
+
const result = await controller.listProducts({ status: "active" });
|
|
308
|
+
expect(result.products).toHaveLength(1);
|
|
309
|
+
expect(result.products[0].status).toBe("active");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("filters by featured", async () => {
|
|
313
|
+
await createTestProduct({ slug: "featured", isFeatured: true });
|
|
314
|
+
await createTestProduct({ slug: "normal", isFeatured: false });
|
|
315
|
+
|
|
316
|
+
const result = await controller.listProducts({ featured: true });
|
|
317
|
+
expect(result.products).toHaveLength(1);
|
|
318
|
+
expect(result.products[0].isFeatured).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("filters by price range", async () => {
|
|
322
|
+
await createTestProduct({ slug: "cheap", price: 1000 });
|
|
323
|
+
await createTestProduct({ slug: "mid", price: 3000 });
|
|
324
|
+
await createTestProduct({ slug: "expensive", price: 5000 });
|
|
325
|
+
|
|
326
|
+
const result = await controller.listProducts({
|
|
327
|
+
minPrice: 2000,
|
|
328
|
+
maxPrice: 4000,
|
|
329
|
+
});
|
|
330
|
+
expect(result.products).toHaveLength(1);
|
|
331
|
+
expect(result.products[0].slug).toBe("mid");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("filters by in-stock", async () => {
|
|
335
|
+
await createTestProduct({ slug: "in-stock", inventory: 10 });
|
|
336
|
+
await createTestProduct({ slug: "out-of-stock", inventory: 0 });
|
|
337
|
+
|
|
338
|
+
const result = await controller.listProducts({ inStock: true });
|
|
339
|
+
expect(result.products).toHaveLength(1);
|
|
340
|
+
expect(result.products[0].slug).toBe("in-stock");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("filters by tag", async () => {
|
|
344
|
+
await createTestProduct({ slug: "tagged", tags: ["Sale", "New"] });
|
|
345
|
+
await createTestProduct({ slug: "untagged", tags: [] });
|
|
346
|
+
|
|
347
|
+
const result = await controller.listProducts({ tag: "sale" });
|
|
348
|
+
expect(result.products).toHaveLength(1);
|
|
349
|
+
expect(result.products[0].slug).toBe("tagged");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("filters by search query", async () => {
|
|
353
|
+
await createTestProduct({
|
|
354
|
+
name: "Blue Widget",
|
|
355
|
+
slug: "blue-widget",
|
|
356
|
+
});
|
|
357
|
+
await createTestProduct({
|
|
358
|
+
name: "Red Gadget",
|
|
359
|
+
slug: "red-gadget",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const result = await controller.listProducts({ search: "widget" });
|
|
363
|
+
expect(result.products).toHaveLength(1);
|
|
364
|
+
expect(result.products[0].name).toBe("Blue Widget");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("filters by category", async () => {
|
|
368
|
+
const cat = await createTestCategory();
|
|
369
|
+
await createTestProduct({ slug: "in-cat", categoryId: cat.id });
|
|
370
|
+
await createTestProduct({ slug: "no-cat" });
|
|
371
|
+
|
|
372
|
+
const result = await controller.listProducts({ category: cat.id });
|
|
373
|
+
expect(result.products).toHaveLength(1);
|
|
374
|
+
expect(result.products[0].slug).toBe("in-cat");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("includes variants in results", async () => {
|
|
378
|
+
const product = await createTestProduct();
|
|
379
|
+
await controller.createVariant({
|
|
380
|
+
productId: product.id,
|
|
381
|
+
name: "V1",
|
|
382
|
+
price: 100,
|
|
383
|
+
options: { size: "S" },
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = await controller.listProducts();
|
|
387
|
+
expect(result.products[0].variants).toHaveLength(1);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── searchProducts ──
|
|
392
|
+
|
|
393
|
+
describe("searchProducts", () => {
|
|
394
|
+
it("searches active products by name", async () => {
|
|
395
|
+
await createTestProduct({
|
|
396
|
+
name: "Blue Widget",
|
|
397
|
+
slug: "blue-widget",
|
|
398
|
+
status: "active",
|
|
399
|
+
});
|
|
400
|
+
await createTestProduct({
|
|
401
|
+
name: "Red Gadget",
|
|
402
|
+
slug: "red-gadget",
|
|
403
|
+
status: "active",
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const results = await controller.searchProducts("widget");
|
|
407
|
+
expect(results).toHaveLength(1);
|
|
408
|
+
expect(results[0].name).toBe("Blue Widget");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("excludes non-active products", async () => {
|
|
412
|
+
await createTestProduct({
|
|
413
|
+
name: "Draft Widget",
|
|
414
|
+
slug: "draft-widget",
|
|
415
|
+
status: "draft",
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const results = await controller.searchProducts("widget");
|
|
419
|
+
expect(results).toHaveLength(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("searches by description", async () => {
|
|
423
|
+
await createTestProduct({
|
|
424
|
+
slug: "p1",
|
|
425
|
+
status: "active",
|
|
426
|
+
description: "A wonderful ceramic vase",
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const results = await controller.searchProducts("ceramic");
|
|
430
|
+
expect(results).toHaveLength(1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("searches by tags", async () => {
|
|
434
|
+
await createTestProduct({
|
|
435
|
+
slug: "p1",
|
|
436
|
+
status: "active",
|
|
437
|
+
tags: ["handmade", "artisan"],
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const results = await controller.searchProducts("artisan");
|
|
441
|
+
expect(results).toHaveLength(1);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("is case-insensitive", async () => {
|
|
445
|
+
await createTestProduct({
|
|
446
|
+
name: "BLUE Widget",
|
|
447
|
+
slug: "blue-widget",
|
|
448
|
+
status: "active",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const results = await controller.searchProducts("blue widget");
|
|
452
|
+
expect(results).toHaveLength(1);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("respects limit", async () => {
|
|
456
|
+
for (let i = 0; i < 5; i++) {
|
|
457
|
+
await createTestProduct({
|
|
458
|
+
name: `Widget ${i}`,
|
|
459
|
+
slug: `widget-${i}`,
|
|
460
|
+
status: "active",
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const results = await controller.searchProducts("Widget", 2);
|
|
465
|
+
expect(results).toHaveLength(2);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ── getFeaturedProducts ──
|
|
470
|
+
|
|
471
|
+
describe("getFeaturedProducts", () => {
|
|
472
|
+
it("returns only active featured products", async () => {
|
|
473
|
+
await createTestProduct({
|
|
474
|
+
slug: "featured-active",
|
|
475
|
+
isFeatured: true,
|
|
476
|
+
status: "active",
|
|
477
|
+
});
|
|
478
|
+
await createTestProduct({
|
|
479
|
+
slug: "featured-draft",
|
|
480
|
+
isFeatured: true,
|
|
481
|
+
status: "draft",
|
|
482
|
+
});
|
|
483
|
+
await createTestProduct({
|
|
484
|
+
slug: "not-featured",
|
|
485
|
+
isFeatured: false,
|
|
486
|
+
status: "active",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const results = await controller.getFeaturedProducts();
|
|
490
|
+
expect(results).toHaveLength(1);
|
|
491
|
+
expect(results[0].slug).toBe("featured-active");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("respects limit", async () => {
|
|
495
|
+
for (let i = 0; i < 5; i++) {
|
|
496
|
+
await createTestProduct({
|
|
497
|
+
slug: `featured-${i}`,
|
|
498
|
+
isFeatured: true,
|
|
499
|
+
status: "active",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const results = await controller.getFeaturedProducts(3);
|
|
504
|
+
expect(results).toHaveLength(3);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("returns empty array when no featured products", async () => {
|
|
508
|
+
await createTestProduct({ isFeatured: false, status: "active" });
|
|
509
|
+
const results = await controller.getFeaturedProducts();
|
|
510
|
+
expect(results).toHaveLength(0);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ── getProductsByCategory ──
|
|
515
|
+
|
|
516
|
+
describe("getProductsByCategory", () => {
|
|
517
|
+
it("returns active products in a category", async () => {
|
|
518
|
+
const cat = await createTestCategory();
|
|
519
|
+
await createTestProduct({
|
|
520
|
+
slug: "active",
|
|
521
|
+
categoryId: cat.id,
|
|
522
|
+
status: "active",
|
|
523
|
+
});
|
|
524
|
+
await createTestProduct({
|
|
525
|
+
slug: "draft",
|
|
526
|
+
categoryId: cat.id,
|
|
527
|
+
status: "draft",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const results = await controller.getProductsByCategory(cat.id);
|
|
531
|
+
expect(results).toHaveLength(1);
|
|
532
|
+
expect(results[0].slug).toBe("active");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("returns empty for category with no products", async () => {
|
|
536
|
+
const results = await controller.getProductsByCategory("empty-cat");
|
|
537
|
+
expect(results).toHaveLength(0);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ── getRelatedProducts ──
|
|
542
|
+
|
|
543
|
+
describe("getRelatedProducts", () => {
|
|
544
|
+
it("scores higher for same category", async () => {
|
|
545
|
+
const cat = await createTestCategory();
|
|
546
|
+
const product = await createTestProduct({
|
|
547
|
+
slug: "main",
|
|
548
|
+
categoryId: cat.id,
|
|
549
|
+
status: "active",
|
|
550
|
+
});
|
|
551
|
+
await createTestProduct({
|
|
552
|
+
slug: "same-cat",
|
|
553
|
+
categoryId: cat.id,
|
|
554
|
+
status: "active",
|
|
555
|
+
});
|
|
556
|
+
await createTestProduct({
|
|
557
|
+
slug: "diff-cat",
|
|
558
|
+
status: "active",
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const result = await controller.getRelatedProducts(product.id);
|
|
562
|
+
expect(result.products[0].slug).toBe("same-cat");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("scores higher for shared tags", async () => {
|
|
566
|
+
const product = await createTestProduct({
|
|
567
|
+
slug: "main",
|
|
568
|
+
tags: ["electronics", "sale"],
|
|
569
|
+
status: "active",
|
|
570
|
+
});
|
|
571
|
+
await createTestProduct({
|
|
572
|
+
slug: "two-tags",
|
|
573
|
+
tags: ["electronics", "sale"],
|
|
574
|
+
status: "active",
|
|
575
|
+
});
|
|
576
|
+
await createTestProduct({
|
|
577
|
+
slug: "one-tag",
|
|
578
|
+
tags: ["electronics"],
|
|
579
|
+
status: "active",
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const result = await controller.getRelatedProducts(product.id);
|
|
583
|
+
expect(result.products[0].slug).toBe("two-tags");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("excludes the product itself", async () => {
|
|
587
|
+
const product = await createTestProduct({
|
|
588
|
+
slug: "main",
|
|
589
|
+
status: "active",
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const result = await controller.getRelatedProducts(product.id);
|
|
593
|
+
expect(result.products.find((p) => p.id === product.id)).toBeUndefined();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("returns empty for non-existent product", async () => {
|
|
597
|
+
const result = await controller.getRelatedProducts("missing");
|
|
598
|
+
expect(result.products).toHaveLength(0);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("respects limit", async () => {
|
|
602
|
+
const product = await createTestProduct({
|
|
603
|
+
slug: "main",
|
|
604
|
+
status: "active",
|
|
605
|
+
});
|
|
606
|
+
for (let i = 0; i < 5; i++) {
|
|
607
|
+
await createTestProduct({ slug: `related-${i}`, status: "active" });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const result = await controller.getRelatedProducts(product.id, 2);
|
|
611
|
+
expect(result.products).toHaveLength(2);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ── Inventory ──
|
|
616
|
+
|
|
617
|
+
describe("checkAvailability", () => {
|
|
618
|
+
it("returns available when inventory sufficient", async () => {
|
|
619
|
+
const product = await createTestProduct({ inventory: 10 });
|
|
620
|
+
const result = await controller.checkAvailability(product.id);
|
|
621
|
+
expect(result.available).toBe(true);
|
|
622
|
+
expect(result.inventory).toBe(10);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("returns unavailable when inventory insufficient", async () => {
|
|
626
|
+
const product = await createTestProduct({ inventory: 2 });
|
|
627
|
+
const result = await controller.checkAvailability(
|
|
628
|
+
product.id,
|
|
629
|
+
undefined,
|
|
630
|
+
5,
|
|
631
|
+
);
|
|
632
|
+
expect(result.available).toBe(false);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("returns available when backorder allowed", async () => {
|
|
636
|
+
const product = await createTestProduct({
|
|
637
|
+
inventory: 0,
|
|
638
|
+
allowBackorder: true,
|
|
639
|
+
});
|
|
640
|
+
const result = await controller.checkAvailability(product.id);
|
|
641
|
+
expect(result.available).toBe(true);
|
|
642
|
+
expect(result.allowBackorder).toBe(true);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("always available when trackInventory is false", async () => {
|
|
646
|
+
const product = await createTestProduct({
|
|
647
|
+
inventory: 0,
|
|
648
|
+
trackInventory: false,
|
|
649
|
+
});
|
|
650
|
+
const result = await controller.checkAvailability(product.id);
|
|
651
|
+
expect(result.available).toBe(true);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("checks variant inventory", async () => {
|
|
655
|
+
const product = await createTestProduct();
|
|
656
|
+
const variant = await controller.createVariant({
|
|
657
|
+
productId: product.id,
|
|
658
|
+
name: "Small",
|
|
659
|
+
price: 100,
|
|
660
|
+
options: { size: "S" },
|
|
661
|
+
inventory: 5,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const result = await controller.checkAvailability(product.id, variant.id);
|
|
665
|
+
expect(result.available).toBe(true);
|
|
666
|
+
expect(result.inventory).toBe(5);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("returns unavailable for missing product", async () => {
|
|
670
|
+
const result = await controller.checkAvailability("missing");
|
|
671
|
+
expect(result.available).toBe(false);
|
|
672
|
+
expect(result.inventory).toBe(0);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("returns unavailable for missing variant", async () => {
|
|
676
|
+
const product = await createTestProduct();
|
|
677
|
+
const result = await controller.checkAvailability(
|
|
678
|
+
product.id,
|
|
679
|
+
"missing-variant",
|
|
680
|
+
);
|
|
681
|
+
expect(result.available).toBe(false);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe("decrementInventory", () => {
|
|
686
|
+
it("decrements product inventory", async () => {
|
|
687
|
+
const product = await createTestProduct({ inventory: 10 });
|
|
688
|
+
await controller.decrementInventory(product.id, 3);
|
|
689
|
+
|
|
690
|
+
const updated = await controller.getProduct(product.id);
|
|
691
|
+
expect(updated?.inventory).toBe(7);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("allows inventory to go negative (no floor)", async () => {
|
|
695
|
+
const product = await createTestProduct({ inventory: 2 });
|
|
696
|
+
await controller.decrementInventory(product.id, 5);
|
|
697
|
+
|
|
698
|
+
const updated = await controller.getProduct(product.id);
|
|
699
|
+
expect(updated?.inventory).toBe(-3);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("skips untracked products", async () => {
|
|
703
|
+
const product = await createTestProduct({
|
|
704
|
+
inventory: 10,
|
|
705
|
+
trackInventory: false,
|
|
706
|
+
});
|
|
707
|
+
await controller.decrementInventory(product.id, 3);
|
|
708
|
+
|
|
709
|
+
const updated = await controller.getProduct(product.id);
|
|
710
|
+
expect(updated?.inventory).toBe(10);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("decrements variant inventory", async () => {
|
|
714
|
+
const product = await createTestProduct();
|
|
715
|
+
const variant = await controller.createVariant({
|
|
716
|
+
productId: product.id,
|
|
717
|
+
name: "Small",
|
|
718
|
+
price: 100,
|
|
719
|
+
options: { size: "S" },
|
|
720
|
+
inventory: 10,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
await controller.decrementInventory(product.id, 4, variant.id);
|
|
724
|
+
|
|
725
|
+
const updated = await controller.getVariant(variant.id);
|
|
726
|
+
expect(updated?.inventory).toBe(6);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
describe("incrementInventory", () => {
|
|
731
|
+
it("increments product inventory", async () => {
|
|
732
|
+
const product = await createTestProduct({ inventory: 5 });
|
|
733
|
+
await controller.incrementInventory(product.id, 10);
|
|
734
|
+
|
|
735
|
+
const updated = await controller.getProduct(product.id);
|
|
736
|
+
expect(updated?.inventory).toBe(15);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("skips untracked products", async () => {
|
|
740
|
+
const product = await createTestProduct({
|
|
741
|
+
inventory: 5,
|
|
742
|
+
trackInventory: false,
|
|
743
|
+
});
|
|
744
|
+
await controller.incrementInventory(product.id, 10);
|
|
745
|
+
|
|
746
|
+
const updated = await controller.getProduct(product.id);
|
|
747
|
+
expect(updated?.inventory).toBe(5);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("increments variant inventory", async () => {
|
|
751
|
+
const product = await createTestProduct();
|
|
752
|
+
const variant = await controller.createVariant({
|
|
753
|
+
productId: product.id,
|
|
754
|
+
name: "Small",
|
|
755
|
+
price: 100,
|
|
756
|
+
options: { size: "S" },
|
|
757
|
+
inventory: 5,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
await controller.incrementInventory(product.id, 10, variant.id);
|
|
761
|
+
|
|
762
|
+
const updated = await controller.getVariant(variant.id);
|
|
763
|
+
expect(updated?.inventory).toBe(15);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// ── Variants ──
|
|
768
|
+
|
|
769
|
+
describe("createVariant", () => {
|
|
770
|
+
it("creates a variant with required fields", async () => {
|
|
771
|
+
const product = await createTestProduct();
|
|
772
|
+
const variant = await controller.createVariant({
|
|
773
|
+
productId: product.id,
|
|
774
|
+
name: "Small Blue",
|
|
775
|
+
price: 2999,
|
|
776
|
+
options: { size: "S", color: "blue" },
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
expect(variant.id).toBeDefined();
|
|
780
|
+
expect(variant.productId).toBe(product.id);
|
|
781
|
+
expect(variant.name).toBe("Small Blue");
|
|
782
|
+
expect(variant.price).toBe(2999);
|
|
783
|
+
expect(variant.options).toEqual({ size: "S", color: "blue" });
|
|
784
|
+
expect(variant.inventory).toBe(0);
|
|
785
|
+
expect(variant.position).toBe(0);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("updates parent product updatedAt", async () => {
|
|
789
|
+
const product = await createTestProduct();
|
|
790
|
+
const originalUpdatedAt = product.updatedAt;
|
|
791
|
+
|
|
792
|
+
await controller.createVariant({
|
|
793
|
+
productId: product.id,
|
|
794
|
+
name: "Small",
|
|
795
|
+
price: 100,
|
|
796
|
+
options: { size: "S" },
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const updatedProduct = await controller.getProduct(product.id);
|
|
800
|
+
expect(updatedProduct?.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
801
|
+
originalUpdatedAt.getTime(),
|
|
802
|
+
);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("applies defaults for optional fields", async () => {
|
|
806
|
+
const product = await createTestProduct();
|
|
807
|
+
const variant = await controller.createVariant({
|
|
808
|
+
productId: product.id,
|
|
809
|
+
name: "Default",
|
|
810
|
+
price: 100,
|
|
811
|
+
options: {},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
expect(variant.inventory).toBe(0);
|
|
815
|
+
expect(variant.images).toEqual([]);
|
|
816
|
+
expect(variant.position).toBe(0);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe("getVariant", () => {
|
|
821
|
+
it("returns variant by ID", async () => {
|
|
822
|
+
const product = await createTestProduct();
|
|
823
|
+
const variant = await controller.createVariant({
|
|
824
|
+
productId: product.id,
|
|
825
|
+
name: "Small",
|
|
826
|
+
price: 100,
|
|
827
|
+
options: { size: "S" },
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const found = await controller.getVariant(variant.id);
|
|
831
|
+
expect(found?.name).toBe("Small");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("returns null for non-existent variant", async () => {
|
|
835
|
+
const found = await controller.getVariant("missing");
|
|
836
|
+
expect(found).toBeNull();
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
describe("getVariantsByProduct", () => {
|
|
841
|
+
it("returns variants sorted by position", async () => {
|
|
842
|
+
const product = await createTestProduct();
|
|
843
|
+
await controller.createVariant({
|
|
844
|
+
productId: product.id,
|
|
845
|
+
name: "Second",
|
|
846
|
+
price: 200,
|
|
847
|
+
options: { size: "L" },
|
|
848
|
+
position: 2,
|
|
849
|
+
});
|
|
850
|
+
await controller.createVariant({
|
|
851
|
+
productId: product.id,
|
|
852
|
+
name: "First",
|
|
853
|
+
price: 100,
|
|
854
|
+
options: { size: "S" },
|
|
855
|
+
position: 1,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
const variants = await controller.getVariantsByProduct(product.id);
|
|
859
|
+
expect(variants).toHaveLength(2);
|
|
860
|
+
expect(variants[0].name).toBe("First");
|
|
861
|
+
expect(variants[1].name).toBe("Second");
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("returns empty for product with no variants", async () => {
|
|
865
|
+
const product = await createTestProduct();
|
|
866
|
+
const variants = await controller.getVariantsByProduct(product.id);
|
|
867
|
+
expect(variants).toHaveLength(0);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("isolates variants per product", async () => {
|
|
871
|
+
const p1 = await createTestProduct({ slug: "p1" });
|
|
872
|
+
const p2 = await createTestProduct({ slug: "p2" });
|
|
873
|
+
|
|
874
|
+
await controller.createVariant({
|
|
875
|
+
productId: p1.id,
|
|
876
|
+
name: "P1 Var",
|
|
877
|
+
price: 100,
|
|
878
|
+
options: {},
|
|
879
|
+
});
|
|
880
|
+
await controller.createVariant({
|
|
881
|
+
productId: p2.id,
|
|
882
|
+
name: "P2 Var",
|
|
883
|
+
price: 200,
|
|
884
|
+
options: {},
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const p1Variants = await controller.getVariantsByProduct(p1.id);
|
|
888
|
+
expect(p1Variants).toHaveLength(1);
|
|
889
|
+
expect(p1Variants[0].name).toBe("P1 Var");
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
describe("updateVariant", () => {
|
|
894
|
+
it("updates variant fields", async () => {
|
|
895
|
+
const product = await createTestProduct();
|
|
896
|
+
const variant = await controller.createVariant({
|
|
897
|
+
productId: product.id,
|
|
898
|
+
name: "Small",
|
|
899
|
+
price: 100,
|
|
900
|
+
options: { size: "S" },
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
const updated = await controller.updateVariant(variant.id, {
|
|
904
|
+
name: "Medium",
|
|
905
|
+
price: 200,
|
|
906
|
+
});
|
|
907
|
+
expect(updated.name).toBe("Medium");
|
|
908
|
+
expect(updated.price).toBe(200);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("updates parent product updatedAt", async () => {
|
|
912
|
+
const product = await createTestProduct();
|
|
913
|
+
const variant = await controller.createVariant({
|
|
914
|
+
productId: product.id,
|
|
915
|
+
name: "Small",
|
|
916
|
+
price: 100,
|
|
917
|
+
options: { size: "S" },
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
await controller.updateVariant(variant.id, { price: 150 });
|
|
921
|
+
|
|
922
|
+
const updatedProduct = await controller.getProduct(product.id);
|
|
923
|
+
expect(updatedProduct?.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
924
|
+
product.updatedAt.getTime(),
|
|
925
|
+
);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("throws for non-existent variant", async () => {
|
|
929
|
+
await expect(
|
|
930
|
+
controller.updateVariant("missing", { name: "X" }),
|
|
931
|
+
).rejects.toThrow("Variant missing not found");
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
describe("deleteVariant", () => {
|
|
936
|
+
it("deletes a variant", async () => {
|
|
937
|
+
const product = await createTestProduct();
|
|
938
|
+
const variant = await controller.createVariant({
|
|
939
|
+
productId: product.id,
|
|
940
|
+
name: "Small",
|
|
941
|
+
price: 100,
|
|
942
|
+
options: { size: "S" },
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
await controller.deleteVariant(variant.id);
|
|
946
|
+
const found = await controller.getVariant(variant.id);
|
|
947
|
+
expect(found).toBeNull();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("updates parent product updatedAt", async () => {
|
|
951
|
+
const product = await createTestProduct();
|
|
952
|
+
const variant = await controller.createVariant({
|
|
953
|
+
productId: product.id,
|
|
954
|
+
name: "Small",
|
|
955
|
+
price: 100,
|
|
956
|
+
options: { size: "S" },
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
await controller.deleteVariant(variant.id);
|
|
960
|
+
|
|
961
|
+
const updatedProduct = await controller.getProduct(product.id);
|
|
962
|
+
expect(updatedProduct?.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
963
|
+
product.updatedAt.getTime(),
|
|
964
|
+
);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// ── Categories ──
|
|
969
|
+
|
|
970
|
+
describe("createCategory", () => {
|
|
971
|
+
it("creates a category with defaults", async () => {
|
|
972
|
+
const cat = await createTestCategory();
|
|
973
|
+
expect(cat.id).toBeDefined();
|
|
974
|
+
expect(cat.name).toBe("Test Category");
|
|
975
|
+
expect(cat.slug).toBe("test-category");
|
|
976
|
+
expect(cat.position).toBe(0);
|
|
977
|
+
expect(cat.isVisible).toBe(true);
|
|
978
|
+
expect(cat.createdAt).toBeInstanceOf(Date);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("creates with all optional fields", async () => {
|
|
982
|
+
const cat = await createTestCategory({
|
|
983
|
+
description: "A category",
|
|
984
|
+
parentId: "parent-1",
|
|
985
|
+
image: "cat.jpg",
|
|
986
|
+
position: 5,
|
|
987
|
+
isVisible: false,
|
|
988
|
+
metadata: { key: "value" },
|
|
989
|
+
});
|
|
990
|
+
expect(cat.description).toBe("A category");
|
|
991
|
+
expect(cat.parentId).toBe("parent-1");
|
|
992
|
+
expect(cat.image).toBe("cat.jpg");
|
|
993
|
+
expect(cat.position).toBe(5);
|
|
994
|
+
expect(cat.isVisible).toBe(false);
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
describe("getCategory", () => {
|
|
999
|
+
it("returns category by ID", async () => {
|
|
1000
|
+
const cat = await createTestCategory();
|
|
1001
|
+
const found = await controller.getCategory(cat.id);
|
|
1002
|
+
expect(found?.name).toBe("Test Category");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("returns null for missing category", async () => {
|
|
1006
|
+
const found = await controller.getCategory("missing");
|
|
1007
|
+
expect(found).toBeNull();
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
describe("getCategoryBySlug", () => {
|
|
1012
|
+
it("returns category by slug", async () => {
|
|
1013
|
+
await createTestCategory();
|
|
1014
|
+
const found = await controller.getCategoryBySlug("test-category");
|
|
1015
|
+
expect(found?.name).toBe("Test Category");
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("returns null for missing slug", async () => {
|
|
1019
|
+
const found = await controller.getCategoryBySlug("missing");
|
|
1020
|
+
expect(found).toBeNull();
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
describe("listCategories", () => {
|
|
1025
|
+
it("returns categories sorted by position", async () => {
|
|
1026
|
+
await createTestCategory({ slug: "second", position: 2 });
|
|
1027
|
+
await createTestCategory({ slug: "first", position: 1 });
|
|
1028
|
+
|
|
1029
|
+
const result = await controller.listCategories();
|
|
1030
|
+
expect(result.categories[0].slug).toBe("first");
|
|
1031
|
+
expect(result.categories[1].slug).toBe("second");
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("filters by parentId", async () => {
|
|
1035
|
+
const parent = await createTestCategory({ slug: "parent" });
|
|
1036
|
+
await createTestCategory({ slug: "child", parentId: parent.id });
|
|
1037
|
+
await createTestCategory({ slug: "orphan" });
|
|
1038
|
+
|
|
1039
|
+
const result = await controller.listCategories({
|
|
1040
|
+
parentId: parent.id,
|
|
1041
|
+
});
|
|
1042
|
+
expect(result.categories).toHaveLength(1);
|
|
1043
|
+
expect(result.categories[0].slug).toBe("child");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("filters by visible", async () => {
|
|
1047
|
+
await createTestCategory({ slug: "visible", isVisible: true });
|
|
1048
|
+
await createTestCategory({ slug: "hidden", isVisible: false });
|
|
1049
|
+
|
|
1050
|
+
const result = await controller.listCategories({ visible: true });
|
|
1051
|
+
expect(result.categories).toHaveLength(1);
|
|
1052
|
+
expect(result.categories[0].slug).toBe("visible");
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
describe("getCategoryTree", () => {
|
|
1057
|
+
it("builds hierarchical tree", async () => {
|
|
1058
|
+
const parent = await createTestCategory({
|
|
1059
|
+
slug: "parent",
|
|
1060
|
+
isVisible: true,
|
|
1061
|
+
});
|
|
1062
|
+
await createTestCategory({
|
|
1063
|
+
slug: "child-1",
|
|
1064
|
+
parentId: parent.id,
|
|
1065
|
+
isVisible: true,
|
|
1066
|
+
position: 1,
|
|
1067
|
+
});
|
|
1068
|
+
await createTestCategory({
|
|
1069
|
+
slug: "child-2",
|
|
1070
|
+
parentId: parent.id,
|
|
1071
|
+
isVisible: true,
|
|
1072
|
+
position: 2,
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const tree = await controller.getCategoryTree();
|
|
1076
|
+
expect(tree).toHaveLength(1);
|
|
1077
|
+
expect(tree[0].slug).toBe("parent");
|
|
1078
|
+
expect(tree[0].children).toHaveLength(2);
|
|
1079
|
+
expect(tree[0].children[0].slug).toBe("child-1");
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("excludes invisible categories", async () => {
|
|
1083
|
+
await createTestCategory({ slug: "visible", isVisible: true });
|
|
1084
|
+
await createTestCategory({ slug: "hidden", isVisible: false });
|
|
1085
|
+
|
|
1086
|
+
const tree = await controller.getCategoryTree();
|
|
1087
|
+
expect(tree).toHaveLength(1);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("promotes orphans to root when parent is not visible", async () => {
|
|
1091
|
+
const parent = await createTestCategory({
|
|
1092
|
+
slug: "hidden-parent",
|
|
1093
|
+
isVisible: false,
|
|
1094
|
+
});
|
|
1095
|
+
await createTestCategory({
|
|
1096
|
+
slug: "orphan-child",
|
|
1097
|
+
parentId: parent.id,
|
|
1098
|
+
isVisible: true,
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
const tree = await controller.getCategoryTree();
|
|
1102
|
+
expect(tree).toHaveLength(1);
|
|
1103
|
+
expect(tree[0].slug).toBe("orphan-child");
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
describe("updateCategory", () => {
|
|
1108
|
+
it("updates category fields", async () => {
|
|
1109
|
+
const cat = await createTestCategory();
|
|
1110
|
+
const updated = await controller.updateCategory(cat.id, {
|
|
1111
|
+
name: "Updated Category",
|
|
1112
|
+
});
|
|
1113
|
+
expect(updated.name).toBe("Updated Category");
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("throws for non-existent category", async () => {
|
|
1117
|
+
await expect(
|
|
1118
|
+
controller.updateCategory("missing", { name: "X" }),
|
|
1119
|
+
).rejects.toThrow("Category missing not found");
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("preserves createdAt", async () => {
|
|
1123
|
+
const cat = await createTestCategory();
|
|
1124
|
+
const updated = await controller.updateCategory(cat.id, {
|
|
1125
|
+
name: "Updated",
|
|
1126
|
+
});
|
|
1127
|
+
expect(updated.createdAt.getTime()).toBe(cat.createdAt.getTime());
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
describe("deleteCategory", () => {
|
|
1132
|
+
it("deletes a category", async () => {
|
|
1133
|
+
const cat = await createTestCategory();
|
|
1134
|
+
await controller.deleteCategory(cat.id);
|
|
1135
|
+
const found = await controller.getCategory(cat.id);
|
|
1136
|
+
expect(found).toBeNull();
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("orphans products in the category", async () => {
|
|
1140
|
+
const cat = await createTestCategory();
|
|
1141
|
+
const product = await createTestProduct({ categoryId: cat.id });
|
|
1142
|
+
|
|
1143
|
+
await controller.deleteCategory(cat.id);
|
|
1144
|
+
|
|
1145
|
+
const updated = await controller.getProduct(product.id);
|
|
1146
|
+
expect(updated?.categoryId).toBeUndefined();
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("orphans subcategories", async () => {
|
|
1150
|
+
const parent = await createTestCategory({ slug: "parent" });
|
|
1151
|
+
const child = await createTestCategory({
|
|
1152
|
+
slug: "child",
|
|
1153
|
+
parentId: parent.id,
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
await controller.deleteCategory(parent.id);
|
|
1157
|
+
|
|
1158
|
+
const updated = await controller.getCategory(child.id);
|
|
1159
|
+
expect(updated?.parentId).toBeUndefined();
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// ── Bulk ──
|
|
1164
|
+
|
|
1165
|
+
describe("bulkUpdateStatus", () => {
|
|
1166
|
+
it("updates status of multiple products", async () => {
|
|
1167
|
+
const p1 = await createTestProduct({ slug: "p1" });
|
|
1168
|
+
const p2 = await createTestProduct({ slug: "p2" });
|
|
1169
|
+
|
|
1170
|
+
const result = await controller.bulkUpdateStatus(
|
|
1171
|
+
[p1.id, p2.id],
|
|
1172
|
+
"active",
|
|
1173
|
+
);
|
|
1174
|
+
expect(result.updated).toBe(2);
|
|
1175
|
+
|
|
1176
|
+
const u1 = await controller.getProduct(p1.id);
|
|
1177
|
+
const u2 = await controller.getProduct(p2.id);
|
|
1178
|
+
expect(u1?.status).toBe("active");
|
|
1179
|
+
expect(u2?.status).toBe("active");
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("skips non-existent products", async () => {
|
|
1183
|
+
const p1 = await createTestProduct({ slug: "p1" });
|
|
1184
|
+
const result = await controller.bulkUpdateStatus(
|
|
1185
|
+
[p1.id, "missing"],
|
|
1186
|
+
"active",
|
|
1187
|
+
);
|
|
1188
|
+
expect(result.updated).toBe(1);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it("returns 0 for empty ids", async () => {
|
|
1192
|
+
const result = await controller.bulkUpdateStatus([], "active");
|
|
1193
|
+
expect(result.updated).toBe(0);
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
describe("bulkDelete", () => {
|
|
1198
|
+
it("deletes multiple products with their variants", async () => {
|
|
1199
|
+
const p1 = await createTestProduct({ slug: "p1" });
|
|
1200
|
+
const p2 = await createTestProduct({ slug: "p2" });
|
|
1201
|
+
await controller.createVariant({
|
|
1202
|
+
productId: p1.id,
|
|
1203
|
+
name: "V1",
|
|
1204
|
+
price: 100,
|
|
1205
|
+
options: {},
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
const result = await controller.bulkDelete([p1.id, p2.id]);
|
|
1209
|
+
expect(result.deleted).toBe(2);
|
|
1210
|
+
|
|
1211
|
+
expect(await controller.getProduct(p1.id)).toBeNull();
|
|
1212
|
+
expect(await controller.getProduct(p2.id)).toBeNull();
|
|
1213
|
+
expect(await controller.getVariantsByProduct(p1.id)).toHaveLength(0);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it("skips non-existent products", async () => {
|
|
1217
|
+
const result = await controller.bulkDelete(["missing"]);
|
|
1218
|
+
expect(result.deleted).toBe(0);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it("returns 0 for empty ids", async () => {
|
|
1222
|
+
const result = await controller.bulkDelete([]);
|
|
1223
|
+
expect(result.deleted).toBe(0);
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// ── Import ──
|
|
1228
|
+
|
|
1229
|
+
describe("importProducts", () => {
|
|
1230
|
+
it("creates new products from import rows", async () => {
|
|
1231
|
+
const result = await controller.importProducts([
|
|
1232
|
+
{ name: "Widget A", price: 19.99 },
|
|
1233
|
+
{ name: "Widget B", price: 29.99 },
|
|
1234
|
+
]);
|
|
1235
|
+
expect(result.created).toBe(2);
|
|
1236
|
+
expect(result.updated).toBe(0);
|
|
1237
|
+
expect(result.errors).toHaveLength(0);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it("updates existing products by SKU", async () => {
|
|
1241
|
+
await createTestProduct({ sku: "SKU-001", price: 1000 });
|
|
1242
|
+
|
|
1243
|
+
const result = await controller.importProducts([
|
|
1244
|
+
{ name: "Updated Widget", price: 20.0, sku: "SKU-001" },
|
|
1245
|
+
]);
|
|
1246
|
+
expect(result.updated).toBe(1);
|
|
1247
|
+
expect(result.created).toBe(0);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
it("reports errors for missing name", async () => {
|
|
1251
|
+
const result = await controller.importProducts([{ name: "", price: 10 }]);
|
|
1252
|
+
expect(result.errors).toHaveLength(1);
|
|
1253
|
+
expect(result.errors[0].field).toBe("name");
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it("reports errors for missing price", async () => {
|
|
1257
|
+
const result = await controller.importProducts([
|
|
1258
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
1259
|
+
{ name: "Widget", price: undefined as any },
|
|
1260
|
+
]);
|
|
1261
|
+
expect(result.errors).toHaveLength(1);
|
|
1262
|
+
expect(result.errors[0].field).toBe("price");
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it("reports errors for invalid price", async () => {
|
|
1266
|
+
const result = await controller.importProducts([
|
|
1267
|
+
{ name: "Widget", price: -10 },
|
|
1268
|
+
]);
|
|
1269
|
+
expect(result.errors).toHaveLength(1);
|
|
1270
|
+
expect(result.errors[0].field).toBe("price");
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("auto-generates slugs from name", async () => {
|
|
1274
|
+
const result = await controller.importProducts([
|
|
1275
|
+
{ name: "My Cool Widget", price: 10 },
|
|
1276
|
+
]);
|
|
1277
|
+
expect(result.created).toBe(1);
|
|
1278
|
+
|
|
1279
|
+
const products = (await controller.listProducts()).products;
|
|
1280
|
+
expect(products[0].slug).toBe("my-cool-widget");
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("deduplicates slugs", async () => {
|
|
1284
|
+
await createTestProduct({ slug: "widget" });
|
|
1285
|
+
|
|
1286
|
+
const result = await controller.importProducts([
|
|
1287
|
+
{ name: "Widget", price: 10 },
|
|
1288
|
+
]);
|
|
1289
|
+
expect(result.created).toBe(1);
|
|
1290
|
+
|
|
1291
|
+
const allProducts = (await controller.listProducts({ limit: 100 }))
|
|
1292
|
+
.products;
|
|
1293
|
+
const slugs = allProducts.map((p) => p.slug);
|
|
1294
|
+
expect(new Set(slugs).size).toBe(slugs.length);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("resolves category by name", async () => {
|
|
1298
|
+
const cat = await createTestCategory({ name: "Electronics" });
|
|
1299
|
+
|
|
1300
|
+
const result = await controller.importProducts([
|
|
1301
|
+
{ name: "Widget", price: 10, category: "electronics" },
|
|
1302
|
+
]);
|
|
1303
|
+
expect(result.created).toBe(1);
|
|
1304
|
+
|
|
1305
|
+
const products = (await controller.listProducts()).products;
|
|
1306
|
+
expect(products[0].categoryId).toBe(cat.id);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("converts prices to cents", async () => {
|
|
1310
|
+
const result = await controller.importProducts([
|
|
1311
|
+
{ name: "Widget", price: "29.99" },
|
|
1312
|
+
]);
|
|
1313
|
+
expect(result.created).toBe(1);
|
|
1314
|
+
|
|
1315
|
+
const products = (await controller.listProducts()).products;
|
|
1316
|
+
expect(products[0].price).toBe(2999);
|
|
1317
|
+
});
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
// ── Collections ──
|
|
1321
|
+
|
|
1322
|
+
describe("createCollection", () => {
|
|
1323
|
+
it("creates a collection with defaults", async () => {
|
|
1324
|
+
const col = await createTestCollection();
|
|
1325
|
+
expect(col.id).toBeDefined();
|
|
1326
|
+
expect(col.name).toBe("Test Collection");
|
|
1327
|
+
expect(col.slug).toBe("test-collection");
|
|
1328
|
+
expect(col.isFeatured).toBe(false);
|
|
1329
|
+
expect(col.isVisible).toBe(true);
|
|
1330
|
+
expect(col.position).toBe(0);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it("creates with all optional fields", async () => {
|
|
1334
|
+
const col = await createTestCollection({
|
|
1335
|
+
description: "A collection",
|
|
1336
|
+
image: "col.jpg",
|
|
1337
|
+
isFeatured: true,
|
|
1338
|
+
isVisible: false,
|
|
1339
|
+
position: 3,
|
|
1340
|
+
metadata: { key: "val" },
|
|
1341
|
+
});
|
|
1342
|
+
expect(col.description).toBe("A collection");
|
|
1343
|
+
expect(col.image).toBe("col.jpg");
|
|
1344
|
+
expect(col.isFeatured).toBe(true);
|
|
1345
|
+
expect(col.isVisible).toBe(false);
|
|
1346
|
+
expect(col.position).toBe(3);
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
describe("getCollection", () => {
|
|
1351
|
+
it("returns collection by ID", async () => {
|
|
1352
|
+
const col = await createTestCollection();
|
|
1353
|
+
const found = await controller.getCollection(col.id);
|
|
1354
|
+
expect(found?.name).toBe("Test Collection");
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it("returns null for missing collection", async () => {
|
|
1358
|
+
const found = await controller.getCollection("missing");
|
|
1359
|
+
expect(found).toBeNull();
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
describe("getCollectionBySlug", () => {
|
|
1364
|
+
it("returns collection by slug", async () => {
|
|
1365
|
+
await createTestCollection();
|
|
1366
|
+
const found = await controller.getCollectionBySlug("test-collection");
|
|
1367
|
+
expect(found?.name).toBe("Test Collection");
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("returns null for missing slug", async () => {
|
|
1371
|
+
const found = await controller.getCollectionBySlug("missing");
|
|
1372
|
+
expect(found).toBeNull();
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
describe("listCollections", () => {
|
|
1377
|
+
it("returns collections sorted by position", async () => {
|
|
1378
|
+
await createTestCollection({ slug: "second", position: 2 });
|
|
1379
|
+
await createTestCollection({ slug: "first", position: 1 });
|
|
1380
|
+
|
|
1381
|
+
const result = await controller.listCollections();
|
|
1382
|
+
expect(result.collections[0].slug).toBe("first");
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
it("filters by featured", async () => {
|
|
1386
|
+
await createTestCollection({ slug: "featured", isFeatured: true });
|
|
1387
|
+
await createTestCollection({ slug: "normal", isFeatured: false });
|
|
1388
|
+
|
|
1389
|
+
const result = await controller.listCollections({ featured: true });
|
|
1390
|
+
expect(result.collections).toHaveLength(1);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it("filters by visible", async () => {
|
|
1394
|
+
await createTestCollection({ slug: "visible", isVisible: true });
|
|
1395
|
+
await createTestCollection({ slug: "hidden", isVisible: false });
|
|
1396
|
+
|
|
1397
|
+
const result = await controller.listCollections({ visible: true });
|
|
1398
|
+
expect(result.collections).toHaveLength(1);
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
describe("searchCollections", () => {
|
|
1403
|
+
it("searches by name", async () => {
|
|
1404
|
+
await createTestCollection({
|
|
1405
|
+
name: "Summer Sale",
|
|
1406
|
+
slug: "summer-sale",
|
|
1407
|
+
isVisible: true,
|
|
1408
|
+
});
|
|
1409
|
+
await createTestCollection({
|
|
1410
|
+
name: "Winter Collection",
|
|
1411
|
+
slug: "winter",
|
|
1412
|
+
isVisible: true,
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const results = await controller.searchCollections("summer");
|
|
1416
|
+
expect(results).toHaveLength(1);
|
|
1417
|
+
expect(results[0].name).toBe("Summer Sale");
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it("excludes non-visible collections", async () => {
|
|
1421
|
+
await createTestCollection({
|
|
1422
|
+
name: "Hidden Sale",
|
|
1423
|
+
slug: "hidden",
|
|
1424
|
+
isVisible: false,
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
const results = await controller.searchCollections("sale");
|
|
1428
|
+
expect(results).toHaveLength(0);
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it("respects limit", async () => {
|
|
1432
|
+
for (let i = 0; i < 5; i++) {
|
|
1433
|
+
await createTestCollection({
|
|
1434
|
+
name: `Sale ${i}`,
|
|
1435
|
+
slug: `sale-${i}`,
|
|
1436
|
+
isVisible: true,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const results = await controller.searchCollections("Sale", 2);
|
|
1441
|
+
expect(results).toHaveLength(2);
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
describe("updateCollection", () => {
|
|
1446
|
+
it("updates collection fields", async () => {
|
|
1447
|
+
const col = await createTestCollection();
|
|
1448
|
+
const updated = await controller.updateCollection(col.id, {
|
|
1449
|
+
name: "Updated Collection",
|
|
1450
|
+
});
|
|
1451
|
+
expect(updated.name).toBe("Updated Collection");
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it("throws for non-existent collection", async () => {
|
|
1455
|
+
await expect(
|
|
1456
|
+
controller.updateCollection("missing", { name: "X" }),
|
|
1457
|
+
).rejects.toThrow("Collection missing not found");
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
describe("deleteCollection", () => {
|
|
1462
|
+
it("deletes collection and its product links", async () => {
|
|
1463
|
+
const col = await createTestCollection();
|
|
1464
|
+
const product = await createTestProduct({ status: "active" });
|
|
1465
|
+
await controller.addProductToCollection(col.id, product.id);
|
|
1466
|
+
|
|
1467
|
+
await controller.deleteCollection(col.id);
|
|
1468
|
+
|
|
1469
|
+
expect(await controller.getCollection(col.id)).toBeNull();
|
|
1470
|
+
const links = await controller.listCollectionProducts(col.id);
|
|
1471
|
+
expect(links.products).toHaveLength(0);
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
describe("addProductToCollection", () => {
|
|
1476
|
+
it("adds a product to a collection", async () => {
|
|
1477
|
+
const col = await createTestCollection();
|
|
1478
|
+
const product = await createTestProduct();
|
|
1479
|
+
|
|
1480
|
+
const link = await controller.addProductToCollection(col.id, product.id);
|
|
1481
|
+
expect(link.collectionId).toBe(col.id);
|
|
1482
|
+
expect(link.productId).toBe(product.id);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("returns existing link for duplicate", async () => {
|
|
1486
|
+
const col = await createTestCollection();
|
|
1487
|
+
const product = await createTestProduct();
|
|
1488
|
+
|
|
1489
|
+
const first = await controller.addProductToCollection(col.id, product.id);
|
|
1490
|
+
const second = await controller.addProductToCollection(
|
|
1491
|
+
col.id,
|
|
1492
|
+
product.id,
|
|
1493
|
+
);
|
|
1494
|
+
expect(first.id).toBe(second.id);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("throws for non-existent collection", async () => {
|
|
1498
|
+
await expect(
|
|
1499
|
+
controller.addProductToCollection("missing", "prod-1"),
|
|
1500
|
+
).rejects.toThrow("Collection missing not found");
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it("updates collection updatedAt", async () => {
|
|
1504
|
+
const col = await createTestCollection();
|
|
1505
|
+
const product = await createTestProduct();
|
|
1506
|
+
|
|
1507
|
+
await controller.addProductToCollection(col.id, product.id);
|
|
1508
|
+
|
|
1509
|
+
const updated = await controller.getCollection(col.id);
|
|
1510
|
+
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
1511
|
+
col.updatedAt.getTime(),
|
|
1512
|
+
);
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
describe("removeProductFromCollection", () => {
|
|
1517
|
+
it("removes a product from a collection", async () => {
|
|
1518
|
+
const col = await createTestCollection();
|
|
1519
|
+
const product = await createTestProduct();
|
|
1520
|
+
await controller.addProductToCollection(col.id, product.id);
|
|
1521
|
+
|
|
1522
|
+
await controller.removeProductFromCollection(col.id, product.id);
|
|
1523
|
+
|
|
1524
|
+
const links = await controller.listCollectionProducts(col.id);
|
|
1525
|
+
expect(links.products).toHaveLength(0);
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
describe("getCollectionWithProducts", () => {
|
|
1530
|
+
it("returns collection with active products", async () => {
|
|
1531
|
+
const col = await createTestCollection();
|
|
1532
|
+
const activeProduct = await createTestProduct({
|
|
1533
|
+
slug: "active",
|
|
1534
|
+
status: "active",
|
|
1535
|
+
});
|
|
1536
|
+
const draftProduct = await createTestProduct({
|
|
1537
|
+
slug: "draft",
|
|
1538
|
+
status: "draft",
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
await controller.addProductToCollection(col.id, activeProduct.id);
|
|
1542
|
+
await controller.addProductToCollection(col.id, draftProduct.id);
|
|
1543
|
+
|
|
1544
|
+
const result = await controller.getCollectionWithProducts(col.id);
|
|
1545
|
+
expect(result?.products).toHaveLength(1);
|
|
1546
|
+
expect(result?.products[0].slug).toBe("active");
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it("returns null for missing collection", async () => {
|
|
1550
|
+
const result = await controller.getCollectionWithProducts("missing");
|
|
1551
|
+
expect(result).toBeNull();
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
describe("listCollectionProducts", () => {
|
|
1556
|
+
it("returns all products regardless of status", async () => {
|
|
1557
|
+
const col = await createTestCollection();
|
|
1558
|
+
const active = await createTestProduct({
|
|
1559
|
+
slug: "active",
|
|
1560
|
+
status: "active",
|
|
1561
|
+
});
|
|
1562
|
+
const draft = await createTestProduct({
|
|
1563
|
+
slug: "draft",
|
|
1564
|
+
status: "draft",
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
await controller.addProductToCollection(col.id, active.id);
|
|
1568
|
+
await controller.addProductToCollection(col.id, draft.id);
|
|
1569
|
+
|
|
1570
|
+
const result = await controller.listCollectionProducts(col.id);
|
|
1571
|
+
expect(result.products).toHaveLength(2);
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
it("returns empty for non-existent collection", async () => {
|
|
1575
|
+
const result = await controller.listCollectionProducts("missing");
|
|
1576
|
+
expect(result.products).toHaveLength(0);
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
// ── Integration ──
|
|
1581
|
+
|
|
1582
|
+
describe("integration", () => {
|
|
1583
|
+
it("full product lifecycle: create → add variants → update → delete", async () => {
|
|
1584
|
+
// Create
|
|
1585
|
+
const product = await controller.createProduct({
|
|
1586
|
+
name: "Lifecycle Product",
|
|
1587
|
+
slug: "lifecycle",
|
|
1588
|
+
price: 2999,
|
|
1589
|
+
status: "active",
|
|
1590
|
+
tags: ["test"],
|
|
1591
|
+
});
|
|
1592
|
+
expect(product.id).toBeDefined();
|
|
1593
|
+
|
|
1594
|
+
// Add variants
|
|
1595
|
+
const v1 = await controller.createVariant({
|
|
1596
|
+
productId: product.id,
|
|
1597
|
+
name: "Small",
|
|
1598
|
+
price: 2999,
|
|
1599
|
+
options: { size: "S" },
|
|
1600
|
+
inventory: 10,
|
|
1601
|
+
});
|
|
1602
|
+
const v2 = await controller.createVariant({
|
|
1603
|
+
productId: product.id,
|
|
1604
|
+
name: "Large",
|
|
1605
|
+
price: 3499,
|
|
1606
|
+
options: { size: "L" },
|
|
1607
|
+
inventory: 5,
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
// Verify with variants
|
|
1611
|
+
const withVariants = await controller.getProductWithVariants(product.id);
|
|
1612
|
+
expect(withVariants?.variants).toHaveLength(2);
|
|
1613
|
+
|
|
1614
|
+
// Check availability
|
|
1615
|
+
const avail = await controller.checkAvailability(product.id, v1.id);
|
|
1616
|
+
expect(avail.available).toBe(true);
|
|
1617
|
+
expect(avail.inventory).toBe(10);
|
|
1618
|
+
|
|
1619
|
+
// Decrement inventory
|
|
1620
|
+
await controller.decrementInventory(product.id, 7, v1.id);
|
|
1621
|
+
const afterDecrement = await controller.getVariant(v1.id);
|
|
1622
|
+
expect(afterDecrement?.inventory).toBe(3);
|
|
1623
|
+
|
|
1624
|
+
// Update product
|
|
1625
|
+
await controller.updateProduct(product.id, {
|
|
1626
|
+
name: "Updated Lifecycle",
|
|
1627
|
+
});
|
|
1628
|
+
const updated = await controller.getProduct(product.id);
|
|
1629
|
+
expect(updated?.name).toBe("Updated Lifecycle");
|
|
1630
|
+
|
|
1631
|
+
// Search should find it
|
|
1632
|
+
const searchResults = await controller.searchProducts("lifecycle");
|
|
1633
|
+
expect(searchResults).toHaveLength(1);
|
|
1634
|
+
|
|
1635
|
+
// Delete cascades variants
|
|
1636
|
+
await controller.deleteProduct(product.id);
|
|
1637
|
+
expect(await controller.getProduct(product.id)).toBeNull();
|
|
1638
|
+
expect(await controller.getVariant(v1.id)).toBeNull();
|
|
1639
|
+
expect(await controller.getVariant(v2.id)).toBeNull();
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
it("category hierarchy with products", async () => {
|
|
1643
|
+
const parent = await controller.createCategory({
|
|
1644
|
+
name: "Electronics",
|
|
1645
|
+
slug: "electronics",
|
|
1646
|
+
isVisible: true,
|
|
1647
|
+
});
|
|
1648
|
+
const child = await controller.createCategory({
|
|
1649
|
+
name: "Phones",
|
|
1650
|
+
slug: "phones",
|
|
1651
|
+
parentId: parent.id,
|
|
1652
|
+
isVisible: true,
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
const product = await controller.createProduct({
|
|
1656
|
+
name: "iPhone",
|
|
1657
|
+
slug: "iphone",
|
|
1658
|
+
price: 99900,
|
|
1659
|
+
status: "active",
|
|
1660
|
+
categoryId: child.id,
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
// Tree should show hierarchy
|
|
1664
|
+
const tree = await controller.getCategoryTree();
|
|
1665
|
+
expect(tree).toHaveLength(1);
|
|
1666
|
+
expect(tree[0].children).toHaveLength(1);
|
|
1667
|
+
|
|
1668
|
+
// Deleting parent orphans child
|
|
1669
|
+
await controller.deleteCategory(parent.id);
|
|
1670
|
+
const updatedChild = await controller.getCategory(child.id);
|
|
1671
|
+
expect(updatedChild?.parentId).toBeUndefined();
|
|
1672
|
+
|
|
1673
|
+
// Product still exists
|
|
1674
|
+
const foundProduct = await controller.getProduct(product.id);
|
|
1675
|
+
expect(foundProduct?.categoryId).toBe(child.id);
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
it("collection with multiple products", async () => {
|
|
1679
|
+
const col = await controller.createCollection({
|
|
1680
|
+
name: "Summer Sale",
|
|
1681
|
+
slug: "summer-sale",
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
const p1 = await createTestProduct({
|
|
1685
|
+
slug: "p1",
|
|
1686
|
+
status: "active",
|
|
1687
|
+
});
|
|
1688
|
+
const p2 = await createTestProduct({
|
|
1689
|
+
slug: "p2",
|
|
1690
|
+
status: "active",
|
|
1691
|
+
});
|
|
1692
|
+
const p3 = await createTestProduct({
|
|
1693
|
+
slug: "p3",
|
|
1694
|
+
status: "draft",
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
await controller.addProductToCollection(col.id, p1.id, 1);
|
|
1698
|
+
await controller.addProductToCollection(col.id, p2.id, 2);
|
|
1699
|
+
await controller.addProductToCollection(col.id, p3.id, 3);
|
|
1700
|
+
|
|
1701
|
+
// getWithProducts filters to active only
|
|
1702
|
+
const withProducts = await controller.getCollectionWithProducts(col.id);
|
|
1703
|
+
expect(withProducts?.products).toHaveLength(2);
|
|
1704
|
+
|
|
1705
|
+
// listCollectionProducts includes all statuses
|
|
1706
|
+
const allProducts = await controller.listCollectionProducts(col.id);
|
|
1707
|
+
expect(allProducts.products).toHaveLength(3);
|
|
1708
|
+
|
|
1709
|
+
// Remove one product
|
|
1710
|
+
await controller.removeProductFromCollection(col.id, p1.id);
|
|
1711
|
+
const afterRemove = await controller.listCollectionProducts(col.id);
|
|
1712
|
+
expect(afterRemove.products).toHaveLength(2);
|
|
1713
|
+
|
|
1714
|
+
// Delete collection cleans up links
|
|
1715
|
+
await controller.deleteCollection(col.id);
|
|
1716
|
+
expect(await controller.getCollection(col.id)).toBeNull();
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it("import with mixed create and update", async () => {
|
|
1720
|
+
// Seed existing product
|
|
1721
|
+
await createTestProduct({ slug: "existing", sku: "EXISTING-SKU" });
|
|
1722
|
+
|
|
1723
|
+
// Seed category
|
|
1724
|
+
await createTestCategory({ name: "Widgets" });
|
|
1725
|
+
|
|
1726
|
+
const result = await controller.importProducts([
|
|
1727
|
+
// New product
|
|
1728
|
+
{ name: "New Widget", price: 15.0, category: "widgets" },
|
|
1729
|
+
// Update by SKU
|
|
1730
|
+
{
|
|
1731
|
+
name: "Updated Product",
|
|
1732
|
+
price: 25.0,
|
|
1733
|
+
sku: "EXISTING-SKU",
|
|
1734
|
+
description: "Updated via import",
|
|
1735
|
+
},
|
|
1736
|
+
// Invalid: missing name
|
|
1737
|
+
{ name: "", price: 10 },
|
|
1738
|
+
]);
|
|
1739
|
+
|
|
1740
|
+
expect(result.created).toBe(1);
|
|
1741
|
+
expect(result.updated).toBe(1);
|
|
1742
|
+
expect(result.errors).toHaveLength(1);
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
});
|