Initial Commit
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user