Initial Commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user