fix(jobindex-search): reject a --sort other than score or date instead of sending it to the portal (#647)

* fix(jobindex-search): reject a --sort other than score or date instead of sending it to the portal

--sort was a free z.string(), and jobindex does not reject an unknown sort:
--sort newest came back exit 0 in an order that is neither relevance nor
date, so the newest-first sort a caller asked for was silently not applied.
Make it an enum of the two documented values, like --format, so any other
value fails schema validation before a request is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* changelog: place the #647 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 19:59:10 +02:00
1 parent ce2755e5aa
commit f6063fd8a1
3 files changed
+86 -1

No files matched your search

@@ -16,7 +16,9 @@ export const search = defineCommand({
jobage: option(z.coerce.number().int().min(1).default(9999), { jobage: option(z.coerce.number().int().min(1).default(9999), {
description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)", description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)",
}), }),
sort: option(z.string().default("score"), { // An enum, not a free string: jobindex answers an unknown sort with an
// unrelated order and no error, so a typo silently lost the date sort.
sort: option(z.enum(["score", "date"]).default("score"), {
description: "Sort order: score (relevance) or date (newest first)", description: "Sort order: score (relevance) or date (newest first)",
}), }),
limit: option(z.coerce.number().int().min(1).optional(), { limit: option(z.coerce.number().int().min(1).optional(), {
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// --sort was a free z.string(), so any value went to jobindex verbatim. The
// portal does not reject an unknown sort: `--sort newest` came back exit 0 in
// an order that is neither relevance nor date (checked live 2026-10-18), with
// nothing telling the caller the documented `date` sort was not applied.
// The CLI runs against a loopback server so each outbound request is counted.
async function search(args: string[]) {
const requests: URL[] = [];
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
requests.push(new URL(request.url));
return new Response('<script>var Stash = {"jobsearch":{"result_app":{"storeData":{"searchResponse":{"hitcount":0,"results":[]}}}}};</script>');
},
});
const dir = mkdtempSync(join(tmpdir(), "jobindex-sort-"));
const preload = join(dir, "fetch.ts");
writeFileSync(preload, `
const originalFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.protocol === "data:") return originalFetch(input, init);
if (url.origin !== "https://www.jobindex.dk" || url.pathname !== "/jobsoegning") {
throw new Error("Unexpected search endpoint");
}
return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init);
};
`);
try {
const proc = Bun.spawn([
process.execPath, "run", "--preload", preload,
join(import.meta.dir, "../src/cli.ts"), "search", ...args,
], { stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode, requests };
} finally {
server.stop(true);
rmSync(dir, { recursive: true, force: true });
}
}
describe("--sort values", () => {
for (const value of ["newest", "Date", "relevance"]) {
test(`--sort ${value} is rejected before any request`, async () => {
const result = await search(["--query", "python", "--sort", value]);
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("sort");
expect(result.requests).toHaveLength(0);
});
}
for (const [args, expected] of [
[["--sort", "date"], "date"],
[["--sort", "score"], "score"],
[[], "score"],
] as const) {
test(`${args.join(" ") || "no --sort"} sends sort=${expected}`, async () => {
const result = await search(["--query", "python", ...args]);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(result.requests).toHaveLength(1);
expect(result.requests[0].searchParams.get("sort")).toBe(expected);
});
}
});
+7
View File
@@ -131,6 +131,13 @@ per-file diff commands.
the flag is still a location-only search. The Danish portals (#573) and `freehire-search` the flag is still a location-only search. The Danish portals (#573) and `freehire-search`
(#568) already reject a value-less option. (#568) already reject a value-less option.
- **`jobindex-search` rejects a `--sort` other than `score` or `date` instead of sending it to the
portal** (#647, `.agents/skills/jobindex-search/cli/src/commands/search.ts`,
`tests/sort-values.test.ts`) - the option was a free string, and jobindex does not reject an
unknown sort: `--sort newest` returned exit 0 in an order that was neither relevance nor date,
so the requested newest-first sort was silently not applied. The option is now an enum like
`--format`, and any other value exits 1 with the CLI's validation error before any request.
## [1.7.3] - 2026-10-19 ## [1.7.3] - 2026-10-19
### Added ### Added