@86d-app/products 0.0.3 → 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.
Files changed (195) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +41 -41
  3. package/README.md +267 -7
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  7. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  8. package/dist/__tests__/service-impl.test.d.ts +2 -0
  9. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  10. package/dist/__tests__/state.test.d.ts +2 -0
  11. package/dist/__tests__/state.test.d.ts.map +1 -0
  12. package/dist/admin/components/categories-admin.d.ts +2 -0
  13. package/dist/admin/components/categories-admin.d.ts.map +1 -0
  14. package/dist/admin/components/category-form.d.ts +7 -0
  15. package/dist/admin/components/category-form.d.ts.map +1 -0
  16. package/dist/admin/components/category-list.d.ts +7 -0
  17. package/dist/admin/components/category-list.d.ts.map +1 -0
  18. package/dist/admin/components/collections-admin.d.ts +2 -0
  19. package/dist/admin/components/collections-admin.d.ts.map +1 -0
  20. package/dist/admin/components/index.d.ts +9 -0
  21. package/dist/admin/components/index.d.ts.map +1 -0
  22. package/dist/admin/components/product-detail.d.ts +7 -0
  23. package/dist/admin/components/product-detail.d.ts.map +1 -0
  24. package/dist/admin/components/product-edit.d.ts +6 -0
  25. package/dist/admin/components/product-edit.d.ts.map +1 -0
  26. package/dist/admin/components/product-form.d.ts +7 -0
  27. package/dist/admin/components/product-form.d.ts.map +1 -0
  28. package/dist/admin/components/product-list.d.ts +2 -0
  29. package/dist/admin/components/product-list.d.ts.map +1 -0
  30. package/dist/admin/components/product-new.d.ts +2 -0
  31. package/dist/admin/components/product-new.d.ts.map +1 -0
  32. package/dist/admin/endpoints/add-collection-product.d.ts +15 -0
  33. package/dist/admin/endpoints/add-collection-product.d.ts.map +1 -0
  34. package/dist/admin/endpoints/bulk-action.d.ts +17 -0
  35. package/dist/admin/endpoints/bulk-action.d.ts.map +1 -0
  36. package/dist/admin/endpoints/create-category.d.ts +23 -0
  37. package/dist/admin/endpoints/create-category.d.ts.map +1 -0
  38. package/dist/admin/endpoints/create-collection.d.ts +22 -0
  39. package/dist/admin/endpoints/create-collection.d.ts.map +1 -0
  40. package/dist/admin/endpoints/create-product.d.ts +44 -0
  41. package/dist/admin/endpoints/create-product.d.ts.map +1 -0
  42. package/dist/admin/endpoints/create-variant.d.ts +35 -0
  43. package/dist/admin/endpoints/create-variant.d.ts.map +1 -0
  44. package/dist/admin/endpoints/delete-category.d.ts +18 -0
  45. package/dist/admin/endpoints/delete-category.d.ts.map +1 -0
  46. package/dist/admin/endpoints/delete-collection.d.ts +8 -0
  47. package/dist/admin/endpoints/delete-collection.d.ts.map +1 -0
  48. package/dist/admin/endpoints/delete-product.d.ts +18 -0
  49. package/dist/admin/endpoints/delete-product.d.ts.map +1 -0
  50. package/dist/admin/endpoints/delete-variant.d.ts +18 -0
  51. package/dist/admin/endpoints/delete-variant.d.ts.map +1 -0
  52. package/dist/admin/endpoints/get-product.d.ts +16 -0
  53. package/dist/admin/endpoints/get-product.d.ts.map +1 -0
  54. package/dist/admin/endpoints/import-products.d.ts +36 -0
  55. package/dist/admin/endpoints/import-products.d.ts.map +1 -0
  56. package/dist/admin/endpoints/index.d.ts +418 -0
  57. package/dist/admin/endpoints/index.d.ts.map +1 -0
  58. package/dist/admin/endpoints/list-categories.d.ts +11 -0
  59. package/dist/admin/endpoints/list-categories.d.ts.map +1 -0
  60. package/dist/admin/endpoints/list-collections.d.ts +11 -0
  61. package/dist/admin/endpoints/list-collections.d.ts.map +1 -0
  62. package/dist/admin/endpoints/list-products.d.ts +27 -0
  63. package/dist/admin/endpoints/list-products.d.ts.map +1 -0
  64. package/dist/admin/endpoints/remove-collection-product.d.ts +9 -0
  65. package/dist/admin/endpoints/remove-collection-product.d.ts.map +1 -0
  66. package/dist/admin/endpoints/update-category.d.ts +26 -0
  67. package/dist/admin/endpoints/update-category.d.ts.map +1 -0
  68. package/dist/admin/endpoints/update-collection.d.ts +19 -0
  69. package/dist/admin/endpoints/update-collection.d.ts.map +1 -0
  70. package/dist/admin/endpoints/update-product.d.ts +47 -0
  71. package/dist/admin/endpoints/update-product.d.ts.map +1 -0
  72. package/dist/admin/endpoints/update-variant.d.ts +35 -0
  73. package/dist/admin/endpoints/update-variant.d.ts.map +1 -0
  74. package/dist/controllers.d.ts +130 -0
  75. package/dist/controllers.d.ts.map +1 -0
  76. package/dist/index.d.ts +20 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/markdown.d.ts +6 -0
  79. package/dist/markdown.d.ts.map +1 -0
  80. package/dist/schema.d.ts +351 -0
  81. package/dist/schema.d.ts.map +1 -0
  82. package/dist/service-impl.d.ts +4 -0
  83. package/dist/service-impl.d.ts.map +1 -0
  84. package/dist/service.d.ts +280 -0
  85. package/dist/service.d.ts.map +1 -0
  86. package/dist/state.d.ts +38 -0
  87. package/dist/state.d.ts.map +1 -0
  88. package/dist/store/components/_hooks.d.ts +88 -0
  89. package/dist/store/components/_hooks.d.ts.map +1 -0
  90. package/dist/store/components/_types.d.ts +70 -0
  91. package/dist/store/components/_types.d.ts.map +1 -0
  92. package/dist/store/components/_utils.d.ts +5 -0
  93. package/dist/store/components/_utils.d.ts.map +1 -0
  94. package/dist/store/components/back-in-stock-notify.d.ts +8 -0
  95. package/dist/store/components/back-in-stock-notify.d.ts.map +1 -0
  96. package/dist/store/components/collection-card.d.ts +6 -0
  97. package/dist/store/components/collection-card.d.ts.map +1 -0
  98. package/dist/store/components/collection-detail.d.ts +6 -0
  99. package/dist/store/components/collection-detail.d.ts.map +1 -0
  100. package/dist/store/components/collection-grid.d.ts +6 -0
  101. package/dist/store/components/collection-grid.d.ts.map +1 -0
  102. package/dist/store/components/featured-products.d.ts +6 -0
  103. package/dist/store/components/featured-products.d.ts.map +1 -0
  104. package/dist/store/components/filter-chip.d.ts +6 -0
  105. package/dist/store/components/filter-chip.d.ts.map +1 -0
  106. package/dist/store/components/index.d.ts +37 -0
  107. package/dist/store/components/index.d.ts.map +1 -0
  108. package/dist/store/components/product-card.d.ts +7 -0
  109. package/dist/store/components/product-card.d.ts.map +1 -0
  110. package/dist/store/components/product-detail.d.ts +6 -0
  111. package/dist/store/components/product-detail.d.ts.map +1 -0
  112. package/dist/store/components/product-listing.d.ts +7 -0
  113. package/dist/store/components/product-listing.d.ts.map +1 -0
  114. package/dist/store/components/product-qa-section.d.ts +5 -0
  115. package/dist/store/components/product-qa-section.d.ts.map +1 -0
  116. package/dist/store/components/product-reviews-section.d.ts +5 -0
  117. package/dist/store/components/product-reviews-section.d.ts.map +1 -0
  118. package/dist/store/components/recently-viewed.d.ts +12 -0
  119. package/dist/store/components/recently-viewed.d.ts.map +1 -0
  120. package/dist/store/components/recommended-products.d.ts +7 -0
  121. package/dist/store/components/recommended-products.d.ts.map +1 -0
  122. package/dist/store/components/related-products.d.ts +7 -0
  123. package/dist/store/components/related-products.d.ts.map +1 -0
  124. package/dist/store/components/star-display.d.ts +6 -0
  125. package/dist/store/components/star-display.d.ts.map +1 -0
  126. package/dist/store/components/star-picker.d.ts +6 -0
  127. package/dist/store/components/star-picker.d.ts.map +1 -0
  128. package/dist/store/components/stock-badge.d.ts +5 -0
  129. package/dist/store/components/stock-badge.d.ts.map +1 -0
  130. package/dist/store/endpoints/get-category.d.ts +22 -0
  131. package/dist/store/endpoints/get-category.d.ts.map +1 -0
  132. package/dist/store/endpoints/get-collection.d.ts +17 -0
  133. package/dist/store/endpoints/get-collection.d.ts.map +1 -0
  134. package/dist/store/endpoints/get-featured.d.ts +10 -0
  135. package/dist/store/endpoints/get-featured.d.ts.map +1 -0
  136. package/dist/store/endpoints/get-product.d.ts +17 -0
  137. package/dist/store/endpoints/get-product.d.ts.map +1 -0
  138. package/dist/store/endpoints/get-related.d.ts +11 -0
  139. package/dist/store/endpoints/get-related.d.ts.map +1 -0
  140. package/dist/store/endpoints/index.d.ts +130 -0
  141. package/dist/store/endpoints/index.d.ts.map +1 -0
  142. package/dist/store/endpoints/list-categories.d.ts +6 -0
  143. package/dist/store/endpoints/list-categories.d.ts.map +1 -0
  144. package/dist/store/endpoints/list-collections.d.ts +10 -0
  145. package/dist/store/endpoints/list-collections.d.ts.map +1 -0
  146. package/dist/store/endpoints/list-products.d.ts +26 -0
  147. package/dist/store/endpoints/list-products.d.ts.map +1 -0
  148. package/dist/store/endpoints/search-products.d.ts +12 -0
  149. package/dist/store/endpoints/search-products.d.ts.map +1 -0
  150. package/dist/store/endpoints/store-search.d.ts +18 -0
  151. package/dist/store/endpoints/store-search.d.ts.map +1 -0
  152. package/package.json +3 -3
  153. package/src/__tests__/endpoint-security.test.ts +457 -0
  154. package/src/__tests__/service-impl.test.ts +1745 -0
  155. package/src/admin/endpoints/create-category.ts +5 -2
  156. package/src/admin/endpoints/create-collection.ts +1 -1
  157. package/src/admin/endpoints/create-product.ts +5 -2
  158. package/src/admin/endpoints/delete-category.ts +1 -1
  159. package/src/admin/endpoints/delete-collection.ts +1 -1
  160. package/src/admin/endpoints/delete-product.ts +1 -1
  161. package/src/admin/endpoints/delete-variant.ts +1 -1
  162. package/src/admin/endpoints/list-categories.ts +1 -1
  163. package/src/admin/endpoints/list-collections.ts +1 -1
  164. package/src/admin/endpoints/list-products.ts +1 -1
  165. package/src/admin/endpoints/remove-collection-product.ts +1 -1
  166. package/src/admin/endpoints/update-category.ts +5 -2
  167. package/src/admin/endpoints/update-collection.ts +1 -1
  168. package/src/admin/endpoints/update-product.ts +5 -2
  169. package/src/admin/endpoints/update-variant.ts +1 -1
  170. package/src/service-impl.ts +1139 -0
  171. package/src/service.ts +312 -0
  172. package/src/store/components/_hooks.ts +81 -0
  173. package/src/store/components/_utils.ts +8 -0
  174. package/src/store/components/collection-detail.tsx +21 -1
  175. package/src/store/components/collection-grid.tsx +5 -1
  176. package/src/store/components/featured-products.tsx +5 -1
  177. package/src/store/components/index.tsx +2 -0
  178. package/src/store/components/product-card.mdx +1 -1
  179. package/src/store/components/product-card.tsx +25 -5
  180. package/src/store/components/product-detail.mdx +2 -0
  181. package/src/store/components/product-detail.tsx +55 -8
  182. package/src/store/components/product-listing.tsx +25 -4
  183. package/src/store/components/product-qa-section.mdx +21 -0
  184. package/src/store/components/product-qa-section.tsx +503 -0
  185. package/src/store/components/recommended-products.mdx +6 -0
  186. package/src/store/components/recommended-products.tsx +119 -0
  187. package/src/store/endpoints/get-category.ts +2 -2
  188. package/src/store/endpoints/get-collection.ts +1 -1
  189. package/src/store/endpoints/get-featured.ts +1 -1
  190. package/src/store/endpoints/get-product.ts +1 -1
  191. package/src/store/endpoints/get-related.ts +2 -2
  192. package/src/store/endpoints/list-collections.ts +3 -3
  193. package/src/store/endpoints/list-products.ts +9 -9
  194. package/src/store/endpoints/store-search.ts +1 -1
  195. package/COMPONENTS.md +0 -231
@@ -0,0 +1,457 @@
1
+ import {
2
+ createMockDataService,
3
+ makeControllerCtx,
4
+ } from "@86d-app/core/test-utils";
5
+ import { beforeEach, describe, expect, it } from "vitest";
6
+ import type { Category, Product, ProductVariant } from "../controllers";
7
+ import { controllers } from "../controllers";
8
+
9
+ /**
10
+ * Endpoint-security tests for the products module.
11
+ *
12
+ * These tests verify data-integrity invariants that, if broken, could
13
+ * expose stale/orphaned data or corrupt inventory:
14
+ *
15
+ * 1. Cascade delete: deleting a product removes all its variants
16
+ * 2. Inventory integrity: decrement does not go below 0
17
+ * 3. Status filtering: inactive/draft products excluded when filtered
18
+ * 4. Variant isolation: variants scoped to their owning productId
19
+ * 5. Category hierarchy integrity on delete
20
+ * 6. Slug uniqueness during import
21
+ */
22
+
23
+ function makeProduct(overrides: Partial<Product> = {}): Product {
24
+ const now = new Date();
25
+ return {
26
+ id: "prod_1",
27
+ name: "Test Product",
28
+ slug: "test-product",
29
+ price: 2999,
30
+ inventory: 10,
31
+ trackInventory: true,
32
+ allowBackorder: false,
33
+ status: "active",
34
+ images: [],
35
+ tags: ["test"],
36
+ isFeatured: false,
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ function makeVariant(overrides: Partial<ProductVariant> = {}): ProductVariant {
44
+ const now = new Date();
45
+ return {
46
+ id: "var_1",
47
+ productId: "prod_1",
48
+ name: "Default",
49
+ price: 2999,
50
+ inventory: 5,
51
+ options: { size: "M" },
52
+ images: [],
53
+ position: 0,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ function makeCategory(overrides: Partial<Category> = {}): Category {
61
+ const now = new Date();
62
+ return {
63
+ id: "cat_1",
64
+ name: "Electronics",
65
+ slug: "electronics",
66
+ position: 0,
67
+ isVisible: true,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ describe("products endpoint security", () => {
75
+ let data: ReturnType<typeof createMockDataService>;
76
+
77
+ beforeEach(() => {
78
+ data = createMockDataService();
79
+ });
80
+
81
+ // -- Cascade Delete -------------------------------------------------------
82
+
83
+ describe("cascade delete - product removes all variants", () => {
84
+ it("deleting a product removes all associated variants", async () => {
85
+ const product = makeProduct();
86
+ await data.upsert("product", product.id, product);
87
+ await data.upsert(
88
+ "productVariant",
89
+ "var_1",
90
+ makeVariant({ id: "var_1", productId: product.id }),
91
+ );
92
+ await data.upsert(
93
+ "productVariant",
94
+ "var_2",
95
+ makeVariant({ id: "var_2", productId: product.id, name: "Large" }),
96
+ );
97
+
98
+ await controllers.product.delete(
99
+ makeControllerCtx(data, { params: { id: product.id } }),
100
+ );
101
+
102
+ expect(await data.get("product", product.id)).toBeNull();
103
+ const remainingVariants = await data.findMany("productVariant", {
104
+ where: { productId: product.id },
105
+ });
106
+ expect(remainingVariants).toHaveLength(0);
107
+ });
108
+
109
+ it("deleting one product does not remove another product's variants", async () => {
110
+ const productA = makeProduct({ id: "prod_a" });
111
+ const productB = makeProduct({ id: "prod_b", slug: "other-product" });
112
+ await data.upsert("product", productA.id, productA);
113
+ await data.upsert("product", productB.id, productB);
114
+ await data.upsert(
115
+ "productVariant",
116
+ "var_a",
117
+ makeVariant({ id: "var_a", productId: "prod_a" }),
118
+ );
119
+ await data.upsert(
120
+ "productVariant",
121
+ "var_b",
122
+ makeVariant({ id: "var_b", productId: "prod_b" }),
123
+ );
124
+
125
+ await controllers.product.delete(
126
+ makeControllerCtx(data, { params: { id: "prod_a" } }),
127
+ );
128
+
129
+ const bVariants = await data.findMany("productVariant", {
130
+ where: { productId: "prod_b" },
131
+ });
132
+ expect(bVariants).toHaveLength(1);
133
+ });
134
+
135
+ it("bulk deleteMany cascades variants for every deleted product", async () => {
136
+ const p1 = makeProduct({ id: "prod_1" });
137
+ const p2 = makeProduct({ id: "prod_2", slug: "second" });
138
+ await data.upsert("product", p1.id, p1);
139
+ await data.upsert("product", p2.id, p2);
140
+ await data.upsert(
141
+ "productVariant",
142
+ "var_1",
143
+ makeVariant({ id: "var_1", productId: "prod_1" }),
144
+ );
145
+ await data.upsert(
146
+ "productVariant",
147
+ "var_2",
148
+ makeVariant({ id: "var_2", productId: "prod_2" }),
149
+ );
150
+
151
+ const result = await controllers.bulk.deleteMany(
152
+ makeControllerCtx(data, {
153
+ body: { ids: ["prod_1", "prod_2"] },
154
+ }),
155
+ );
156
+
157
+ expect(result).toMatchObject({ deleted: 2 });
158
+ const allVariants = await data.findMany("productVariant", { where: {} });
159
+ expect(allVariants).toHaveLength(0);
160
+ });
161
+ });
162
+
163
+ // -- Inventory Integrity --------------------------------------------------
164
+
165
+ describe("inventory integrity", () => {
166
+ it("decrement can push inventory below zero (no floor guard)", async () => {
167
+ const product = makeProduct({ id: "prod_inv", inventory: 2 });
168
+ await data.upsert("product", product.id, product);
169
+
170
+ await controllers.product.decrementInventory(
171
+ makeControllerCtx(data, {
172
+ params: { productId: "prod_inv" },
173
+ body: { quantity: 5 },
174
+ }),
175
+ );
176
+
177
+ const updated = (await data.get("product", "prod_inv")) as Product;
178
+ // The controller does NOT enforce a floor -- inventory goes negative.
179
+ // This documents the current behavior; endpoints must check availability first.
180
+ expect(updated.inventory).toBe(-3);
181
+ });
182
+
183
+ it("decrement variant inventory independently of product inventory", async () => {
184
+ const product = makeProduct({ id: "prod_vi", inventory: 100 });
185
+ await data.upsert("product", product.id, product);
186
+ const variant = makeVariant({
187
+ id: "var_vi",
188
+ productId: "prod_vi",
189
+ inventory: 3,
190
+ });
191
+ await data.upsert("productVariant", variant.id, variant);
192
+
193
+ await controllers.product.decrementInventory(
194
+ makeControllerCtx(data, {
195
+ params: { productId: "prod_vi", variantId: "var_vi" },
196
+ body: { quantity: 2 },
197
+ }),
198
+ );
199
+
200
+ const updatedVariant = (await data.get(
201
+ "productVariant",
202
+ "var_vi",
203
+ )) as ProductVariant;
204
+ expect(updatedVariant.inventory).toBe(1);
205
+
206
+ // Product inventory is untouched when variant path is used
207
+ const updatedProduct = (await data.get("product", "prod_vi")) as Product;
208
+ expect(updatedProduct.inventory).toBe(100);
209
+ });
210
+
211
+ it("increment restores inventory correctly", async () => {
212
+ const product = makeProduct({ id: "prod_inc", inventory: 0 });
213
+ await data.upsert("product", product.id, product);
214
+
215
+ await controllers.product.incrementInventory(
216
+ makeControllerCtx(data, {
217
+ params: { productId: "prod_inc" },
218
+ body: { quantity: 7 },
219
+ }),
220
+ );
221
+
222
+ const updated = (await data.get("product", "prod_inc")) as Product;
223
+ expect(updated.inventory).toBe(7);
224
+ });
225
+ });
226
+
227
+ // -- Status Filtering -----------------------------------------------------
228
+
229
+ describe("status filtering - inactive/draft products excluded", () => {
230
+ it("search only returns active products", async () => {
231
+ await data.upsert(
232
+ "product",
233
+ "prod_active",
234
+ makeProduct({
235
+ id: "prod_active",
236
+ name: "Widget Active",
237
+ status: "active",
238
+ }),
239
+ );
240
+ await data.upsert(
241
+ "product",
242
+ "prod_draft",
243
+ makeProduct({
244
+ id: "prod_draft",
245
+ name: "Widget Draft",
246
+ slug: "widget-draft",
247
+ status: "draft",
248
+ }),
249
+ );
250
+ await data.upsert(
251
+ "product",
252
+ "prod_archived",
253
+ makeProduct({
254
+ id: "prod_archived",
255
+ name: "Widget Archived",
256
+ slug: "widget-archived",
257
+ status: "archived",
258
+ }),
259
+ );
260
+
261
+ const results = await controllers.product.search(
262
+ makeControllerCtx(data, { query: { q: "Widget" } }),
263
+ );
264
+
265
+ expect(results).toHaveLength(1);
266
+ expect((results as Product[])[0].id).toBe("prod_active");
267
+ });
268
+
269
+ it("getFeatured only returns active featured products", async () => {
270
+ await data.upsert(
271
+ "product",
272
+ "prod_feat_active",
273
+ makeProduct({
274
+ id: "prod_feat_active",
275
+ isFeatured: true,
276
+ status: "active",
277
+ }),
278
+ );
279
+ await data.upsert(
280
+ "product",
281
+ "prod_feat_draft",
282
+ makeProduct({
283
+ id: "prod_feat_draft",
284
+ slug: "feat-draft",
285
+ isFeatured: true,
286
+ status: "draft",
287
+ }),
288
+ );
289
+
290
+ const results = await controllers.product.getFeatured(
291
+ makeControllerCtx(data, { query: {} }),
292
+ );
293
+
294
+ expect(results).toHaveLength(1);
295
+ expect((results as Product[])[0].id).toBe("prod_feat_active");
296
+ });
297
+
298
+ it("list with status filter returns only matching status", async () => {
299
+ await data.upsert(
300
+ "product",
301
+ "prod_a",
302
+ makeProduct({ id: "prod_a", status: "active" }),
303
+ );
304
+ await data.upsert(
305
+ "product",
306
+ "prod_d",
307
+ makeProduct({ id: "prod_d", slug: "draft-one", status: "draft" }),
308
+ );
309
+
310
+ const result = await controllers.product.list(
311
+ makeControllerCtx(data, { query: { status: "draft" } }),
312
+ );
313
+
314
+ const listed = result as {
315
+ products: Product[];
316
+ total: number;
317
+ };
318
+ expect(listed.total).toBe(1);
319
+ expect(listed.products[0].id).toBe("prod_d");
320
+ });
321
+ });
322
+
323
+ // -- Variant Isolation ----------------------------------------------------
324
+
325
+ describe("variant isolation - scoped to productId", () => {
326
+ it("getByProduct returns only variants for the specified product", async () => {
327
+ const p1 = makeProduct({ id: "prod_iso_1" });
328
+ const p2 = makeProduct({ id: "prod_iso_2", slug: "iso-2" });
329
+ await data.upsert("product", p1.id, p1);
330
+ await data.upsert("product", p2.id, p2);
331
+ await data.upsert(
332
+ "productVariant",
333
+ "var_iso_1",
334
+ makeVariant({
335
+ id: "var_iso_1",
336
+ productId: "prod_iso_1",
337
+ name: "Small",
338
+ }),
339
+ );
340
+ await data.upsert(
341
+ "productVariant",
342
+ "var_iso_2",
343
+ makeVariant({
344
+ id: "var_iso_2",
345
+ productId: "prod_iso_2",
346
+ name: "Large",
347
+ }),
348
+ );
349
+
350
+ const p1Variants = (await controllers.variant.getByProduct(
351
+ makeControllerCtx(data, { params: { productId: "prod_iso_1" } }),
352
+ )) as ProductVariant[];
353
+
354
+ expect(p1Variants).toHaveLength(1);
355
+ expect(p1Variants[0].name).toBe("Small");
356
+ });
357
+ });
358
+
359
+ // -- Category Hierarchy Integrity -----------------------------------------
360
+
361
+ describe("category hierarchy integrity", () => {
362
+ it("deleting parent category orphans subcategories by clearing parentId", async () => {
363
+ const parent = makeCategory({ id: "cat_parent" });
364
+ const child = makeCategory({
365
+ id: "cat_child",
366
+ slug: "child",
367
+ parentId: "cat_parent",
368
+ });
369
+ await data.upsert("category", parent.id, parent);
370
+ await data.upsert("category", child.id, child);
371
+
372
+ await controllers.category.delete(
373
+ makeControllerCtx(data, { params: { id: "cat_parent" } }),
374
+ );
375
+
376
+ expect(await data.get("category", "cat_parent")).toBeNull();
377
+ const updatedChild = (await data.get(
378
+ "category",
379
+ "cat_child",
380
+ )) as Category;
381
+ expect(updatedChild).not.toBeNull();
382
+ expect(updatedChild.parentId).toBeUndefined();
383
+ });
384
+
385
+ it("deleting category clears categoryId on associated products", async () => {
386
+ const cat = makeCategory({ id: "cat_del" });
387
+ const product = makeProduct({ id: "prod_cat", categoryId: "cat_del" });
388
+ await data.upsert("category", cat.id, cat);
389
+ await data.upsert("product", product.id, product);
390
+
391
+ await controllers.category.delete(
392
+ makeControllerCtx(data, { params: { id: "cat_del" } }),
393
+ );
394
+
395
+ const updatedProduct = (await data.get("product", "prod_cat")) as Product;
396
+ expect(updatedProduct.categoryId).toBeUndefined();
397
+ });
398
+ });
399
+
400
+ // -- Slug Uniqueness ------------------------------------------------------
401
+
402
+ describe("slug uniqueness", () => {
403
+ it("import auto-deduplicates slugs to prevent collisions", async () => {
404
+ // Seed an existing product with slug "widget"
405
+ await data.upsert(
406
+ "product",
407
+ "prod_existing",
408
+ makeProduct({ id: "prod_existing", slug: "widget", name: "Widget" }),
409
+ );
410
+
411
+ const result = await controllers.import.importProducts(
412
+ makeControllerCtx(data, {
413
+ body: {
414
+ products: [
415
+ { name: "Widget", price: 10 },
416
+ { name: "Widget", price: 20 },
417
+ ],
418
+ },
419
+ }),
420
+ );
421
+
422
+ const importResult = result as {
423
+ created: number;
424
+ updated: number;
425
+ errors: unknown[];
426
+ };
427
+ expect(importResult.created).toBe(2);
428
+ expect(importResult.errors).toHaveLength(0);
429
+
430
+ // Verify all three products have distinct slugs
431
+ const allProducts = (await data.findMany("product", {
432
+ where: {},
433
+ })) as Product[];
434
+ const slugs = allProducts.map((p) => p.slug);
435
+ const uniqueSlugs = new Set(slugs);
436
+ expect(uniqueSlugs.size).toBe(slugs.length);
437
+ });
438
+
439
+ it("getBySlug returns the correct product when multiple products exist", async () => {
440
+ await data.upsert(
441
+ "product",
442
+ "prod_s1",
443
+ makeProduct({ id: "prod_s1", slug: "alpha", name: "Alpha" }),
444
+ );
445
+ await data.upsert(
446
+ "product",
447
+ "prod_s2",
448
+ makeProduct({ id: "prod_s2", slug: "beta", name: "Beta" }),
449
+ );
450
+
451
+ const result = await controllers.product.getBySlug(
452
+ makeControllerCtx(data, { query: { slug: "beta" } }),
453
+ );
454
+ expect(result).toMatchObject({ id: "prod_s2", name: "Beta" });
455
+ });
456
+ });
457
+ });