/** * 上传去重/幂等工具单测(Issue #1714) */ import { describe, it, expect, vi } from "vitest" import { computeFileHash, findDuplicateInQueue, HASH_FULL_READ_LIMIT, HASH_SAMPLE_CHUNK, makeClientUploadId, makeFileFingerprint, } from "@/api/assets/uploadDedup" const makeFile = (name: string, size = 100, lastModified = 1_700_000_000_000) => new File([new Uint8Array(size)], name, { type: "video/mp4", lastModified }) describe("makeFileFingerprint", () => { it("同一文件(name+size+lastModified 相同)指纹一致", () => { const a = makeFile("a.mp4", 1000, 12345) const b = makeFile("a.mp4", 1000, 12345) expect(makeFileFingerprint(a)).toBe(makeFileFingerprint(b)) }) it("文件名/大小/修改时间任一不同指纹即不同", () => { const base = makeFile("a.mp4", 1000, 100) expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("b.mp4", 1000, 100))) expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("a.mp4", 1001, 100))) expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("a.mp4", 1000, 101))) }) }) describe("findDuplicateInQueue", () => { const queue = [ { fileKey: "k1", status: "preparing" }, { fileKey: "k2", status: "uploading" }, { fileKey: "k3", status: "ingesting" }, { fileKey: "k4", status: "done" }, { fileKey: "k5", status: "error" }, ] it("在途状态(preparing/uploading/ingesting/done)命中重复", () => { expect(findDuplicateInQueue(queue, "k1")?.status).toBe("preparing") expect(findDuplicateInQueue(queue, "k2")?.status).toBe("uploading") expect(findDuplicateInQueue(queue, "k3")?.status).toBe("ingesting") expect(findDuplicateInQueue(queue, "k4")?.status).toBe("done") }) it("未命中返回 null", () => { expect(findDuplicateInQueue(queue, "missing")).toBeNull() }) it("排除 error 状态后,失败项不算重复(允许重新激活)", () => { expect(findDuplicateInQueue(queue, "k5", ["error"])).toBeNull() }) it("同时排除 done 后,已完成项也不算重复", () => { expect(findDuplicateInQueue(queue, "k4", ["error", "done"])).toBeNull() // 但在途的仍然命中 expect(findDuplicateInQueue(queue, "k1", ["error", "done"])).not.toBeNull() }) }) describe("makeClientUploadId", () => { it("生成带前缀且互不相同的幂等 token", () => { const ids = new Set(Array.from({ length: 20 }, () => makeClientUploadId())) expect(ids.size).toBe(20) for (const id of ids) expect(id.startsWith("up_")).toBe(true) }) }) describe("computeFileHash", () => { it("相同内容 hash 一致、不同内容 hash 不同", async () => { const f1 = makeFile("a.mp4", 4096) const f2 = makeFile("b.mp4", 4096) // 两个文件都是 0 填充,内容相同 → hash 一致 expect(await computeFileHash(f1)).toBe(await computeFileHash(f2)) const f3 = new File([new Uint8Array(4096).fill(7)], "c.mp4", { type: "video/mp4" }) expect(await computeFileHash(f1)).not.toBe(await computeFileHash(f3)) }) it("返回 64 位十六进制(SHA-256,与后端 file_hash 长度一致)", async () => { const hash = await computeFileHash(makeFile("a.mp4", 1024)) expect(hash).toMatch(/^[0-9a-f]{64}$/) }) }) describe("computeFileHash 大文件抽样(>64MB)", () => { it("抽样路径正常返回 64 位 hex,且大小不同则 hash 不同", async () => { // mock 一个「声称」300MB 的 File:slice 返回小 buffer 即可,不真分配 300MB const makeBig = (declaredSize: number, head: number) => { const f = new File([new Uint8Array([head, 2, 3])], "big.mov", { type: "video/quicktime" }) Object.defineProperty(f, "size", { value: declaredSize, configurable: true }) // slice 仍按真实内容返回小片段(头尾片段内容由底层小 buffer 决定) return f } const h1 = await computeFileHash(makeBig(300 * 1024 * 1024, 1)) const h2 = await computeFileHash(makeBig(301 * 1024 * 1024, 1)) expect(h1).toMatch(/^[0-9a-f]{64}$/) // 声明大小不同 → 写入的 64 位 size 字段不同 → hash 必须不同(锁定 setBigUint64 路径) expect(h1).not.toBe(h2) }) it("≤64MB 走全量读取(slice 一次覆盖整个文件)", async () => { const f = new File([new Uint8Array(1024).fill(9)], "full.mp4", { type: "video/mp4" }) Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT, configurable: true }) const sliceSpy = vi.spyOn(f, "slice") await computeFileHash(f) // 全量路径:唯一一次 slice 为 (0, size) expect(sliceSpy).toHaveBeenCalledTimes(1) expect(sliceSpy).toHaveBeenCalledWith(0, HASH_FULL_READ_LIMIT) sliceSpy.mockRestore() }) it(">64MB 只读取头尾各 16MB 抽样,绝不整文件读入内存", async () => { const f = new File([new Uint8Array(1024).fill(9)], "big.mp4", { type: "video/mp4" }) Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT + 1, configurable: true }) const sliceSpy = vi.spyOn(f, "slice") await computeFileHash(f) // 抽样路径:两次 slice —— 头部 (0, 16MB) 与尾部 (size-16MB, size) expect(sliceSpy).toHaveBeenCalledTimes(2) expect(sliceSpy).toHaveBeenNthCalledWith(1, 0, HASH_SAMPLE_CHUNK) expect(sliceSpy).toHaveBeenNthCalledWith( 2, HASH_FULL_READ_LIMIT + 1 - HASH_SAMPLE_CHUNK, HASH_FULL_READ_LIMIT + 1, ) sliceSpy.mockRestore() }) })