fix(jobnet-search): emit source: search-fallback on stdout for external ads (#549) (#550)

* fix(jobnet-search): emit source: search-fallback on stdout for external ads (#549)

Move the degraded search summary signal from stderr to stdout as a source: search-fallback field on DetailApiResponse. Keeps stderr quiet on success across all portal CLIs, renders the degraded signal in plain and table format outputs, documents the optional field in the CLI README, and covers the fallback execution path with tests.

* changelog: place the #550 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 20:59:12 +02:00
1 parent 9a53f5e05b
commit 2b4097177a
5 files changed
+324 -5

No files matched your search

+4 -2
View File
@@ -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 <id> [--format json|plain]
bun run src/cli.ts detail <id> [--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.
---
@@ -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}`)
}
@@ -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<void> }).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("<p>Great job opening at Tactile.</p>")
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: "<p>Great job opening at Tactile.</p>",
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: "<p>Work at standard employer.</p>",
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()
})
})
@@ -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)");
});
});
+13
View File
@@ -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