docker-executor.test.ts 7.6 KB

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