transcription-worker.test.mjs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { mkdtemp, rm } from 'node:fs/promises';
  4. import { join } from 'node:path';
  5. import { tmpdir } from 'node:os';
  6. import { AppError } from '../src/errors.mjs';
  7. import { JobStore } from '../src/job-store.mjs';
  8. import { TranscriptionWorker } from '../src/transcription-worker.mjs';
  9. async function waitFor(predicate, timeoutMs = 2_000) {
  10. const startedAt = Date.now();
  11. while (Date.now() - startedAt < timeoutMs) {
  12. const value = predicate();
  13. if (value) return value;
  14. await new Promise(resolve => setTimeout(resolve, 5));
  15. }
  16. throw new Error('等待任务状态超时');
  17. }
  18. async function fixture(t, provider) {
  19. const directory = await mkdtemp(join(tmpdir(), 'yuban-worker-test-'));
  20. t.after(() => rm(directory, { recursive: true, force: true }));
  21. const store = new JobStore(directory, 86_400_000);
  22. await store.init();
  23. const config = {
  24. maxJobConcurrency: 1,
  25. maxJobAttempts: 3,
  26. retryBaseDelayMs: 5,
  27. jobRetentionMs: 86_400_000,
  28. jobTempDir: directory,
  29. iflytek: { pollTimeoutMs: 1_000 },
  30. };
  31. const media = {
  32. async downloadRemoteAudio() {},
  33. async transcodeToPcmWav() {},
  34. async probeDurationMs() { return 1_800_000; },
  35. };
  36. const worker = new TranscriptionWorker({
  37. config,
  38. store,
  39. provider,
  40. media,
  41. sleepFn: async () => {},
  42. });
  43. return { store, worker };
  44. }
  45. async function createJob(store, overrides = {}) {
  46. const now = new Date();
  47. return store.create({
  48. id: overrides.id || '00000000-0000-4000-8000-000000000001',
  49. ownerKey: 'owner',
  50. idempotencyKey: 'stable-key',
  51. status: 'queued',
  52. stage: 'queued',
  53. progress: 0,
  54. attempt: 0,
  55. runAttempt: 0,
  56. request: {
  57. audioUrl: 'https://file.yuban.co/recording.webm',
  58. durationMs: 1_800_000,
  59. roleType: 1,
  60. roleNum: 0,
  61. },
  62. createdAt: now.toISOString(),
  63. updatedAt: now.toISOString(),
  64. expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
  65. ...overrides,
  66. });
  67. }
  68. test('可重试错误进入 retry_wait,并由 Worker 自动完成下一次 attempt', async t => {
  69. let submitCount = 0;
  70. const provider = {
  71. async submit() {
  72. submitCount += 1;
  73. if (submitCount === 1) {
  74. throw new AppError('讯飞暂时不可用', {
  75. code: 'PROVIDER_TEMPORARY',
  76. status: 502,
  77. retryable: true,
  78. });
  79. }
  80. return { orderId: 'provider-order-1', estimateTimeMs: 1 };
  81. },
  82. async getResult() {
  83. return {
  84. status: 'completed',
  85. result: {
  86. text: '中断前后的完整逐字稿',
  87. segments: [{ text: '中断前后的完整逐字稿', startMs: 0, endMs: 1000, speakerId: '1' }],
  88. },
  89. };
  90. },
  91. };
  92. const { store, worker } = await fixture(t, provider);
  93. const job = await createJob(store);
  94. worker.enqueue(job.id);
  95. const completed = await waitFor(() => {
  96. const current = store.get(job.id);
  97. return current?.status === 'completed' ? current : null;
  98. });
  99. assert.equal(submitCount, 2);
  100. assert.equal(completed.attempt, 2);
  101. assert.equal(completed.result.text, '中断前后的完整逐字稿');
  102. assert.equal(completed.error, undefined);
  103. });
  104. test('人工重试失败任务会创建新的 jobId 并保留父任务', async t => {
  105. const provider = {
  106. async submit() { return { orderId: 'unused', estimateTimeMs: 1 }; },
  107. async getResult() { return { status: 'pending' }; },
  108. };
  109. const { store, worker } = await fixture(t, provider);
  110. const failed = await createJob(store, {
  111. status: 'failed',
  112. stage: 'failed',
  113. attempt: 3,
  114. runAttempt: 3,
  115. error: { code: 'NETWORK', message: '网络错误', retryable: true },
  116. });
  117. const retried = await worker.retry(failed.id);
  118. assert.notEqual(retried.id, failed.id);
  119. assert.equal(retried.parentJobId, failed.id);
  120. assert.equal(retried.status, 'queued');
  121. assert.equal(retried.attempt, 3);
  122. assert.equal(store.findByIdempotency('stable-key').id, retried.id);
  123. await worker.cancel(retried.id);
  124. });