statistics-reset-at.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. // dbResultMock controls what every DB chain resolves to when awaited
  3. const dbResultMock = vi.fn<[], unknown>().mockReturnValue([{ total: 0 }]);
  4. // Build a chainable mock that resolves to dbResultMock() on await
  5. function chain(): Record<string, unknown> {
  6. const obj: Record<string, unknown> = {};
  7. for (const method of ["select", "from", "where", "groupBy", "limit"]) {
  8. obj[method] = vi.fn(() => chain());
  9. }
  10. // Make it thenable so `await db.select().from().where()` works
  11. // biome-ignore lint/suspicious/noThenProperty: thenable mock for drizzle query chain
  12. obj.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) => {
  13. try {
  14. resolve(dbResultMock());
  15. } catch (e) {
  16. reject(e);
  17. }
  18. };
  19. return obj;
  20. }
  21. vi.mock("@/drizzle/db", () => ({
  22. db: chain(),
  23. }));
  24. // Mock drizzle schema -- preserve all exports so module-level sql`` calls work
  25. vi.mock("@/drizzle/schema", async (importOriginal) => {
  26. const actual = await importOriginal<typeof import("@/drizzle/schema")>();
  27. return { ...actual };
  28. });
  29. // Mock logger
  30. vi.mock("@/lib/logger", () => ({
  31. logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
  32. }));
  33. describe("statistics resetAt parameter", () => {
  34. beforeEach(() => {
  35. vi.clearAllMocks();
  36. dbResultMock.mockReturnValue([{ total: 0 }]);
  37. });
  38. describe("sumUserTotalCost", () => {
  39. test("with valid resetAt -- queries DB and returns cost", async () => {
  40. const resetAt = new Date("2026-02-15T00:00:00Z");
  41. dbResultMock.mockReturnValue([{ total: 42.5 }]);
  42. const { sumUserTotalCost } = await import("@/repository/statistics");
  43. const result = await sumUserTotalCost(10, 365, resetAt);
  44. expect(result).toBe(42.5);
  45. expect(dbResultMock).toHaveBeenCalled();
  46. });
  47. test("without resetAt -- uses maxAgeDays cutoff instead", async () => {
  48. dbResultMock.mockReturnValue([{ total: 100.0 }]);
  49. const { sumUserTotalCost } = await import("@/repository/statistics");
  50. const result = await sumUserTotalCost(10, 365);
  51. expect(result).toBe(100.0);
  52. expect(dbResultMock).toHaveBeenCalled();
  53. });
  54. test("with null resetAt -- treated same as undefined", async () => {
  55. dbResultMock.mockReturnValue([{ total: 50.0 }]);
  56. const { sumUserTotalCost } = await import("@/repository/statistics");
  57. const result = await sumUserTotalCost(10, 365, null);
  58. expect(result).toBe(50.0);
  59. expect(dbResultMock).toHaveBeenCalled();
  60. });
  61. test("with invalid Date (NaN) -- skips resetAt, falls through to maxAgeDays", async () => {
  62. const invalidDate = new Date("invalid");
  63. dbResultMock.mockReturnValue([{ total: 75.0 }]);
  64. const { sumUserTotalCost } = await import("@/repository/statistics");
  65. const result = await sumUserTotalCost(10, 365, invalidDate);
  66. expect(result).toBe(75.0);
  67. expect(dbResultMock).toHaveBeenCalled();
  68. });
  69. });
  70. describe("sumKeyTotalCost", () => {
  71. test("with valid resetAt -- uses resetAt instead of maxAgeDays cutoff", async () => {
  72. const resetAt = new Date("2026-02-20T00:00:00Z");
  73. dbResultMock.mockReturnValue([{ total: 15.0 }]);
  74. const { sumKeyTotalCost } = await import("@/repository/statistics");
  75. const result = await sumKeyTotalCost("sk-hash", 365, resetAt);
  76. expect(result).toBe(15.0);
  77. expect(dbResultMock).toHaveBeenCalled();
  78. });
  79. test("without resetAt -- falls back to maxAgeDays", async () => {
  80. dbResultMock.mockReturnValue([{ total: 30.0 }]);
  81. const { sumKeyTotalCost } = await import("@/repository/statistics");
  82. const result = await sumKeyTotalCost("sk-hash", 365);
  83. expect(result).toBe(30.0);
  84. expect(dbResultMock).toHaveBeenCalled();
  85. });
  86. });
  87. describe("sumUserTotalCostBatch", () => {
  88. test("with resetAtMap -- splits users: individual queries for reset users", async () => {
  89. const resetAtMap = new Map([[10, new Date("2026-02-15T00:00:00Z")]]);
  90. // Calls: 1) individual sumUserTotalCost(10) => where => [{ total: 25 }]
  91. // 2) batch for user 20 => groupBy => [{ userId: 20, total: 50 }]
  92. dbResultMock
  93. .mockReturnValueOnce([{ total: 25.0 }])
  94. .mockReturnValueOnce([{ userId: 20, total: 50.0 }]);
  95. const { sumUserTotalCostBatch } = await import("@/repository/statistics");
  96. const result = await sumUserTotalCostBatch([10, 20], 365, resetAtMap);
  97. expect(result.get(10)).toBe(25.0);
  98. expect(result.get(20)).toBe(50.0);
  99. });
  100. test("with empty resetAtMap -- single batch query for all users", async () => {
  101. dbResultMock.mockReturnValue([
  102. { userId: 10, total: 25.0 },
  103. { userId: 20, total: 50.0 },
  104. ]);
  105. const { sumUserTotalCostBatch } = await import("@/repository/statistics");
  106. const result = await sumUserTotalCostBatch([10, 20], 365, new Map());
  107. expect(result.get(10)).toBe(25.0);
  108. expect(result.get(20)).toBe(50.0);
  109. });
  110. test("empty userIds -- returns empty map immediately", async () => {
  111. const { sumUserTotalCostBatch } = await import("@/repository/statistics");
  112. const result = await sumUserTotalCostBatch([], 365);
  113. expect(result.size).toBe(0);
  114. });
  115. });
  116. describe("sumKeyTotalCostBatchByIds", () => {
  117. test("with resetAtMap -- splits keys into individual vs batch", async () => {
  118. const resetAtMap = new Map([[1, new Date("2026-02-15T00:00:00Z")]]);
  119. dbResultMock
  120. // 1) PK lookup: key strings
  121. .mockReturnValueOnce([
  122. { id: 1, key: "sk-a" },
  123. { id: 2, key: "sk-b" },
  124. ])
  125. // 2) individual sumKeyTotalCost for key 1
  126. .mockReturnValueOnce([{ total: 10.0 }])
  127. // 3) batch for key 2
  128. .mockReturnValueOnce([{ key: "sk-b", total: 20.0 }]);
  129. const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics");
  130. const result = await sumKeyTotalCostBatchByIds([1, 2], 365, resetAtMap);
  131. expect(result.get(1)).toBe(10.0);
  132. expect(result.get(2)).toBe(20.0);
  133. });
  134. test("empty keyIds -- returns empty map immediately", async () => {
  135. const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics");
  136. const result = await sumKeyTotalCostBatchByIds([], 365);
  137. expect(result.size).toBe(0);
  138. });
  139. });
  140. describe("sumUserQuotaCosts", () => {
  141. const ranges = {
  142. range5h: {
  143. startTime: new Date("2026-03-01T07:00:00Z"),
  144. endTime: new Date("2026-03-01T12:00:00Z"),
  145. },
  146. rangeDaily: {
  147. startTime: new Date("2026-03-01T00:00:00Z"),
  148. endTime: new Date("2026-03-01T12:00:00Z"),
  149. },
  150. rangeWeekly: {
  151. startTime: new Date("2026-02-23T00:00:00Z"),
  152. endTime: new Date("2026-03-01T12:00:00Z"),
  153. },
  154. rangeMonthly: {
  155. startTime: new Date("2026-02-01T00:00:00Z"),
  156. endTime: new Date("2026-03-01T12:00:00Z"),
  157. },
  158. };
  159. test("with resetAt -- returns correct cost summary", async () => {
  160. const resetAt = new Date("2026-02-25T00:00:00Z");
  161. dbResultMock.mockReturnValue([
  162. {
  163. cost5h: "1.0",
  164. costDaily: "2.0",
  165. costWeekly: "3.0",
  166. costMonthly: "4.0",
  167. costTotal: "5.0",
  168. },
  169. ]);
  170. const { sumUserQuotaCosts } = await import("@/repository/statistics");
  171. const result = await sumUserQuotaCosts(10, ranges, 365, resetAt);
  172. expect(result.cost5h).toBe(1.0);
  173. expect(result.costDaily).toBe(2.0);
  174. expect(result.costWeekly).toBe(3.0);
  175. expect(result.costMonthly).toBe(4.0);
  176. expect(result.costTotal).toBe(5.0);
  177. });
  178. test("without resetAt -- uses only maxAgeDays cutoff", async () => {
  179. dbResultMock.mockReturnValue([
  180. { cost5h: "0", costDaily: "0", costWeekly: "0", costMonthly: "0", costTotal: "0" },
  181. ]);
  182. const { sumUserQuotaCosts } = await import("@/repository/statistics");
  183. const result = await sumUserQuotaCosts(10, ranges, 365);
  184. expect(result.cost5h).toBe(0);
  185. expect(result.costTotal).toBe(0);
  186. });
  187. });
  188. describe("sumKeyQuotaCostsById", () => {
  189. test("with resetAt -- same cutoff logic as sumUserQuotaCosts", async () => {
  190. const resetAt = new Date("2026-02-25T00:00:00Z");
  191. const ranges = {
  192. range5h: {
  193. startTime: new Date("2026-03-01T07:00:00Z"),
  194. endTime: new Date("2026-03-01T12:00:00Z"),
  195. },
  196. rangeDaily: {
  197. startTime: new Date("2026-03-01T00:00:00Z"),
  198. endTime: new Date("2026-03-01T12:00:00Z"),
  199. },
  200. rangeWeekly: {
  201. startTime: new Date("2026-02-23T00:00:00Z"),
  202. endTime: new Date("2026-03-01T12:00:00Z"),
  203. },
  204. rangeMonthly: {
  205. startTime: new Date("2026-02-01T00:00:00Z"),
  206. endTime: new Date("2026-03-01T12:00:00Z"),
  207. },
  208. };
  209. // First: getKeyStringByIdCached lookup, then main query
  210. dbResultMock.mockReturnValueOnce([{ key: "sk-test-hash" }]).mockReturnValueOnce([
  211. {
  212. cost5h: "2.0",
  213. costDaily: "4.0",
  214. costWeekly: "6.0",
  215. costMonthly: "8.0",
  216. costTotal: "10.0",
  217. },
  218. ]);
  219. const { sumKeyQuotaCostsById } = await import("@/repository/statistics");
  220. const result = await sumKeyQuotaCostsById(42, ranges, 365, resetAt);
  221. expect(result.cost5h).toBe(2.0);
  222. expect(result.costTotal).toBe(10.0);
  223. });
  224. });
  225. });