168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { once } from "node:events";
|
|
import { createServer, type Server } from "node:http";
|
|
import { after, before, test } from "node:test";
|
|
import {
|
|
AsrError,
|
|
downloadTranscript,
|
|
extractTranscript,
|
|
getAsrConfig,
|
|
MAX_BYTES,
|
|
MAX_DURATION_MS,
|
|
queryTranscription,
|
|
submitTranscription,
|
|
validateMedia,
|
|
} from "../lib/stt/dashscope";
|
|
import { transcriptionState } from "../lib/stt/types";
|
|
|
|
let server: Server;
|
|
let base: string;
|
|
let response: unknown = { output: { task_id: "task-1" } };
|
|
let status = 200;
|
|
const calls: {
|
|
url?: string;
|
|
headers: Record<string, unknown>;
|
|
body: string;
|
|
}[] = [];
|
|
const savedEnv = { ...process.env };
|
|
before(async () => {
|
|
server = createServer(async (req, res) => {
|
|
let body = "";
|
|
for await (const chunk of req) body += chunk;
|
|
calls.push({ url: req.url, headers: req.headers, body });
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(response));
|
|
}).listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
process.env.DASHSCOPE_API_KEY = "test-key";
|
|
process.env.DASHSCOPE_BASE_URL = `${base}/api/v1`;
|
|
delete process.env.DASHSCOPE_ASR_MODEL;
|
|
});
|
|
after(async () => {
|
|
process.env = savedEnv;
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
test("native ASR submission sends only audio URL and recognition options", async () => {
|
|
assert.equal(
|
|
await submitTranscription("https://storage.test/audio.mp3"),
|
|
"task-1",
|
|
);
|
|
const call = calls.at(-1);
|
|
assert.ok(call);
|
|
assert.equal(call.url, "/api/v1/services/audio/asr/transcription");
|
|
assert.equal(call.headers.authorization, "Bearer test-key");
|
|
assert.equal(call.headers["x-dashscope-async"], "enable");
|
|
assert.deepEqual(JSON.parse(call.body), {
|
|
model: "qwen3-asr-flash-filetrans",
|
|
input: { file_url: "https://storage.test/audio.mp3" },
|
|
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
|
|
});
|
|
});
|
|
test("query pending, running, success, failure and unknown", async () => {
|
|
for (const state of ["PENDING", "RUNNING"] as const) {
|
|
response = { output: { task_status: state } };
|
|
assert.deepEqual(await queryTranscription("task-1"), { status: state });
|
|
}
|
|
response = {
|
|
output: {
|
|
task_status: "SUCCEEDED",
|
|
result: { transcription_url: `${base}/result` },
|
|
},
|
|
};
|
|
assert.deepEqual(await queryTranscription("task-1"), {
|
|
status: "SUCCEEDED",
|
|
resultURL: `${base}/result`,
|
|
});
|
|
response = {
|
|
output: {
|
|
task_status: "FAILED",
|
|
code: "FILE_403_FORBIDDEN",
|
|
message: "secret-url",
|
|
},
|
|
};
|
|
await assert.rejects(queryTranscription("task-1"), /FILE_403_FORBIDDEN/);
|
|
response = { output: { task_status: "UNKNOWN" } };
|
|
await assert.rejects(queryTranscription("task-1"), /UNKNOWN/);
|
|
});
|
|
test("extract only text, retain order, fall back to full text", async () => {
|
|
response = {
|
|
transcripts: [
|
|
{
|
|
sentences: [
|
|
{ text: " 第一段 ", emotion: "happy", language: "zh" },
|
|
{ text: "第二段" },
|
|
],
|
|
text: "全文",
|
|
},
|
|
{ sentences: [], text: "备用全文" },
|
|
],
|
|
};
|
|
assert.deepEqual(await downloadTranscript(`${base}/result`), [
|
|
"第一段",
|
|
"第二段",
|
|
"备用全文",
|
|
]);
|
|
assert.equal(calls.at(-1)?.headers.authorization, undefined);
|
|
assert.deepEqual(extractTranscript({ transcripts: [] }), []);
|
|
assert.deepEqual(
|
|
extractTranscript({ transcripts: [{ text: " ", sentences: [] }] }),
|
|
[],
|
|
);
|
|
assert.throws(() => extractTranscript({}), /transcripts/);
|
|
assert.throws(
|
|
() =>
|
|
extractTranscript({
|
|
transcripts: [{ sentences: [{ emotion: "happy" }] }],
|
|
}),
|
|
/文字/,
|
|
);
|
|
});
|
|
test("limits are inclusive and oversized media fails without truncation", () => {
|
|
validateMedia(MAX_DURATION_MS, MAX_BYTES);
|
|
assert.throws(() => validateMedia(MAX_DURATION_MS + 1, 1), /12 小时/);
|
|
assert.throws(() => validateMedia(1, MAX_BYTES + 1), /2 GB/);
|
|
assert.throws(() => validateMedia(1, 0), /为空/);
|
|
});
|
|
test("HTTP throttling can retry; authentication cannot; no provider error leakage", async () => {
|
|
response = { code: "InvalidApiKey", message: "sensitive" };
|
|
status = 401;
|
|
await assert.rejects(
|
|
queryTranscription("task-1"),
|
|
(e) =>
|
|
e instanceof AsrError && !e.retryable && !e.message.includes("sensitive"),
|
|
);
|
|
status = 429;
|
|
await assert.rejects(
|
|
queryTranscription("task-1"),
|
|
(e) => e instanceof AsrError && e.retryable,
|
|
);
|
|
status = 200;
|
|
});
|
|
test("missing key and incompatible model produce explicit errors", () => {
|
|
delete process.env.DASHSCOPE_API_KEY;
|
|
assert.throws(getAsrConfig, /DASHSCOPE_API_KEY/);
|
|
process.env.DASHSCOPE_API_KEY = "test-key";
|
|
process.env.DASHSCOPE_ASR_MODEL = "gemini-2.5-flash";
|
|
assert.throws(getAsrConfig, /qwen3-asr-flash-filetrans/);
|
|
delete process.env.DASHSCOPE_ASR_MODEL;
|
|
});
|
|
test("historical empty transcript is complete; failed retry retains old text", () => {
|
|
assert.equal(
|
|
transcriptionState({ transcript: [] }, null).status,
|
|
"SUCCEEDED",
|
|
);
|
|
assert.deepEqual(
|
|
transcriptionState(
|
|
{ transcript: ["旧文字"] },
|
|
{ status: "FAILED", lastError: "失败" },
|
|
),
|
|
{
|
|
status: "FAILED",
|
|
error: "失败",
|
|
transcript: ["旧文字"],
|
|
},
|
|
);
|
|
});
|