/** * 治根 #oss-3tb-orphan: 稳定路径单元测试 * * 核心设计原则: **1 个内容 = 1 个文件**。同一内容多次调用, OSS 上始终只覆盖同一对象, * 垃圾不可能暴涨 (max 1 文件 1 次写)。 * * 测试策略: mock ossService.uploadFile, 验证 storageService 传给它的 objectKey 是稳定的 * (同一 contentKey 多次 → 同一 key; 不同 contentKey → 不同 key) * * 注意: storageService 是单例, constructor 在 import 时读 STORAGE_TYPE. * 每次测试用 vi.resetModules + 动态 import 强制重新读 env. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mocks = vi.hoisted(() => ({ uploadFile: vi.fn(), })); // mock ossService 必须在 import storageService 之前生效 vi.mock('@/services/oss.service', () => ({ ossService: { uploadFile: mocks.uploadFile }, })); async function freshStorageService() { vi.resetModules(); vi.stubEnv('STORAGE_TYPE', 'oss'); // 重新 import, 会让 storageService 的 constructor 重读 STORAGE_TYPE const mod = await import('@/services/storage.service'); return mod.storageService; } beforeEach(() => { mocks.uploadFile.mockReset(); mocks.uploadFile.mockImplementation(async (_localPath: string, objectKey: string) => `https://bucket.oss.aliyuncs.com/${objectKey}`, ); }); describe('storageService.uploadAudioByContentHash - 稳定 key 治根', () => { it('同一 contentKey 多次调用, 传给 OSS 的 objectKey 完全相同', async () => { const storageService = await freshStorageService(); const url1 = await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'voiceA|1.0|0|50'); const url2 = await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'preview', 'voiceA|1.0|0|50'); expect(mocks.uploadFile).toHaveBeenCalledTimes(2); const key1 = mocks.uploadFile.mock.calls[0][1] as string; const key2 = mocks.uploadFile.mock.calls[1][1] as string; expect(key1).toBe(key2); // ⭐ 核心: 同一 contentKey 永远产生同一 OSS key expect(url1).toBe(url2); }); it('不同 contentKey 产生不同 OSS key', async () => { const storageService = await freshStorageService(); await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'voiceA'); await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'preview', 'voiceB'); const keyA = mocks.uploadFile.mock.calls[0][1] as string; const keyB = mocks.uploadFile.mock.calls[1][1] as string; expect(keyA).not.toBe(keyB); }); it('不同 kind 隔离 OSS 目录 (preview / sync / segment 不能串)', async () => { const storageService = await freshStorageService(); await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'sameKey'); await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'sync', 'sameKey'); const key1 = mocks.uploadFile.mock.calls[0][1] as string; const key2 = mocks.uploadFile.mock.calls[1][1] as string; expect(key1).toMatch(/^audio\/preview\//); expect(key2).toMatch(/^audio\/sync\//); }); it('contentKey 长度不影响 OSS key 长度 (sha256 截断到 32 字符)', async () => { const storageService = await freshStorageService(); await storageService.uploadAudioByContentHash('/tmp/a.mp3', 'preview', 'x'); await storageService.uploadAudioByContentHash('/tmp/b.mp3', 'preview', 'x'.repeat(10000)); const key1 = mocks.uploadFile.mock.calls[0][1] as string; const key2 = mocks.uploadFile.mock.calls[1][1] as string; expect(key1).toMatch(/^audio\/preview\/[0-9a-f]{32}\.mp3$/); expect(key2).toMatch(/^audio\/preview\/[0-9a-f]{32}\.mp3$/); }); }); describe('storageService.uploadChapterAudio - 稳定 key 治根', () => { it('同一 chapterId 多次产生完全相同 key', async () => { const storageService = await freshStorageService(); const url1 = await storageService.uploadChapterAudio('/tmp/a.mp3', 42); const url2 = await storageService.uploadChapterAudio('/tmp/b.mp3', 42); const key1 = mocks.uploadFile.mock.calls[0][1] as string; const key2 = mocks.uploadFile.mock.calls[1][1] as string; expect(key1).toBe('audio/chapter/42/full.mp3'); expect(key2).toBe('audio/chapter/42/full.mp3'); expect(url1).toBe(url2); }); it('不同 chapterId 产生不同 key', async () => { const storageService = await freshStorageService(); await storageService.uploadChapterAudio('/tmp/a.mp3', 42); await storageService.uploadChapterAudio('/tmp/b.mp3', 43); const key1 = mocks.uploadFile.mock.calls[0][1] as string; const key2 = mocks.uploadFile.mock.calls[1][1] as string; expect(key1).toBe('audio/chapter/42/full.mp3'); expect(key2).toBe('audio/chapter/43/full.mp3'); }); });