import { afterEach, beforeEach, describe, expect, it, } from "@suaveplan/testing/runner"; import { GarageS3Fixture } from "./garage-s3-fixture.js"; type GarageCommand = string[]; type ExecResponse = { stdout: string; exitCode: number }; function makeGarageContainerFake( portMap: Record = { 3900: 9900, 3902: 9902 }, execResponses?: Partial> ) { const callLog: GarageCommand[] = []; const stopped: boolean[] = []; function defaultExec(cmd: GarageCommand): ExecResponse { const sub = cmd[1]; if (sub === "status") { return { stdout: "ID\tStatus\tAddr\nabc123def456 alive 127.0.0.1:3901\n", exitCode: 0, }; } if (sub === "key" && cmd[2] === "info") { return { stdout: "Key ID: GK1234567890\nSecret key: sk_abcdefghij\n", exitCode: 0, }; } return { stdout: "", exitCode: 0 }; } const fakeStarted = { getMappedPort: (port: number) => portMap[port as keyof typeof portMap] ?? port, stop: async () => void stopped.push(true), exec: async (cmd: GarageCommand) => { callLog.push(cmd); const key = cmd.join(" "); const resp = execResponses?.[key] ?? defaultExec(cmd); return { output: resp.stdout, stdout: resp.stdout, stderr: "", exitCode: resp.exitCode, }; }, }; const fakeContainer = { withExposedPorts: function () { return this; }, withEnvironment: function () { return this; }, withWaitStrategy: function () { return this; }, withCopyContentToContainer: function () { return this; }, start: async () => fakeStarted, }; return { fakeContainer, fakeStarted, callLog, stopped, factory: (_img: string) => fakeContainer as never, execFn: async (cmd: GarageCommand): Promise => { callLog.push(cmd); return execResponses?.[cmd.join(" ")] ?? defaultExec(cmd); }, }; } const ENV_KEYS = [ "GARAGE_S3_ENDPOINT", "GARAGE_S3_ADMIN_ENDPOINT", "GARAGE_S3_ACCESS_KEY_ID", "GARAGE_S3_SECRET_ACCESS_KEY", ]; describe("GarageS3Fixture", () => { beforeEach(() => { for (const key of ENV_KEYS) delete process.env[key]; }); afterEach(() => { for (const key of ENV_KEYS) delete process.env[key]; }); it("name property returns garage-s3", { timeout: 5000 }, () => { const fixture = new GarageS3Fixture(); expect(fixture.name).toBe("garage-s3"); }); it("passthrough mode (all 4 vars set): returns config from env", { timeout: 30000, }, async () => { process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900"; process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902"; process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY"; process.env["GARAGE_S3_SECRET_ACCESS_KEY"] = "sk_env_secret"; const fixture = new GarageS3Fixture(); const config = await fixture.start(); expect(config.endpoint).toBe("http://localhost:3900"); expect(config.adminEndpoint).toBe("http://localhost:3902"); expect(config.accessKeyId).toBe("GK_ENV_KEY"); expect(config.secretAccessKey).toBe("sk_env_secret"); expect(config.region).toBe("garage"); expect(config.bucket).toBe("test-bucket"); }); it("passthrough mode: stop is a no-op", { timeout: 30000 }, async () => { process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900"; process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902"; process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY"; process.env["GARAGE_S3_SECRET_ACCESS_KEY"] = "sk_env_secret"; const fixture = new GarageS3Fixture(); await fixture.start(); await expect(fixture.stop()).resolves.toBeUndefined(); }); it("partial passthrough (3 of 4 vars): falls through to container mode", { timeout: 30000, }, async () => { process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900"; process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902"; process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY"; // SECRET_ACCESS_KEY not set const { factory, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); const config = await fixture.start(); expect(config.accessKeyId).toBe("GK1234567890"); }); it("container mode: full init sequence happy path", { timeout: 30000, }, async () => { const { factory, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); const config = await fixture.start(); expect(config.accessKeyId).toBe("GK1234567890"); expect(config.secretAccessKey).toBe("sk_abcdefghij"); expect(config.endpoint).toBe("http://localhost:9900"); expect(config.adminEndpoint).toBe("http://localhost:9902"); expect(config.bucket).toBe("test-bucket"); expect(config.region).toBe("garage"); }); it("container mode: init sequence runs 7 commands in order", { timeout: 30000, }, async () => { const { factory, callLog, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); expect(callLog).toHaveLength(7); expect(callLog[0]?.[1]).toBe("status"); expect(callLog[1]?.[1]).toBe("layout"); expect(callLog[1]?.[2]).toBe("assign"); expect(callLog[2]?.[1]).toBe("layout"); expect(callLog[2]?.[2]).toBe("apply"); expect(callLog[3]?.[1]).toBe("bucket"); expect(callLog[3]?.[2]).toBe("create"); expect(callLog[4]?.[1]).toBe("key"); expect(callLog[4]?.[2]).toBe("create"); expect(callLog[5]?.[1]).toBe("bucket"); expect(callLog[5]?.[2]).toBe("allow"); expect(callLog[6]?.[1]).toBe("key"); expect(callLog[6]?.[2]).toBe("info"); }); it("node ID passed to layout assign", { timeout: 30000 }, async () => { const { factory, callLog, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); const assignCmd = callLog[1]; expect(assignCmd).toContain("abc123def456"); }); it("bucket name used in bucket create and bucket allow", { timeout: 30000, }, async () => { const { factory, callLog, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ bucket: "my-custom-bucket", containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); expect(callLog[3]).toContain("my-custom-bucket"); expect(callLog[5]).toContain("my-custom-bucket"); }); it("healthCheck returns true via injected fetchFn", { timeout: 30000, }, async () => { const { factory, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); const healthy = await fixture.healthCheck(); expect(healthy).toBe(true); }); it("healthCheck returns false via injected fetchFn (ok: false)", { timeout: 30000, }, async () => { const { factory, execFn } = makeGarageContainerFake(); let callCount = 0; const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => { callCount++; return { ok: callCount === 1 } as Response; }, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); const healthy = await fixture.healthCheck(); expect(healthy).toBe(false); }); it("healthCheck returns false when fetch throws", { timeout: 30000, }, async () => { const { factory, execFn } = makeGarageContainerFake(); let callCount = 0; const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => { callCount++; if (callCount > 1) throw new Error("ECONNREFUSED"); return { ok: true } as Response; }, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); const healthy = await fixture.healthCheck(); expect(healthy).toBe(false); }); it("healthCheck returns false when no container", { timeout: 30000, }, async () => { const fixture = new GarageS3Fixture(); const healthy = await fixture.healthCheck(); expect(healthy).toBe(false); }); it("stop calls container.stop()", { timeout: 30000 }, async () => { const { factory, stopped, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); await fixture.stop(); expect(stopped).toHaveLength(1); }); it("stop when passthrough mode is a no-op", { timeout: 30000, }, async () => { process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900"; process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902"; process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY"; process.env["GARAGE_S3_SECRET_ACCESS_KEY"] = "sk_env_secret"; const fixture = new GarageS3Fixture(); await fixture.start(); await expect(fixture.stop()).resolves.toBeUndefined(); }); it("stop when no container is a no-op", { timeout: 30000 }, async () => { const fixture = new GarageS3Fixture(); await expect(fixture.stop()).resolves.toBeUndefined(); }); it("waitForHealth: throws on timeout when health never passes", { timeout: 30000, }, async () => { const { factory, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, fetchFn: async () => ({ ok: false }) as Response, healthTimeoutMs: 10, healthPollIntervalMs: 5, }); await expect(fixture.start()).rejects.toThrow( /Health check timed out for fixture "garage-s3"/ ); }); it("image override: factory called with custom image", { timeout: 30000, }, async () => { const calledWith: string[] = []; const { fakeContainer, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ image: "ghcr.io/deuxfleurs/garage:custom", containerFactory: (img) => { calledWith.push(img); return fakeContainer as never; }, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); expect(calledWith[0]).toBe("ghcr.io/deuxfleurs/garage:custom"); }); it("bucket override: reflected in init commands and returned config", { timeout: 30000, }, async () => { const { factory, callLog, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ bucket: "custom-bucket", containerFactory: factory, execFn, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); const config = await fixture.start(); expect(config.bucket).toBe("custom-bucket"); expect(callLog[3]).toContain("custom-bucket"); }); it("getExecFn: uses injected execFn when provided", { timeout: 30000, }, async () => { const called: string[][] = []; const { factory } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn: async (cmd) => { called.push(cmd); if (cmd[1] === "status") { return { stdout: "ID\tStatus\tAddr\nabc123test alive 127.0.0.1\n", exitCode: 0, }; } if (cmd[1] === "key" && cmd[2] === "info") { return { stdout: "Key ID: GK_TEST\nSecret key: sk_TEST\n", exitCode: 0, }; } return { stdout: "", exitCode: 0 }; }, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); expect(called.length).toBeGreaterThan(0); }); it("getExecFn: falls back to container.exec when no execFn provided", { timeout: 30000, }, async () => { const { factory, callLog } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, fetchFn: async () => ({ ok: true }) as Response, healthTimeoutMs: 1000, healthPollIntervalMs: 10, }); await fixture.start(); expect(callLog.length).toBeGreaterThan(0); }); it("resolveContainerFactory: falls back to GenericContainer import", { timeout: 60000, }, async () => { class Exposed extends GarageS3Fixture { resolveFactory() { return this.resolveContainerFactory(); } } const exposed = new Exposed(); const factory = await exposed.resolveFactory(); expect(typeof factory).toBe("function"); const container = factory("ghcr.io/deuxfleurs/garage:v2.0.0"); expect(container).toBeDefined(); }); it("start(): stops container and clears reference when waitForHealth throws", { timeout: 30000, }, async () => { const { factory, stopped } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, fetchFn: async () => ({ ok: false }) as Response, healthTimeoutMs: 10, healthPollIntervalMs: 5, }); await expect(fixture.start()).rejects.toThrow( /Health check timed out for fixture "garage-s3"/ ); // Container must have been stopped and cleared to prevent leaks expect(stopped).toHaveLength(1); }); it("waitForHealth: uses GARAGE_S3_HEALTH_TIMEOUT_MS and GARAGE_S3_HEALTH_POLL_INTERVAL_MS constants when no options provided", { timeout: 30000, }, async () => { // Create fixture without healthTimeoutMs / healthPollIntervalMs so the // default-constant branches in waitForHealth() are taken. const { factory, execFn } = makeGarageContainerFake(); const fixture = new GarageS3Fixture({ containerFactory: factory, execFn, // fetchFn returns ok:true on first call so health passes immediately fetchFn: async () => ({ ok: true }) as Response, // Intentionally omit healthTimeoutMs and healthPollIntervalMs }); const config = await fixture.start(); expect(config.accessKeyId).toBe("GK1234567890"); }); });