stable-key.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /**
  2. * 治根 #oss-3tb-orphan: 稳定路径单元测试
  3. *
  4. * 核心设计原则: **1 个内容 = 1 个文件**。同一内容多次调用, OSS 上始终只覆盖同一对象,
  5. * 垃圾不可能暴涨 (max 1 文件 1 次写)。
  6. *
  7. * 测试策略: mock ossService.uploadFile, 验证 storageService 传给它的 objectKey 是稳定的
  8. * (同一 contentKey 多次 → 同一 key; 不同 contentKey → 不同 key)
  9. *
  10. * 注意: storageService 是单例, constructor 在 import 时读 STORAGE_TYPE.
  11. * 每次测试用 vi.resetModules + 动态 import 强制重新读 env.
  12. */
  13. import { describe, it, expect, vi, beforeEach } from 'vitest';
  14. const mocks = vi.hoisted(() => ({
  15. uploadFile: vi.fn(),
  16. }));
  17. // mock ossService 必须在 import storageService 之前生效
  18. vi.mock('@/services/oss.service', () => ({
  19. ossService: { uploadFile: mocks.uploadFile },
  20. }));
  21. async function freshStorageService() {
  22. vi.resetModules();
  23. vi.stubEnv('STORAGE_TYPE', 'oss');
  24. // 重新 import, 会让 storageService 的 constructor 重读 STORAGE_TYPE
  25. const mod = await import('@/services/storage.service');
  26. return mod.storageService;
  27. }
  28. beforeEach(() => {
  29. mocks.uploadFile.mockReset();
  30. mocks.uploadFile.mockImplementation(async (_localPath: string, objectKey: string) =>
  31. `https://bucket.oss.aliyuncs.com/${objectKey}`,
  32. );
  33. });
  34. describe('storageService.uploadAudioByContentHash - 稳定 key 治根', () => {
  35. it('同一 contentKey 多次调用, 传给 OSS 的 objectKey 完全相同', async () => {
  36. const storageService = await freshStorageService();
  37. const url1 = await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'voiceA|1.0|0|50');
  38. const url2 = await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'preview', 'voiceA|1.0|0|50');
  39. expect(mocks.uploadFile).toHaveBeenCalledTimes(2);
  40. const key1 = mocks.uploadFile.mock.calls[0][1] as string;
  41. const key2 = mocks.uploadFile.mock.calls[1][1] as string;
  42. expect(key1).toBe(key2); // ⭐ 核心: 同一 contentKey 永远产生同一 OSS key
  43. expect(url1).toBe(url2);
  44. });
  45. it('不同 contentKey 产生不同 OSS key', async () => {
  46. const storageService = await freshStorageService();
  47. await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'voiceA');
  48. await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'preview', 'voiceB');
  49. const keyA = mocks.uploadFile.mock.calls[0][1] as string;
  50. const keyB = mocks.uploadFile.mock.calls[1][1] as string;
  51. expect(keyA).not.toBe(keyB);
  52. });
  53. it('不同 kind 隔离 OSS 目录 (preview / sync / segment 不能串)', async () => {
  54. const storageService = await freshStorageService();
  55. await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'sameKey');
  56. await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'sync', 'sameKey');
  57. const key1 = mocks.uploadFile.mock.calls[0][1] as string;
  58. const key2 = mocks.uploadFile.mock.calls[1][1] as string;
  59. expect(key1).toMatch(/^audio\/preview\//);
  60. expect(key2).toMatch(/^audio\/sync\//);
  61. });
  62. it('contentKey 长度不影响 OSS key 长度 (sha256 截断到 32 字符)', async () => {
  63. const storageService = await freshStorageService();
  64. await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'x');
  65. await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'preview', 'x'.repeat(10000));
  66. const key1 = mocks.uploadFile.mock.calls[0][1] as string;
  67. const key2 = mocks.uploadFile.mock.calls[1][1] as string;
  68. expect(key1).toMatch(/^audio\/preview\/[0-9a-f]{32}\.mp3$/);
  69. expect(key2).toMatch(/^audio\/preview\/[0-9a-f]{32}\.mp3$/);
  70. });
  71. });
  72. describe('storageService.uploadChapterAudio - 稳定 key 治根', () => {
  73. it('同一 chapterId 多次产生完全相同 key', async () => {
  74. const storageService = await freshStorageService();
  75. const url1 = await storageService.uploadChapterAudio('/tmp/a.mp3', 42);
  76. const url2 = await storageService.uploadChapterAudio('/tmp/b.mp3', 42);
  77. const key1 = mocks.uploadFile.mock.calls[0][1] as string;
  78. const key2 = mocks.uploadFile.mock.calls[1][1] as string;
  79. expect(key1).toBe('audio/chapter/42/full.mp3');
  80. expect(key2).toBe('audio/chapter/42/full.mp3');
  81. expect(url1).toBe(url2);
  82. });
  83. it('不同 chapterId 产生不同 key', async () => {
  84. const storageService = await freshStorageService();
  85. await storageService.uploadChapterAudio('/tmp/a.mp3', 42);
  86. await storageService.uploadChapterAudio('/tmp/b.mp3', 43);
  87. const key1 = mocks.uploadFile.mock.calls[0][1] as string;
  88. const key2 = mocks.uploadFile.mock.calls[1][1] as string;
  89. expect(key1).toBe('audio/chapter/42/full.mp3');
  90. expect(key2).toBe('audio/chapter/43/full.mp3');
  91. });
  92. });