From b2439a8011bc0ad8f92d6fb0f58a4601025ced94 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:52:46 -0400 Subject: [PATCH] fix(jobbank-search): retain all documented repeated filter values (#577) * fix(jobbank-search): retain all documented repeated filter values Declare the six array-capable search options repeatable in Bunli metadata. Without it, the CLI parser overwrites earlier occurrences before the handler receives the array, so documented OR filters only search the final value. Exercise actual search CLI subprocesses through loopback HTTP and assert every RSS and total-count request retains repeated values. Cover all six filters, mixed equals/comma input, and unchanged single-value input; no live portal requests. * docs(changelog): record fix for #577 --- .../jobbank-search/cli/src/commands/search.ts | 6 ++ .../cli/tests/repeated-filter-cli.test.ts | 84 +++++++++++++++++++ CHANGELOG.md | 2 + 3 files changed, 92 insertions(+) create mode 100644 .agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts diff --git a/.agents/skills/jobbank-search/cli/src/commands/search.ts b/.agents/skills/jobbank-search/cli/src/commands/search.ts index cfc064bf..1b1eb89f 100644 --- a/.agents/skills/jobbank-search/cli/src/commands/search.ts +++ b/.agents/skills/jobbank-search/cli/src/commands/search.ts @@ -37,21 +37,27 @@ export const search = defineCommand({ description: "Exclude keywords (antikey)", }), type: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Job type code (cvtype). Repeatable: --type 3 --type 6", }), education: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Education field code (udd). Repeatable.", }), location: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Region code (amt). Repeatable.", }), "work-area": option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Work area / function code (erf). Repeatable.", }), industry: option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Industry code (branche). Repeatable.", }), "suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), { + repeatable: true, description: "Suitable-for code (andet). Repeatable.", }), company: option(z.coerce.number().int().min(1).optional(), { diff --git a/.agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts b/.agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts new file mode 100644 index 00000000..0245c4b4 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/repeated-filter-cli.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +async function search(args: string[]): Promise { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url); + // Empty protocol fixtures isolate option serialization, not HTML parsing. + return new Response(url.pathname === "/job/rss" + ? '' + : '0 relevante job'); + }, + }); + const dir = mkdtempSync(join(tmpdir(), "jobbank-repeatable-")); + 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)); + // Bunli's bundled layout engine may load its local WASM as a data URL. + if (url.protocol === "data:") return originalFetch(input, init); + if (url.origin !== "https://jobbank.dk" || !["/job/rss", "/job/"].includes(url.pathname)) { + throw new Error("Unexpected fixture request"); + } + 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, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ meta: { total: 0 }, results: [] }); + expect(requests.map((url) => url.pathname)).toEqual(["/job/rss", "/job/"]); + return requests; + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + } +} + +// SKILL.md documents repeated values as OR filters, including the concrete +// industry/location example. Assert the actual CLI's RSS and count requests; +// rssFetch({ key: [a, b] }) alone bypasses the argument parser that lost a. +const filters = [ + ["industry", "branche", "10331", "10358"], + ["location", "amt", "2", "8"], + ["type", "cvtype", "3", "6"], + ["education", "udd", "24", "21"], + ["work-area", "erf", "31", "43"], + ["suitable-for", "andet", "2", "4"], +]; + +for (const [flag, parameter, first, second] of filters) { + test(`search preserves repeated --${flag} values in both portal requests`, async () => { + for (const url of await search([`--${flag}`, first, `--${flag}`, second])) { + expect(url.searchParams.getAll(parameter)).toEqual([first, second]); + } + }); +} + +test("mixed equals-form and comma-separated repeated values are retained", async () => { + for (const url of await search(["--type=3,6", "--type=13"])) { + expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6", "13"]); + } +}); + +test("a single value and a comma list keep their existing request shape", async () => { + for (const url of await search(["--industry", "10331", "--location=2,8"])) { + expect(url.searchParams.getAll("branche")).toEqual(["10331"]); + expect(url.searchParams.getAll("amt")).toEqual(["2", "8"]); + } +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index f21c2a5a..cc0fd264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,8 @@ per-file diff commands. a longer number is still caught. Two new `test_verify_pdf.py` cases; the numeric one fails on the unbounded pattern. +- **`jobbank-search` preserves repeated filter values** (#577) - repeated documented filter options now retain all supplied values instead of only the last value. + - **`check_framework_version.py`'s git diff read no longer crashes on non-ASCII framework-file content** (`tools/check_framework_version.py`) - `run_git()` called `subprocess.run(text=True)` without an explicit `encoding`, so output decoded via the host