diff --git a/.agents/skills/jobnet-search/cli/README.md b/.agents/skills/jobnet-search/cli/README.md index cabf8473..70eb54f5 100644 --- a/.agents/skills/jobnet-search/cli/README.md +++ b/.agents/skills/jobnet-search/cli/README.md @@ -170,10 +170,10 @@ bun run src/cli.ts search \ ## `detail` — Full job ad detail -**Endpoint**: `GET /FindJob/JobAdDetails/{id}` +**Endpoint**: `GET /FindJob/JobAdDetails/{id}` (falls back to `GET /FindJob/Search?searchString={id}` on 404 for external ads) ```bash -bun run src/cli.ts detail [--format json|plain] +bun run src/cli.ts detail [--format json|table|plain] ``` The `id` is the `jobAdId` UUID from `search` results. @@ -252,6 +252,8 @@ bun run src/cli.ts detail 9ef43bce-d82b-4ea1-a098-7ff6520f99be --format plain > **Note**: `body` contains raw HTML. In `--format plain` output the CLI strips HTML tags to produce readable text. +> **Note**: The optional `source` field indicates whether detail was synthesized from the search endpoint fallback (`"search-fallback"`) when `JobAdDetails` returned 404 for an external ad. Direct responses omit `source`. When present, plain and table formats render a `Source: search-fallback (detail unavailable, synthesised from search)` line. + > **Note**: `application.url` may be empty for some job ads — the employer may only accept applications through Jobnet's internal system. --- diff --git a/.agents/skills/jobnet-search/cli/src/commands/detail.ts b/.agents/skills/jobnet-search/cli/src/commands/detail.ts index 83850066..2d2d5da1 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/detail.ts @@ -17,6 +17,7 @@ export interface DetailApiResponse { hasLogo: boolean logoUrl: string | null isExternal?: boolean + source?: string employer: { cvrNumber: string | null pNumber: string | null @@ -81,6 +82,7 @@ export function mapSearchAdToDetail(raw: JobAdRaw & { jobAdUrl?: string | null; hasLogo: Boolean(raw.hasLogo), logoUrl: raw.logoUrl ?? null, isExternal: true, + source: "search-fallback", employer: { cvrNumber: raw.cvr ?? null, pNumber: null, @@ -184,7 +186,6 @@ export const detail = defineCommand({ }) const match = searchResult.jobAds?.find((ad) => ad.jobAdId === id) if (match) { - process.stderr.write("note: detail endpoint returned 404; retrieved external posting summary from search endpoint\n") data = prepareDetail(mapSearchAdToDetail(match)) } } catch { @@ -217,6 +218,9 @@ function outputTable(data: DetailApiResponse): void { console.log(`ID: ${data.id}`) console.log(`Title: ${data.title}`) console.log(`Employer: ${data.employer.name}`) + if (data.source) { + console.log(`Source: ${data.source} (detail unavailable, synthesised from search)`) + } console.log(`Type: ${data.job.type ?? "-"}`) console.log(`City: ${data.job.address.city ?? "-"}`) console.log(`Postal: ${data.job.address.postalCode ?? "-"}`) @@ -241,6 +245,10 @@ export function formatDetailPlain(data: DetailApiResponse): string { `Positions: ${data.application.availablePositions ?? "-"}`, ] + if (data.source) { + lines.push(`Source: ${data.source} (detail unavailable, synthesised from search)`) + } + if (data.application.url) { lines.push(`Apply: ${data.application.url}`) } diff --git a/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts index 87bb958c..1757dc26 100644 --- a/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts @@ -1,7 +1,52 @@ -import { describe, expect, test } from "bun:test" -import { mapSearchAdToDetail } from "../src/commands/detail" +import { afterEach, describe, expect, test } from "bun:test" +import { detail, mapSearchAdToDetail } from "../src/commands/detail" import type { JobAdRaw } from "../src/commands/search" +const originalFetch = globalThis.fetch +const originalExit = process.exit +const originalLog = console.log +const originalStderrWrite = process.stderr.write + +afterEach(() => { + globalThis.fetch = originalFetch + process.exit = originalExit + console.log = originalLog + process.stderr.write = originalStderrWrite +}) + +class ExitCalled extends Error { + constructor(public code: number | undefined) { + super(`process.exit(${code})`) + } +} + +function throwingExit() { + process.exit = ((code?: number) => { + throw new ExitCalled(code) + }) as unknown as typeof process.exit +} + +function captureOutput(): { stdout: string[]; stderr: string[] } { + const out = { stdout: [] as string[], stderr: [] as string[] } + console.log = ((...args: unknown[]) => out.stdout.push(args.join(" "))) as typeof console.log + process.stderr.write = ((chunk: string | Uint8Array) => { + out.stderr.push(String(chunk)) + return true + }) as typeof process.stderr.write + return out +} + +async function runDetail(id: string, format = "json"): Promise<{ exit: number | null }> { + const handler = (detail as unknown as { handler: (ctx: unknown) => Promise }).handler + try { + await handler({ flags: { format }, positional: [id], signal: new AbortController().signal }) + return { exit: null } + } catch (err) { + if (err instanceof ExitCalled) return { exit: err.code ?? 0 } + throw err + } +} + describe("mapSearchAdToDetail (Issue #432 external ad fallback)", () => { const sampleAd: JobAdRaw & { jobAdUrl?: string; jobAnnouncementTypeName?: string } = { jobAdId: "ext-123", @@ -37,6 +82,7 @@ describe("mapSearchAdToDetail (Issue #432 external ad fallback)", () => { expect(detail.body).toBe("

Great job opening at Tactile.

") expect(detail.publicationDateTime).toBe("2026-09-05T00:00:00+02:00") expect(detail.isExternal).toBe(true) + expect(detail.source).toBe("search-fallback") expect(detail.views).toBeNull() expect(detail.approvalStatus).toBeNull() expect(detail.isAnonymousEmployer).toBeNull() @@ -86,3 +132,245 @@ describe("mapSearchAdToDetail (Issue #432 external ad fallback)", () => { expect(detail.application.isApplicationDeadlineASAP).toBe(true) }) }) + +describe("detail command handler external ad fallback path", () => { + const sampleAd: JobAdRaw & { jobAdUrl?: string; jobAnnouncementTypeName?: string } = { + jobAdId: "ext-123", + title: "AI Technical Artist", + hiringOrgName: "Tactile Games", + occupation: "Programmør og systemudvikler", + conceptUriDa: "http://data.star.dk/esco/occupation/8b6456a3-ae9a-45a0-a65b-fed797521753", + jobAnnouncementTypeName: "Almindelige vilkår", + workHourPartTime: false, + jobAdUrl: "https://job-boards.eu.greenhouse.io/tactilegames/jobs/4890782101", + hasLogo: true, + logoUrl: "/bff/logo/123", + workPlaceAddress: " Trekronergade 26 ", + cvr: "32319882", + description: "

Great job opening at Tactile.

", + applicationDeadline: "2026-12-05T00:00:00+01:00", + applicationDeadlineStatus: "ExpirationDate", + country: "Danmark", + municipality: "København", + postalCode: 2500, + postalDistrictName: "Valby", + publicationDate: "2026-09-05T00:00:00+02:00", + isExternal: true, + isSeen: false, + isFavorite: false, + } + + test("emits source: 'search-fallback' on stdout and keeps stderr empty on 404 fallback", async () => { + throwingExit() + const out = captureOutput() + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.includes("/JobAdDetails/ext-123")) { + return new Response("", { status: 404, statusText: "Not Found" }) + } + if (url.includes("/FindJob/Search")) { + return new Response( + JSON.stringify({ + jobAds: [sampleAd], + searchFacets: {}, + totalJobAdCount: 1, + searchString: "ext-123", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + } + return new Response("", { status: 500, statusText: "Unexpected URL" }) + }) as unknown as typeof fetch + + const result = await runDetail("ext-123") + + expect(result.exit).toBeNull() + expect(out.stderr.join("")).toBe("") + + const parsed = JSON.parse(out.stdout.join("\n")) + expect(parsed.id).toBe("ext-123") + expect(parsed.title).toBe("AI Technical Artist") + expect(parsed.isExternal).toBe(true) + expect(parsed.source).toBe("search-fallback") + }) + + test("reports NOT_FOUND on stderr when fallback search returns no matching ad", async () => { + throwingExit() + const out = captureOutput() + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.includes("/JobAdDetails/ext-999")) { + return new Response("", { status: 404, statusText: "Not Found" }) + } + if (url.includes("/FindJob/Search")) { + return new Response( + JSON.stringify({ + jobAds: [], + searchFacets: {}, + totalJobAdCount: 0, + searchString: "ext-999", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + } + return new Response("", { status: 500, statusText: "Unexpected URL" }) + }) as unknown as typeof fetch + + const result = await runDetail("ext-999") + + expect(result.exit).toBe(1) + expect(out.stdout.join("")).toBe("") + + const err = JSON.parse(out.stderr.join("").trim()) + expect(err).toEqual({ + error: "Job ad not found", + code: "NOT_FOUND", + }) + }) + + test("formats plain output cleanly without stderr note on 404 fallback", async () => { + throwingExit() + const out = captureOutput() + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.includes("/JobAdDetails/ext-123")) { + return new Response("", { status: 404, statusText: "Not Found" }) + } + if (url.includes("/FindJob/Search")) { + return new Response( + JSON.stringify({ + jobAds: [sampleAd], + searchFacets: {}, + totalJobAdCount: 1, + searchString: "ext-123", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + } + return new Response("", { status: 500, statusText: "Unexpected URL" }) + }) as unknown as typeof fetch + + const result = await runDetail("ext-123", "plain") + + expect(result.exit).toBeNull() + expect(out.stderr.join("")).toBe("") + const stdout = out.stdout.join("\n") + expect(stdout).toContain("Title: AI Technical Artist") + expect(stdout).toContain("Employer: Tactile Games") + expect(stdout).toContain("Source: search-fallback (detail unavailable, synthesised from search)") + }) + + test("formats table output cleanly with source line on 404 fallback", async () => { + throwingExit() + const out = captureOutput() + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.includes("/JobAdDetails/ext-123")) { + return new Response("", { status: 404, statusText: "Not Found" }) + } + if (url.includes("/FindJob/Search")) { + return new Response( + JSON.stringify({ + jobAds: [sampleAd], + searchFacets: {}, + totalJobAdCount: 1, + searchString: "ext-123", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + } + return new Response("", { status: 500, statusText: "Unexpected URL" }) + }) as unknown as typeof fetch + + const result = await runDetail("ext-123", "table") + + expect(result.exit).toBeNull() + expect(out.stderr.join("")).toBe("") + const stdout = out.stdout.join("\n") + expect(stdout).toContain("Title: AI Technical Artist") + expect(stdout).toContain("Employer: Tactile Games") + expect(stdout).toContain("Source: search-fallback (detail unavailable, synthesised from search)") + }) + + test("a standard ad from JobAdDetails does not set source: 'search-fallback'", async () => { + throwingExit() + const out = captureOutput() + + const standardDetail = { + id: "std-456", + title: "Senior Backend Developer", + body: "

Work at standard employer.

", + publicationDateTime: "2026-09-01T00:00:00+02:00", + unpublicationDateTime: null, + approvalStatus: null, + views: 12, + createdDateTime: "2026-09-01T00:00:00+02:00", + updatedDateTime: "2026-09-01T00:00:00+02:00", + isAnonymousEmployer: false, + hasLogo: false, + logoUrl: null, + employer: { + cvrNumber: "12345678", + pNumber: null, + name: "Standard Corp", + hasCompanyLogo: false, + }, + job: { + type: "FullTime", + address: { + streetName: "Main St", + city: "Aarhus", + postalCode: "8000", + municipality: "Aarhus", + countryCode: "DK", + countryName: "Danmark", + }, + noFixedWorkplace: null, + isLimitedPeriod: null, + isDisabilityFriendly: null, + isPartTime: false, + employmentDate: null, + conceptUriDa: null, + preferredLabelDa: null, + driversLicenses: [], + classifications: [], + shifts: [], + isFavorite: false, + }, + application: { + deadlineDate: "2026-10-01T00:00:00+02:00", + availablePositions: 1, + contactPersons: [], + url: null, + urlText: null, + isApplicationDeadlineASAP: false, + }, + organisationTypeId: null, + user: null, + } + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.includes("/JobAdDetails/std-456")) { + return new Response(JSON.stringify(standardDetail), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + return new Response("", { status: 500, statusText: "Unexpected URL" }) + }) as unknown as typeof fetch + + const result = await runDetail("std-456") + + expect(result.exit).toBeNull() + expect(out.stderr.join("")).toBe("") + + const parsed = JSON.parse(out.stdout.join("\n")) + expect(parsed.id).toBe("std-456") + expect(parsed.source).toBeUndefined() + }) +}) diff --git a/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts index ce3799e6..be218c42 100644 --- a/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts @@ -92,6 +92,14 @@ describe("formatDetailPlain", () => { expect(formatted).toContain("Deadline: -"); expect(formatted).not.toContain("Apply:"); }); + + test("renders source fallback warning line when source is present and omits it when absent", () => { + const directFormatted = formatDetailPlain(detail()); + expect(directFormatted).not.toContain("Source:"); + + const fallbackFormatted = formatDetailPlain(detail({ source: "search-fallback" })); + expect(fallbackFormatted).toContain("Source: search-fallback (detail unavailable, synthesised from search)"); + }); }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 226a6718..0b15e11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,6 +288,19 @@ per-file diff commands. characters are quoted whole as before. One new case fails on master; the short-line case pins the unchanged behaviour. +- **Jobnet CLI detail command emits degraded search summary signal on stdout** (#550, fixes #549, + `.agents/skills/jobnet-search/cli/src/commands/detail.ts`, + `.agents/skills/jobnet-search/cli/tests/detail-fallback.test.ts`, + `.agents/skills/jobnet-search/cli/tests/detail-formatting.test.ts`, + `.agents/skills/jobnet-search/cli/README.md`) - when `runDetail` + resolved an external ad via the search endpoint fallback introduced in #432, it wrote an + informational note directly to `process.stderr`. To keep `jobnet-search` stderr behavior + consistent with the other portal CLIs (silent `stderr` on exit 0), the signal that detail + was synthesized from search results has moved to `stdout` as a `source: "search-fallback"` + field on the detail object, rendered in plain and table output as `Source: search-fallback + (detail unavailable, synthesised from search)`, documented in the CLI README, and covered + by automated tests. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a