test(jobnet): extract pure helpers and add normalization tests (#133)

* docs: clean all add-template compile artifacts

* Add Jobnet CLI normalization tests
This commit is contained in:
Ayobami Adegoke
2026-07-12 18:02:17 +02:00
committed by GitHub
parent 1526afcbfe
commit ff78dae48a
4 changed files with 335 additions and 77 deletions
@@ -2,7 +2,7 @@ import { defineCommand, option } from "@bunli/core"
import { z } from "zod" import { z } from "zod"
import { apiFetch, writeError, stripHtml } from "../helpers.js" import { apiFetch, writeError, stripHtml } from "../helpers.js"
interface DetailApiResponse { export interface DetailApiResponse {
id: string id: string
title: string title: string
body: string body: string
@@ -118,15 +118,23 @@ function outputTable(data: DetailApiResponse): void {
} }
function outputPlain(data: DetailApiResponse): void { function outputPlain(data: DetailApiResponse): void {
console.log(`Title: ${data.title}`) console.log(formatDetailPlain(data))
console.log(`Employer: ${data.employer.name}`) }
console.log(`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`)
console.log(`Published: ${data.publicationDateTime}`) export function formatDetailPlain(data: DetailApiResponse): string {
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`) const lines = [
console.log(`Positions: ${data.application.availablePositions}`) `Title: ${data.title}`,
`Employer: ${data.employer.name}`,
`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`,
`Published: ${data.publicationDateTime}`,
`Deadline: ${data.application.deadlineDate ?? "-"}`,
`Positions: ${data.application.availablePositions}`,
]
if (data.application.url) { if (data.application.url) {
console.log(`Apply: ${data.application.url}`) lines.push(`Apply: ${data.application.url}`)
} }
console.log("")
console.log(stripHtml(data.body)) lines.push("", stripHtml(data.body))
return lines.join("\n")
} }
@@ -2,14 +2,14 @@ import { defineCommand, option } from "@bunli/core"
import { z } from "zod" import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js" import { apiFetch, writeError } from "../helpers.js"
interface SearchApiResponse { export interface SearchApiResponse {
jobAds: JobAdRaw[] jobAds: JobAdRaw[]
searchFacets: SearchFacetsRaw searchFacets: SearchFacetsRaw
totalJobAdCount: number totalJobAdCount: number
searchString: string | null searchString: string | null
} }
interface JobAdRaw { export interface JobAdRaw {
jobAdId: string jobAdId: string
title: string title: string
hiringOrgName: string hiringOrgName: string
@@ -33,7 +33,7 @@ interface JobAdRaw {
description?: string description?: string
} }
interface SearchFacetsRaw { export interface SearchFacetsRaw {
regions: Array<{ type: string; jobAdCount: number }> regions: Array<{ type: string; jobAdCount: number }>
workHours: Array<{ type: string; jobAdCount: number }> workHours: Array<{ type: string; jobAdCount: number }>
employmentDurations: Array<{ type: string; jobAdCount: number }> employmentDurations: Array<{ type: string; jobAdCount: number }>
@@ -41,6 +41,89 @@ interface SearchFacetsRaw {
countries: Array<{ label: string; identifier: string; jobAdCount: number }> countries: Array<{ label: string; identifier: string; jobAdCount: number }>
} }
export interface SearchFlags {
"search-string"?: string
page: number
"per-page": number
order: string
region?: string
"work-hours"?: string
duration?: string
"job-type"?: string
"postal-code"?: string
radius: number
"occupation-area"?: string
"occupation-group"?: string
limit?: number
}
export function buildSearchParams(flags: SearchFlags): Record<string, string> {
const params: Record<string, string> = {
resultsPerPage: String(flags["per-page"]),
pageNumber: String(flags.page),
orderType: flags.order,
}
if (flags["search-string"]) params["searchString"] = flags["search-string"]
if (flags.region) params["regions"] = flags.region
if (flags["work-hours"]) params["workHoursType"] = flags["work-hours"]
if (flags.duration) params["employmentDurationType"] = flags.duration
if (flags["job-type"]) params["jobAnnouncementType"] = flags["job-type"]
if (flags["postal-code"]) {
params["postalCode"] = flags["postal-code"]
params["kmRadius"] = String(flags.radius)
}
if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"]
if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"]
return params
}
export function createSearchOutput(data: SearchApiResponse, flags: SearchFlags) {
let results = data.jobAds.map((job) => ({
jobAdId: job.jobAdId,
title: job.title,
hiringOrgName: job.hiringOrgName,
occupation: job.occupation ?? null,
municipality: job.municipality ?? null,
postalCode: job.postalCode ?? null,
postalDistrictName: job.postalDistrictName ?? null,
country: job.country,
publicationDate: job.publicationDate,
applicationDeadline: job.applicationDeadline ?? null,
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
workHourPartTime: job.workHourPartTime,
isExternal: job.isExternal,
hasLogo: job.hasLogo,
logoUrl: job.logoUrl ?? null,
cvr: job.cvr ?? null,
workPlaceAddress: job.workPlaceAddress ?? "",
isSeen: job.isSeen,
isFavorite: job.isFavorite,
}))
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const facets = {
regions: data.searchFacets.regions ?? [],
workHours: data.searchFacets.workHours ?? [],
employmentDurations: data.searchFacets.employmentDurations ?? [],
occupationAreas: data.searchFacets.occupationAreas ?? [],
countries: data.searchFacets.countries ?? [],
}
const meta = {
totalJobAdCount: data.totalJobAdCount,
pageNumber: flags.page,
resultsPerPage: flags["per-page"],
searchString: data.searchString ?? null,
}
return { meta, facets, results }
}
export const search = defineCommand({ export const search = defineCommand({
name: "search", name: "search",
description: "Search for job ads with filters", description: "Search for job ads with filters",
@@ -91,79 +174,21 @@ export const search = defineCommand({
handler: async ({ flags, signal }) => { handler: async ({ flags, signal }) => {
if (signal.aborted) return if (signal.aborted) return
const params: Record<string, string> = { const params = buildSearchParams(flags)
resultsPerPage: String(flags["per-page"]),
pageNumber: String(flags.page),
orderType: flags.order,
}
if (flags["search-string"]) params["searchString"] = flags["search-string"]
if (flags.region) params["regions"] = flags.region
if (flags["work-hours"]) params["workHoursType"] = flags["work-hours"]
if (flags.duration) params["employmentDurationType"] = flags.duration
if (flags["job-type"]) params["jobAnnouncementType"] = flags["job-type"]
if (flags["postal-code"]) {
params["postalCode"] = flags["postal-code"]
params["kmRadius"] = String(flags.radius)
}
if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"]
if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"]
try { try {
const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params) const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params)
if (signal.aborted) return if (signal.aborted) return
// Map raw job ads to documented output shape (omit description) const output = createSearchOutput(data, flags)
let results = data.jobAds.map((job) => ({
jobAdId: job.jobAdId,
title: job.title,
hiringOrgName: job.hiringOrgName,
occupation: job.occupation ?? null,
municipality: job.municipality ?? null,
postalCode: job.postalCode ?? null,
postalDistrictName: job.postalDistrictName ?? null,
country: job.country,
publicationDate: job.publicationDate,
applicationDeadline: job.applicationDeadline ?? null,
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
workHourPartTime: job.workHourPartTime,
isExternal: job.isExternal,
hasLogo: job.hasLogo,
logoUrl: job.logoUrl ?? null,
cvr: job.cvr ?? null,
workPlaceAddress: job.workPlaceAddress ?? "",
isSeen: job.isSeen,
isFavorite: job.isFavorite,
}))
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const facets = {
regions: data.searchFacets.regions ?? [],
workHours: data.searchFacets.workHours ?? [],
employmentDurations: data.searchFacets.employmentDurations ?? [],
occupationAreas: data.searchFacets.occupationAreas ?? [],
countries: data.searchFacets.countries ?? [],
}
const meta = {
totalJobAdCount: data.totalJobAdCount,
pageNumber: flags.page,
resultsPerPage: flags["per-page"],
searchString: data.searchString ?? null,
}
const output = { meta, facets, results }
if (flags.format === "json") { if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2)) console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") { } else if (flags.format === "table") {
outputTable(results) outputTable(output.results)
} else { } else {
outputPlain(results) outputPlain(output.results)
} }
} catch (err) { } catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR") writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import { formatDetailPlain, type DetailApiResponse } from "../src/commands/detail";
function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse {
return {
id: "job-1",
title: "Data Engineer",
body: "<p>Build ETL &amp; analytics flows.</p><p>Use SQL&nbsp;daily.</p>",
publicationDateTime: "2026-07-01T09:00:00+02:00",
unpublicationDateTime: "2026-08-01T23:59:00+02:00",
approvalStatus: "Godkendt",
views: 10,
createdDateTime: "2026-07-01T08:00:00+02:00",
updatedDateTime: "2026-07-01T08:30:00+02:00",
isAnonymousEmployer: false,
hasLogo: true,
logoUrl: "/logo/job-1",
employer: {
cvrNumber: "12345678",
pNumber: "87654321",
name: "Acme",
hasCompanyLogo: true,
},
job: {
type: "FullTime",
address: {
streetName: "Examplevej 1",
city: "København",
postalCode: "2100",
municipality: "København",
countryCode: "DK",
countryName: "Danmark",
},
noFixedWorkplace: false,
isLimitedPeriod: false,
isDisabilityFriendly: false,
isPartTime: false,
employmentDate: null,
conceptUriDa: null,
preferredLabelDa: null,
driversLicenses: [],
classifications: [],
shifts: [],
isFavorite: false,
},
application: {
deadlineDate: "2026-08-01T23:59:00+02:00",
availablePositions: 2,
contactPersons: [],
url: "https://example.test/apply",
urlText: "Apply",
isApplicationDeadlineASAP: false,
},
organisationTypeId: null,
user: null,
...overrides,
};
}
describe("formatDetailPlain", () => {
test("renders the plain view with cleaned body text and apply URL", () => {
const formatted = formatDetailPlain(detail());
expect(formatted).toContain("Title: Data Engineer");
expect(formatted).toContain("Employer: Acme");
expect(formatted).toContain("Location: København, Danmark");
expect(formatted).toContain("Apply: https://example.test/apply");
expect(formatted).toContain("Build ETL & analytics flows. Use SQL daily.");
expect(formatted).not.toContain("<p>");
expect(formatted).not.toContain("&amp;");
});
test("uses placeholders when optional city, deadline, and apply URL are absent", () => {
const formatted = formatDetailPlain(
detail({
job: {
...detail().job,
address: {
...detail().job.address,
city: null,
},
},
application: {
...detail().application,
deadlineDate: null,
url: null,
},
}),
);
expect(formatted).toContain("Location: -, Danmark");
expect(formatted).toContain("Deadline: -");
expect(formatted).not.toContain("Apply:");
});
});
@@ -0,0 +1,130 @@
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);
});
});