mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
@@ -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 & analytics flows.</p><p>Use SQL 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("&");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user