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 { apiFetch, writeError, stripHtml } from "../helpers.js"
interface DetailApiResponse {
export interface DetailApiResponse {
id: string
title: string
body: string
@@ -118,15 +118,23 @@ function outputTable(data: DetailApiResponse): void {
}
function outputPlain(data: DetailApiResponse): void {
console.log(`Title: ${data.title}`)
console.log(`Employer: ${data.employer.name}`)
console.log(`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`)
console.log(`Published: ${data.publicationDateTime}`)
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
console.log(`Positions: ${data.application.availablePositions}`)
if (data.application.url) {
console.log(`Apply: ${data.application.url}`)
}
console.log("")
console.log(stripHtml(data.body))
console.log(formatDetailPlain(data))
}
export function formatDetailPlain(data: DetailApiResponse): string {
const lines = [
`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) {
lines.push(`Apply: ${data.application.url}`)
}
lines.push("", stripHtml(data.body))
return lines.join("\n")
}
@@ -2,14 +2,14 @@ import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { apiFetch, writeError } from "../helpers.js"
interface SearchApiResponse {
export interface SearchApiResponse {
jobAds: JobAdRaw[]
searchFacets: SearchFacetsRaw
totalJobAdCount: number
searchString: string | null
}
interface JobAdRaw {
export interface JobAdRaw {
jobAdId: string
title: string
hiringOrgName: string
@@ -33,7 +33,7 @@ interface JobAdRaw {
description?: string
}
interface SearchFacetsRaw {
export interface SearchFacetsRaw {
regions: Array<{ type: string; jobAdCount: number }>
workHours: 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 }>
}
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({
name: "search",
description: "Search for job ads with filters",
@@ -91,79 +174,21 @@ export const search = defineCommand({
handler: async ({ flags, signal }) => {
if (signal.aborted) return
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"]
const params = buildSearchParams(flags)
try {
const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params)
if (signal.aborted) return
// Map raw job ads to documented output shape (omit description)
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 }
const output = createSearchOutput(data, flags)
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") {
outputTable(results)
outputTable(output.results)
} else {
outputPlain(results)
outputPlain(output.results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")