@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,146 @@
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 } = makeMockDb();
4
+ vi.mock("../../../../providers/db-firebase", () => ({
5
+ getAdminDb: () => db,
6
+ BaseRepository: class {
7
+ get db() { return db; }
8
+ constructor(col) { this.collection = col; }
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ getCollection() { return db.collection(this.collection); }
11
+ mapDoc(snap) {
12
+ return { id: snap.id, ...snap.data() };
13
+ }
14
+ async findById(id) {
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ const snap = await db.collection(this.collection).doc(id).get();
17
+ return snap.exists ? { id: snap.id, ...snap.data() } : null;
18
+ }
19
+ async update(id, data) {
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ return db.collection(this.collection).doc(id).update(data);
22
+ }
23
+ async delete(id) {
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ return db.collection(this.collection).doc(id).delete();
26
+ }
27
+ async createWithId(id, data) {
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ await db.collection(this.collection).doc(id).set(data);
30
+ return { id, ...data };
31
+ }
32
+ },
33
+ }));
34
+ vi.mock("../../../../monitoring", () => ({
35
+ serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
36
+ }));
37
+ vi.mock("../token-helpers", () => ({
38
+ generateSessionId: () => "session-id-abc123",
39
+ }));
40
+ vi.mock("../../../../errors/normalize", () => ({
41
+ normalizeError: vi.fn(),
42
+ }));
43
+ import { SessionRepository } from "../session.repository";
44
+ import { SESSION_EXPIRATION_MS } from "../../schemas";
45
+ const repo = new SessionRepository();
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
49
+ mockDocRef.get.mockResolvedValue(makeSnap(null));
50
+ mockDocRef.set.mockResolvedValue(undefined);
51
+ mockDocRef.update.mockResolvedValue(undefined);
52
+ mockDocRef.delete.mockResolvedValue(undefined);
53
+ mockCollection.doc.mockReturnValue(mockDocRef);
54
+ db.collection.mockReturnValue(mockCollection);
55
+ });
56
+ describe("SessionRepository.createSession", () => {
57
+ it("sets isActive: true", async () => {
58
+ await repo.createSession("user-1", {
59
+ deviceInfo: { browser: "Chrome", os: "Windows", device: "Desktop", ip: "127.0.0.1" },
60
+ location: { country: "IN" },
61
+ });
62
+ expect(mockDocRef.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: true }));
63
+ });
64
+ it("expiresAt is now + SESSION_EXPIRATION_MS (within 1s tolerance)", async () => {
65
+ const before = Date.now();
66
+ await repo.createSession("user-1", {
67
+ deviceInfo: { browser: "Chrome", os: "Windows", device: "Desktop", ip: "127.0.0.1" },
68
+ location: { country: "IN" },
69
+ });
70
+ const after = Date.now();
71
+ const setCall = mockDocRef.set.mock.calls[0][0];
72
+ const expiresAt = setCall.expiresAt.getTime();
73
+ expect(expiresAt).toBeGreaterThanOrEqual(before + SESSION_EXPIRATION_MS - 1000);
74
+ expect(expiresAt).toBeLessThanOrEqual(after + SESSION_EXPIRATION_MS + 1000);
75
+ });
76
+ it("stores deviceInfo from input", async () => {
77
+ await repo.createSession("user-1", {
78
+ deviceInfo: { browser: "Firefox", os: "macOS", device: "Desktop", ip: "10.0.0.1" },
79
+ location: { country: "US" },
80
+ });
81
+ expect(mockDocRef.set).toHaveBeenCalledWith(expect.objectContaining({ deviceInfo: { browser: "Firefox", os: "macOS", device: "Desktop", ip: "10.0.0.1" } }));
82
+ });
83
+ });
84
+ describe("SessionRepository.updateActivity", () => {
85
+ it("updates lastActivity timestamp", async () => {
86
+ mockDocRef.update.mockResolvedValue(undefined);
87
+ await repo.updateActivity("session-id");
88
+ expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ lastActivity: expect.any(Date) }));
89
+ });
90
+ it("does NOT throw when Firestore NOT_FOUND (swallowed)", async () => {
91
+ mockDocRef.update.mockRejectedValue({ code: 5, message: "NOT_FOUND" });
92
+ await expect(repo.updateActivity("session-id")).resolves.toBeUndefined();
93
+ });
94
+ });
95
+ describe("SessionRepository.revokeSession", () => {
96
+ it("sets isActive: false on the targeted session", async () => {
97
+ await repo.revokeSession("session-id", "admin-uid");
98
+ expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ isActive: false }));
99
+ });
100
+ });
101
+ describe("SessionRepository.findActiveByUser", () => {
102
+ it("queries where isActive == true for the given userId", async () => {
103
+ const sessions = makeQuerySnap([
104
+ { id: "s1", data: { userId: "user-1", isActive: true, expiresAt: new Date(Date.now() + 60000), lastActivity: new Date() } },
105
+ ]);
106
+ mockQuery.get.mockResolvedValue(sessions);
107
+ const result = await repo.findActiveByUser("user-1");
108
+ expect(result).toHaveLength(1);
109
+ // Verify isActive and userId filters are used
110
+ expect(mockCollection.where).toHaveBeenCalledWith("userId", "==", "user-1");
111
+ expect(mockQuery.where).toHaveBeenCalledWith("isActive", "==", true);
112
+ });
113
+ it("returns empty array when user has no active sessions", async () => {
114
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
115
+ const result = await repo.findActiveByUser("user-1");
116
+ expect(result).toEqual([]);
117
+ });
118
+ });
119
+ describe("SessionRepository.cleanupExpiredSessions", () => {
120
+ it("deletes documents returned by expired query, returns count", async () => {
121
+ const expiredDocs = makeQuerySnap([
122
+ { id: "s-expired-1", data: { userId: "u", isActive: true } },
123
+ { id: "s-expired-2", data: { userId: "u", isActive: false } },
124
+ ]);
125
+ mockQuery.get.mockResolvedValue(expiredDocs);
126
+ const count = await repo.cleanupExpiredSessions();
127
+ expect(count).toBe(2);
128
+ });
129
+ it("does not delete non-expired sessions", async () => {
130
+ mockQuery.get.mockResolvedValue(makeQuerySnap([]));
131
+ const count = await repo.cleanupExpiredSessions();
132
+ expect(count).toBe(0);
133
+ expect(mockDocRef.delete).not.toHaveBeenCalled();
134
+ });
135
+ });
136
+ describe("SessionRepository.countActiveByUser", () => {
137
+ it("returns the count of active sessions", async () => {
138
+ const sessions = makeQuerySnap([
139
+ { id: "s1", data: { userId: "user-1", isActive: true, expiresAt: new Date(Date.now() + 60000), lastActivity: new Date() } },
140
+ { id: "s2", data: { userId: "user-1", isActive: true, expiresAt: new Date(Date.now() + 60000), lastActivity: new Date() } },
141
+ ]);
142
+ mockQuery.get.mockResolvedValue(sessions);
143
+ const count = await repo.countActiveByUser("user-1");
144
+ expect(count).toBe(2);
145
+ });
146
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { makeMockDb, makeSnap } from "../../../../../tests/helpers/mock-firestore";
3
+ const { db, mockDocRef, mockCollection, mockTxn } = 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() },
22
+ }));
23
+ import { SmsCounterRepository } from "../sms-counter.repository";
24
+ import { SMS_DAILY_LIMIT } from "../../schemas";
25
+ const repo = new SmsCounterRepository();
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ db.collection.mockReturnValue(mockCollection);
29
+ mockCollection.doc.mockReturnValue(mockDocRef);
30
+ mockTxn.get.mockResolvedValue(makeSnap(null));
31
+ mockTxn.set.mockReturnValue(undefined);
32
+ mockTxn.update.mockReturnValue(undefined);
33
+ db.runTransaction.mockImplementation(async (cb) => cb(mockTxn));
34
+ });
35
+ // ---------------------------------------------------------------------------
36
+ // checkAndIncrement
37
+ // ---------------------------------------------------------------------------
38
+ describe("SmsCounterRepository.checkAndIncrement", () => {
39
+ it("first call of the day (no doc) → returns { allowed: true, count: 1 }", async () => {
40
+ mockTxn.get.mockResolvedValue(makeSnap(null));
41
+ const result = await repo.checkAndIncrement("2026-06-27");
42
+ expect(result.allowed).toBe(true);
43
+ expect(result.count).toBe(1);
44
+ });
45
+ it("uses Firestore transaction (runTransaction called)", async () => {
46
+ mockTxn.get.mockResolvedValue(makeSnap(null));
47
+ await repo.checkAndIncrement("2026-06-27");
48
+ expect(db.runTransaction).toHaveBeenCalledOnce();
49
+ });
50
+ it("increments existing count by 1", async () => {
51
+ mockTxn.get.mockResolvedValue(makeSnap({ count: 4 }));
52
+ const result = await repo.checkAndIncrement("2026-06-27");
53
+ expect(result.count).toBe(5);
54
+ expect(result.allowed).toBe(true);
55
+ });
56
+ it("count at SMS_DAILY_LIMIT - 1 → increments to limit, returns allowed: true", async () => {
57
+ mockTxn.get.mockResolvedValue(makeSnap({ count: SMS_DAILY_LIMIT - 1 }));
58
+ const result = await repo.checkAndIncrement("2026-06-27");
59
+ expect(result.allowed).toBe(true);
60
+ expect(result.count).toBe(SMS_DAILY_LIMIT);
61
+ });
62
+ it("count at SMS_DAILY_LIMIT → returns { allowed: false, count: limit }", async () => {
63
+ mockTxn.get.mockResolvedValue(makeSnap({ count: SMS_DAILY_LIMIT }));
64
+ const result = await repo.checkAndIncrement("2026-06-27");
65
+ expect(result.allowed).toBe(false);
66
+ expect(result.count).toBe(SMS_DAILY_LIMIT);
67
+ });
68
+ it("count exceeds SMS_DAILY_LIMIT → still returns { allowed: false }", async () => {
69
+ mockTxn.get.mockResolvedValue(makeSnap({ count: SMS_DAILY_LIMIT + 5 }));
70
+ const result = await repo.checkAndIncrement("2026-06-27");
71
+ expect(result.allowed).toBe(false);
72
+ });
73
+ it("when allowed, calls tx.set with incremented count", async () => {
74
+ mockTxn.get.mockResolvedValue(makeSnap({ count: 2 }));
75
+ await repo.checkAndIncrement("2026-06-27");
76
+ expect(mockTxn.set).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ count: 3 }), { merge: true });
77
+ });
78
+ it("when NOT allowed, does NOT call tx.set", async () => {
79
+ mockTxn.get.mockResolvedValue(makeSnap({ count: SMS_DAILY_LIMIT }));
80
+ await repo.checkAndIncrement("2026-06-27");
81
+ expect(mockTxn.set).not.toHaveBeenCalled();
82
+ });
83
+ it("uses dateStr as the document ID (one doc per date)", async () => {
84
+ mockTxn.get.mockResolvedValue(makeSnap(null));
85
+ await repo.checkAndIncrement("2026-06-27");
86
+ expect(mockCollection.doc).toHaveBeenCalledWith("2026-06-27");
87
+ });
88
+ it("stores updatedAt timestamp in the tx.set payload", async () => {
89
+ mockTxn.get.mockResolvedValue(makeSnap(null));
90
+ await repo.checkAndIncrement("2026-06-27");
91
+ const setArg = mockTxn.set.mock.calls[0][1];
92
+ expect(setArg).toHaveProperty("updatedAt");
93
+ });
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // getCount
97
+ // ---------------------------------------------------------------------------
98
+ describe("SmsCounterRepository.getCount", () => {
99
+ it("returns 0 when no document exists for the date", async () => {
100
+ mockDocRef.get.mockResolvedValue(makeSnap(null));
101
+ const count = await repo.getCount("2026-06-27");
102
+ expect(count).toBe(0);
103
+ });
104
+ it("returns the stored count", async () => {
105
+ mockDocRef.get.mockResolvedValue(makeSnap({ count: 7 }));
106
+ const count = await repo.getCount("2026-06-27");
107
+ expect(count).toBe(7);
108
+ });
109
+ it("returns 0 if document exists but count field is missing", async () => {
110
+ mockDocRef.get.mockResolvedValue(makeSnap({}));
111
+ const count = await repo.getCount("2026-06-27");
112
+ expect(count).toBe(0);
113
+ });
114
+ });
115
+ // ---------------------------------------------------------------------------
116
+ // checkAndSetUserCooldown
117
+ // ---------------------------------------------------------------------------
118
+ describe("SmsCounterRepository.checkAndSetUserCooldown", () => {
119
+ it("first call (no existing cooldown doc) → returns { allowed: true }", async () => {
120
+ mockTxn.get.mockResolvedValue(makeSnap(null));
121
+ const result = await repo.checkAndSetUserCooldown("user-ravi");
122
+ expect(result.allowed).toBe(true);
123
+ expect(result.retryAfterSeconds).toBe(0);
124
+ });
125
+ it("uses Firestore transaction", async () => {
126
+ mockTxn.get.mockResolvedValue(makeSnap(null));
127
+ await repo.checkAndSetUserCooldown("user-ravi");
128
+ expect(db.runTransaction).toHaveBeenCalledOnce();
129
+ });
130
+ it("cooldown expired (last request >15 min ago) → returns { allowed: true }", async () => {
131
+ const sixteenMinutesAgo = new Date(Date.now() - 16 * 60 * 1000);
132
+ mockTxn.get.mockResolvedValue(makeSnap({ lastRequestedAt: { toDate: () => sixteenMinutesAgo } }));
133
+ const result = await repo.checkAndSetUserCooldown("user-ravi");
134
+ expect(result.allowed).toBe(true);
135
+ expect(result.retryAfterSeconds).toBe(0);
136
+ });
137
+ it("within 15 min cooldown → returns { allowed: false, retryAfterSeconds > 0 }", async () => {
138
+ const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
139
+ mockTxn.get.mockResolvedValue(makeSnap({ lastRequestedAt: { toDate: () => twoMinutesAgo } }));
140
+ const result = await repo.checkAndSetUserCooldown("user-ravi");
141
+ expect(result.allowed).toBe(false);
142
+ expect(result.retryAfterSeconds).toBeGreaterThan(0);
143
+ // 15 min - 2 min = 13 min = 780s (±1s tolerance)
144
+ expect(result.retryAfterSeconds).toBeGreaterThanOrEqual(778);
145
+ expect(result.retryAfterSeconds).toBeLessThanOrEqual(780);
146
+ });
147
+ it("just within cooldown (1 second in) → retryAfterSeconds ≈ 899", async () => {
148
+ const oneSecondAgo = new Date(Date.now() - 1000);
149
+ mockTxn.get.mockResolvedValue(makeSnap({ lastRequestedAt: { toDate: () => oneSecondAgo } }));
150
+ const result = await repo.checkAndSetUserCooldown("user-ravi");
151
+ expect(result.allowed).toBe(false);
152
+ expect(result.retryAfterSeconds).toBeGreaterThanOrEqual(898);
153
+ expect(result.retryAfterSeconds).toBeLessThanOrEqual(900);
154
+ });
155
+ it("document ID is user_cooldown_{userId}", async () => {
156
+ mockTxn.get.mockResolvedValue(makeSnap(null));
157
+ await repo.checkAndSetUserCooldown("user-ravi");
158
+ expect(mockCollection.doc).toHaveBeenCalledWith("user_cooldown_user-ravi");
159
+ });
160
+ it("when allowed, calls tx.set with lastRequestedAt timestamp", async () => {
161
+ mockTxn.get.mockResolvedValue(makeSnap(null));
162
+ await repo.checkAndSetUserCooldown("user-ravi");
163
+ expect(mockTxn.set).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ lastRequestedAt: expect.any(Date) }), { merge: true });
164
+ });
165
+ it("when NOT allowed, does NOT call tx.set", async () => {
166
+ const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
167
+ mockTxn.get.mockResolvedValue(makeSnap({ lastRequestedAt: { toDate: () => twoMinutesAgo } }));
168
+ await repo.checkAndSetUserCooldown("user-ravi");
169
+ expect(mockTxn.set).not.toHaveBeenCalled();
170
+ });
171
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { makeMockDb, makeSnap } from "../../../../../tests/helpers/mock-firestore";
3
+ const { db, mockTxn } = makeMockDb();
4
+ vi.mock("../../../../providers/db-firebase", () => ({
5
+ getAdminDb: () => db,
6
+ BaseRepository: class {
7
+ get db() { return db; }
8
+ constructor(col) { this.collection = col; }
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ getCollection() { return db.collection(this.collection); }
11
+ },
12
+ }));
13
+ vi.mock("../../../../monitoring", () => ({
14
+ serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
15
+ }));
16
+ import { SmsCounterRepository } from "../sms-counter.repository";
17
+ import { SMS_DAILY_LIMIT } from "../../schemas";
18
+ const repo = new SmsCounterRepository();
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ db.runTransaction.mockImplementation(async (fn) => fn(mockTxn));
22
+ mockTxn.set.mockReturnValue(undefined);
23
+ });
24
+ describe("SmsCounterRepository.checkAndIncrement", () => {
25
+ it("first call of the day → count becomes 1, allowed: true", async () => {
26
+ mockTxn.get.mockResolvedValue(makeSnap(null));
27
+ const result = await repo.checkAndIncrement("2026-01-01");
28
+ expect(result.allowed).toBe(true);
29
+ expect(result.count).toBe(1);
30
+ });
31
+ it("count at LIMIT - 1 → increments, allowed: true", async () => {
32
+ mockTxn.get.mockResolvedValue(makeSnap({ count: SMS_DAILY_LIMIT - 1, date: "2026-01-01", updatedAt: new Date() }));
33
+ const result = await repo.checkAndIncrement("2026-01-01");
34
+ expect(result.allowed).toBe(true);
35
+ expect(result.count).toBe(SMS_DAILY_LIMIT);
36
+ });
37
+ it("count at LIMIT → not allowed, count unchanged", async () => {
38
+ mockTxn.get.mockResolvedValue(makeSnap({ count: SMS_DAILY_LIMIT, date: "2026-01-01", updatedAt: new Date() }));
39
+ const result = await repo.checkAndIncrement("2026-01-01");
40
+ expect(result.allowed).toBe(false);
41
+ expect(result.count).toBe(SMS_DAILY_LIMIT);
42
+ expect(mockTxn.set).not.toHaveBeenCalled();
43
+ });
44
+ it("uses Firestore transaction (runTransaction called)", async () => {
45
+ mockTxn.get.mockResolvedValue(makeSnap(null));
46
+ await repo.checkAndIncrement("2026-01-01");
47
+ expect(db.runTransaction).toHaveBeenCalledOnce();
48
+ });
49
+ it("next UTC day uses different doc ID → starts at 1", async () => {
50
+ mockTxn.get.mockResolvedValue(makeSnap(null));
51
+ const result = await repo.checkAndIncrement("2026-01-02");
52
+ expect(result.allowed).toBe(true);
53
+ expect(result.count).toBe(1);
54
+ });
55
+ });
56
+ describe("SmsCounterRepository.checkAndSetUserCooldown", () => {
57
+ it("first call → allowed: true, sets cooldown doc", async () => {
58
+ mockTxn.get.mockResolvedValue(makeSnap(null));
59
+ const result = await repo.checkAndSetUserCooldown("user-1");
60
+ expect(result.allowed).toBe(true);
61
+ expect(result.retryAfterSeconds).toBe(0);
62
+ expect(mockTxn.set).toHaveBeenCalledOnce();
63
+ });
64
+ it("called within 15 minutes → allowed: false with retryAfterSeconds", async () => {
65
+ const recentlyRequested = new Date(Date.now() - 5 * 60 * 1000); // 5 min ago
66
+ mockTxn.get.mockResolvedValue(makeSnap({
67
+ lastRequestedAt: { toDate: () => recentlyRequested },
68
+ }));
69
+ const result = await repo.checkAndSetUserCooldown("user-1");
70
+ expect(result.allowed).toBe(false);
71
+ expect(result.retryAfterSeconds).toBeGreaterThan(0);
72
+ expect(result.retryAfterSeconds).toBeLessThanOrEqual(10 * 60); // ~10 min remaining
73
+ });
74
+ it("called after 15 minutes → allowed: true", async () => {
75
+ const old = new Date(Date.now() - 20 * 60 * 1000); // 20 min ago
76
+ mockTxn.get.mockResolvedValue(makeSnap({
77
+ lastRequestedAt: { toDate: () => old },
78
+ }));
79
+ const result = await repo.checkAndSetUserCooldown("user-1");
80
+ expect(result.allowed).toBe(true);
81
+ expect(mockTxn.set).toHaveBeenCalledOnce();
82
+ });
83
+ it("uses Firestore transaction", async () => {
84
+ mockTxn.get.mockResolvedValue(makeSnap(null));
85
+ await repo.checkAndSetUserCooldown("user-1");
86
+ expect(db.runTransaction).toHaveBeenCalledOnce();
87
+ });
88
+ });