import "./stt-test-env"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { after, before, beforeEach, describe, test } from "node:test"; import type { NextRequest } from "next/server"; import { GET, POST } from "../app/api/stt/route"; import { prisma } from "../lib/prisma"; import { AsrError } from "../lib/stt/dashscope"; import { claimTask, completeTask, enqueueMissingTranscriptions, enqueueTranscription, readTranscription, } from "../lib/stt/tasks"; import { processTask } from "../lib/stt/worker"; const database = process.env.TEST_DATABASE_URL; const migration = readFileSync( "prisma/migrations/20261003000000_plain_transcription/migration.sql", "utf8", ); const sql = (source: string) => execFileSync("psql", [database ?? "", "-X", "-v", "ON_ERROR_STOP=1", "-q"], { input: source, stdio: ["pipe", "pipe", "pipe"], }); const fakeServices = { prepareAudio: async () => "https://storage.test/audio.mp3" as string | null, submitTranscription: async () => "provider-1", queryTranscription: async () => ({ status: "SUCCEEDED" as const, resultURL: "https://result.test/file", }), downloadTranscript: async () => ["new searchable words"], }; async function due(videoId: string) { await prisma.transcriptionTask.update({ where: { videoId }, data: { nextPollAt: new Date(0) }, }); } async function claim() { const task = await claimTask(); assert.ok(task); return task; } function request(url: string, body?: unknown): NextRequest { return Object.assign( new Request( url, body === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ), { nextUrl: new URL(url) }, ) as NextRequest; } describe("PostgreSQL transcription lifecycle", { skip: !database }, () => { before(async () => { sql("DROP SCHEMA public CASCADE; CREATE SCHEMA public;"); sql( readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"), ); await prisma.author.create({ data: { sec_uid: "test-author", uid: "uid", nickname: "测试作者", signature: "", avatar_url: "", follower_count: 0, total_favorited: 0, short_id: "test", }, }); for (const id of ["historical", "empty-history", "v1", "v2", "v3", "v4"]) { await prisma.video.create({ data: { aweme_id: id, desc: "测试视频", duration_ms: 1000, created_at: new Date(), share_url: "", digg_count: 0, comment_count: 0, share_count: 0, collect_count: 0, authorId: "test-author", tags: [], video_url: "video.mp4", }, }); } sql( `INSERT INTO "VideoTranscript" (id,"videoId",speech_detected,transcript,"updatedAt") VALUES ('old','historical',true,ARRAY['original searchable words'],NOW()), ('empty','empty-history',false,ARRAY[]::text[],NOW());`, ); sql(`ALTER TABLE "VideoTranscript" ADD COLUMN content_tags TEXT[] DEFAULT ARRAY[]::TEXT[]; CREATE TEXT SEARCH CONFIGURATION public.zhcfg (COPY = pg_catalog.simple); CREATE FUNCTION public.vt_update_transcript_zh_tsv() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_vt_transcript_zh_tsv BEFORE INSERT OR UPDATE OF transcript ON "VideoTranscript" FOR EACH ROW EXECUTE FUNCTION public.vt_update_transcript_zh_tsv();`); sql(migration); sql(migration); // migration DDL is repeatable, historical text is unchanged }); beforeEach(async () => { process.env.DASHSCOPE_API_KEY = "test-key"; delete process.env.DASHSCOPE_BASE_URL; delete process.env.DASHSCOPE_ASR_MODEL; await prisma.transcriptionTask.deleteMany(); await prisma.videoTranscript.deleteMany({ where: { videoId: { in: ["v1", "v2", "v3", "v4"] } }, }); }); after(async () => { await prisma.$disconnect(); }); test("migration preserves historical and empty text and rebuilds search", async () => { assert.equal( (await readTranscription("empty-history"))?.status, "SUCCEEDED", ); assert.deepEqual((await readTranscription("historical"))?.transcript, [ "original searchable words", ]); const rows = await prisma.$queryRaw< { count: bigint }[] >`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`; assert.equal(Number(rows[0].count), 1); }); test("concurrent enqueue is idempotent; automatic enqueue never retranscribes history", async () => { await Promise.all( Array.from({ length: 10 }, () => enqueueTranscription("v1", true)), ); assert.equal(await prisma.transcriptionTask.count(), 1); assert.equal( (await enqueueTranscription("historical"))?.status, "SUCCEEDED", ); assert.equal(await prisma.transcriptionTask.count(), 1); }); test("batch skips history and active tasks and retries failed missing transcripts", async () => { await enqueueTranscription("v1"); await prisma.transcriptionTask.update({ where: { videoId: "v1" }, data: { status: "FAILED" }, }); await enqueueTranscription("v2"); assert.equal(await enqueueMissingTranscriptions(), 3); assert.equal(await enqueueMissingTranscriptions(), 0); assert.equal(await prisma.transcriptionTask.count(), 4); }); test("admission never exceeds two active provider jobs across workers", async () => { await enqueueMissingTranscriptions(); await prisma.transcriptionTask.updateMany({ data: { nextPollAt: new Date(0) }, }); const claims = await Promise.all([ claimTask(), claimTask(), claimTask(), claimTask(), ]); assert.equal(claims.filter(Boolean).length, 2); assert.equal( await prisma.transcriptionTask.count({ where: { status: "PROCESSING" } }), 2, ); }); test("provider ID survives restart; completion saves text and search atomically", async () => { await enqueueTranscription("v1"); let submits = 0; const services = { ...fakeServices, submitTranscription: async () => { submits++; return "restart-task"; }, }; await processTask(await claim(), services); assert.equal( ( await prisma.transcriptionTask.findUniqueOrThrow({ where: { videoId: "v1" }, }) ).providerTaskId, "restart-task", ); await due("v1"); await processTask(await claim(), services); assert.equal(submits, 1); assert.deepEqual(await readTranscription("v1"), { status: "SUCCEEDED", error: null, transcript: ["new searchable words"], }); const hits = await prisma.$queryRaw< { count: bigint }[] >`SELECT COUNT(*) FROM "VideoTranscript" WHERE "videoId" = 'v1' AND transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'new')`; assert.equal(Number(hits[0].count), 1); }); test("failed retranscription retains old text; success replaces text and clears stale search", async () => { await prisma.videoTranscript.create({ data: { videoId: "v1", transcript: ["oldword"] }, }); await enqueueTranscription("v1", true); await processTask(await claim(), fakeServices); await due("v1"); await processTask(await claim(), { ...fakeServices, queryTranscription: async () => { throw new AsrError("FILE_403_FORBIDDEN"); }, }); assert.equal((await readTranscription("v1"))?.status, "FAILED"); assert.deepEqual((await readTranscription("v1"))?.transcript, ["oldword"]); await enqueueTranscription("v1", true); await processTask(await claim(), fakeServices); await due("v1"); await processTask(await claim(), { ...fakeServices, downloadTranscript: async () => [], }); assert.deepEqual((await readTranscription("v1"))?.transcript, []); const hits = await prisma.$queryRaw< { count: bigint }[] >`SELECT COUNT(*) FROM "VideoTranscript" WHERE "videoId" = 'v1' AND transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'oldword')`; assert.equal(Number(hits[0].count), 0); }); test("crashed/ambiguous submission is never automatically resubmitted", async () => { await enqueueTranscription("v1"); const task = await claim(); await prisma.transcriptionTask.update({ where: { videoId: "v1" }, data: { submittingAt: new Date(), leaseUntil: new Date(0) }, }); let submits = 0; await processTask(await claim(), { ...fakeServices, submitTranscription: async () => { submits++; return "bad"; }, }); assert.equal(submits, 0); assert.equal((await readTranscription("v1"))?.status, "FAILED"); await assert.rejects(completeTask(task, ["stale result"]), /租约/); assert.equal((await readTranscription("v1"))?.transcript, null); }); test("temporary query errors back off and keep provider ID", async () => { await enqueueTranscription("v1"); await processTask(await claim(), fakeServices); await due("v1"); await processTask(await claim(), { ...fakeServices, queryTranscription: async () => { throw new AsrError("网络超时", true); }, }); const task = await prisma.transcriptionTask.findUniqueOrThrow({ where: { videoId: "v1" }, }); assert.equal(task.status, "PROCESSING"); assert.equal(task.providerTaskId, "provider-1"); assert.ok(task.nextPollAt.getTime() > Date.now()); assert.equal(task.attempts, 1); }); test("video with no audio track completes with no ASR submission", async () => { await enqueueTranscription("v1"); await processTask(await claim(), { ...fakeServices, prepareAudio: async () => null, submitTranscription: async () => { throw new Error("must not submit"); }, }); assert.deepEqual(await readTranscription("v1"), { status: "SUCCEEDED", error: null, transcript: [], }); }); test("missing key is visible; GET has no side effect; POST validates input", async () => { delete process.env.DASHSCOPE_API_KEY; const state = await enqueueTranscription("v1"); assert.equal(state?.status, "FAILED"); assert.match(state?.error ?? "", /DASHSCOPE_API_KEY/); assert.equal( (await GET(request("http://local/api/stt?awemeId=v2"))).status, 200, ); assert.equal(await prisma.transcriptionTask.count(), 1); assert.equal( (await GET(request("http://local/api/stt?awemeId=missing"))).status, 404, ); assert.equal((await POST(request("http://local/api/stt", {}))).status, 400); process.env.DASHSCOPE_API_KEY = "test-key"; assert.equal( (await POST(request("http://local/api/stt", { awemeId: "v2" }))).status, 202, ); }); });