docker-executor.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  2. const { mockSpawn, mockCreateReadStream } = vi.hoisted(() => ({
  3. mockSpawn: vi.fn(),
  4. mockCreateReadStream: vi.fn(() => ({ pipe: vi.fn(), on: vi.fn() })),
  5. }));
  6. vi.mock("node:child_process", () => ({
  7. default: { spawn: mockSpawn },
  8. spawn: mockSpawn,
  9. }));
  10. vi.mock("node:fs", () => ({
  11. default: { createReadStream: mockCreateReadStream },
  12. createReadStream: mockCreateReadStream,
  13. }));
  14. vi.mock("@/drizzle/db", () => ({
  15. db: { execute: vi.fn() },
  16. }));
  17. vi.mock("drizzle-orm", () => ({
  18. sql: (strings: TemplateStringsArray, ..._values: unknown[]) => ({
  19. strings,
  20. }),
  21. }));
  22. vi.mock("@/lib/logger", () => ({
  23. logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
  24. }));
  25. vi.mock("@/lib/database-backup/db-config", () => ({
  26. getDatabaseConfig: vi.fn(() => ({
  27. host: "localhost",
  28. port: 5432,
  29. user: "postgres",
  30. password: "secret",
  31. database: "testdb",
  32. })),
  33. }));
  34. function makeFakeProcess(opts?: { withStdin?: boolean }) {
  35. return {
  36. stdout: { on: vi.fn() },
  37. stderr: { on: vi.fn() },
  38. stdin: opts?.withStdin ? { write: vi.fn(), end: vi.fn() } : null,
  39. on: vi.fn(),
  40. kill: vi.fn(),
  41. };
  42. }
  43. describe("getDockerComposeExec", () => {
  44. const saved = process.env.PG_COMPOSE_EXEC;
  45. afterEach(() => {
  46. if (saved === undefined) {
  47. delete process.env.PG_COMPOSE_EXEC;
  48. } else {
  49. process.env.PG_COMPOSE_EXEC = saved;
  50. }
  51. });
  52. test("returns null when PG_COMPOSE_EXEC is unset", async () => {
  53. delete process.env.PG_COMPOSE_EXEC;
  54. const { getDockerComposeExec } = await import("@/lib/database-backup/docker-executor");
  55. expect(getDockerComposeExec()).toBeNull();
  56. });
  57. test("returns null when PG_COMPOSE_EXEC is empty string", async () => {
  58. process.env.PG_COMPOSE_EXEC = "";
  59. const { getDockerComposeExec } = await import("@/lib/database-backup/docker-executor");
  60. expect(getDockerComposeExec()).toBeNull();
  61. });
  62. test("parses command with spaces correctly", async () => {
  63. process.env.PG_COMPOSE_EXEC = "docker compose -f /home/dev/docker-compose.yaml -p cch-dev";
  64. const { getDockerComposeExec } = await import("@/lib/database-backup/docker-executor");
  65. expect(getDockerComposeExec()).toEqual([
  66. "docker",
  67. "compose",
  68. "-f",
  69. "/home/dev/docker-compose.yaml",
  70. "-p",
  71. "cch-dev",
  72. ]);
  73. });
  74. });
  75. describe("spawnPgTool", () => {
  76. const saved = process.env.PG_COMPOSE_EXEC;
  77. beforeEach(() => {
  78. mockSpawn.mockReset();
  79. });
  80. afterEach(() => {
  81. if (saved === undefined) {
  82. delete process.env.PG_COMPOSE_EXEC;
  83. } else {
  84. process.env.PG_COMPOSE_EXEC = saved;
  85. }
  86. });
  87. test("direct mode: spawns the command directly with merged env", async () => {
  88. delete process.env.PG_COMPOSE_EXEC;
  89. const fakeProc = makeFakeProcess();
  90. mockSpawn.mockReturnValue(fakeProc);
  91. const { spawnPgTool } = await import("@/lib/database-backup/docker-executor");
  92. const result = spawnPgTool("pg_dump", ["-h", "localhost"], {
  93. PGPASSWORD: "secret",
  94. });
  95. expect(result).toBe(fakeProc);
  96. expect(mockSpawn).toHaveBeenCalledWith(
  97. "pg_dump",
  98. ["-h", "localhost"],
  99. expect.objectContaining({
  100. env: expect.objectContaining({ PGPASSWORD: "secret" }),
  101. })
  102. );
  103. });
  104. test("docker exec mode: wraps command with docker compose exec", async () => {
  105. process.env.PG_COMPOSE_EXEC = "docker compose -f /dev/dc.yaml -p proj";
  106. const fakeProc = makeFakeProcess();
  107. mockSpawn.mockReturnValue(fakeProc);
  108. const { spawnPgTool } = await import("@/lib/database-backup/docker-executor");
  109. const result = spawnPgTool("pg_dump", ["-Fc", "-v"], {
  110. PGPASSWORD: "secret",
  111. });
  112. expect(result).toBe(fakeProc);
  113. expect(mockSpawn).toHaveBeenCalledWith(
  114. "docker",
  115. [
  116. "compose",
  117. "-f",
  118. "/dev/dc.yaml",
  119. "-p",
  120. "proj",
  121. "exec",
  122. "-T",
  123. "-e",
  124. "PGPASSWORD=secret",
  125. "postgres",
  126. "pg_dump",
  127. "-Fc",
  128. "-v",
  129. ],
  130. expect.objectContaining({ env: expect.any(Object) })
  131. );
  132. });
  133. test("docker exec mode with stdin: adds -i flag", async () => {
  134. process.env.PG_COMPOSE_EXEC = "docker compose -p proj";
  135. const fakeProc = makeFakeProcess({ withStdin: true });
  136. mockSpawn.mockReturnValue(fakeProc);
  137. const { spawnPgTool } = await import("@/lib/database-backup/docker-executor");
  138. spawnPgTool("pg_restore", ["-d", "mydb"], { PGPASSWORD: "pw" }, { stdin: true });
  139. const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
  140. // -T and -i should both be present before "postgres"
  141. const postgresIdx = spawnArgs.indexOf("postgres");
  142. const flags = spawnArgs.slice(spawnArgs.indexOf("exec") + 1, postgresIdx);
  143. expect(flags).toContain("-T");
  144. expect(flags).toContain("-i");
  145. });
  146. test("docker exec mode without PGPASSWORD: no -e flag", async () => {
  147. process.env.PG_COMPOSE_EXEC = "docker compose -p proj";
  148. const fakeProc = makeFakeProcess();
  149. mockSpawn.mockReturnValue(fakeProc);
  150. const { spawnPgTool } = await import("@/lib/database-backup/docker-executor");
  151. spawnPgTool("pg_dump", [], {});
  152. const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
  153. expect(spawnArgs).not.toContain("-e");
  154. });
  155. });
  156. describe("checkDatabaseConnection", () => {
  157. test("returns true when db.execute succeeds", async () => {
  158. const { db } = await import("@/drizzle/db");
  159. (db.execute as MockInstance).mockResolvedValueOnce([{ "?column?": 1 }]);
  160. const { checkDatabaseConnection } = await import("@/lib/database-backup/docker-executor");
  161. expect(await checkDatabaseConnection()).toBe(true);
  162. });
  163. test("returns false when db.execute throws", async () => {
  164. const { db } = await import("@/drizzle/db");
  165. (db.execute as MockInstance).mockRejectedValueOnce(new Error("connection refused"));
  166. const { checkDatabaseConnection } = await import("@/lib/database-backup/docker-executor");
  167. expect(await checkDatabaseConnection()).toBe(false);
  168. });
  169. });
  170. describe("getDatabaseInfo", () => {
  171. test("parses SQL result correctly", async () => {
  172. const { db } = await import("@/drizzle/db");
  173. (db.execute as MockInstance).mockResolvedValueOnce([
  174. {
  175. size: "42 MB",
  176. table_count: "15",
  177. version: "PostgreSQL 16.2 on aarch64-apple-darwin",
  178. },
  179. ]);
  180. const { getDatabaseInfo } = await import("@/lib/database-backup/docker-executor");
  181. const info = await getDatabaseInfo();
  182. expect(info).toEqual({
  183. size: "42 MB",
  184. tableCount: 15,
  185. version: "PostgreSQL",
  186. });
  187. });
  188. test("returns defaults when row fields are missing", async () => {
  189. const { db } = await import("@/drizzle/db");
  190. (db.execute as MockInstance).mockResolvedValueOnce([{}]);
  191. const { getDatabaseInfo } = await import("@/lib/database-backup/docker-executor");
  192. const info = await getDatabaseInfo();
  193. expect(info).toEqual({
  194. size: "Unknown",
  195. tableCount: 0,
  196. version: "Unknown",
  197. });
  198. });
  199. test("returns defaults when result is empty", async () => {
  200. const { db } = await import("@/drizzle/db");
  201. (db.execute as MockInstance).mockResolvedValueOnce([]);
  202. const { getDatabaseInfo } = await import("@/lib/database-backup/docker-executor");
  203. const info = await getDatabaseInfo();
  204. expect(info).toEqual({
  205. size: "Unknown",
  206. tableCount: 0,
  207. version: "Unknown",
  208. });
  209. });
  210. });