From ae0f4c2675ec9019cf39254e57eadb6afa935cb0 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 11 Jul 2026 09:15:15 -0400 Subject: [PATCH] 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. --- test/classify-http-error.test.ts | 95 ++++++++++++++++++++++ test/detection.test.ts | 134 +++++++++++++++++++++++++++++-- vitest.config.ts | 8 +- 3 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 test/classify-http-error.test.ts diff --git a/test/classify-http-error.test.ts b/test/classify-http-error.test.ts new file mode 100644 index 0000000..d30e707 --- /dev/null +++ b/test/classify-http-error.test.ts @@ -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"); + }); +}); diff --git a/test/detection.test.ts b/test/detection.test.ts index b6112be..e11db4f 100644 --- a/test/detection.test.ts +++ b/test/detection.test.ts @@ -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"); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index f518074..7285912 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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, }, }, },