@mohasinac/appkit 3.0.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +42 -0
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +86 -0
  3. package/dist/_internal/client/features/lottery/LotteryAdminSlotView.d.ts +14 -0
  4. package/dist/_internal/client/features/lottery/LotteryAdminSlotView.js +46 -0
  5. package/dist/_internal/client/features/lottery/LotteryDetailView.d.ts +28 -0
  6. package/dist/_internal/client/features/lottery/LotteryDetailView.js +17 -0
  7. package/dist/_internal/client/features/lottery/LotteryEntriesView.d.ts +23 -0
  8. package/dist/_internal/client/features/lottery/LotteryEntriesView.js +58 -0
  9. package/dist/_internal/client/features/lottery/LotteryListView.d.ts +21 -0
  10. package/dist/_internal/client/features/lottery/LotteryListView.js +25 -0
  11. package/dist/_internal/client/features/lottery/LotteryPullForm.d.ts +19 -0
  12. package/dist/_internal/client/features/lottery/LotteryPullForm.js +65 -0
  13. package/dist/_internal/client/features/lottery/LotterySlotGrid.d.ts +13 -0
  14. package/dist/_internal/client/features/lottery/LotterySlotGrid.js +21 -0
  15. package/dist/_internal/client/features/lottery/PrizeDrawLotteryDetailView.d.ts +28 -0
  16. package/dist/_internal/client/features/lottery/PrizeDrawLotteryDetailView.js +18 -0
  17. package/dist/_internal/server/features/auctions/__tests__/service.test.d.ts +1 -0
  18. package/dist/_internal/server/features/auctions/__tests__/service.test.js +140 -0
  19. package/dist/_internal/server/features/checkout/__tests__/actions.test.d.ts +1 -0
  20. package/dist/_internal/server/features/checkout/__tests__/actions.test.js +600 -0
  21. package/dist/_internal/server/features/lottery/actions.d.ts +43 -0
  22. package/dist/_internal/server/features/lottery/actions.js +243 -0
  23. package/dist/_internal/server/features/lottery/adapters.d.ts +9 -0
  24. package/dist/_internal/server/features/lottery/adapters.js +21 -0
  25. package/dist/_internal/server/features/lottery/data.d.ts +20 -0
  26. package/dist/_internal/server/features/lottery/data.js +41 -0
  27. package/dist/_internal/server/features/lottery/metadata.d.ts +6 -0
  28. package/dist/_internal/server/features/lottery/metadata.js +31 -0
  29. package/dist/_internal/server/features/refunds/__tests__/actions.test.d.ts +1 -0
  30. package/dist/_internal/server/features/refunds/__tests__/actions.test.js +263 -0
  31. package/dist/_internal/server/jobs/core/__tests__/auctionSettlement.test.d.ts +1 -0
  32. package/dist/_internal/server/jobs/core/__tests__/auctionSettlement.test.js +249 -0
  33. package/dist/_internal/server/jobs/core/__tests__/onBidPlaced.test.d.ts +1 -0
  34. package/dist/_internal/server/jobs/core/__tests__/onBidPlaced.test.js +201 -0
  35. package/dist/_internal/server/jobs/core/__tests__/onOrderCreate.test.d.ts +1 -0
  36. package/dist/_internal/server/jobs/core/__tests__/onOrderCreate.test.js +169 -0
  37. package/dist/_internal/server/jobs/core/__tests__/onOrderStatusChange.test.d.ts +1 -0
  38. package/dist/_internal/server/jobs/core/__tests__/onOrderStatusChange.test.js +148 -0
  39. package/dist/_internal/server/jobs/core/__tests__/onProductStockChange.test.d.ts +1 -0
  40. package/dist/_internal/server/jobs/core/__tests__/onProductStockChange.test.js +210 -0
  41. package/dist/_internal/server/jobs/core/__tests__/onReviewWrite.test.d.ts +1 -0
  42. package/dist/_internal/server/jobs/core/__tests__/onReviewWrite.test.js +190 -0
  43. package/dist/_internal/server/jobs/core/__tests__/onUserBanChange.test.d.ts +1 -0
  44. package/dist/_internal/server/jobs/core/__tests__/onUserBanChange.test.js +161 -0
  45. package/dist/_internal/server/jobs/core/__tests__/payoutBatch.test.d.ts +1 -0
  46. package/dist/_internal/server/jobs/core/__tests__/payoutBatch.test.js +302 -0
  47. package/dist/_internal/server/jobs/core/__tests__/triggerEventRaffle.test.d.ts +1 -0
  48. package/dist/_internal/server/jobs/core/__tests__/triggerEventRaffle.test.js +252 -0
  49. package/dist/_internal/server/jobs/core/__tests__/wrapJobHandler.test.d.ts +1 -0
  50. package/dist/_internal/server/jobs/core/__tests__/wrapJobHandler.test.js +129 -0
  51. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  52. package/dist/_internal/shared/actions/action-registry.js +46 -0
  53. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  54. package/dist/client.d.ts +9 -0
  55. package/dist/client.js +9 -0
  56. package/dist/constants/field-names.d.ts +5 -1
  57. package/dist/constants/field-names.js +5 -1
  58. package/dist/features/addresses/repository/__tests__/addresses.repository.test.d.ts +1 -0
  59. package/dist/features/addresses/repository/__tests__/addresses.repository.test.js +386 -0
  60. package/dist/features/admin/api/bids/route.d.ts +2 -2
  61. package/dist/features/admin/api/coupons/route.d.ts +2 -2
  62. package/dist/features/admin/api/products/route.d.ts +2 -2
  63. package/dist/features/admin/api/reviews/route.d.ts +2 -2
  64. package/dist/features/admin/repository/__tests__/notification.repository.test.d.ts +1 -0
  65. package/dist/features/admin/repository/__tests__/notification.repository.test.js +356 -0
  66. package/dist/features/admin/repository/__tests__/site-settings.repository.test.d.ts +1 -0
  67. package/dist/features/admin/repository/__tests__/site-settings.repository.test.js +298 -0
  68. package/dist/features/auctions/repository/__tests__/bid.repository.test.d.ts +1 -0
  69. package/dist/features/auctions/repository/__tests__/bid.repository.test.js +290 -0
  70. package/dist/features/auth/api/route.d.ts +2 -2
  71. package/dist/features/auth/repository/__tests__/session.repository.test.d.ts +1 -0
  72. package/dist/features/auth/repository/__tests__/session.repository.test.js +310 -0
  73. package/dist/features/auth/repository/__tests__/session.test.d.ts +1 -0
  74. package/dist/features/auth/repository/__tests__/session.test.js +146 -0
  75. package/dist/features/auth/repository/__tests__/sms-counter.repository.test.d.ts +1 -0
  76. package/dist/features/auth/repository/__tests__/sms-counter.repository.test.js +171 -0
  77. package/dist/features/auth/repository/__tests__/sms-counter.test.d.ts +1 -0
  78. package/dist/features/auth/repository/__tests__/sms-counter.test.js +88 -0
  79. package/dist/features/auth/repository/__tests__/token.repository.test.d.ts +1 -0
  80. package/dist/features/auth/repository/__tests__/token.repository.test.js +260 -0
  81. package/dist/features/auth/repository/__tests__/token.test.d.ts +1 -0
  82. package/dist/features/auth/repository/__tests__/token.test.js +140 -0
  83. package/dist/features/auth/repository/__tests__/user.repository.test.d.ts +1 -0
  84. package/dist/features/auth/repository/__tests__/user.repository.test.js +352 -0
  85. package/dist/features/auth/repository/__tests__/user.test.d.ts +1 -0
  86. package/dist/features/auth/repository/__tests__/user.test.js +226 -0
  87. package/dist/features/auth/repository/session.repository.js +10 -1
  88. package/dist/features/cart/repository/__tests__/cart.repository.test.d.ts +1 -0
  89. package/dist/features/cart/repository/__tests__/cart.repository.test.js +371 -0
  90. package/dist/features/cart/repository/cart.repository.js +2 -2
  91. package/dist/features/categories/api/[id]/route.d.ts +4 -4
  92. package/dist/features/categories/api/route.d.ts +2 -2
  93. package/dist/features/categories/repository/__tests__/categories.repository.test.d.ts +1 -0
  94. package/dist/features/categories/repository/__tests__/categories.repository.test.js +813 -0
  95. package/dist/features/events/components/EventCard.js +1 -0
  96. package/dist/features/events/repository/__tests__/event.repository.test.d.ts +1 -0
  97. package/dist/features/events/repository/__tests__/event.repository.test.js +299 -0
  98. package/dist/features/events/schemas/firestore.d.ts +3 -0
  99. package/dist/features/events/schemas/index.d.ts +834 -14
  100. package/dist/features/events/schemas/index.js +80 -1
  101. package/dist/features/events/types/index.d.ts +4 -1
  102. package/dist/features/faq/repository/__tests__/faqs.repository.test.d.ts +1 -0
  103. package/dist/features/faq/repository/__tests__/faqs.repository.test.js +311 -0
  104. package/dist/features/history/repository/__tests__/user-history.repository.test.d.ts +1 -0
  105. package/dist/features/history/repository/__tests__/user-history.repository.test.js +180 -0
  106. package/dist/features/homepage/api/[id]/route.d.ts +4 -4
  107. package/dist/features/homepage/api/carousel/[id]/route.d.ts +4 -4
  108. package/dist/features/homepage/api/carousel/route.d.ts +2 -2
  109. package/dist/features/homepage/api/route.d.ts +2 -2
  110. package/dist/features/homepage/repository/__tests__/carousel.repository.test.d.ts +1 -0
  111. package/dist/features/homepage/repository/__tests__/carousel.repository.test.js +182 -0
  112. package/dist/features/homepage/repository/__tests__/homepage-sections.repository.test.d.ts +1 -0
  113. package/dist/features/homepage/repository/__tests__/homepage-sections.repository.test.js +185 -0
  114. package/dist/features/lottery/repository/lottery-entry.repository.d.ts +33 -0
  115. package/dist/features/lottery/repository/lottery-entry.repository.js +130 -0
  116. package/dist/features/lottery/schemas/firestore.d.ts +49 -0
  117. package/dist/features/lottery/schemas/firestore.js +25 -0
  118. package/dist/features/lottery/schemas/zod.d.ts +113 -0
  119. package/dist/features/lottery/schemas/zod.js +39 -0
  120. package/dist/features/lottery/types/index.d.ts +67 -0
  121. package/dist/features/lottery/types/index.js +55 -0
  122. package/dist/features/messages/repository/__tests__/conversations.repository.test.d.ts +1 -0
  123. package/dist/features/messages/repository/__tests__/conversations.repository.test.js +257 -0
  124. package/dist/features/orders/repository/__tests__/orders.repository.test.d.ts +1 -0
  125. package/dist/features/orders/repository/__tests__/orders.repository.test.js +211 -0
  126. package/dist/features/payments/repository/__tests__/payout.repository.test.d.ts +1 -0
  127. package/dist/features/payments/repository/__tests__/payout.repository.test.js +292 -0
  128. package/dist/features/products/api/[id]/route.d.ts +4 -4
  129. package/dist/features/products/api/route.d.ts +2 -2
  130. package/dist/features/products/repository/__tests__/product-features.repository.test.d.ts +1 -0
  131. package/dist/features/products/repository/__tests__/product-features.repository.test.js +329 -0
  132. package/dist/features/products/repository/__tests__/products.repository.test.d.ts +1 -0
  133. package/dist/features/products/repository/__tests__/products.repository.test.js +607 -0
  134. package/dist/features/products/schemas/firestore.d.ts +15 -1
  135. package/dist/features/products/schemas/index.d.ts +12 -12
  136. package/dist/features/products/schemas/index.js +4 -2
  137. package/dist/features/products/types/index.d.ts +1 -1
  138. package/dist/features/promotions/repository/__tests__/claimed-coupons.repository.test.d.ts +1 -0
  139. package/dist/features/promotions/repository/__tests__/claimed-coupons.repository.test.js +219 -0
  140. package/dist/features/promotions/repository/__tests__/coupons.repository.test.d.ts +1 -0
  141. package/dist/features/promotions/repository/__tests__/coupons.repository.test.js +480 -0
  142. package/dist/features/reviews/api/[id]/route.d.ts +4 -4
  143. package/dist/features/reviews/api/route.d.ts +2 -2
  144. package/dist/features/reviews/repository/__tests__/reviews.repository.test.d.ts +1 -0
  145. package/dist/features/reviews/repository/__tests__/reviews.repository.test.js +327 -0
  146. package/dist/features/scams/repository/__tests__/scammer.repository.test.d.ts +1 -0
  147. package/dist/features/scams/repository/__tests__/scammer.repository.test.js +385 -0
  148. package/dist/features/search/repository/__tests__/search.repository.test.d.ts +1 -0
  149. package/dist/features/search/repository/__tests__/search.repository.test.js +369 -0
  150. package/dist/features/seller/api/coupons/route.d.ts +2 -2
  151. package/dist/features/seller/api/offers/route.d.ts +2 -2
  152. package/dist/features/seller/api/products/route.d.ts +2 -2
  153. package/dist/features/seller/api/store/route.d.ts +2 -2
  154. package/dist/features/seller/repository/__tests__/offer.repository.test.d.ts +1 -0
  155. package/dist/features/seller/repository/__tests__/offer.repository.test.js +458 -0
  156. package/dist/features/stores/repository/__tests__/store.repository.test.d.ts +1 -0
  157. package/dist/features/stores/repository/__tests__/store.repository.test.js +261 -0
  158. package/dist/features/support/repository/__tests__/support.repository.test.d.ts +1 -0
  159. package/dist/features/support/repository/__tests__/support.repository.test.js +312 -0
  160. package/dist/features/wishlist/repository/__tests__/user-wishlist.repository.test.d.ts +1 -0
  161. package/dist/features/wishlist/repository/__tests__/user-wishlist.repository.test.js +139 -0
  162. package/dist/index.d.ts +14 -0
  163. package/dist/index.js +17 -0
  164. package/dist/next/api/routeHandler.d.ts +1 -1
  165. package/dist/next/routing/route-map.d.ts +13 -0
  166. package/dist/next/routing/route-map.js +6 -0
  167. package/dist/repositories/index.d.ts +1 -0
  168. package/dist/repositories/index.js +2 -0
  169. package/dist/security/index.d.ts +1 -1
  170. package/dist/security/index.js +1 -1
  171. package/dist/security/pii-schemas.d.ts +2 -0
  172. package/dist/security/pii-schemas.js +6 -0
  173. package/dist/seed/actions/demo-seed-actions.d.ts +1 -1
  174. package/dist/seed/events-seed-data.js +85 -2
  175. package/dist/seed/grouped-listings-seed-data.d.ts +3 -1
  176. package/dist/seed/index.d.ts +1 -0
  177. package/dist/seed/index.js +1 -0
  178. package/dist/seed/lottery-entries-seed-data.d.ts +2 -0
  179. package/dist/seed/lottery-entries-seed-data.js +108 -0
  180. package/dist/seed/manifest.js +1 -0
  181. package/dist/seed/products-preorders-seed-data.d.ts +3 -1
  182. package/dist/server.d.ts +6 -0
  183. package/dist/server.js +6 -0
  184. package/dist/styles.css +1 -1
  185. package/dist/tailwind-utilities.css +1 -1
  186. package/package.json +1 -1
@@ -0,0 +1,813 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { makeMockDb, makeSnap, makeQuerySnap } from "../../../../../tests/helpers/mock-firestore";
3
+ const { db, mockDocRef, mockCollection, mockQuery, mockBatch } = makeMockDb();
4
+ vi.mock("../../../../providers/db-firebase/admin", () => ({
5
+ getAdminDb: () => db,
6
+ }));
7
+ vi.mock("../../../../providers/db-firebase", async (importOriginal) => {
8
+ const actual = await importOriginal();
9
+ return { ...actual, prepareForFirestore: (d) => d };
10
+ });
11
+ vi.mock("../../../../contracts/field-ops", () => ({
12
+ serverTimestamp: () => new Date(),
13
+ increment: (n) => n,
14
+ arrayUnion: (...args) => args,
15
+ arrayRemove: (...args) => args,
16
+ deleteField: () => null,
17
+ registerFieldOps: vi.fn(),
18
+ }));
19
+ vi.mock("../../../../errors/normalize", () => ({ normalizeError: vi.fn() }));
20
+ vi.mock("../../../../monitoring", () => ({
21
+ serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
22
+ }));
23
+ import { CategoriesRepository } from "../categories.repository";
24
+ import { MIN_ITEMS_FOR_FEATURED } from "../../schemas";
25
+ const repo = new CategoriesRepository();
26
+ function makeCategoryDoc(overrides = {}) {
27
+ return {
28
+ id: "category-action-figures",
29
+ name: "Action Figures",
30
+ slug: "action-figures",
31
+ categoryType: "category",
32
+ tier: 0,
33
+ parentIds: [],
34
+ rootId: "category-action-figures",
35
+ path: "action-figures",
36
+ ancestors: [],
37
+ childrenIds: [],
38
+ isLeaf: true,
39
+ isActive: true,
40
+ isBrand: false,
41
+ isFeatured: false,
42
+ isSearchable: true,
43
+ order: 1,
44
+ metrics: {
45
+ productCount: 0,
46
+ productIds: [],
47
+ auctionCount: 0,
48
+ auctionIds: [],
49
+ totalProductCount: 0,
50
+ totalAuctionCount: 0,
51
+ totalItemCount: 0,
52
+ lastUpdated: new Date(),
53
+ },
54
+ display: {},
55
+ seo: {},
56
+ createdAt: new Date(),
57
+ updatedAt: new Date(),
58
+ ...overrides,
59
+ };
60
+ }
61
+ beforeEach(() => {
62
+ // resetAllMocks clears call history AND the mockResolvedValueOnce queue,
63
+ // preventing unconsumed "once" values from bleeding into the next test.
64
+ vi.resetAllMocks();
65
+ db.collection.mockReturnValue(mockCollection);
66
+ db.batch.mockReturnValue(mockBatch);
67
+ mockCollection.doc.mockReturnValue(mockDocRef);
68
+ mockCollection.where.mockReturnValue(mockQuery);
69
+ mockCollection.orderBy = vi.fn().mockReturnValue(mockQuery);
70
+ mockCollection.get = vi.fn().mockResolvedValue(makeQuerySnap([]));
71
+ mockQuery.where.mockReturnValue(mockQuery);
72
+ mockQuery.orderBy.mockReturnValue(mockQuery);
73
+ mockQuery.limit.mockReturnValue(mockQuery);
74
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
75
+ mockDocRef.get.mockResolvedValue(makeSnap(null));
76
+ mockDocRef.set.mockResolvedValue(undefined);
77
+ mockDocRef.update.mockResolvedValue(undefined);
78
+ mockDocRef.delete.mockResolvedValue(undefined);
79
+ mockBatch.update.mockReturnValue(undefined);
80
+ mockBatch.delete.mockReturnValue(undefined);
81
+ mockBatch.commit.mockResolvedValue(undefined);
82
+ });
83
+ // ---------------------------------------------------------------------------
84
+ // createWithHierarchy — root category (no parent)
85
+ // ---------------------------------------------------------------------------
86
+ describe("CategoriesRepository.createWithHierarchy — root category", () => {
87
+ it("creates a root category with tier 0 when no parentId provided", async () => {
88
+ // No parent lookup (parentIds is empty) — first get is findByIdOrFail at the end
89
+ const createdDoc = makeCategoryDoc({ tier: 0, parentIds: [] });
90
+ mockDocRef.get.mockResolvedValue(makeSnap(createdDoc, createdDoc.id));
91
+ const result = await repo.createWithHierarchy({
92
+ name: "Action Figures",
93
+ slug: "action-figures",
94
+ categoryType: "category",
95
+ isActive: true,
96
+ isBrand: false,
97
+ isSearchable: true,
98
+ order: 1,
99
+ display: {},
100
+ seo: {},
101
+ });
102
+ expect(result.tier).toBe(0);
103
+ });
104
+ it("sets parentIds: [] for a root category", async () => {
105
+ const createdDoc = makeCategoryDoc({ tier: 0, parentIds: [] });
106
+ mockDocRef.get.mockResolvedValue(makeSnap(createdDoc, createdDoc.id));
107
+ const result = await repo.createWithHierarchy({
108
+ name: "Action Figures",
109
+ slug: "action-figures",
110
+ categoryType: "category",
111
+ isActive: true,
112
+ isBrand: false,
113
+ isSearchable: true,
114
+ order: 1,
115
+ display: {},
116
+ seo: {},
117
+ });
118
+ expect(result.parentIds).toEqual([]);
119
+ });
120
+ it("sets isLeaf: true on a freshly created root category", async () => {
121
+ const createdDoc = makeCategoryDoc({ isLeaf: true });
122
+ mockDocRef.get.mockResolvedValue(makeSnap(createdDoc, createdDoc.id));
123
+ const result = await repo.createWithHierarchy({
124
+ name: "Action Figures",
125
+ slug: "action-figures",
126
+ categoryType: "category",
127
+ isActive: true,
128
+ isBrand: false,
129
+ isSearchable: true,
130
+ order: 1,
131
+ display: {},
132
+ seo: {},
133
+ });
134
+ expect(result.isLeaf).toBe(true);
135
+ });
136
+ it("calls db.collection.doc.set to persist the document", async () => {
137
+ const createdDoc = makeCategoryDoc();
138
+ mockDocRef.get.mockResolvedValue(makeSnap(createdDoc, createdDoc.id));
139
+ await repo.createWithHierarchy({
140
+ name: "Action Figures",
141
+ slug: "action-figures",
142
+ categoryType: "category",
143
+ isActive: true,
144
+ isBrand: false,
145
+ isSearchable: true,
146
+ order: 1,
147
+ display: {},
148
+ seo: {},
149
+ });
150
+ expect(mockDocRef.set).toHaveBeenCalledOnce();
151
+ });
152
+ it("does NOT update any parent's childrenIds when creating a root category", async () => {
153
+ const createdDoc = makeCategoryDoc();
154
+ mockDocRef.get.mockResolvedValue(makeSnap(createdDoc, createdDoc.id));
155
+ await repo.createWithHierarchy({
156
+ name: "Action Figures",
157
+ slug: "action-figures",
158
+ categoryType: "category",
159
+ isActive: true,
160
+ isBrand: false,
161
+ isSearchable: true,
162
+ order: 1,
163
+ display: {},
164
+ seo: {},
165
+ });
166
+ // update is only called for parent's childrenIds — should NOT be called for root
167
+ expect(mockDocRef.update).not.toHaveBeenCalled();
168
+ });
169
+ });
170
+ // ---------------------------------------------------------------------------
171
+ // createWithHierarchy — child category
172
+ // ---------------------------------------------------------------------------
173
+ describe("CategoriesRepository.createWithHierarchy — child category", () => {
174
+ const parentDoc = makeCategoryDoc({
175
+ id: "category-action-figures",
176
+ name: "Action Figures",
177
+ tier: 0,
178
+ parentIds: [],
179
+ childrenIds: [],
180
+ rootId: "category-action-figures",
181
+ path: "action-figures",
182
+ ancestors: [],
183
+ });
184
+ it("sets tier = parent.tier + 1 for a child category", async () => {
185
+ const childDoc = makeCategoryDoc({
186
+ id: "category-marvel",
187
+ name: "Marvel",
188
+ tier: 1,
189
+ parentIds: ["category-action-figures"],
190
+ });
191
+ // First get: parent lookup (findById), second get: findByIdOrFail for return
192
+ mockDocRef.get
193
+ .mockResolvedValueOnce(makeSnap(parentDoc, "category-action-figures"))
194
+ .mockResolvedValueOnce(makeSnap(childDoc, "category-marvel"));
195
+ const result = await repo.createWithHierarchy({
196
+ name: "Marvel",
197
+ slug: "marvel",
198
+ categoryType: "category",
199
+ parentIds: ["category-action-figures"],
200
+ isActive: true,
201
+ isBrand: false,
202
+ isSearchable: true,
203
+ order: 1,
204
+ display: {},
205
+ seo: {},
206
+ });
207
+ expect(result.tier).toBe(1);
208
+ });
209
+ it("includes parentId in parentIds for a child category", async () => {
210
+ const childDoc = makeCategoryDoc({
211
+ id: "category-marvel",
212
+ name: "Marvel",
213
+ tier: 1,
214
+ parentIds: ["category-action-figures"],
215
+ });
216
+ mockDocRef.get
217
+ .mockResolvedValueOnce(makeSnap(parentDoc, "category-action-figures"))
218
+ .mockResolvedValueOnce(makeSnap(childDoc, "category-marvel"));
219
+ const result = await repo.createWithHierarchy({
220
+ name: "Marvel",
221
+ slug: "marvel",
222
+ categoryType: "category",
223
+ parentIds: ["category-action-figures"],
224
+ isActive: true,
225
+ isBrand: false,
226
+ isSearchable: true,
227
+ order: 1,
228
+ display: {},
229
+ seo: {},
230
+ });
231
+ expect(result.parentIds).toContain("category-action-figures");
232
+ });
233
+ it("updates parent's childrenIds to include the new child", async () => {
234
+ const childDoc = makeCategoryDoc({
235
+ id: "category-marvel",
236
+ name: "Marvel",
237
+ tier: 1,
238
+ parentIds: ["category-action-figures"],
239
+ });
240
+ mockDocRef.get
241
+ .mockResolvedValueOnce(makeSnap(parentDoc, "category-action-figures"))
242
+ .mockResolvedValueOnce(makeSnap(childDoc, "category-marvel"));
243
+ await repo.createWithHierarchy({
244
+ name: "Marvel",
245
+ slug: "marvel",
246
+ categoryType: "category",
247
+ parentIds: ["category-action-figures"],
248
+ isActive: true,
249
+ isBrand: false,
250
+ isSearchable: true,
251
+ order: 1,
252
+ display: {},
253
+ seo: {},
254
+ });
255
+ // Should call update on parent — childrenIds gets the new child's ID appended
256
+ expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({
257
+ childrenIds: expect.any(Array),
258
+ isLeaf: false,
259
+ }));
260
+ });
261
+ it("sets isLeaf: false on parent after adding child", async () => {
262
+ const childDoc = makeCategoryDoc({
263
+ id: "category-marvel",
264
+ name: "Marvel",
265
+ tier: 1,
266
+ parentIds: ["category-action-figures"],
267
+ });
268
+ mockDocRef.get
269
+ .mockResolvedValueOnce(makeSnap(parentDoc, "category-action-figures"))
270
+ .mockResolvedValueOnce(makeSnap(childDoc, "category-marvel"));
271
+ await repo.createWithHierarchy({
272
+ name: "Marvel",
273
+ slug: "marvel",
274
+ categoryType: "category",
275
+ parentIds: ["category-action-figures"],
276
+ isActive: true,
277
+ isBrand: false,
278
+ isSearchable: true,
279
+ order: 1,
280
+ display: {},
281
+ seo: {},
282
+ });
283
+ const updateArg = mockDocRef.update.mock.calls[0][0];
284
+ expect(updateArg.isLeaf).toBe(false);
285
+ });
286
+ });
287
+ // ---------------------------------------------------------------------------
288
+ // getRootCategories
289
+ // ---------------------------------------------------------------------------
290
+ describe("CategoriesRepository.getRootCategories", () => {
291
+ it("queries tier == 0 and isActive == true", async () => {
292
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
293
+ await repo.getRootCategories();
294
+ expect(mockCollection.where).toHaveBeenCalledWith("tier", "==", 0);
295
+ expect(mockQuery.where).toHaveBeenCalledWith("isActive", "==", true);
296
+ });
297
+ it("orders by order asc", async () => {
298
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
299
+ await repo.getRootCategories();
300
+ expect(mockQuery.orderBy).toHaveBeenCalledWith("order", "asc");
301
+ });
302
+ it("excludes categories with isBrand: true", async () => {
303
+ const regularCat = makeCategoryDoc({ id: "cat-1", isBrand: false, tier: 0 });
304
+ const brandCat = makeCategoryDoc({ id: "cat-brand", isBrand: true, tier: 0 });
305
+ mockQuery.get.mockResolvedValue(makeQuerySnap([
306
+ { id: "cat-1", data: regularCat },
307
+ { id: "cat-brand", data: brandCat },
308
+ ]));
309
+ const results = await repo.getRootCategories();
310
+ expect(results.every((c) => !c.isBrand)).toBe(true);
311
+ expect(results.some((c) => c.id === "cat-1")).toBe(true);
312
+ expect(results.some((c) => c.id === "cat-brand")).toBe(false);
313
+ });
314
+ it("returns empty array when no root categories exist", async () => {
315
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
316
+ const results = await repo.getRootCategories();
317
+ expect(results).toEqual([]);
318
+ });
319
+ });
320
+ // ---------------------------------------------------------------------------
321
+ // getLeafCategories
322
+ // ---------------------------------------------------------------------------
323
+ describe("CategoriesRepository.getLeafCategories", () => {
324
+ it("queries isLeaf == true and isActive == true", async () => {
325
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
326
+ await repo.getLeafCategories();
327
+ expect(mockCollection.where).toHaveBeenCalledWith("isLeaf", "==", true);
328
+ expect(mockQuery.where).toHaveBeenCalledWith("isActive", "==", true);
329
+ });
330
+ it("limits to 500 documents", async () => {
331
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
332
+ await repo.getLeafCategories();
333
+ expect(mockQuery.limit).toHaveBeenCalledWith(500);
334
+ });
335
+ it("returns matching leaf categories", async () => {
336
+ const leaf = makeCategoryDoc({ id: "cat-leaf", isLeaf: true, isActive: true });
337
+ mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: "cat-leaf", data: leaf }]));
338
+ const results = await repo.getLeafCategories();
339
+ expect(results).toHaveLength(1);
340
+ expect(results[0].isLeaf).toBe(true);
341
+ });
342
+ });
343
+ // ---------------------------------------------------------------------------
344
+ // toggleFeatured
345
+ // ---------------------------------------------------------------------------
346
+ describe("CategoriesRepository.toggleFeatured", () => {
347
+ it("throws DatabaseError when totalItemCount < MIN_ITEMS_FOR_FEATURED", async () => {
348
+ const cat = makeCategoryDoc({
349
+ id: "cat-sparse",
350
+ metrics: {
351
+ productCount: 0,
352
+ productIds: [],
353
+ auctionCount: 0,
354
+ auctionIds: [],
355
+ totalProductCount: 0,
356
+ totalAuctionCount: 0,
357
+ totalItemCount: MIN_ITEMS_FOR_FEATURED - 1,
358
+ lastUpdated: new Date(),
359
+ },
360
+ });
361
+ // findByIdOrFail call + return value at end
362
+ mockDocRef.get
363
+ .mockResolvedValueOnce(makeSnap(cat, "cat-sparse"))
364
+ .mockResolvedValueOnce(makeSnap(cat, "cat-sparse"));
365
+ await expect(repo.toggleFeatured("cat-sparse", true)).rejects.toThrow(/at least/i);
366
+ });
367
+ it("throws when totalItemCount is exactly 0", async () => {
368
+ const cat = makeCategoryDoc({
369
+ id: "cat-empty",
370
+ metrics: {
371
+ productCount: 0,
372
+ productIds: [],
373
+ auctionCount: 0,
374
+ auctionIds: [],
375
+ totalProductCount: 0,
376
+ totalAuctionCount: 0,
377
+ totalItemCount: 0,
378
+ lastUpdated: new Date(),
379
+ },
380
+ });
381
+ mockDocRef.get
382
+ .mockResolvedValueOnce(makeSnap(cat, "cat-empty"))
383
+ .mockResolvedValueOnce(makeSnap(cat, "cat-empty"));
384
+ await expect(repo.toggleFeatured("cat-empty", true)).rejects.toThrow();
385
+ });
386
+ it("allows featuring when totalItemCount >= MIN_ITEMS_FOR_FEATURED", async () => {
387
+ const cat = makeCategoryDoc({
388
+ id: "cat-rich",
389
+ isFeatured: false,
390
+ metrics: {
391
+ productCount: MIN_ITEMS_FOR_FEATURED,
392
+ productIds: [],
393
+ auctionCount: 0,
394
+ auctionIds: [],
395
+ totalProductCount: MIN_ITEMS_FOR_FEATURED,
396
+ totalAuctionCount: 0,
397
+ totalItemCount: MIN_ITEMS_FOR_FEATURED,
398
+ lastUpdated: new Date(),
399
+ },
400
+ });
401
+ const featured = { ...cat, isFeatured: true };
402
+ mockDocRef.get
403
+ .mockResolvedValueOnce(makeSnap(cat, "cat-rich"))
404
+ .mockResolvedValueOnce(makeSnap(featured, "cat-rich"));
405
+ const result = await repo.toggleFeatured("cat-rich", true);
406
+ expect(result.isFeatured).toBe(true);
407
+ expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ isFeatured: true }));
408
+ });
409
+ it("allows UN-featuring regardless of totalItemCount (no guard when featured=false)", async () => {
410
+ const cat = makeCategoryDoc({
411
+ id: "cat-unfeature",
412
+ isFeatured: true,
413
+ metrics: {
414
+ productCount: 0,
415
+ productIds: [],
416
+ auctionCount: 0,
417
+ auctionIds: [],
418
+ totalProductCount: 0,
419
+ totalAuctionCount: 0,
420
+ totalItemCount: 0,
421
+ lastUpdated: new Date(),
422
+ },
423
+ });
424
+ const unfeatured = { ...cat, isFeatured: false };
425
+ mockDocRef.get
426
+ .mockResolvedValueOnce(makeSnap(cat, "cat-unfeature"))
427
+ .mockResolvedValueOnce(makeSnap(unfeatured, "cat-unfeature"));
428
+ const result = await repo.toggleFeatured("cat-unfeature", false);
429
+ expect(result.isFeatured).toBe(false);
430
+ expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ isFeatured: false }));
431
+ });
432
+ });
433
+ // ---------------------------------------------------------------------------
434
+ // moveCategory
435
+ // ---------------------------------------------------------------------------
436
+ describe("CategoriesRepository.moveCategory", () => {
437
+ const oldParent = makeCategoryDoc({
438
+ id: "cat-old-parent",
439
+ name: "Old Parent",
440
+ tier: 0,
441
+ parentIds: [],
442
+ childrenIds: ["cat-child"],
443
+ rootId: "cat-old-parent",
444
+ });
445
+ const newParent = makeCategoryDoc({
446
+ id: "cat-new-parent",
447
+ name: "New Parent",
448
+ tier: 0,
449
+ parentIds: [],
450
+ childrenIds: [],
451
+ rootId: "cat-new-parent",
452
+ });
453
+ const childCat = makeCategoryDoc({
454
+ id: "cat-child",
455
+ name: "Child",
456
+ tier: 1,
457
+ parentIds: ["cat-old-parent"],
458
+ childrenIds: [],
459
+ rootId: "cat-old-parent",
460
+ });
461
+ it("removes the category from old parent's childrenIds via batch", async () => {
462
+ // findByIdOrFail(categoryId) → child
463
+ // findByIdOrFail(newParentId) → new parent
464
+ // findById(oldParentId) → old parent (for isLeaf check)
465
+ // findByIdOrFail(categoryId) → child (return value)
466
+ mockDocRef.get
467
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"))
468
+ .mockResolvedValueOnce(makeSnap(newParent, "cat-new-parent"))
469
+ .mockResolvedValueOnce(makeSnap(oldParent, "cat-old-parent"))
470
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"));
471
+ await repo.moveCategory({
472
+ categoryId: "cat-child",
473
+ newParentId: "cat-new-parent",
474
+ });
475
+ // batch.update called for: category itself, old parent, new parent
476
+ const updateCalls = mockBatch.update.mock.calls;
477
+ const oldParentUpdate = updateCalls.find(([, data]) => data["childrenIds"] !== undefined && Array.isArray(data["childrenIds"]));
478
+ expect(oldParentUpdate).toBeDefined();
479
+ });
480
+ it("adds the category to new parent's childrenIds via batch", async () => {
481
+ mockDocRef.get
482
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"))
483
+ .mockResolvedValueOnce(makeSnap(newParent, "cat-new-parent"))
484
+ .mockResolvedValueOnce(makeSnap(oldParent, "cat-old-parent"))
485
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"));
486
+ await repo.moveCategory({
487
+ categoryId: "cat-child",
488
+ newParentId: "cat-new-parent",
489
+ });
490
+ const updateCalls = mockBatch.update.mock.calls;
491
+ const newParentUpdate = updateCalls.find(([, data]) => data.isLeaf === false);
492
+ expect(newParentUpdate).toBeDefined();
493
+ });
494
+ it("commits the batch after all updates", async () => {
495
+ mockDocRef.get
496
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"))
497
+ .mockResolvedValueOnce(makeSnap(newParent, "cat-new-parent"))
498
+ .mockResolvedValueOnce(makeSnap(oldParent, "cat-old-parent"))
499
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"));
500
+ await repo.moveCategory({
501
+ categoryId: "cat-child",
502
+ newParentId: "cat-new-parent",
503
+ });
504
+ expect(mockBatch.commit).toHaveBeenCalledOnce();
505
+ });
506
+ it("throws DatabaseError when moving a category to itself (circular)", async () => {
507
+ mockDocRef.get.mockResolvedValueOnce(makeSnap(childCat, "cat-child"));
508
+ await expect(repo.moveCategory({ categoryId: "cat-child", newParentId: "cat-child" })).rejects.toThrow(/circular|invalid/i);
509
+ });
510
+ it("marks old parent as isLeaf: true when it has exactly 1 child (the one being moved)", async () => {
511
+ const oldParentWith1Child = { ...oldParent, childrenIds: ["cat-child"] };
512
+ mockDocRef.get
513
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"))
514
+ .mockResolvedValueOnce(makeSnap(newParent, "cat-new-parent"))
515
+ .mockResolvedValueOnce(makeSnap(oldParentWith1Child, "cat-old-parent"))
516
+ .mockResolvedValueOnce(makeSnap(childCat, "cat-child"));
517
+ await repo.moveCategory({
518
+ categoryId: "cat-child",
519
+ newParentId: "cat-new-parent",
520
+ });
521
+ const updateCalls = mockBatch.update.mock.calls;
522
+ const leafUpdate = updateCalls.find(([, data]) => data.isLeaf === true);
523
+ expect(leafUpdate).toBeDefined();
524
+ });
525
+ });
526
+ // ---------------------------------------------------------------------------
527
+ // buildTree
528
+ // ---------------------------------------------------------------------------
529
+ describe("CategoriesRepository.buildTree", () => {
530
+ it("fetches all active non-brand categories ordered by tier then order", async () => {
531
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
532
+ await repo.buildTree();
533
+ expect(mockCollection.where).toHaveBeenCalledWith("isActive", "==", true);
534
+ expect(mockQuery.orderBy).toHaveBeenCalledWith("tier", "asc");
535
+ });
536
+ it("returns an empty array when there are no categories", async () => {
537
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
538
+ const result = await repo.buildTree();
539
+ expect(result).toEqual([]);
540
+ });
541
+ it("builds nested tree correctly from flat category list", async () => {
542
+ const root = makeCategoryDoc({
543
+ id: "cat-root",
544
+ name: "Root",
545
+ tier: 0,
546
+ parentIds: [],
547
+ rootId: "cat-root",
548
+ childrenIds: ["cat-child"],
549
+ isBrand: false,
550
+ });
551
+ const child = makeCategoryDoc({
552
+ id: "cat-child",
553
+ name: "Child",
554
+ tier: 1,
555
+ parentIds: ["cat-root"],
556
+ rootId: "cat-root",
557
+ childrenIds: [],
558
+ isBrand: false,
559
+ });
560
+ mockQuery.get.mockResolvedValue(makeQuerySnap([
561
+ { id: "cat-root", data: root },
562
+ { id: "cat-child", data: child },
563
+ ]));
564
+ const tree = await repo.buildTree();
565
+ // CategoryTreeNode has { category, children, depth }, not a top-level id
566
+ expect(tree.length).toBeGreaterThanOrEqual(1);
567
+ const rootNode = tree.find((n) => n.category.id === "cat-root");
568
+ expect(rootNode).toBeDefined();
569
+ });
570
+ it("filters out isBrand categories from the global tree", async () => {
571
+ const brand = makeCategoryDoc({
572
+ id: "cat-brand",
573
+ name: "Brand Category",
574
+ tier: 0,
575
+ parentIds: [],
576
+ isBrand: true,
577
+ });
578
+ mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: "cat-brand", data: brand }]));
579
+ const tree = await repo.buildTree();
580
+ expect(tree.find((n) => n.category.id === "cat-brand")).toBeUndefined();
581
+ });
582
+ });
583
+ // ---------------------------------------------------------------------------
584
+ // listByType
585
+ // ---------------------------------------------------------------------------
586
+ describe("CategoriesRepository.listByType", () => {
587
+ it("queries categoryType == the requested type", async () => {
588
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
589
+ await repo.listByType("sublisting");
590
+ expect(mockCollection.where).toHaveBeenCalledWith("categoryType", "==", "sublisting");
591
+ });
592
+ it("returns only documents matching the requested categoryType", async () => {
593
+ const sublisting = makeCategoryDoc({
594
+ id: "sublisting-holos",
595
+ categoryType: "sublisting",
596
+ });
597
+ mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: "sublisting-holos", data: sublisting }]));
598
+ const results = await repo.listByType("sublisting");
599
+ expect(results).toHaveLength(1);
600
+ expect(results[0].id).toBe("sublisting-holos");
601
+ });
602
+ it("applies activeOnly filter when opts.activeOnly is true", async () => {
603
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
604
+ await repo.listByType("brand", { activeOnly: true });
605
+ expect(mockQuery.where).toHaveBeenCalledWith("isActive", "==", true);
606
+ });
607
+ it("does NOT apply activeOnly filter by default", async () => {
608
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
609
+ await repo.listByType("brand");
610
+ // where on query should not have been called for isActive
611
+ const queryWhereCalls = mockQuery.where.mock.calls;
612
+ const isActiveCall = queryWhereCalls.find(([field]) => field === "isActive");
613
+ expect(isActiveCall).toBeUndefined();
614
+ });
615
+ it("applies limit when opts.limit is set", async () => {
616
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
617
+ await repo.listByType("bundle", { limit: 10 });
618
+ expect(mockQuery.limit).toHaveBeenCalledWith(10);
619
+ });
620
+ it("returns empty array when no documents match the type", async () => {
621
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
622
+ const results = await repo.listByType("bundle");
623
+ expect(results).toEqual([]);
624
+ });
625
+ });
626
+ // ---------------------------------------------------------------------------
627
+ // findBySlugAndType
628
+ // ---------------------------------------------------------------------------
629
+ describe("CategoriesRepository.findBySlugAndType", () => {
630
+ it("queries by slug field", async () => {
631
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
632
+ await repo.findBySlugAndType("action-figures");
633
+ expect(mockCollection.where).toHaveBeenCalledWith("slug", "==", "action-figures");
634
+ });
635
+ it("limits to 1 result", async () => {
636
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
637
+ await repo.findBySlugAndType("action-figures");
638
+ expect(mockQuery.limit).toHaveBeenCalledWith(1);
639
+ });
640
+ it("returns null when no document matches the slug", async () => {
641
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
642
+ const result = await repo.findBySlugAndType("nonexistent-slug");
643
+ expect(result).toBeNull();
644
+ });
645
+ it("returns the category document when slug matches", async () => {
646
+ const cat = makeCategoryDoc({ id: "category-action-figures", slug: "action-figures" });
647
+ mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: "category-action-figures", data: cat }]));
648
+ const result = await repo.findBySlugAndType("action-figures");
649
+ expect(result).not.toBeNull();
650
+ expect(result?.slug).toBe("action-figures");
651
+ });
652
+ it("adds categoryType filter when type argument is provided", async () => {
653
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
654
+ await repo.findBySlugAndType("some-brand", "brand");
655
+ expect(mockQuery.where).toHaveBeenCalledWith("categoryType", "==", "brand");
656
+ });
657
+ it("does NOT add categoryType filter when type is not provided", async () => {
658
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
659
+ await repo.findBySlugAndType("action-figures");
660
+ const queryWhereCalls = mockQuery.where.mock.calls;
661
+ const typeFilterCall = queryWhereCalls.find(([field]) => field === "categoryType");
662
+ expect(typeFilterCall).toBeUndefined();
663
+ });
664
+ });
665
+ // ---------------------------------------------------------------------------
666
+ // getChildren
667
+ // ---------------------------------------------------------------------------
668
+ describe("CategoriesRepository.getChildren", () => {
669
+ it("queries parentIds array-contains the given parentId", async () => {
670
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
671
+ await repo.getChildren("category-action-figures");
672
+ expect(mockCollection.where).toHaveBeenCalledWith("parentIds", "array-contains", "category-action-figures");
673
+ });
674
+ it("limits to 100 documents", async () => {
675
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
676
+ await repo.getChildren("category-action-figures");
677
+ expect(mockQuery.limit).toHaveBeenCalledWith(100);
678
+ });
679
+ it("filters in-memory to return only DIRECT children (parentIds last element === parentId)", async () => {
680
+ // Direct child: parentIds last item = parentId
681
+ const directChild = makeCategoryDoc({
682
+ id: "cat-direct",
683
+ parentIds: ["category-action-figures"],
684
+ });
685
+ // Non-direct descendant: parentIds last item is something else (it's a grandchild)
686
+ const grandChild = makeCategoryDoc({
687
+ id: "cat-grandchild",
688
+ parentIds: ["category-action-figures", "cat-direct"],
689
+ });
690
+ mockQuery.get.mockResolvedValue(makeQuerySnap([
691
+ { id: "cat-direct", data: directChild },
692
+ { id: "cat-grandchild", data: grandChild },
693
+ ]));
694
+ const results = await repo.getChildren("category-action-figures");
695
+ expect(results.map((r) => r.id)).toContain("cat-direct");
696
+ expect(results.map((r) => r.id)).not.toContain("cat-grandchild");
697
+ });
698
+ it("returns empty array when no children exist", async () => {
699
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
700
+ const results = await repo.getChildren("cat-leaf-node");
701
+ expect(results).toEqual([]);
702
+ });
703
+ });
704
+ // ---------------------------------------------------------------------------
705
+ // updateMetricsInBatch (synchronous batch staging)
706
+ // ---------------------------------------------------------------------------
707
+ describe("CategoriesRepository.updateMetricsInBatch", () => {
708
+ it("stages a batch.update for the direct category", () => {
709
+ const batch = {
710
+ update: vi.fn(),
711
+ commit: vi.fn().mockResolvedValue(undefined),
712
+ };
713
+ repo.updateMetricsInBatch(batch, "category-action-figures", [], 1, 0);
714
+ expect(batch.update).toHaveBeenCalledOnce();
715
+ });
716
+ it("stages batch.update for every ancestor in parentIds", () => {
717
+ const batch = {
718
+ update: vi.fn(),
719
+ commit: vi.fn().mockResolvedValue(undefined),
720
+ };
721
+ repo.updateMetricsInBatch(batch, "cat-child", ["cat-root", "cat-parent"], 1, 0);
722
+ // 1 for direct + 2 for ancestors = 3 total
723
+ expect(batch.update).toHaveBeenCalledTimes(3);
724
+ });
725
+ it("does NOT call batch.commit (caller is responsible)", () => {
726
+ const batch = {
727
+ update: vi.fn(),
728
+ commit: vi.fn().mockResolvedValue(undefined),
729
+ };
730
+ repo.updateMetricsInBatch(batch, "cat-child", [], 1, 0);
731
+ expect(batch.commit).not.toHaveBeenCalled();
732
+ });
733
+ it("includes productId in arrayUnion for productIds field when productDelta > 0", () => {
734
+ const capturedUpdates = [];
735
+ const batch = {
736
+ update: vi.fn((_ref, data) => capturedUpdates.push(data)),
737
+ commit: vi.fn().mockResolvedValue(undefined),
738
+ };
739
+ repo.updateMetricsInBatch(batch, "cat-child", [], 1, 0, "product-test-123");
740
+ const directUpdate = capturedUpdates[0];
741
+ // arrayUnion mock returns args as array
742
+ expect(directUpdate["metrics.productIds"]).toEqual(["product-test-123"]);
743
+ });
744
+ });
745
+ // ---------------------------------------------------------------------------
746
+ // getCategoryBySlug
747
+ // ---------------------------------------------------------------------------
748
+ describe("CategoriesRepository.getCategoryBySlug", () => {
749
+ it("queries slug == given slug and limits to 1", async () => {
750
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
751
+ await repo.getCategoryBySlug("action-figures");
752
+ expect(mockCollection.where).toHaveBeenCalledWith("slug", "==", "action-figures");
753
+ expect(mockQuery.limit).toHaveBeenCalledWith(1);
754
+ });
755
+ it("returns null when no category matches the slug", async () => {
756
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
757
+ const result = await repo.getCategoryBySlug("nonexistent");
758
+ expect(result).toBeNull();
759
+ });
760
+ it("returns the category when slug matches", async () => {
761
+ const cat = makeCategoryDoc({ id: "cat-af", slug: "action-figures" });
762
+ mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: "cat-af", data: cat }]));
763
+ const result = await repo.getCategoryBySlug("action-figures");
764
+ expect(result?.slug).toBe("action-figures");
765
+ });
766
+ });
767
+ // ---------------------------------------------------------------------------
768
+ // findActiveBrands
769
+ // ---------------------------------------------------------------------------
770
+ describe("CategoriesRepository.findActiveBrands", () => {
771
+ it("queries categoryType == brand and isActive == true", async () => {
772
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
773
+ await repo.findActiveBrands();
774
+ expect(mockCollection.where).toHaveBeenCalledWith("categoryType", "==", "brand");
775
+ expect(mockQuery.where).toHaveBeenCalledWith("isActive", "==", true);
776
+ });
777
+ it("orders by order asc", async () => {
778
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
779
+ await repo.findActiveBrands();
780
+ expect(mockQuery.orderBy).toHaveBeenCalledWith("order", "asc");
781
+ });
782
+ it("returns active brand categories", async () => {
783
+ const brand = makeCategoryDoc({
784
+ id: "cat-bandai",
785
+ categoryType: "brand",
786
+ isActive: true,
787
+ });
788
+ mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: "cat-bandai", data: brand }]));
789
+ const results = await repo.findActiveBrands();
790
+ expect(results).toHaveLength(1);
791
+ expect(results[0].categoryType).toBe("brand");
792
+ });
793
+ });
794
+ // ---------------------------------------------------------------------------
795
+ // generateSublistingId
796
+ // ---------------------------------------------------------------------------
797
+ describe("CategoriesRepository.generateSublistingId", () => {
798
+ it("returns sublisting- prefixed slug from name", () => {
799
+ expect(repo.generateSublistingId("Holo Cards")).toBe("sublisting-holo-cards");
800
+ });
801
+ it("does not double-prefix if name already starts with sublisting-", () => {
802
+ expect(repo.generateSublistingId("sublisting-holos")).toBe("sublisting-holos");
803
+ });
804
+ it("converts uppercase to lowercase", () => {
805
+ expect(repo.generateSublistingId("POKEMON")).toBe("sublisting-pokemon");
806
+ });
807
+ it("replaces non-alphanumeric chars with hyphens", () => {
808
+ expect(repo.generateSublistingId("First Edition!")).toBe("sublisting-first-edition");
809
+ });
810
+ it("trims leading and trailing hyphens", () => {
811
+ expect(repo.generateSublistingId(" holos ")).toBe("sublisting-holos");
812
+ });
813
+ });