test: close tea and error-classifier coverage gaps, raise thresholds
Cover the previously untested credential and error-classification paths: - tea.ts: failing/invalid/non-array `tea login list`, a failing token helper, and an empty token, all driven through the CLI seam via the fake tea harness (now parametrizable for exit codes and output). - errors.ts: a unit-tier test of classifyHttpError for the pure-classifier branches no shipped command reaches yet (issue/pull 404s, the already-classified passthrough, transport failures, no-body defaults). errors.ts is now fully covered and tea.ts is at 96% (the remainder is defensive code unreachable through the seam). Overall coverage rose from ~87% to ~93% statements / ~89% branches; the ratchet is raised to 92/87/95/92 accordingly.
This commit was merged in pull request #1.
This commit is contained in:
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 });
|
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. */
|
/** 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++}`);
|
const bin = join(root, `bin-${sandboxCounter++}`);
|
||||||
mkdirSync(bin);
|
mkdirSync(bin);
|
||||||
symlinkSync(gitPath, join(bin, "git"));
|
symlinkSync(gitPath, join(bin, "git"));
|
||||||
symlinkSync(catPath, join(bin, "cat"));
|
symlinkSync(catPath, join(bin, "cat"));
|
||||||
if (options.tea !== false) {
|
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
|
const script = `#!/bin/sh
|
||||||
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
|
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
|
||||||
cat <<'JSON'
|
cat <<'LISTEOF'
|
||||||
${JSON.stringify(options.logins ?? [])}
|
${listOutput}
|
||||||
JSON
|
LISTEOF
|
||||||
exit 0
|
${options.listStderr ? ` echo '${options.listStderr}' >&2\n` : ""} exit ${options.listExitCode ?? 0}
|
||||||
fi
|
fi
|
||||||
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
|
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
|
||||||
cat > /dev/null
|
cat > /dev/null
|
||||||
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
|
cat <<'HELPEREOF'
|
||||||
exit 0
|
${helperOutput}
|
||||||
|
HELPEREOF
|
||||||
|
${options.helperStderr ? ` echo '${options.helperStderr}' >&2\n` : ""} exit ${options.helperExitCode ?? 0}
|
||||||
fi
|
fi
|
||||||
echo "unexpected tea invocation: $*" >&2
|
echo "unexpected tea invocation: $*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -254,4 +279,99 @@ describe("repository context detection", () => {
|
|||||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
|
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
|
||||||
expect(stdout).toContain("--login fixture");
|
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
|
// 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.
|
// these as coverage climbs; never lower them to make a red build pass.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 85,
|
statements: 92,
|
||||||
branches: 78,
|
branches: 87,
|
||||||
functions: 90,
|
functions: 95,
|
||||||
lines: 85,
|
lines: 92,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user