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.
This commit is contained in:
Ayobami Adegoke
2026-09-03 19:37:21 +02:00
committed by GitHub
parent ba9b1d8370
commit c844359ed9
3 changed files with 86 additions and 15 deletions
@@ -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})`)
}
}
}
@@ -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<AutocompleteGroup[]>), 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"]);
});
});
+12
View File
@@ -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