import { afterEach, beforeEach, describe, expect, it, } from "@suaveplan/testing/runner"; import { PythonAudioService } from "../src/client/python-audio-service.js"; import type { FingerprintResponse, HealthCheckResponse, MatchResponse, } from "../src/types/audio-fingerprint.types.js"; type FetchHandler = ( url: string, init?: RequestInit ) => Promise | Response; const originalFetch = globalThis.fetch; function installFetch(handler: FetchHandler): { calls: Array<{ url: string; init?: RequestInit }>; } { const calls: Array<{ url: string; init?: RequestInit }> = []; globalThis.fetch = (async ( input: RequestInfo | URL, init?: RequestInit ) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; calls.push({ url, init }); return handler(url, init); }) as typeof fetch; return { calls }; } function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, ...init, }); } async function formDataEntries( init: RequestInit | undefined ): Promise> { if (!init || !(init.body instanceof FormData)) { return []; } return Array.from(init.body.entries()); } describe("PythonAudioService", () => { afterEach(() => { globalThis.fetch = originalFetch; }); describe("constructor defaults", () => { it("uses the documented baseUrl and timeout when no config is supplied", { timeout: 5000, }, async () => { const { calls } = installFetch(() => jsonResponse({ status: "ok", service: "audio-fingerprinting", }) ); const client = new PythonAudioService(); await client.healthCheck(); expect(calls[0]?.url).toBe("http://localhost:8000/"); }); }); describe("healthCheck", () => { it("healthCheck returns the parsed body", { timeout: 5000, }, async () => { const payload: HealthCheckResponse = { status: "ok", service: "audio-fingerprinting", }; installFetch(() => jsonResponse(payload)); const client = new PythonAudioService({ baseUrl: "http://example.test", }); const result = await client.healthCheck(); expect(result).toEqual(payload); }); }); describe("fingerprint", () => { it("fingerprint posts the file and optional params via FormData", { timeout: 5000, }, async () => { const payload: FingerprintResponse = { filename: "test.wav", duration: 1.0, peaks_count: 1, fingerprints_count: 1, fingerprints: [{ hash: 1, time: 0, delta: 0 }], }; const { calls } = installFetch(() => jsonResponse(payload)); const client = new PythonAudioService(); const file = new Blob(["RIFF...."], { type: "audio/wav" }); const result = await client.fingerprint({ file, peak_threshold: 0.6, fan_value: 20, }); expect(result).toEqual(payload); const entries = await formDataEntries(calls[0]?.init); const keys = entries.map(([k]) => k); expect(keys).toContain("file"); expect(entries).toContainEqual(["peak_threshold", "0.6"]); expect(entries).toContainEqual(["fan_value", "20"]); expect(calls[0]?.url).toBe("http://localhost:8000/fingerprint"); }); it("fingerprint omits optional params when undefined", { timeout: 5000, }, async () => { const { calls } = installFetch(() => jsonResponse({ filename: "x", duration: 0, peaks_count: 0, fingerprints_count: 0, fingerprints: [], }) ); const client = new PythonAudioService(); await client.fingerprint({ file: new Blob(["a"]) }); const entries = await formDataEntries(calls[0]?.init); const keys = entries.map(([k]) => k); expect(keys).toEqual(["file"]); }); }); describe("match", () => { it("match posts both files and all optional params", { timeout: 5000, }, async () => { const payload: MatchResponse = { query_file: "q.wav", reference_file: "r.wav", query_duration: 1.0, reference_duration: 1.0, matched: true, match_count: 10, confidence: 0.9, time_offset: 0, }; const { calls } = installFetch(() => jsonResponse(payload)); const client = new PythonAudioService(); const queryFile = new Blob(["q"], { type: "audio/wav" }); const referenceFile = new Blob(["r"], { type: "audio/wav" }); const result = await client.match({ query_file: queryFile, reference_file: referenceFile, peak_threshold: 0.6, fan_value: 20, min_matches: 5, }); expect(result).toEqual(payload); const entries = await formDataEntries(calls[0]?.init); const keys = entries.map(([k]) => k); expect(keys).toContain("query_file"); expect(keys).toContain("reference_file"); expect(entries).toContainEqual(["peak_threshold", "0.6"]); expect(entries).toContainEqual(["fan_value", "20"]); expect(entries).toContainEqual(["min_matches", "5"]); }); it("match omits optional params when undefined", { timeout: 5000, }, async () => { const { calls } = installFetch(() => jsonResponse({ query_file: "", reference_file: "", query_duration: 0, reference_duration: 0, matched: false, match_count: 0, confidence: 0, time_offset: 0, }) ); const client = new PythonAudioService(); await client.match({ query_file: new Blob(["a"]), reference_file: new Blob(["b"]), }); const entries = await formDataEntries(calls[0]?.init); const keys = entries.map(([k]) => k); expect(keys).toEqual(["query_file", "reference_file"]); }); }); describe("error and timeout handling", () => { let realSetTimeout: typeof setTimeout; beforeEach(() => { realSetTimeout = globalThis.setTimeout; }); afterEach(() => { globalThis.setTimeout = realSetTimeout; }); it("timeout aborts the request", { timeout: 5000 }, async () => { installFetch( (_, init) => new Promise((_resolve, reject) => { const signal = init?.signal; if (!signal) { throw new Error("missing AbortSignal"); } signal.addEventListener("abort", () => { const err = new Error("aborted"); err.name = "AbortError"; reject(err); }); }) ); const client = new PythonAudioService({ timeout: 5 }); await expect(client.healthCheck()).rejects.toThrow( /Request timeout after 5ms: \// ); }); it("non-json error body falls back to status text", { timeout: 5000, }, async () => { installFetch( () => new Response("not-json", { status: 500, statusText: "Internal Server Error", headers: { "content-type": "text/plain" }, }) ); const client = new PythonAudioService(); await expect(client.healthCheck()).rejects.toThrow( "HTTP 500: Internal Server Error" ); }); it("json error body surfaces the detail field", { timeout: 5000, }, async () => { installFetch(() => jsonResponse( { detail: "Failed to process audio file: bad" }, { status: 500, statusText: "Internal Server Error" } ) ); const client = new PythonAudioService(); await expect(client.healthCheck()).rejects.toThrow( "Failed to process audio file: bad" ); }); it("json error body without detail falls back to status text", { timeout: 5000, }, async () => { installFetch(() => jsonResponse( { error: "missing-detail-field" }, { status: 500, statusText: "Internal Server Error" } ) ); const client = new PythonAudioService(); await expect(client.healthCheck()).rejects.toThrow( "HTTP 500: Internal Server Error" ); }); it("non-Error thrown by fetch surfaces as Unknown error", { timeout: 5000, }, async () => { globalThis.fetch = (() => { throw "raw-string-throw"; }) as typeof fetch; const client = new PythonAudioService(); await expect(client.healthCheck()).rejects.toThrow( "Unknown error occurred: raw-string-throw" ); }); it("non-AbortError thrown by fetch is re-raised verbatim", { timeout: 5000, }, async () => { globalThis.fetch = (() => { throw new Error("network down"); }) as typeof fetch; const client = new PythonAudioService(); await expect(client.healthCheck()).rejects.toThrow("network down"); }); }); });