ci: add end-to-end test tier, CI workflow, and coverage gating #1
95
test/classify-http-error.test.ts
Normal file
95
test/classify-http-error.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { AxiError } from "axi-sdk-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyHttpError } from "../src/errors.js";
|
||||
|
||||
/**
|
||||
* Unit-tier coverage of the HTTP error classifier (a pure function, no I/O).
|
||||
* The integration tier drives the classifier through the issue-list seam, but
|
||||
* some branches belong to paths no shipped command reaches yet — pull-request
|
||||
* 404s, the already-classified passthrough, and non-HTTP transport failures —
|
||||
* so they are exercised directly here against crafted inputs.
|
||||
*/
|
||||
function httpError(status: number, url: string, body?: unknown) {
|
||||
return { status, url, error: body };
|
||||
}
|
||||
|
||||
const REPO = "http://gitea.example/api/v1/repos/o/r";
|
||||
|
||||
describe("classifyHttpError", () => {
|
||||
it("passes an already-classified AxiError through unchanged", () => {
|
||||
const original = new AxiError("boom", "FORBIDDEN", ["hint"]);
|
||||
expect(classifyHttpError(original)).toBe(original);
|
||||
});
|
||||
|
||||
it("classifies a 404 on an issue path as ISSUE_NOT_FOUND", () => {
|
||||
const result = classifyHttpError(httpError(404, `${REPO}/issues/42`));
|
||||
expect(result.code).toBe("ISSUE_NOT_FOUND");
|
||||
expect(result.message).toContain("#42");
|
||||
});
|
||||
|
||||
it("classifies a 404 on a pull path as PR_NOT_FOUND", () => {
|
||||
const result = classifyHttpError(httpError(404, `${REPO}/pulls/7`));
|
||||
expect(result.code).toBe("PR_NOT_FOUND");
|
||||
expect(result.message).toContain("#7");
|
||||
});
|
||||
|
||||
it("classifies a 404 on the repo subtree as REPO_NOT_FOUND", () => {
|
||||
const result = classifyHttpError(httpError(404, `${REPO}/issues`));
|
||||
expect(result.code).toBe("REPO_NOT_FOUND");
|
||||
expect(result.message).toContain("o/r");
|
||||
});
|
||||
|
||||
it("classifies a 404 on a non-repo path as UNKNOWN", () => {
|
||||
const result = classifyHttpError(httpError(404, "http://gitea.example/api/v1/version"));
|
||||
expect(result.code).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
it("falls back to the raw url when the response url does not parse", () => {
|
||||
const result = classifyHttpError(httpError(404, "::not a url::"));
|
||||
expect(result.code).toBe("UNKNOWN");
|
||||
expect(result.message).toContain("::not a url::");
|
||||
});
|
||||
|
||||
it("uses the body message when present, and a default when absent", () => {
|
||||
expect(classifyHttpError(httpError(401, REPO, { message: "token expired" })).message).toBe(
|
||||
"token expired",
|
||||
);
|
||||
expect(classifyHttpError(httpError(401, REPO, {})).message).toBe("Authentication required");
|
||||
});
|
||||
|
||||
it("maps 403 and every validation status (405/409/422) and 429 to their codes", () => {
|
||||
expect(classifyHttpError(httpError(403, REPO, {})).code).toBe("FORBIDDEN");
|
||||
expect(classifyHttpError(httpError(405, REPO, {})).code).toBe("VALIDATION_ERROR");
|
||||
expect(classifyHttpError(httpError(409, REPO, {})).code).toBe("VALIDATION_ERROR");
|
||||
expect(classifyHttpError(httpError(422, REPO, {})).code).toBe("VALIDATION_ERROR");
|
||||
expect(classifyHttpError(httpError(429, REPO, {})).code).toBe("RATE_LIMITED");
|
||||
});
|
||||
|
||||
it("maps an unexpected status to UNKNOWN, with and without a body message", () => {
|
||||
expect(classifyHttpError(httpError(500, REPO, { message: "kaboom" })).message).toContain(
|
||||
"kaboom",
|
||||
);
|
||||
const bare = classifyHttpError(httpError(503, REPO, {}));
|
||||
expect(bare.code).toBe("UNKNOWN");
|
||||
expect(bare.message).toContain("503");
|
||||
});
|
||||
|
||||
it("classifies a plain Error transport failure as UNKNOWN", () => {
|
||||
const result = classifyHttpError(new Error("network down"));
|
||||
expect(result.code).toBe("UNKNOWN");
|
||||
expect(result.message).toContain("network down");
|
||||
});
|
||||
|
||||
it("includes the underlying cause when a transport failure carries one", () => {
|
||||
const error = new Error("fetch failed", { cause: new Error("ECONNREFUSED") });
|
||||
const result = classifyHttpError(error);
|
||||
expect(result.message).toContain("fetch failed");
|
||||
expect(result.message).toContain("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("stringifies a non-Error thrown value", () => {
|
||||
const result = classifyHttpError("just a string");
|
||||
expect(result.code).toBe("UNKNOWN");
|
||||
expect(result.message).toContain("just a string");
|
||||
});
|
||||
});
|
||||
@@ -24,24 +24,49 @@ afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface SandboxOptions {
|
||||
logins?: FakeLogin[];
|
||||
token?: string;
|
||||
/** Set false to omit the tea binary entirely (TEA_NOT_INSTALLED path). */
|
||||
tea?: boolean;
|
||||
/** Raw stdout for `tea login list`, overriding the logins JSON. */
|
||||
listOutput?: string;
|
||||
/** Exit code for `tea login list` (default 0). */
|
||||
listExitCode?: number;
|
||||
/** stderr line emitted by `tea login list` when it fails. */
|
||||
listStderr?: string;
|
||||
/** Raw stdout for `tea login helper get`, overriding the credential block. */
|
||||
helperOutput?: string;
|
||||
/** Exit code for `tea login helper get` (default 0). */
|
||||
helperExitCode?: number;
|
||||
/** stderr line emitted by `tea login helper get` when it fails. */
|
||||
helperStderr?: string;
|
||||
}
|
||||
|
||||
/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */
|
||||
function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string {
|
||||
function makeSandbox(options: SandboxOptions): string {
|
||||
const bin = join(root, `bin-${sandboxCounter++}`);
|
||||
mkdirSync(bin);
|
||||
symlinkSync(gitPath, join(bin, "git"));
|
||||
symlinkSync(catPath, join(bin, "cat"));
|
||||
if (options.tea !== false) {
|
||||
const listOutput = options.listOutput ?? JSON.stringify(options.logins ?? []);
|
||||
const helperOutput =
|
||||
options.helperOutput ??
|
||||
`protocol=http\nhost=fixture\nusername=u\npassword=${options.token ?? ""}`;
|
||||
const script = `#!/bin/sh
|
||||
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
|
||||
cat <<'JSON'
|
||||
${JSON.stringify(options.logins ?? [])}
|
||||
JSON
|
||||
exit 0
|
||||
cat <<'LISTEOF'
|
||||
${listOutput}
|
||||
LISTEOF
|
||||
${options.listStderr ? ` echo '${options.listStderr}' >&2\n` : ""} exit ${options.listExitCode ?? 0}
|
||||
fi
|
||||
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
|
||||
cat > /dev/null
|
||||
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
|
||||
exit 0
|
||||
cat <<'HELPEREOF'
|
||||
${helperOutput}
|
||||
HELPEREOF
|
||||
${options.helperStderr ? ` echo '${options.helperStderr}' >&2\n` : ""} exit ${options.helperExitCode ?? 0}
|
||||
fi
|
||||
echo "unexpected tea invocation: $*" >&2
|
||||
exit 1
|
||||
@@ -254,4 +279,99 @@ describe("repository context detection", () => {
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
|
||||
expect(stdout).toContain("--login fixture");
|
||||
});
|
||||
|
||||
it("maps a failing `tea login list` to UNKNOWN, surfacing the stderr detail", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ listExitCode: 1, listStderr: "config file is corrupt" });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: UNKNOWN");
|
||||
expect(stdout).toContain("tea login list");
|
||||
expect(stdout).toContain("config file is corrupt");
|
||||
});
|
||||
|
||||
it("maps invalid JSON from `tea login list` to UNKNOWN", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ listOutput: "not json at all {" });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: UNKNOWN");
|
||||
expect(stdout).toContain("invalid JSON");
|
||||
});
|
||||
|
||||
it("maps non-array JSON from `tea login list` to UNKNOWN", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ listOutput: '{"not":"an array"}' });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: UNKNOWN");
|
||||
expect(stdout).toContain("unexpected output");
|
||||
});
|
||||
|
||||
it("tolerates login entries with missing fields", async () => {
|
||||
// A login object with no name/url/ssh_host exercises the field fallbacks;
|
||||
// it matches no host, so resolution ends in REPO_NOT_FOUND.
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ listOutput: "[{}]" });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("maps a failing token helper to AUTH_REQUIRED with a repair hint", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url: "https://gitea.example.com", default: "true" }],
|
||||
helperExitCode: 1,
|
||||
helperStderr: "credential store is locked",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||
expect(stdout).toContain("tea login edit fixture");
|
||||
expect(stdout).toContain("credential store is locked");
|
||||
});
|
||||
|
||||
it("maps an empty token from the helper to AUTH_REQUIRED", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url: "https://gitea.example.com", default: "true" }],
|
||||
// A credential block that carries no usable password value.
|
||||
helperOutput: "protocol=http\nhost=gitea.example.com\nusername=u\npassword=",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||
expect(stdout).toContain("tea login edit fixture");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,10 +23,10 @@ export default defineConfig({
|
||||
// coverage so a real drop fails CI while trivial churn does not. Raise
|
||||
// these as coverage climbs; never lower them to make a red build pass.
|
||||
thresholds: {
|
||||
statements: 85,
|
||||
branches: 78,
|
||||
functions: 90,
|
||||
lines: 85,
|
||||
statements: 92,
|
||||
branches: 87,
|
||||
functions: 95,
|
||||
lines: 92,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user