mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
Silently discarded flags produced silently wrong results: jobdanmark with --query (its real flag is --text) returned all 13,862 jobs as if they matched, exit 0, empty stderr - indistinguishable from a real result set. The four bunli CLIs get an argv preflight built from each command's own options object; linkedin and freehire validate parsed flags against per-command known sets. help/version still pass, and add-portal.md's existing bogus-flag-exits-1 contract now holds for the reference implementations contributors copy. One linkedin pin updated: "--jobage-minutes -5" now fails as UNKNOWN_FLAG (the stray -5 token) rather than BAD_ARG - same loud-failure invariant, earlier gate. Review finding F13 (2026-08-19), decision approved by Mads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
4.1 KiB
TypeScript
98 lines
4.1 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { runCLI } from "./helpers";
|
|
|
|
// All cases fail schema validation (or the required-flag guard) before any
|
|
// network request, so the suite is network-free. Regression context: a bare
|
|
// z.coerce.number() accepted --limit=-1 / --per-page=-1, and slice(0, -1)
|
|
// then silently dropped the last result instead of erroring. The --radius
|
|
// filter flag also accepted negative and fractional values that were sent
|
|
// raw to the portal.
|
|
|
|
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
const error = JSON.parse(result.stderr);
|
|
expect(error.ok).toBe(false);
|
|
expect(error.error.kind).toBe("validation");
|
|
expect(error.error.option).toBe(option);
|
|
}
|
|
|
|
describe("Jobnet CLI flag validation", () => {
|
|
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
|
|
const result = await runCLI(["search", "--limit=-1"]);
|
|
expectValidationError(result, "limit");
|
|
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
|
});
|
|
|
|
test("search --page=0 is rejected on the 1-indexed API", async () => {
|
|
const result = await runCLI(["search", "--page=0"]);
|
|
expectValidationError(result, "page");
|
|
});
|
|
|
|
test("search --per-page=-5 is rejected", async () => {
|
|
const result = await runCLI(["search", "--per-page=-5"]);
|
|
expectValidationError(result, "per-page");
|
|
});
|
|
|
|
test("search --limit=1.5 is rejected as non-integer", async () => {
|
|
const result = await runCLI(["search", "--limit=1.5"]);
|
|
expectValidationError(result, "limit");
|
|
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
|
});
|
|
|
|
test("search --radius=-10 is rejected", async () => {
|
|
const result = await runCLI(["search", "--radius=-10"]);
|
|
expectValidationError(result, "radius");
|
|
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
|
});
|
|
|
|
test("search --radius=2.5 is rejected as non-integer", async () => {
|
|
const result = await runCLI(["search", "--radius=2.5"]);
|
|
expectValidationError(result, "radius");
|
|
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
|
});
|
|
|
|
test("occupations --per-page=-1 is rejected", async () => {
|
|
const result = await runCLI(["occupations", "--per-page=-1"]);
|
|
expectValidationError(result, "per-page");
|
|
});
|
|
|
|
test("suggestions --limit=-1 is rejected", async () => {
|
|
const result = await runCLI(["suggestions", "--limit=-1"]);
|
|
expectValidationError(result, "limit");
|
|
});
|
|
|
|
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
|
|
const result = await runCLI(["occupations", "--per-page=5"]);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(JSON.parse(result.stderr)).toEqual({
|
|
error: "--search-string is required",
|
|
code: "MISSING_REQUIRED",
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
describe("unknown flag rejection", () => {
|
|
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
|
|
// with a JSON error on stderr". A silently discarded flag is worse than an
|
|
// error: on jobdanmark a wrong flag name returned the entire database
|
|
// (13,862 results) as if it matched the query (review finding F13,
|
|
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
|
|
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
|
|
const result = await runCLI(["search", "--search-string", "test", "--bogus-flag", "xyz"]);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
const error = JSON.parse(result.stderr);
|
|
expect(error.code).toBe("UNKNOWN_FLAG");
|
|
expect(error.error).toContain("--bogus-flag");
|
|
});
|
|
|
|
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
|
|
const result = await runCLI(["search", "--query", "test"]);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
|
|
});
|
|
});
|