http-app.test.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { createServer } from 'node:http';
  4. import { mkdtemp, rm } from 'node:fs/promises';
  5. import { tmpdir } from 'node:os';
  6. import { join } from 'node:path';
  7. import { JobStore } from '../src/job-store.mjs';
  8. import { createHttpHandler } from '../src/http-app.mjs';
  9. async function fixture() {
  10. const directory = await mkdtemp(join(tmpdir(), 'yuban-server-test-'));
  11. const store = new JobStore(directory, 86_400_000);
  12. await store.init();
  13. const enqueued = [];
  14. let retrySequence = 0;
  15. const worker = {
  16. enqueue(id) {
  17. enqueued.push(id);
  18. },
  19. async cancel(id) {
  20. return store.update(id, { status: 'cancelled', stage: 'cancelled' });
  21. },
  22. async retry(id) {
  23. const previous = store.get(id);
  24. retrySequence += 1;
  25. const now = new Date();
  26. const next = await store.create({
  27. ...previous,
  28. id: `00000000-0000-4000-8000-${String(retrySequence).padStart(12, '0')}`,
  29. parentJobId: previous.id,
  30. status: 'queued',
  31. stage: 'queued',
  32. progress: 0,
  33. runAttempt: 0,
  34. createdAt: now.toISOString(),
  35. updatedAt: now.toISOString(),
  36. expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
  37. });
  38. enqueued.push(next.id);
  39. return next;
  40. },
  41. };
  42. const config = {
  43. corsAllowedOrigins: [
  44. 'https://www.yuban.co',
  45. 'https://yuban.co',
  46. 'http://localhost:4200',
  47. 'http://127.0.0.1:4200',
  48. ],
  49. allowedAudioHosts: ['file.yuban.co'],
  50. jobRetentionMs: 86_400_000,
  51. maxActiveJobsPerUser: 2,
  52. maxRetainedJobsPerUser: 20,
  53. };
  54. const authenticator = {
  55. async authenticate() {
  56. return { ownerKey: 'owner-a', userId: 'user-a' };
  57. },
  58. };
  59. const server = createServer(
  60. createHttpHandler({
  61. config,
  62. store,
  63. worker,
  64. authenticator,
  65. validateAudioUrl: async value => new URL(value),
  66. }),
  67. );
  68. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  69. const address = server.address();
  70. return {
  71. store,
  72. enqueued,
  73. baseUrl: `http://127.0.0.1:${address.port}`,
  74. async close() {
  75. await new Promise(resolve => server.close(resolve));
  76. await rm(directory, { recursive: true, force: true });
  77. },
  78. };
  79. }
  80. test('健康检查不要求认证', async t => {
  81. const app = await fixture();
  82. t.after(() => app.close());
  83. const response = await fetch(`${app.baseUrl}/health`);
  84. assert.equal(response.status, 200);
  85. const body = await response.json();
  86. assert.equal(body.service, 'yuban-server');
  87. });
  88. test('仅允许配置的网页来源通过 CORS 预检', async t => {
  89. const app = await fixture();
  90. t.after(() => app.close());
  91. for (const origin of [
  92. 'https://www.yuban.co',
  93. 'https://yuban.co',
  94. 'http://localhost:4200',
  95. 'http://127.0.0.1:4200',
  96. ]) {
  97. const response = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  98. method: 'OPTIONS',
  99. headers: {
  100. Origin: origin,
  101. 'Access-Control-Request-Method': 'POST',
  102. 'Access-Control-Request-Headers': 'Authorization, Content-Type, Idempotency-Key',
  103. },
  104. });
  105. assert.equal(response.status, 204, origin);
  106. assert.equal(response.headers.get('access-control-allow-origin'), origin);
  107. assert.match(response.headers.get('vary') || '', /Origin/i);
  108. assert.match(response.headers.get('access-control-allow-methods') || '', /POST/);
  109. }
  110. const rejected = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  111. method: 'OPTIONS',
  112. headers: {
  113. Origin: 'https://untrusted.example.com',
  114. 'Access-Control-Request-Method': 'POST',
  115. },
  116. });
  117. assert.equal(rejected.status, 403);
  118. const body = await rejected.json();
  119. assert.equal(body.error.code, 'ORIGIN_NOT_ALLOWED');
  120. assert.equal(rejected.headers.has('access-control-allow-origin'), false);
  121. });
  122. test('创建任务立即返回 202,查询不会暴露音频地址或所有者', async t => {
  123. const app = await fixture();
  124. t.after(() => app.close());
  125. const created = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  126. method: 'POST',
  127. headers: {
  128. 'Content-Type': 'application/json',
  129. Origin: 'http://localhost:4200',
  130. 'Idempotency-Key': 'chat-voice-1',
  131. },
  132. body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
  133. });
  134. assert.equal(created.status, 202);
  135. const createdBody = await created.json();
  136. assert.equal(createdBody.job.status, 'queued');
  137. assert.equal(app.enqueued.length, 1);
  138. const response = await fetch(
  139. `${app.baseUrl}/recording-transcription/jobs/${createdBody.job.id}`,
  140. );
  141. const text = await response.text();
  142. assert.equal(response.status, 200);
  143. assert.equal(text.includes('file.yuban.co'), false);
  144. assert.equal(text.includes('owner-a'), false);
  145. });
  146. test('同一用户和幂等键复用已有任务', async t => {
  147. const app = await fixture();
  148. t.after(() => app.close());
  149. const create = () =>
  150. fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  151. method: 'POST',
  152. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'same-recording' },
  153. body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
  154. });
  155. const first = await (await create()).json();
  156. const second = await (await create()).json();
  157. assert.equal(first.job.id, second.job.id);
  158. assert.equal(second.reused, true);
  159. assert.equal(app.enqueued.length, 1);
  160. });
  161. test('失败任务通过 retry 接口创建新的 attempt,而不是继续查询旧 jobId', async t => {
  162. const app = await fixture();
  163. t.after(() => app.close());
  164. const created = await (await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  165. method: 'POST',
  166. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'retry-recording' },
  167. body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
  168. })).json();
  169. await app.store.update(created.job.id, {
  170. status: 'failed',
  171. stage: 'failed',
  172. error: { code: 'NETWORK', message: '网络错误', retryable: true },
  173. });
  174. const response = await fetch(
  175. `${app.baseUrl}/recording-transcription/jobs/${created.job.id}/retry`,
  176. { method: 'POST' },
  177. );
  178. const body = await response.json();
  179. assert.equal(response.status, 202);
  180. assert.notEqual(body.job.id, created.job.id);
  181. assert.equal(body.job.parentJobId, created.job.id);
  182. assert.equal(body.job.status, 'queued');
  183. });