Files
suaveplan/packages/media/audio/audio-fingerprint/tests/python-audio-service.test.ts
2026-05-27 23:47:53 +01:00

307 lines
11 KiB
TypeScript

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> | 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<Array<[string, FormDataEntryValue]>> {
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<Response>((_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");
});
});
});