Files
Ayobami Adegoke ba9b1d8370 fix(jobnet-search): degrade a null publicationDate to a null date instead of crashing the search (#418) (#419)
date: job.publicationDate.slice(0, 10) trusted a TypeScript interface
claim nothing validates at runtime: apiFetch casts the JSON body, so one
ad with a null publication date threw TypeError inside the jobAds map
and the whole search exited 1 as API_ERROR. The neighboring
applicationDeadline field was already null-guarded with a 1900-01-01
sentinel. publicationDate is now typed nullable so the compiler enforces
the guard, and the ad degrades per-item to date: null. New test verified
to fail on the unfixed code with the exact production TypeError.
2026-09-03 19:33:54 +02:00

184 lines
6.1 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
buildSearchParams,
createSearchOutput,
type SearchApiResponse,
type SearchFlags,
} from "../src/commands/search";
const flags: SearchFlags = {
"search-string": "data engineer",
page: 2,
"per-page": 25,
order: "BestMatch",
region: "HovedstadenOgBornholm",
"work-hours": "FullTime",
duration: "Permanent",
"job-type": "Ordinaert",
"postal-code": "2100",
radius: 25,
"occupation-area": "10000",
"occupation-group": "10060",
limit: 1,
};
function apiResponse(): SearchApiResponse {
return {
totalJobAdCount: 2,
searchString: "data engineer",
searchFacets: {
regions: [{ type: "HovedstadenOgBornholm", jobAdCount: 2 }],
workHours: [{ type: "FullTime", jobAdCount: 2 }],
employmentDurations: [{ type: "Permanent", jobAdCount: 1 }],
occupationAreas: [{ identifier: "10000", jobAdCount: 2 }],
countries: [{ label: "Danmark", identifier: "DK", jobAdCount: 2 }],
},
jobAds: [
{
jobAdId: "job-1",
title: "Data Engineer",
hiringOrgName: "Acme",
occupation: null,
municipality: null,
postalCode: null,
postalDistrictName: null,
country: "Danmark",
publicationDate: "2026-07-01T00:00:00+02:00",
applicationDeadline: null,
applicationDeadlineStatus: null,
workHourPartTime: false,
isExternal: false,
hasLogo: false,
logoUrl: null,
cvr: null,
workPlaceAddress: "",
conceptUriDa: "http://example.test/occupation",
isSeen: false,
isFavorite: false,
description: "<p>Search results should not include this bulky HTML.</p>",
},
{
jobAdId: "job-2",
title: "Analytics Engineer",
hiringOrgName: "Example Co",
occupation: "Softwareudvikler",
municipality: "København",
postalCode: 2100,
postalDistrictName: "København Ø",
country: "Danmark",
publicationDate: "2026-07-02T00:00:00+02:00",
applicationDeadline: "2026-08-01T23:59:00+02:00",
applicationDeadlineStatus: "ExpirationDate",
workHourPartTime: false,
isExternal: true,
hasLogo: true,
logoUrl: "/logo/job-2",
cvr: "12345678",
workPlaceAddress: "Examplevej 1",
isSeen: false,
isFavorite: true,
},
],
};
}
describe("Jobnet search normalization", () => {
test("builds the API query with required paging and optional filters", () => {
expect(buildSearchParams(flags)).toEqual({
resultsPerPage: "25",
pageNumber: "2",
orderType: "BestMatch",
searchString: "data engineer",
regions: "HovedstadenOgBornholm",
workHoursType: "FullTime",
employmentDurationType: "Permanent",
jobAnnouncementType: "Ordinaert",
postalCode: "2100",
kmRadius: "25",
occupationAreas: "10000",
occupationGroups: "10060",
});
});
test("omits radius when postal-code is absent", () => {
const params = buildSearchParams({ ...flags, "postal-code": undefined });
expect(params.postalCode).toBeUndefined();
expect(params.kmRadius).toBeUndefined();
});
test("creates the documented output envelope and omits bulky descriptions", () => {
const output = createSearchOutput(apiResponse(), flags);
expect(output.meta).toEqual({
totalJobAdCount: 2,
pageNumber: 2,
resultsPerPage: 25,
searchString: "data engineer",
});
expect(output.facets.regions).toEqual([{ type: "HovedstadenOgBornholm", jobAdCount: 2 }]);
expect(output.results).toHaveLength(1);
expect(output.results[0]).toMatchObject({
jobAdId: "job-1",
occupation: null,
municipality: null,
postalCode: null,
applicationDeadline: null,
workPlaceAddress: "",
});
expect("description" in output.results[0]).toBe(false);
});
test("additively emits the /scrape contract fields (company, location, date, deadline, url)", () => {
const output = createSearchOutput(apiResponse(), { ...flags, limit: undefined });
expect(output.results).toHaveLength(2);
expect(output.results[0]).toMatchObject({
company: "Acme",
location: null,
date: "2026-07-01",
deadline: null,
url: "https://jobnet.dk/find-job/job-1",
});
expect(output.results[1]).toMatchObject({
company: "Example Co",
location: "København Ø",
date: "2026-07-02",
deadline: "2026-08-01",
url: "https://jobnet.dk/find-job/job-2",
});
expect(output.results[0].hiringOrgName).toBe("Acme");
expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00");
});
test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => {
const response = apiResponse();
response.jobAds[0].applicationDeadline = "1900-01-01T00:00:00+01:00";
response.jobAds[0].applicationDeadlineStatus = "NotDisclosed";
const output = createSearchOutput(response, { ...flags, limit: undefined });
expect(output.results[0].deadline).toBeNull();
expect(output.results[1].deadline).toBe("2026-08-01");
});
});
describe("Jobnet null publicationDate degradation", () => {
// publicationDate: string was a TypeScript claim, not runtime validation -
// apiFetch casts the JSON body, so one ad with a null publication date
// threw TypeError from .slice() inside the jobAds map and killed the whole
// search as API_ERROR (#418). The neighboring applicationDeadline field is
// already guarded (null check + 1900-01-01 sentinel); this pins the same
// per-item degradation for publicationDate: date null, no throw.
test("an ad with a null publicationDate yields date: null instead of crashing the search", () => {
const data = apiResponse();
data.jobAds[0].publicationDate = null;
// The shared fixture flags carry limit: 1, which would slice off the
// second ad; lift the limit so the survives-alongside assertion is real.
const output = createSearchOutput(data, { ...flags, limit: undefined });
expect(output.results[0].date).toBeNull();
expect(output.results[1].date).toBe("2026-07-02");
});
});