From c844359ed9c40c7027db5eaeb148b18201141f3e Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Thu, 3 Sep 2026 18:37:21 +0100 Subject: [PATCH] fix(jobdanmark-search): skip autocomplete items without text instead of crashing (#421) (#422) The filter derefed item.text.toLowerCase() from a cast API response on the same line that already guards g.items ?? [], so one item with a null or missing text threw TypeError and the whole command exited 1 as API_ERROR. The filter is extracted into an exported filterAutocompleteGroups (the jobnet testability pattern), text is typed nullable so the compiler enforces the guard, and an item without usable text is skipped: it can never match the required non-empty query, so downstream output never sees one. The null-text case was verified to fail against the verbatim unguarded extraction with the production TypeError. Closes out the #416/#418 audit. --- .../cli/src/commands/autocomplete.ts | 41 ++++++++++------ .../cli/tests/autocomplete-filtering.test.ts | 48 +++++++++++++++++++ CHANGELOG.md | 12 +++++ 3 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 .agents/skills/jobdanmark-search/cli/tests/autocomplete-filtering.test.ts diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts b/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts index 156f66a..0a5ae1c 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts @@ -4,7 +4,12 @@ import { apiFetch, writeError } from "../helpers.js" interface AutocompleteItem { id: string - text: string + // Nullable because apiFetch casts the JSON body with no runtime validation: + // an item missing its text arrives typed as if it had one, and the filter + // below is the only place the command derefs it (#421). A null text can + // never match the required non-empty query, so such an item is filtered + // out here and downstream output never sees it. + text: string | null value: number category: string slug: string @@ -15,6 +20,23 @@ interface AutocompleteGroup { items: AutocompleteItem[] } +/** + * Filter the API's autocomplete groups to items whose text matches the query + * (the API always returns all categories, so a nonsense query must yield []). + * Exported for tests. + */ +export function filterAutocompleteGroups(raw: AutocompleteGroup[], query: string): AutocompleteGroup[] { + const queryLower = query.toLowerCase() + return raw + .map((g) => ({ + title: g.title, + items: (g.items ?? []).filter( + (item) => typeof item.text === "string" && item.text.toLowerCase().includes(queryLower), + ), + })) + .filter((g) => g.items.length > 0) +} + export const autocomplete = defineCommand({ name: "autocomplete", description: "Suggest job titles and categories for a query", @@ -44,18 +66,7 @@ export const autocomplete = defineCommand({ if (signal.aborted) return - const queryLower = flags.query.toLowerCase() - - // Filter groups: only include items whose text matches the query (API always returns all categories) - // This ensures a nonsense query returns [] - const filtered = raw - .map((g) => ({ - title: g.title, - items: (g.items ?? []).filter((item) => - item.text.toLowerCase().includes(queryLower) - ), - })) - .filter((g) => g.items.length > 0) + const filtered = filterAutocompleteGroups(raw, flags.query) let result = filtered @@ -93,7 +104,7 @@ function outputTable(data: AutocompleteGroup[]): void { for (const item of group.items) { const cat = item.category.padEnd(10) const id = item.id.substring(0, 20).padEnd(20) - const text = item.text.substring(0, 32).padEnd(32) + const text = (item.text ?? "").substring(0, 32).padEnd(32) const value = String(item.value).padEnd(6) const slug = item.slug console.log(`${cat} ${id} ${text} ${value} ${slug}`) @@ -105,7 +116,7 @@ function outputPlain(data: AutocompleteGroup[]): void { for (const group of data) { console.log(`=== ${group.title} ===`) for (const item of group.items) { - console.log(` ${item.text} (${item.category}, id=${item.value}, slug=${item.slug})`) + console.log(` ${item.text ?? ""} (${item.category}, id=${item.value}, slug=${item.slug})`) } } } diff --git a/.agents/skills/jobdanmark-search/cli/tests/autocomplete-filtering.test.ts b/.agents/skills/jobdanmark-search/cli/tests/autocomplete-filtering.test.ts new file mode 100644 index 0000000..6475d20 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/autocomplete-filtering.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { filterAutocompleteGroups } from "../src/commands/autocomplete"; + +function groups() { + return [ + { + title: "Stillingsbetegnelser", + items: [ + { id: "1", text: "Data Engineer", value: 11, category: "title", slug: "data-engineer" }, + { id: "2", text: "Dataanalytiker", value: 12, category: "title", slug: "dataanalytiker" }, + ], + }, + { + title: "Kategorier", + items: [{ id: "3", text: "Marketing", value: 21, category: "category", slug: "marketing" }], + }, + ]; +} + +describe("jobdanmark autocomplete filtering", () => { + test("keeps only items matching the query, drops empty groups", () => { + const out = filterAutocompleteGroups(groups(), "data"); + expect(out).toHaveLength(1); + expect(out[0].items.map((i) => i.text)).toEqual(["Data Engineer", "Dataanalytiker"]); + }); + + test("tolerates a group with missing items (pins the existing ?? [] guard)", () => { + const g = groups(); + // @ts-expect-error - the cast API response can omit fields the interface promises + delete g[1].items; + expect(filterAutocompleteGroups(g, "data")).toHaveLength(1); + }); + + // The API response reaches this code through a bare type cast + // (apiFetch), so an item without text arrives typed as + // if it had one. The unguarded filter threw TypeError from + // item.text.toLowerCase() and the whole command died as API_ERROR (#421). + // An item with no usable text can never match the (required, non-empty) + // query, so it must simply be skipped. + test("skips an item with null text instead of crashing the command", () => { + const g = groups(); + g[0].items.push({ id: "4", text: null as unknown as string, value: 13, category: "title", slug: "x" }); + + const out = filterAutocompleteGroups(g, "data"); + + expect(out[0].items.map((i) => i.slug)).toEqual(["data-engineer", "dataanalytiker"]); + }); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1a33f..d820b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,18 @@ per-file diff commands. ### Fixed +- **`jobdanmark-search` autocomplete no longer dies over one suggestion without text** + (#421, closing out the #416/#418 audit - every other deref site in the six CLIs + checked and confirmed guarded) - the filter derefed `item.text.toLowerCase()` from a + cast API response on the same line that already guards `g.items ?? []`, so one item + with a null or missing `text` threw `TypeError` and the whole command exited 1 as + `API_ERROR`. The filter now lives in an exported `filterAutocompleteGroups` (the + jobnet testability pattern), `text` is typed nullable so the compiler enforces the + guard, and an item without usable text is skipped - it can never match the required + non-empty query, so downstream output never sees one. Pinned by three cases in the + new `autocomplete-filtering.test.ts`; the null-text case fails against the verbatim + unguarded extraction with the exact production TypeError. + - **`jobnet-search` no longer dies over one ad with a null publication date** (#418, the sibling of #416 from the same audit) - `date: job.publicationDate.slice(0, 10)` trusted a TypeScript interface claim (`publicationDate: string`) that nothing validates at