service-count.test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { readFileSync } from "node:fs";
  2. import { resolve } from "node:path";
  3. import { type MockInstance, beforeEach, describe, expect, it, vi } from "vitest";
  4. type ExecuteCountResult = unknown[] & {
  5. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  6. count?: any;
  7. rowCount?: number;
  8. };
  9. vi.mock("@/drizzle/db", () => ({
  10. db: {
  11. execute: vi.fn(),
  12. },
  13. }));
  14. vi.mock("@/lib/logger", () => ({
  15. logger: {
  16. info: vi.fn(),
  17. warn: vi.fn(),
  18. error: vi.fn(),
  19. },
  20. }));
  21. function makeExecuteResult(input: {
  22. count?: number | bigint;
  23. rowCount?: number;
  24. }): ExecuteCountResult {
  25. const result: ExecuteCountResult = [];
  26. if (input.count !== undefined) {
  27. result.count = input.count;
  28. }
  29. if (typeof input.rowCount === "number") {
  30. result.rowCount = input.rowCount;
  31. }
  32. return result;
  33. }
  34. function makeReturningResult(count: number): unknown[] {
  35. return Array.from({ length: count }, () => ({ "?column?": 1 }));
  36. }
  37. describe("log cleanup delete count", () => {
  38. beforeEach(async () => {
  39. const { db } = await import("@/drizzle/db");
  40. (db.execute as MockInstance).mockReset();
  41. });
  42. it("prefers RETURNING array length for row counting", async () => {
  43. const { db } = await import("@/drizzle/db");
  44. (db.execute as MockInstance)
  45. .mockResolvedValueOnce(makeReturningResult(5)) // main delete: 5 rows
  46. .mockResolvedValueOnce([]) // main delete: 0 (exit loop)
  47. .mockResolvedValueOnce([]) // soft-delete purge: 0 (exit)
  48. .mockResolvedValueOnce({}); // VACUUM
  49. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  50. const result = await cleanupLogs(
  51. { beforeDate: new Date() },
  52. {},
  53. { type: "manual", user: "test" }
  54. );
  55. expect(result.error).toBeUndefined();
  56. expect(result.totalDeleted).toBe(5);
  57. expect(result.batchCount).toBe(1);
  58. expect(result.vacuumPerformed).toBe(true);
  59. });
  60. it("reads affected rows from postgres.js count field", async () => {
  61. const { db } = await import("@/drizzle/db");
  62. (db.execute as MockInstance)
  63. .mockResolvedValueOnce(makeExecuteResult({ count: 3 })) // main delete
  64. .mockResolvedValueOnce(makeExecuteResult({ count: 0 })) // main delete exit
  65. .mockResolvedValueOnce([]) // soft-delete purge
  66. .mockResolvedValueOnce({}); // VACUUM
  67. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  68. const result = await cleanupLogs(
  69. { beforeDate: new Date() },
  70. {},
  71. { type: "manual", user: "test" }
  72. );
  73. expect(result.error).toBeUndefined();
  74. expect(result.totalDeleted).toBe(3);
  75. expect(result.batchCount).toBe(1);
  76. });
  77. it("reads affected rows from postgres.js BigInt count field", async () => {
  78. const { db } = await import("@/drizzle/db");
  79. (db.execute as MockInstance)
  80. .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(7) }))
  81. .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) }))
  82. .mockResolvedValueOnce([])
  83. .mockResolvedValueOnce({});
  84. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  85. const result = await cleanupLogs(
  86. { beforeDate: new Date() },
  87. {},
  88. { type: "manual", user: "test" }
  89. );
  90. expect(result.error).toBeUndefined();
  91. expect(result.totalDeleted).toBe(7);
  92. expect(result.batchCount).toBe(1);
  93. });
  94. it("keeps compatibility with rowCount fallback", async () => {
  95. const { db } = await import("@/drizzle/db");
  96. (db.execute as MockInstance)
  97. .mockResolvedValueOnce(makeExecuteResult({ rowCount: 2 }))
  98. .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 }))
  99. .mockResolvedValueOnce([])
  100. .mockResolvedValueOnce({});
  101. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  102. const result = await cleanupLogs(
  103. { beforeDate: new Date() },
  104. {},
  105. { type: "manual", user: "test" }
  106. );
  107. expect(result.error).toBeUndefined();
  108. expect(result.totalDeleted).toBe(2);
  109. expect(result.batchCount).toBe(1);
  110. });
  111. it("purgeSoftDeleted runs after main cleanup and count returned in result", async () => {
  112. const { db } = await import("@/drizzle/db");
  113. (db.execute as MockInstance)
  114. .mockResolvedValueOnce(makeReturningResult(2)) // main delete: 2
  115. .mockResolvedValueOnce([]) // main delete exit
  116. .mockResolvedValueOnce(makeReturningResult(4)) // soft-delete purge: 4
  117. .mockResolvedValueOnce([]) // soft-delete purge exit
  118. .mockResolvedValueOnce({}); // VACUUM
  119. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  120. const result = await cleanupLogs(
  121. { beforeDate: new Date() },
  122. {},
  123. { type: "manual", user: "test" }
  124. );
  125. expect(result.error).toBeUndefined();
  126. expect(result.totalDeleted).toBe(2);
  127. expect(result.softDeletedPurged).toBe(4);
  128. expect(result.vacuumPerformed).toBe(true);
  129. });
  130. it("VACUUM runs after deletion, failure doesn't fail cleanup", async () => {
  131. const { db } = await import("@/drizzle/db");
  132. (db.execute as MockInstance)
  133. .mockResolvedValueOnce(makeReturningResult(1)) // main delete: 1
  134. .mockResolvedValueOnce([]) // main delete exit
  135. .mockResolvedValueOnce([]) // soft-delete purge: 0
  136. .mockRejectedValueOnce(new Error("VACUUM failed")); // VACUUM fails
  137. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  138. const result = await cleanupLogs(
  139. { beforeDate: new Date() },
  140. {},
  141. { type: "manual", user: "test" }
  142. );
  143. expect(result.error).toBeUndefined();
  144. expect(result.totalDeleted).toBe(1);
  145. expect(result.vacuumPerformed).toBe(false);
  146. });
  147. it("VACUUM skipped when 0 records deleted", async () => {
  148. const { db } = await import("@/drizzle/db");
  149. (db.execute as MockInstance)
  150. .mockResolvedValueOnce([]) // main delete: 0 (exit immediately)
  151. .mockResolvedValueOnce([]); // soft-delete purge: 0
  152. const { cleanupLogs } = await import("@/lib/log-cleanup/service");
  153. const result = await cleanupLogs(
  154. { beforeDate: new Date() },
  155. {},
  156. { type: "manual", user: "test" }
  157. );
  158. expect(result.error).toBeUndefined();
  159. expect(result.totalDeleted).toBe(0);
  160. expect(result.softDeletedPurged).toBe(0);
  161. expect(result.vacuumPerformed).toBe(false);
  162. // VACUUM should not have been called (only 2 execute calls total)
  163. expect(db.execute).toHaveBeenCalledTimes(2);
  164. });
  165. });
  166. describe("getAffectedRows", () => {
  167. it("returns array length for RETURNING rows", async () => {
  168. const { getAffectedRows } = await import("@/lib/log-cleanup/service");
  169. expect(getAffectedRows(makeReturningResult(10))).toBe(10);
  170. });
  171. it("falls through to count for empty array with count property", async () => {
  172. const { getAffectedRows } = await import("@/lib/log-cleanup/service");
  173. expect(getAffectedRows(makeExecuteResult({ count: 5 }))).toBe(5);
  174. });
  175. it("handles BigInt count", async () => {
  176. const { getAffectedRows } = await import("@/lib/log-cleanup/service");
  177. expect(getAffectedRows(makeExecuteResult({ count: BigInt(99) }))).toBe(99);
  178. });
  179. it("handles rowCount fallback", async () => {
  180. const { getAffectedRows } = await import("@/lib/log-cleanup/service");
  181. expect(getAffectedRows(makeExecuteResult({ rowCount: 42 }))).toBe(42);
  182. });
  183. it("returns 0 for null/undefined", async () => {
  184. const { getAffectedRows } = await import("@/lib/log-cleanup/service");
  185. expect(getAffectedRows(null)).toBe(0);
  186. expect(getAffectedRows(undefined)).toBe(0);
  187. });
  188. it("returns 0 for empty result", async () => {
  189. const { getAffectedRows } = await import("@/lib/log-cleanup/service");
  190. expect(getAffectedRows([])).toBe(0);
  191. expect(getAffectedRows({})).toBe(0);
  192. });
  193. });
  194. describe("buildWhereConditions", () => {
  195. it("does not filter on deletedAt", async () => {
  196. const { buildWhereConditions } = await import("@/lib/log-cleanup/service");
  197. const conditions = buildWhereConditions({});
  198. expect(conditions).toHaveLength(0);
  199. });
  200. it("returns conditions only for provided filters", async () => {
  201. const { buildWhereConditions } = await import("@/lib/log-cleanup/service");
  202. const conditions = buildWhereConditions({
  203. beforeDate: new Date(),
  204. userIds: [1, 2],
  205. });
  206. // beforeDate + userIds = 2 conditions (no deletedAt)
  207. expect(conditions).toHaveLength(2);
  208. });
  209. });
  210. describe("log cleanup SQL patterns", () => {
  211. const serviceSource = readFileSync(
  212. resolve(process.cwd(), "src/lib/log-cleanup/service.ts"),
  213. "utf-8"
  214. );
  215. it("uses SKIP LOCKED in delete SQL", () => {
  216. expect(serviceSource).toContain("FOR UPDATE SKIP LOCKED");
  217. });
  218. it("uses RETURNING 1 in delete SQL", () => {
  219. expect(serviceSource).toContain("RETURNING 1");
  220. });
  221. it("does not contain deletedAt IS NULL in buildWhereConditions", () => {
  222. const buildFnMatch = serviceSource.match(/function buildWhereConditions[\s\S]*?^}/m);
  223. expect(buildFnMatch).not.toBeNull();
  224. expect(buildFnMatch![0]).not.toContain("deletedAt");
  225. });
  226. it("includes VACUUM ANALYZE", () => {
  227. expect(serviceSource).toContain("VACUUM ANALYZE message_request");
  228. });
  229. });