Files
suaveplan/packages/testkit/testing/testing-integration-garage-s3/src/fixture/garage-s3-fixture.test.ts
2026-05-27 23:47:53 +01:00

483 lines
17 KiB
TypeScript

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<number, number> = { 3900: 9900, 3902: 9902 },
execResponses?: Partial<Record<string, ExecResponse>>
) {
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<ExecResponse> => {
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");
});
});