ff60fdf956
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Validate - Style (push) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Validate - Security (push) Successful in 5m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
/**
|
||
* useAssetUpload 队列批量上传测试
|
||
* - 并发直传不超过 MAX_CONCURRENT(3)
|
||
* - prepare 返回 asset_id 后 invalidate 列表
|
||
* - 状态流转 uploading→ingesting→done;失败可重试;duplicated 命中
|
||
*/
|
||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||
import { renderHook, waitFor, act } from "@testing-library/react"
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||
import type { ReactNode } from "react"
|
||
|
||
const invalidateQueries = vi.fn()
|
||
vi.mock("@/api/assets", () => ({
|
||
prepareDirectUploadHandle: vi.fn(),
|
||
}))
|
||
|
||
const { prepareDirectUploadHandle } = await import("@/api/assets")
|
||
const { useAssetUpload } = await import("@/pages/assets/hooks/useAssetUpload")
|
||
|
||
interface FakeHandle {
|
||
prepared: {
|
||
upload_url: string
|
||
method: string
|
||
storage_key: string
|
||
expires_at: string
|
||
fields: Record<string, string>
|
||
max_size_bytes: number
|
||
asset_id: string
|
||
duplicated?: boolean
|
||
skip_transfer?: boolean
|
||
}
|
||
transfer: ReturnType<typeof vi.fn>
|
||
complete: ReturnType<typeof vi.fn>
|
||
/** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */
|
||
finish: (fail?: boolean) => void
|
||
/** complete 已被调用的次数 */
|
||
completeCalls: { resolve: () => void; reject: (err: unknown) => void }[]
|
||
}
|
||
|
||
/**
|
||
* 创建一个假 handle:
|
||
* - transfer 返回挂起的 promise,finish()/finish(true) 控制成败
|
||
* - complete 每次调用返回独立的挂起 promise,由 completeCalls 记录控制,
|
||
* 成功调 resolve(idx) / 失败调 reject(idx)(模拟超时)
|
||
*/
|
||
const makeFakeHandle = (opts: {
|
||
id: string
|
||
duplicated?: boolean
|
||
failTransfer?: boolean
|
||
completeAuto?: boolean
|
||
/** prepare 阶段就命中去重:prepare 响应 skip_transfer/duplicated=true */
|
||
prepareDedup?: boolean
|
||
}) => {
|
||
const h: FakeHandle = {
|
||
prepared: {
|
||
upload_url: "https://oss.example.com/u",
|
||
method: "POST",
|
||
storage_key: `uploads/${opts.id}/y.mp4`,
|
||
expires_at: "2099-01-01",
|
||
fields: {},
|
||
max_size_bytes: 2_000_000_000,
|
||
asset_id: opts.id,
|
||
duplicated: opts.prepareDedup ? true : undefined,
|
||
skip_transfer: opts.prepareDedup ? true : undefined,
|
||
},
|
||
transfer: vi.fn(),
|
||
complete: vi.fn(),
|
||
finish: () => {},
|
||
completeCalls: [],
|
||
}
|
||
|
||
h.complete.mockImplementation(
|
||
() =>
|
||
new Promise<{
|
||
storage_key: string
|
||
ingest_job_id: string
|
||
url: string
|
||
duplicated: boolean
|
||
asset_id: string
|
||
}>((resolve, reject) => {
|
||
h.completeCalls.push({
|
||
resolve: () =>
|
||
resolve({
|
||
storage_key: "uploads/x/y.mp4",
|
||
ingest_job_id: opts.duplicated ? "" : `job-${opts.id}`,
|
||
url: "https://oss.example.com/u",
|
||
duplicated: !!opts.duplicated,
|
||
asset_id: opts.id,
|
||
}),
|
||
reject,
|
||
})
|
||
// 默认立即成功,保持旧用例简单
|
||
if (opts.completeAuto !== false) {
|
||
const idx = h.completeCalls.length - 1
|
||
Promise.resolve().then(() => h.completeCalls[idx]?.resolve())
|
||
}
|
||
}),
|
||
)
|
||
|
||
h.transfer.mockImplementation(
|
||
() =>
|
||
new Promise<void>((_resolve, reject) => {
|
||
activeTransfers += 1
|
||
maxConcurrent = Math.max(maxConcurrent, activeTransfers)
|
||
h.finish = (fail = false) => {
|
||
activeTransfers -= 1
|
||
if (fail || opts.failTransfer) reject(new Error("OSS boom"))
|
||
else _resolve()
|
||
}
|
||
}),
|
||
)
|
||
return h
|
||
}
|
||
|
||
type FakeHandleLike = ReturnType<typeof makeFakeHandle>
|
||
|
||
let activeTransfers = 0
|
||
let maxConcurrent = 0
|
||
|
||
/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */
|
||
const installPrepareMock = (
|
||
handles: FakeHandleLike[],
|
||
optOverrides?: (id: string) => {
|
||
duplicated?: boolean
|
||
failTransfer?: boolean
|
||
completeAuto?: boolean
|
||
},
|
||
) => {
|
||
let callNo = 0
|
||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||
async () => {
|
||
const id = `asset-${callNo++}`
|
||
const overrides = optOverrides?.(id) ?? {}
|
||
const h = makeFakeHandle({ id, completeAuto: true, ...overrides })
|
||
handles.push(h)
|
||
await new Promise((r) => setTimeout(r, 10))
|
||
return h
|
||
},
|
||
)
|
||
}
|
||
|
||
const createWrapper = () => {
|
||
const qc = new QueryClient({
|
||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||
})
|
||
// 监听 invalidate 调用
|
||
const orig = qc.invalidateQueries.bind(qc)
|
||
qc.invalidateQueries = ((...args: unknown[]) => {
|
||
invalidateQueries()
|
||
return orig(...(args as never))
|
||
}) as never
|
||
return ({ children }: { children: ReactNode }) => (
|
||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||
)
|
||
}
|
||
|
||
const mp4 = (name: string) => new File([new Uint8Array(10)], name, { type: "video/mp4" })
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
activeTransfers = 0
|
||
maxConcurrent = 0
|
||
})
|
||
|
||
describe("useAssetUpload", () => {
|
||
it("5 个文件批量入队:同时直传不超过 3 个,全部完成且刷新列表", async () => {
|
||
const handles: FakeHandleLike[] = []
|
||
installPrepareMock(handles)
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
await act(async () => {
|
||
result.current.enqueueUploads(Array.from({ length: 5 }, (_, i) => mp4(`v${i}.mp4`)))
|
||
})
|
||
|
||
// 3 个进入 uploading(transfer 被挂起),2 个排队
|
||
await waitFor(() => {
|
||
expect(handles.length).toBe(3)
|
||
expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(3)
|
||
expect(result.current.uploadItems.filter((it) => it.status === "preparing").length).toBe(2)
|
||
})
|
||
expect(maxConcurrent).toBe(3)
|
||
|
||
// 完成前 3 个 → 队列拉起后 2 个
|
||
await act(async () => {
|
||
handles[0].finish()
|
||
handles[1].finish()
|
||
handles[2].finish()
|
||
})
|
||
await waitFor(() => {
|
||
expect(handles.length).toBe(5)
|
||
expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(2)
|
||
})
|
||
expect(maxConcurrent).toBeLessThanOrEqual(3)
|
||
await waitFor(() => expect(handles[4].transfer).toHaveBeenCalled())
|
||
|
||
// 完成剩余 2 个
|
||
await act(async () => {
|
||
handles[3].finish()
|
||
handles[4].finish()
|
||
})
|
||
await waitFor(() => {
|
||
expect(result.current.uploadItems.filter((it) => it.status === "done").length).toBe(5)
|
||
})
|
||
expect(invalidateQueries).toHaveBeenCalled()
|
||
})
|
||
|
||
it("传输失败标记 error,重试后成功", async () => {
|
||
const handles: FakeHandleLike[] = []
|
||
let firstCall = true
|
||
installPrepareMock(handles, () => {
|
||
const fail = firstCall
|
||
firstCall = false
|
||
return { failTransfer: fail }
|
||
})
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
await act(async () => {
|
||
result.current.enqueueUploads([mp4("bad.mp4")])
|
||
})
|
||
await waitFor(() => expect(handles.length).toBe(1))
|
||
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
|
||
await act(async () => {
|
||
handles[0].finish()
|
||
})
|
||
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
|
||
|
||
// 失败卡片记录失败阶段与完整错误原因(不再只显示"上传失败")
|
||
const failed = result.current.uploadItems[0]
|
||
expect(failed.failedStage).toBe("transfer")
|
||
expect(failed.error).toContain("OSS boom")
|
||
|
||
// 重试:重新 prepare(handles[1] 成功)
|
||
const tempId = result.current.uploadItems[0].tempId
|
||
await act(async () => {
|
||
result.current.retryUpload(tempId)
|
||
})
|
||
await waitFor(() => expect(handles.length).toBe(2))
|
||
await waitFor(() => expect(handles[1].transfer).toHaveBeenCalled())
|
||
await act(async () => {
|
||
handles[1].finish()
|
||
})
|
||
await waitFor(() => {
|
||
expect(result.current.uploadItems.find((it) => it.tempId === tempId)?.status).toBe("done")
|
||
})
|
||
})
|
||
|
||
it("complete 返回 duplicated 时标记去重完成", async () => {
|
||
const handles: FakeHandleLike[] = []
|
||
installPrepareMock(handles, () => ({ duplicated: true }))
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
await act(async () => {
|
||
result.current.enqueueUploads([mp4("dup.mp4")])
|
||
})
|
||
await waitFor(() => expect(handles.length).toBe(1))
|
||
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
|
||
await act(async () => {
|
||
handles[0].finish()
|
||
})
|
||
await waitFor(() => {
|
||
expect(result.current.uploadItems[0].duplicated).toBe(true)
|
||
expect(result.current.uploadItems[0].status).toBe("done")
|
||
})
|
||
})
|
||
it("同一文件多次选择不重复入队(指纹去重)", async () => {
|
||
const handles: FakeHandleLike[] = []
|
||
installPrepareMock(handles)
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
// 同一文件(name+size+lastModified 完全一致)第一次入队
|
||
const sameFile = mp4("same.mp4")
|
||
await act(async () => {
|
||
result.current.enqueueUploads([sameFile])
|
||
})
|
||
await waitFor(() => expect(handles.length).toBe(1))
|
||
expect(result.current.uploadItems).toHaveLength(1)
|
||
|
||
// transfer 挂起期间,再次选择同一文件(模拟用户反复点选/拖拽)
|
||
await act(async () => {
|
||
result.current.enqueueUploads([sameFile])
|
||
})
|
||
await act(async () => {
|
||
result.current.enqueueUploads([sameFile])
|
||
})
|
||
// 队列表只有 1 项、prepare 只有 1 次
|
||
expect(result.current.uploadItems).toHaveLength(1)
|
||
expect(handles.length).toBe(1)
|
||
|
||
// 完成后再次重复选择(已 done):仍然不新增
|
||
await act(async () => {
|
||
handles[0].finish()
|
||
})
|
||
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("done"))
|
||
await act(async () => {
|
||
result.current.enqueueUploads([sameFile])
|
||
})
|
||
expect(result.current.uploadItems).toHaveLength(1)
|
||
expect(handles.length).toBe(1)
|
||
|
||
// 不同文件正常入队
|
||
await act(async () => {
|
||
result.current.enqueueUploads([mp4("other.mp4")])
|
||
})
|
||
await waitFor(() => expect(handles.length).toBe(2))
|
||
expect(result.current.uploadItems).toHaveLength(2)
|
||
})
|
||
|
||
it("complete 失败(超时)后重试:只重发 complete,不重新 prepare/直传", async () => {
|
||
const handles: FakeHandleLike[] = []
|
||
installPrepareMock(handles, () => ({ completeAuto: false }))
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
await act(async () => {
|
||
result.current.enqueueUploads([mp4("slow.mp4")])
|
||
})
|
||
await waitFor(() => expect(handles.length).toBe(1))
|
||
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
|
||
await act(async () => {
|
||
handles[0].finish()
|
||
})
|
||
// complete 被调用但挂起
|
||
await waitFor(() => expect(handles[0].complete).toHaveBeenCalledTimes(1))
|
||
|
||
// 模拟 complete 超时(后端记录可能已建成)
|
||
await act(async () => {
|
||
handles[0].completeCalls[0]?.reject(new Error("complete timeout (ECONNABORTED)"))
|
||
})
|
||
const tempId = result.current.uploadItems[0].tempId
|
||
await waitFor(() => {
|
||
const it = result.current.uploadItems.find((x) => x.tempId === tempId)
|
||
expect(it?.status).toBe("error")
|
||
expect(it?.failedStage).toBe("complete")
|
||
// 卡片同时展示真实失败原因与"重试不会重新上传"提示
|
||
expect(it?.error).toContain("complete timeout")
|
||
expect(it?.error).toContain("不会重新上传文件")
|
||
})
|
||
|
||
// 点重试:pump 复用 handle,只再调一次 complete(transfer/prepare 不重复)
|
||
await act(async () => {
|
||
result.current.retryUpload(tempId)
|
||
})
|
||
await waitFor(() => expect(handles[0].complete).toHaveBeenCalledTimes(2))
|
||
expect(handles.length).toBe(1) // 没有重新 prepare
|
||
expect(handles[0].transfer).toHaveBeenCalledTimes(1) // 没有重新直传
|
||
|
||
// 第二次 complete 成功
|
||
await act(async () => {
|
||
handles[0].completeCalls[1]?.resolve()
|
||
})
|
||
await waitFor(() => {
|
||
expect(result.current.uploadItems.find((x) => x.tempId === tempId)?.status).toBe("done")
|
||
})
|
||
})
|
||
|
||
it("prepare 阶段失败:标记 prepare 阶段并保留后端错误明细", async () => {
|
||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||
isAxiosError: true,
|
||
response: { status: 500, data: { detail: "签名服务内部错误" } },
|
||
message: "Request failed with status code 500",
|
||
})
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
await act(async () => {
|
||
result.current.enqueueUploads([mp4("prep-fail.mp4")])
|
||
})
|
||
await waitFor(() => expect(result.current.uploadItems[0]?.status).toBe("error"))
|
||
const it = result.current.uploadItems[0]
|
||
expect(it.failedStage).toBe("prepare")
|
||
expect(it.error).toContain("签名服务内部错误")
|
||
})
|
||
it("prepare 返回 skip_transfer=true 时立即跳过 transfer+complete,标记 done+duplicated", async () => {
|
||
const h = makeFakeHandle({ id: "a-skip", prepareDedup: true })
|
||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||
async () => h,
|
||
)
|
||
|
||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||
wrapper: createWrapper(),
|
||
})
|
||
|
||
await act(async () => {
|
||
result.current.enqueueUploads([mp4("skip-transfer.mp4")])
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(h.transfer).not.toHaveBeenCalled()
|
||
expect(h.complete).not.toHaveBeenCalled()
|
||
const it = result.current.uploadItems[0]
|
||
expect(it?.status).toBe("done")
|
||
expect(it?.duplicated).toBe(true)
|
||
expect(it?.assetId).toBe("a-skip")
|
||
})
|
||
})
|
||
})
|