@mohasinac/appkit 3.0.6 → 3.1.1

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,148 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ const { mockSendNotification, mockRtdbPush, mockDecryptPii, } = vi.hoisted(() => ({
3
+ mockSendNotification: vi.fn().mockResolvedValue(undefined),
4
+ mockRtdbPush: vi.fn().mockResolvedValue(undefined),
5
+ mockDecryptPii: vi.fn((v) => v),
6
+ }));
7
+ vi.mock("../../../../../features/admin/actions/notification-actions", () => ({
8
+ sendNotification: mockSendNotification,
9
+ }));
10
+ vi.mock("../../../../../providers/db-firebase", () => ({
11
+ getAdminRealtimeDb: vi.fn(() => ({
12
+ ref: vi.fn(() => ({ push: mockRtdbPush })),
13
+ })),
14
+ }));
15
+ vi.mock("../../../../../security/index", () => ({
16
+ decryptPii: mockDecryptPii,
17
+ }));
18
+ vi.mock("../../../../../errors/normalize", () => ({
19
+ normalizeError: vi.fn(),
20
+ }));
21
+ import { handleOrderStatusChange } from "../onOrderStatusChange";
22
+ // ── Helpers ─────────────────────────────────────────────────────────────────
23
+ function makeCtx() {
24
+ return {
25
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
26
+ };
27
+ }
28
+ function makeInput(beforeStatus, afterStatus, extraAfter = {}) {
29
+ return {
30
+ orderId: "order-1",
31
+ before: { status: beforeStatus },
32
+ after: {
33
+ status: afterStatus,
34
+ userId: "user-ravi",
35
+ userEmail: "ravi@example.com",
36
+ productTitle: "Pokemon Charizard",
37
+ trackingNumber: "TRK123",
38
+ ...extraAfter,
39
+ },
40
+ };
41
+ }
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ mockSendNotification.mockResolvedValue(undefined);
45
+ mockRtdbPush.mockResolvedValue(undefined);
46
+ mockDecryptPii.mockImplementation((v) => v);
47
+ });
48
+ // ── Tests ────────────────────────────────────────────────────────────────────
49
+ describe("handleOrderStatusChange — early exits", () => {
50
+ it("returns immediately when before is null", async () => {
51
+ const ctx = makeCtx();
52
+ await handleOrderStatusChange({ orderId: "o1", before: null, after: null }, ctx);
53
+ expect(mockSendNotification).not.toHaveBeenCalled();
54
+ });
55
+ it("returns immediately when after is null", async () => {
56
+ const ctx = makeCtx();
57
+ await handleOrderStatusChange({ orderId: "o1", before: { status: "pending" }, after: null }, ctx);
58
+ expect(mockSendNotification).not.toHaveBeenCalled();
59
+ });
60
+ it("returns when status has not changed", async () => {
61
+ const ctx = makeCtx();
62
+ await handleOrderStatusChange(makeInput("confirmed", "confirmed"), ctx);
63
+ expect(mockSendNotification).not.toHaveBeenCalled();
64
+ });
65
+ it("logs and returns when new status has no handler config", async () => {
66
+ const ctx = makeCtx();
67
+ await handleOrderStatusChange(makeInput("pending", "returned"), ctx);
68
+ expect(mockSendNotification).not.toHaveBeenCalled();
69
+ expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringMatching(/No handler for status transition/i), expect.any(Object));
70
+ });
71
+ });
72
+ describe("handleOrderStatusChange — confirmed", () => {
73
+ it("sends order_confirmed notification to buyer", async () => {
74
+ await handleOrderStatusChange(makeInput("pending", "confirmed"), makeCtx());
75
+ expect(mockSendNotification).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-ravi", type: "order_confirmed" }));
76
+ });
77
+ it("includes decrypted userEmail in notification payload", async () => {
78
+ mockDecryptPii.mockReturnValue("decrypted@example.com");
79
+ await handleOrderStatusChange(makeInput("pending", "confirmed"), makeCtx());
80
+ expect(mockSendNotification).toHaveBeenCalledWith(expect.objectContaining({ userEmail: "decrypted@example.com" }));
81
+ });
82
+ it("pushes to RTDB for real-time notification", async () => {
83
+ await handleOrderStatusChange(makeInput("pending", "confirmed"), makeCtx());
84
+ expect(mockRtdbPush).toHaveBeenCalledWith(expect.objectContaining({ type: "order_confirmed" }));
85
+ });
86
+ });
87
+ describe("handleOrderStatusChange — shipped", () => {
88
+ it("sends order_shipped notification with high priority", async () => {
89
+ await handleOrderStatusChange(makeInput("confirmed", "shipped"), makeCtx());
90
+ expect(mockSendNotification).toHaveBeenCalledWith(expect.objectContaining({ type: "order_shipped", priority: "high" }));
91
+ });
92
+ it("notification message includes tracking number", async () => {
93
+ await handleOrderStatusChange(makeInput("confirmed", "shipped", { trackingNumber: "AWB123" }), makeCtx());
94
+ const notifCall = mockSendNotification.mock.calls[0][0];
95
+ expect(notifCall.message).toMatch(/AWB123/);
96
+ });
97
+ });
98
+ describe("handleOrderStatusChange — delivered", () => {
99
+ it("sends order_delivered notification", async () => {
100
+ await handleOrderStatusChange(makeInput("shipped", "delivered"), makeCtx());
101
+ expect(mockSendNotification).toHaveBeenCalledWith(expect.objectContaining({ type: "order_delivered" }));
102
+ });
103
+ it("notification relatedId = orderId, relatedType = 'order'", async () => {
104
+ await handleOrderStatusChange(makeInput("shipped", "delivered"), makeCtx());
105
+ expect(mockSendNotification).toHaveBeenCalledWith(expect.objectContaining({ relatedId: "order-1", relatedType: "order" }));
106
+ });
107
+ });
108
+ describe("handleOrderStatusChange — cancelled", () => {
109
+ it("sends order_cancelled notification", async () => {
110
+ await handleOrderStatusChange(makeInput("pending", "cancelled"), makeCtx());
111
+ expect(mockSendNotification).toHaveBeenCalledWith(expect.objectContaining({ type: "order_cancelled" }));
112
+ });
113
+ it("cancelled message mentions the product title", async () => {
114
+ await handleOrderStatusChange(makeInput("pending", "cancelled", { productTitle: "Hot Wheels Redline" }), makeCtx());
115
+ const notifCall = mockSendNotification.mock.calls[0][0];
116
+ expect(notifCall.message).toMatch(/Hot Wheels Redline/);
117
+ });
118
+ });
119
+ describe("handleOrderStatusChange — RTDB failure is non-fatal", () => {
120
+ it("RTDB push failure → logs but does NOT re-throw", async () => {
121
+ mockRtdbPush.mockRejectedValue(new Error("RTDB down"));
122
+ const ctx = makeCtx();
123
+ await expect(handleOrderStatusChange(makeInput("pending", "confirmed"), ctx)).resolves.toBeUndefined();
124
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.stringMatching(/Realtime DB/i), expect.any(Error));
125
+ });
126
+ it("Firestore notification still sent even when RTDB fails", async () => {
127
+ mockRtdbPush.mockRejectedValue(new Error("RTDB down"));
128
+ await handleOrderStatusChange(makeInput("pending", "confirmed"), makeCtx());
129
+ expect(mockSendNotification).toHaveBeenCalled();
130
+ });
131
+ });
132
+ describe("handleOrderStatusChange — error propagation", () => {
133
+ it("sendNotification failure → re-throws", async () => {
134
+ mockSendNotification.mockRejectedValue(new Error("Notification service down"));
135
+ await expect(handleOrderStatusChange(makeInput("pending", "confirmed"), makeCtx())).rejects.toThrow("Notification service down");
136
+ });
137
+ it("logs error context before re-throwing", async () => {
138
+ mockSendNotification.mockRejectedValue(new Error("Notification service down"));
139
+ const ctx = makeCtx();
140
+ try {
141
+ await handleOrderStatusChange(makeInput("pending", "confirmed"), ctx);
142
+ }
143
+ catch {
144
+ // expected
145
+ }
146
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.any(String), expect.any(Error), expect.objectContaining({ orderId: "order-1" }));
147
+ });
148
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ // onProductStockChange uses ctx.db directly, no repository imports needed.
3
+ vi.mock("../../../../../errors/normalize", () => ({
4
+ normalizeError: vi.fn(),
5
+ }));
6
+ import { handleProductStockChange, isAvailable, syncBundlesForProduct, } from "../onProductStockChange";
7
+ import { PRODUCT_FIELDS } from "../../../../../constants/field-names";
8
+ // ── Helpers ─────────────────────────────────────────────────────────────────
9
+ function makeCtx(dbQuery) {
10
+ const mockQuery = dbQuery ?? vi.fn().mockReturnThis();
11
+ const queryChain = { where: vi.fn().mockReturnThis(), get: vi.fn() };
12
+ return {
13
+ db: {
14
+ collection: vi.fn().mockReturnValue(queryChain),
15
+ },
16
+ queryChain,
17
+ now: new Date(),
18
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
19
+ };
20
+ }
21
+ const PUBLISHED = PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED;
22
+ // ── isAvailable (pure function) ───────────────────────────────────────────────
23
+ describe("isAvailable — pure function", () => {
24
+ it("null doc → false", () => {
25
+ expect(isAvailable(null)).toBe(false);
26
+ });
27
+ it("isSold=true → false", () => {
28
+ expect(isAvailable({ isSold: true, availableQuantity: 1, status: PUBLISHED })).toBe(false);
29
+ });
30
+ it("availableQuantity=0 → false", () => {
31
+ expect(isAvailable({ isSold: false, availableQuantity: 0, status: PUBLISHED })).toBe(false);
32
+ });
33
+ it("status != published → false", () => {
34
+ expect(isAvailable({ isSold: false, availableQuantity: 5, status: "draft" })).toBe(false);
35
+ });
36
+ it("status=published, qty>0, not sold → true", () => {
37
+ expect(isAvailable({ isSold: false, availableQuantity: 1, status: PUBLISHED })).toBe(true);
38
+ });
39
+ it("qty undefined (no quantity field) → true (quantity not tracked = always available)", () => {
40
+ // Intentional design: the check is `typeof qty === "number" && qty <= 0`.
41
+ // If qty is undefined, the guard is skipped → published product is available.
42
+ expect(isAvailable({ isSold: false, availableQuantity: undefined, status: PUBLISHED })).toBe(true);
43
+ });
44
+ });
45
+ // ── handleProductStockChange ──────────────────────────────────────────────────
46
+ const INPUT_BASE = {
47
+ productId: "product-charizard",
48
+ isDelete: false,
49
+ };
50
+ function makeAvailableSnap() {
51
+ return { isSold: false, availableQuantity: 1, status: PUBLISHED };
52
+ }
53
+ function makeUnavailableSnap() {
54
+ return { isSold: false, availableQuantity: 0, status: PUBLISHED };
55
+ }
56
+ describe("handleProductStockChange — early exit: no state flip", () => {
57
+ it("skips when available → available (no flip)", async () => {
58
+ const ctx = makeCtx();
59
+ await handleProductStockChange({
60
+ ...INPUT_BASE,
61
+ before: makeAvailableSnap(),
62
+ after: makeAvailableSnap(),
63
+ }, ctx);
64
+ expect(ctx.db.collection).not.toHaveBeenCalled();
65
+ });
66
+ it("skips when unavailable → unavailable (no flip)", async () => {
67
+ const ctx = makeCtx();
68
+ await handleProductStockChange({
69
+ ...INPUT_BASE,
70
+ before: makeUnavailableSnap(),
71
+ after: makeUnavailableSnap(),
72
+ }, ctx);
73
+ expect(ctx.db.collection).not.toHaveBeenCalled();
74
+ });
75
+ it("processes when isDelete=true even if state appears unchanged", async () => {
76
+ const ctx = makeCtx();
77
+ ctx.db.collection = vi.fn().mockReturnValue({
78
+ where: vi.fn().mockReturnThis(),
79
+ get: vi.fn().mockResolvedValue({ docs: [], size: 0 }),
80
+ });
81
+ await handleProductStockChange({
82
+ ...INPUT_BASE,
83
+ isDelete: true,
84
+ before: makeAvailableSnap(),
85
+ after: makeAvailableSnap(),
86
+ }, ctx);
87
+ expect(ctx.db.collection).toHaveBeenCalled();
88
+ });
89
+ });
90
+ describe("handleProductStockChange — state flip triggers sync", () => {
91
+ it("available → unavailable triggers sync", async () => {
92
+ const ctx = makeCtx();
93
+ ctx.db.collection = vi.fn().mockReturnValue({
94
+ where: vi.fn().mockReturnThis(),
95
+ get: vi.fn().mockResolvedValue({ docs: [], size: 0 }),
96
+ });
97
+ await handleProductStockChange({
98
+ ...INPUT_BASE,
99
+ before: makeAvailableSnap(),
100
+ after: makeUnavailableSnap(),
101
+ }, ctx);
102
+ expect(ctx.db.collection).toHaveBeenCalled();
103
+ });
104
+ it("unavailable → available triggers sync", async () => {
105
+ const ctx = makeCtx();
106
+ ctx.db.collection = vi.fn().mockReturnValue({
107
+ where: vi.fn().mockReturnThis(),
108
+ get: vi.fn().mockResolvedValue({ docs: [], size: 0 }),
109
+ });
110
+ await handleProductStockChange({
111
+ ...INPUT_BASE,
112
+ before: makeUnavailableSnap(),
113
+ after: makeAvailableSnap(),
114
+ }, ctx);
115
+ expect(ctx.db.collection).toHaveBeenCalled();
116
+ });
117
+ it("logs synced bundles and groups count", async () => {
118
+ const ctx = makeCtx();
119
+ ctx.db.collection = vi.fn().mockReturnValue({
120
+ where: vi.fn().mockReturnThis(),
121
+ get: vi.fn().mockResolvedValue({ docs: [], size: 0 }),
122
+ });
123
+ await handleProductStockChange({
124
+ ...INPUT_BASE,
125
+ before: makeAvailableSnap(),
126
+ after: makeUnavailableSnap(),
127
+ }, ctx);
128
+ expect(ctx.logger.info).toHaveBeenCalledWith("onProductStockChange synced", expect.objectContaining({ productId: "product-charizard" }));
129
+ });
130
+ it("sync failure is non-fatal — logs error but does not throw", async () => {
131
+ const ctx = makeCtx();
132
+ ctx.db.collection = vi.fn().mockImplementation(() => {
133
+ throw new Error("Firestore unavailable");
134
+ });
135
+ await expect(handleProductStockChange({
136
+ ...INPUT_BASE,
137
+ before: makeAvailableSnap(),
138
+ after: makeUnavailableSnap(),
139
+ }, ctx)).resolves.toBeUndefined();
140
+ expect(ctx.logger.error).toHaveBeenCalled();
141
+ });
142
+ });
143
+ describe("syncBundlesForProduct — bundle recompute", () => {
144
+ it("does nothing when no bundles reference the product", async () => {
145
+ const ctx = makeCtx();
146
+ const mockQuery = { where: vi.fn().mockReturnThis(), get: vi.fn().mockResolvedValue({ docs: [], size: 0 }) };
147
+ ctx.db.collection = vi.fn().mockReturnValue(mockQuery);
148
+ const updated = await syncBundlesForProduct("product-x", ctx);
149
+ expect(updated).toBe(0);
150
+ });
151
+ it("updates bundle when stockStatus changes", async () => {
152
+ // Bundle has 1 product: "product-x" (now unavailable)
153
+ const bundleRef = { update: vi.fn().mockResolvedValue(undefined) };
154
+ const bundleDoc = {
155
+ ref: bundleRef,
156
+ data: () => ({
157
+ bundleProductIds: ["product-x"],
158
+ bundleStockStatus: "in_stock", // currently in_stock...
159
+ }),
160
+ };
161
+ const memberSnap = {
162
+ docs: [{ data: () => ({ isSold: false, availableQuantity: 0, status: PUBLISHED }) }],
163
+ size: 1,
164
+ };
165
+ const ctx = makeCtx();
166
+ let callIdx = 0;
167
+ ctx.db.collection = vi.fn().mockReturnValue({
168
+ where: vi.fn().mockReturnThis(),
169
+ get: vi.fn().mockImplementation(async () => {
170
+ callIdx++;
171
+ // First call = bundles query; subsequent calls = member product queries
172
+ if (callIdx === 1)
173
+ return { docs: [bundleDoc], size: 1 };
174
+ return memberSnap;
175
+ }),
176
+ });
177
+ const updated = await syncBundlesForProduct("product-x", ctx);
178
+ expect(updated).toBe(1);
179
+ expect(bundleRef.update).toHaveBeenCalledWith(expect.objectContaining({ bundleStockStatus: "out_of_stock" }));
180
+ });
181
+ it("does NOT update bundle when stockStatus has not changed", async () => {
182
+ const bundleRef = { update: vi.fn().mockResolvedValue(undefined) };
183
+ const bundleDoc = {
184
+ ref: bundleRef,
185
+ data: () => ({
186
+ bundleProductIds: ["product-x"],
187
+ bundleStockStatus: "in_stock", // already in_stock
188
+ }),
189
+ };
190
+ // Member is available → status stays in_stock → no update needed
191
+ const memberSnap = {
192
+ docs: [{ data: () => ({ isSold: false, availableQuantity: 5, status: PUBLISHED }) }],
193
+ size: 1,
194
+ };
195
+ const ctx = makeCtx();
196
+ let callIdx = 0;
197
+ ctx.db.collection = vi.fn().mockReturnValue({
198
+ where: vi.fn().mockReturnThis(),
199
+ get: vi.fn().mockImplementation(async () => {
200
+ callIdx++;
201
+ if (callIdx === 1)
202
+ return { docs: [bundleDoc], size: 1 };
203
+ return memberSnap;
204
+ }),
205
+ });
206
+ const updated = await syncBundlesForProduct("product-x", ctx);
207
+ expect(updated).toBe(0);
208
+ expect(bundleRef.update).not.toHaveBeenCalled();
209
+ });
210
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ const { mockGetRatingAggregate, mockGetRatingAggregateByStore, mockProductUpdate, mockUpdateReviewStats, } = vi.hoisted(() => ({
3
+ mockGetRatingAggregate: vi.fn(),
4
+ mockGetRatingAggregateByStore: vi.fn(),
5
+ mockProductUpdate: vi.fn().mockResolvedValue(undefined),
6
+ mockUpdateReviewStats: vi.fn().mockResolvedValue(undefined),
7
+ }));
8
+ vi.mock("../../../../../repositories", () => ({
9
+ reviewRepository: {
10
+ getApprovedRatingAggregate: mockGetRatingAggregate,
11
+ getApprovedRatingAggregateByStore: mockGetRatingAggregateByStore,
12
+ },
13
+ storeRepository: {
14
+ updateReviewStats: mockUpdateReviewStats,
15
+ },
16
+ }));
17
+ vi.mock("../../../../../features/reviews/schemas/firestore", () => ({
18
+ ReviewStatusValues: {
19
+ APPROVED: "approved",
20
+ PENDING: "pending",
21
+ REJECTED: "rejected",
22
+ },
23
+ }));
24
+ vi.mock("../../../../../features/products/schemas/firestore", () => ({
25
+ PRODUCT_COLLECTION: "products",
26
+ }));
27
+ import { handleReviewWrite } from "../onReviewWrite";
28
+ // ── Helpers ─────────────────────────────────────────────────────────────────
29
+ function makeCtx() {
30
+ return {
31
+ db: {
32
+ collection: vi.fn().mockReturnValue({
33
+ doc: vi.fn().mockReturnValue({
34
+ update: mockProductUpdate,
35
+ }),
36
+ }),
37
+ },
38
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
39
+ };
40
+ }
41
+ function makeReviewData(overrides = {}) {
42
+ return {
43
+ productId: "product-charizard",
44
+ storeId: "store-A",
45
+ buyerId: "user-ravi",
46
+ rating: 5,
47
+ status: "approved",
48
+ ...overrides,
49
+ };
50
+ }
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ mockProductUpdate.mockResolvedValue(undefined);
54
+ mockUpdateReviewStats.mockResolvedValue(undefined);
55
+ mockGetRatingAggregate.mockResolvedValue({ count: 10, avgRating: 4.5 });
56
+ mockGetRatingAggregateByStore.mockResolvedValue({ count: 5, avgRating: 4.2 });
57
+ });
58
+ // ── Tests ────────────────────────────────────────────────────────────────────
59
+ describe("handleReviewWrite — early exits", () => {
60
+ it("skips if neither before nor after is approved", async () => {
61
+ const ctx = makeCtx();
62
+ await handleReviewWrite({
63
+ reviewId: "r1",
64
+ before: makeReviewData({ status: "pending" }),
65
+ after: makeReviewData({ status: "rejected" }),
66
+ }, ctx);
67
+ expect(mockGetRatingAggregate).not.toHaveBeenCalled();
68
+ });
69
+ it("skips if both before and after are non-approved status", async () => {
70
+ const ctx = makeCtx();
71
+ await handleReviewWrite({
72
+ reviewId: "r1",
73
+ before: makeReviewData({ status: "rejected" }),
74
+ after: makeReviewData({ status: "pending" }),
75
+ }, ctx);
76
+ expect(mockProductUpdate).not.toHaveBeenCalled();
77
+ });
78
+ it("skips if no data (both before and after are null)", async () => {
79
+ const ctx = makeCtx();
80
+ await handleReviewWrite({ reviewId: "r1", before: null, after: null }, ctx);
81
+ expect(mockGetRatingAggregate).not.toHaveBeenCalled();
82
+ });
83
+ it("logs and skips if review has no productId", async () => {
84
+ const ctx = makeCtx();
85
+ await handleReviewWrite({
86
+ reviewId: "r1",
87
+ before: null,
88
+ after: makeReviewData({ status: "approved", productId: undefined }),
89
+ }, ctx);
90
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.stringMatching(/no productId/i), null, expect.any(Object));
91
+ expect(mockGetRatingAggregate).not.toHaveBeenCalled();
92
+ });
93
+ });
94
+ describe("handleReviewWrite — review approved (new approval)", () => {
95
+ it("recalculates rating aggregate for the product", async () => {
96
+ const ctx = makeCtx();
97
+ await handleReviewWrite({
98
+ reviewId: "r1",
99
+ before: makeReviewData({ status: "pending" }),
100
+ after: makeReviewData({ status: "approved" }),
101
+ }, ctx);
102
+ expect(mockGetRatingAggregate).toHaveBeenCalledWith("product-charizard");
103
+ });
104
+ it("updates product avgRating and reviewCount", async () => {
105
+ mockGetRatingAggregate.mockResolvedValue({ count: 7, avgRating: 4.2 });
106
+ const ctx = makeCtx();
107
+ await handleReviewWrite({
108
+ reviewId: "r1",
109
+ before: makeReviewData({ status: "pending" }),
110
+ after: makeReviewData({ status: "approved" }),
111
+ }, ctx);
112
+ expect(mockProductUpdate).toHaveBeenCalledWith(expect.objectContaining({ avgRating: 4.2, reviewCount: 7 }));
113
+ });
114
+ it("updates store review stats when storeId is present", async () => {
115
+ const ctx = makeCtx();
116
+ await handleReviewWrite({
117
+ reviewId: "r1",
118
+ before: makeReviewData({ status: "pending" }),
119
+ after: makeReviewData({ status: "approved", storeId: "store-A" }),
120
+ }, ctx);
121
+ expect(mockGetRatingAggregateByStore).toHaveBeenCalledWith("store-A");
122
+ expect(mockUpdateReviewStats).toHaveBeenCalledWith("store-A", 5, 4.2);
123
+ });
124
+ it("skips store stats update when storeId is absent", async () => {
125
+ const ctx = makeCtx();
126
+ await handleReviewWrite({
127
+ reviewId: "r1",
128
+ before: makeReviewData({ status: "pending" }),
129
+ after: makeReviewData({ status: "approved", storeId: undefined }),
130
+ }, ctx);
131
+ expect(mockGetRatingAggregateByStore).not.toHaveBeenCalled();
132
+ expect(mockUpdateReviewStats).not.toHaveBeenCalled();
133
+ });
134
+ });
135
+ describe("handleReviewWrite — review rejected (was approved)", () => {
136
+ it("recalculates rating when a review transitions from approved to rejected", async () => {
137
+ const ctx = makeCtx();
138
+ await handleReviewWrite({
139
+ reviewId: "r1",
140
+ before: makeReviewData({ status: "approved" }),
141
+ after: makeReviewData({ status: "rejected" }),
142
+ }, ctx);
143
+ expect(mockGetRatingAggregate).toHaveBeenCalled();
144
+ expect(mockProductUpdate).toHaveBeenCalled();
145
+ });
146
+ it("uses afterData for productId when available", async () => {
147
+ const ctx = makeCtx();
148
+ await handleReviewWrite({
149
+ reviewId: "r1",
150
+ before: makeReviewData({ status: "approved", productId: "old-product" }),
151
+ after: makeReviewData({ status: "rejected", productId: "product-charizard" }),
152
+ }, ctx);
153
+ expect(mockGetRatingAggregate).toHaveBeenCalledWith("product-charizard");
154
+ });
155
+ it("falls back to beforeData for productId when after is null (delete)", async () => {
156
+ const ctx = makeCtx();
157
+ await handleReviewWrite({
158
+ reviewId: "r1",
159
+ before: makeReviewData({ status: "approved", productId: "product-abc" }),
160
+ after: null,
161
+ }, ctx);
162
+ expect(mockGetRatingAggregate).toHaveBeenCalledWith("product-abc");
163
+ });
164
+ });
165
+ describe("handleReviewWrite — error propagation", () => {
166
+ it("aggregate calculation failure → re-throws", async () => {
167
+ mockGetRatingAggregate.mockRejectedValue(new Error("Firestore error"));
168
+ const ctx = makeCtx();
169
+ await expect(handleReviewWrite({
170
+ reviewId: "r1",
171
+ before: null,
172
+ after: makeReviewData({ status: "approved" }),
173
+ }, ctx)).rejects.toThrow("Firestore error");
174
+ });
175
+ it("logs error before re-throwing", async () => {
176
+ mockGetRatingAggregate.mockRejectedValue(new Error("Firestore error"));
177
+ const ctx = makeCtx();
178
+ try {
179
+ await handleReviewWrite({
180
+ reviewId: "r1",
181
+ before: null,
182
+ after: makeReviewData({ status: "approved" }),
183
+ }, ctx);
184
+ }
185
+ catch {
186
+ // expected
187
+ }
188
+ expect(ctx.logger.error).toHaveBeenCalledWith(expect.stringMatching(/rating stats/i), expect.any(Error), expect.any(Object));
189
+ });
190
+ });