feat: add fastCRW search request, mapping and error handling
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b80b7eb87d
commit
cc3814deeb
2 files changed
+249
No files matched your search
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import type { Config } from "../src/config.ts"
|
||||
import { search } from "../src/search.ts"
|
||||
|
||||
const config: Config = {
|
||||
baseURL: "http://crw.test:3000",
|
||||
apiKey: undefined,
|
||||
limit: 8,
|
||||
lang: undefined,
|
||||
tbs: undefined,
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
|
||||
type Call = { url: string; init: RequestInit }
|
||||
function respond(body: unknown, status = 200) {
|
||||
const calls: Call[] = []
|
||||
const fetchFn = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init })
|
||||
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status })
|
||||
}) as typeof fetch
|
||||
return { calls, fetchFn }
|
||||
}
|
||||
const ok = (data: unknown) => ({ success: true, data })
|
||||
|
||||
test("sends POST /v1/search with JSON body and no auth header by default", async () => {
|
||||
const { calls, fetchFn } = respond(ok([]))
|
||||
await search(config, "cats", signal, fetchFn)
|
||||
assert.equal(calls[0].url, "http://crw.test:3000/v1/search")
|
||||
assert.equal(calls[0].init.method, "POST")
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
assert.equal(headers["Content-Type"], "application/json")
|
||||
assert.equal("Authorization" in headers, false)
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body as string), { query: "cats", limit: 8 })
|
||||
})
|
||||
|
||||
test("sends Bearer token, lang and tbs when configured", async () => {
|
||||
const { calls, fetchFn } = respond(ok([]))
|
||||
await search({ ...config, apiKey: "k", lang: "de", tbs: "qdr:w", limit: 3 }, "q", signal, fetchFn)
|
||||
const headers = calls[0].init.headers as Record<string, string>
|
||||
assert.equal(headers.Authorization, "Bearer k")
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body as string), {
|
||||
query: "q",
|
||||
limit: 3,
|
||||
lang: "de",
|
||||
tbs: "qdr:w",
|
||||
})
|
||||
})
|
||||
|
||||
test("maps a flat data array", async () => {
|
||||
const { fetchFn } = respond(
|
||||
ok([{ url: "https://a", title: "A", snippet: "s", description: "d" }]),
|
||||
)
|
||||
assert.deepEqual(await search(config, "q", signal, fetchFn), [
|
||||
{ url: "https://a", title: "A", content: "s", time: {} },
|
||||
])
|
||||
})
|
||||
|
||||
test("maps data.results and falls back to description", async () => {
|
||||
const { fetchFn } = respond(ok({ results: [{ url: "https://a", title: "A", description: "d" }] }))
|
||||
const [r] = await search(config, "q", signal, fetchFn)
|
||||
assert.equal(r.content, "d")
|
||||
})
|
||||
|
||||
test("drops entries without a url, falls back to url for title, empty content", async () => {
|
||||
const { fetchFn } = respond(ok([{ title: "no url" }, null, { url: "https://b" }]))
|
||||
assert.deepEqual(await search(config, "q", signal, fetchFn), [
|
||||
{ url: "https://b", title: "https://b", content: "", time: {} },
|
||||
])
|
||||
})
|
||||
|
||||
test("empty results return []", async () => {
|
||||
assert.deepEqual(await search(config, "q", signal, respond(ok([])).fetchFn), [])
|
||||
assert.deepEqual(await search(config, "q", signal, respond(ok({ results: [] })).fetchFn), [])
|
||||
})
|
||||
|
||||
test("unexpected shape throws", async () => {
|
||||
for (const data of [{ web: [] }, "x", null]) {
|
||||
await assert.rejects(
|
||||
search(config, "q", signal, respond(ok(data)).fetchFn),
|
||||
/unexpected response shape/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("API error keeps status and code", async () => {
|
||||
const body = { success: false, error: "slow down", error_code: "rate_limited" }
|
||||
await assert.rejects(search(config, "q", signal, respond(body, 429).fetchFn), {
|
||||
message: "fastCRW 429 rate_limited: slow down",
|
||||
})
|
||||
})
|
||||
|
||||
test("503 search_disabled and 401 carry a hint", async () => {
|
||||
await assert.rejects(
|
||||
search(
|
||||
config,
|
||||
"q",
|
||||
signal,
|
||||
respond({ success: false, error: "off", error_code: "search_disabled" }, 503).fetchFn,
|
||||
),
|
||||
/fastCRW 503 search_disabled: off .*no search backend configured/,
|
||||
)
|
||||
await assert.rejects(
|
||||
search(config, "q", signal, respond({ success: false, error: "Invalid API key" }, 401).fetchFn),
|
||||
/fastCRW 401: Invalid API key .*check apiKey or CRW_API_KEY/,
|
||||
)
|
||||
})
|
||||
|
||||
test("HTTP 200 with success:false is an error with status 200", async () => {
|
||||
const body = { success: false, error: "search failed", error_code: "http_error" }
|
||||
await assert.rejects(search(config, "q", signal, respond(body, 200).fetchFn), {
|
||||
message: "fastCRW 200 http_error: search failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("errors never include the token", async () => {
|
||||
const body = { success: false, error: "bad", error_code: "invalid_request" }
|
||||
await assert.rejects(
|
||||
search({ ...config, apiKey: "SECRET" }, "q", signal, respond(body, 400).fetchFn),
|
||||
(err: Error) => !err.message.includes("SECRET"),
|
||||
)
|
||||
})
|
||||
|
||||
test("non-JSON body reports status and the first 200 chars", async () => {
|
||||
const html = "<html>" + "x".repeat(500)
|
||||
await assert.rejects(
|
||||
search(config, "q", signal, respond(html, 502).fetchFn),
|
||||
(err: Error) =>
|
||||
err.message.startsWith("fastCRW 502: non-JSON response") && err.message.length < 260,
|
||||
)
|
||||
})
|
||||
|
||||
test("network failure names the baseURL and keeps the cause", async () => {
|
||||
const cause = new TypeError("fetch failed")
|
||||
const fetchFn = (async () => {
|
||||
throw cause
|
||||
}) as typeof fetch
|
||||
await assert.rejects(search(config, "q", signal, fetchFn), (err: Error) => {
|
||||
assert.equal(err.message, "fastCRW: cannot reach http://crw.test:3000")
|
||||
assert.equal(err.cause, cause)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// A fetch that never answers and rejects with the signal's reason, like the real one.
|
||||
const hang = ((_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal!.addEventListener("abort", () => reject(init.signal!.reason))
|
||||
})) as typeof fetch
|
||||
|
||||
test("caller abort is rethrown untouched", async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = search(config, "q", controller.signal, hang)
|
||||
controller.abort()
|
||||
await assert.rejects(pending, (err) => err === controller.signal.reason)
|
||||
})
|
||||
|
||||
test("internal timeout aborts the request", async () => {
|
||||
await assert.rejects(search(config, "q", signal, hang, 20), /timed out/)
|
||||
})
|
||||
Reference in new issue
Block a user