diff --git a/.agents/skills/jobnet-search/cli/src/commands/detail.ts b/.agents/skills/jobnet-search/cli/src/commands/detail.ts index b8d20a5..c3cd25a 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/detail.ts @@ -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") } diff --git a/.agents/skills/jobnet-search/cli/src/commands/search.ts b/.agents/skills/jobnet-search/cli/src/commands/search.ts index 32da807..449f44a 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/search.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/search.ts @@ -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 { + const params: Record = { + 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 = { - 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("/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") diff --git a/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts new file mode 100644 index 0000000..28dde67 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { formatDetailPlain, type DetailApiResponse } from "../src/commands/detail"; + +function detail(overrides: Partial = {}): DetailApiResponse { + return { + id: "job-1", + title: "Data Engineer", + body: "

Build ETL & analytics flows.

Use SQL daily.

", + 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("

"); + 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:"); + }); +}); diff --git a/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts b/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts new file mode 100644 index 0000000..8ae00fa --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/search-normalization.test.ts @@ -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: "

Search results should not include this bulky HTML.

", + }, + { + 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); + }); +});