mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
@@ -4,7 +4,12 @@ import { apiFetch, writeError } from "../helpers.js"
|
|||||||
|
|
||||||
interface AutocompleteItem {
|
interface AutocompleteItem {
|
||||||
id: string
|
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
|
value: number
|
||||||
category: string
|
category: string
|
||||||
slug: string
|
slug: string
|
||||||
@@ -15,6 +20,23 @@ interface AutocompleteGroup {
|
|||||||
items: AutocompleteItem[]
|
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({
|
export const autocomplete = defineCommand({
|
||||||
name: "autocomplete",
|
name: "autocomplete",
|
||||||
description: "Suggest job titles and categories for a query",
|
description: "Suggest job titles and categories for a query",
|
||||||
@@ -44,18 +66,7 @@ export const autocomplete = defineCommand({
|
|||||||
|
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|
||||||
const queryLower = flags.query.toLowerCase()
|
const filtered = filterAutocompleteGroups(raw, flags.query)
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
let result = filtered
|
let result = filtered
|
||||||
|
|
||||||
@@ -93,7 +104,7 @@ function outputTable(data: AutocompleteGroup[]): void {
|
|||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
const cat = item.category.padEnd(10)
|
const cat = item.category.padEnd(10)
|
||||||
const id = item.id.substring(0, 20).padEnd(20)
|
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 value = String(item.value).padEnd(6)
|
||||||
const slug = item.slug
|
const slug = item.slug
|
||||||
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
|
console.log(`${cat} ${id} ${text} ${value} ${slug}`)
|
||||||
@@ -105,7 +116,7 @@ function outputPlain(data: AutocompleteGroup[]): void {
|
|||||||
for (const group of data) {
|
for (const group of data) {
|
||||||
console.log(`=== ${group.title} ===`)
|
console.log(`=== ${group.title} ===`)
|
||||||
for (const item of group.items) {
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,18 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **`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
|
sibling of #416 from the same audit) - `date: job.publicationDate.slice(0, 10)` trusted
|
||||||
a TypeScript interface claim (`publicationDate: string`) that nothing validates at
|
a TypeScript interface claim (`publicationDate: string`) that nothing validates at
|
||||||
|
|||||||
Reference in New Issue
Block a user