307 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|