package fileupload import ( "crypto/md5" "fmt" "os" "path/filepath" "sort" "strings" "testing" "github.com/gin-gonic/gin" ) // ── 内存版 Repository(测试桩) ────────────────────────────────────── type memRepo struct { nextID int64 tasks map[int64]*TaskInfo byUID map[string]int64 chunks map[string]map[int32]struct{} } func newMemRepo() *memRepo { return &memRepo{ tasks: map[int64]*TaskInfo{}, byUID: map[string]int64{}, chunks: map[string]map[int32]struct{}{}, } } func (r *memRepo) CreateTask(t *TaskInfo) error { r.nextID++ cp := *t cp.Id = r.nextID r.tasks[cp.Id] = &cp r.byUID[cp.UploadId] = cp.Id return nil } func (r *memRepo) GetTaskByUploadID(uploadID string) (*TaskInfo, error) { id, ok := r.byUID[uploadID] if !ok { return nil, nil } cp := *r.tasks[id] return &cp, nil } func (r *memRepo) FindDoneByMD5(md5 string) (*TaskInfo, error) { var best *TaskInfo for _, t := range r.tasks { if t.FileMd5 == md5 && t.Status == StatusDone { if best == nil || t.Id > best.Id { best = t } } } if best == nil { return nil, nil } cp := *best return &cp, nil } func (r *memRepo) FindUploadingByMD5(md5 string) (*TaskInfo, error) { var best *TaskInfo for _, t := range r.tasks { if t.FileMd5 == md5 && t.Status == StatusUploading { if best == nil || t.Id > best.Id { best = t } } } if best == nil { return nil, nil } cp := *best return &cp, nil } func (r *memRepo) UpdateTaskMerged(id int64, storagePath string) error { r.tasks[id].Status = StatusDone r.tasks[id].StoragePath = storagePath return nil } func (r *memRepo) SetTaskStatus(id int64, status string) error { r.tasks[id].Status = status return nil } func (r *memRepo) CountChunks(uploadID string) (int, error) { return len(r.chunks[uploadID]), nil } func (r *memRepo) ChunkExists(uploadID string, index int32) (bool, error) { _, ok := r.chunks[uploadID][index] return ok, nil } func (r *memRepo) CreateChunk(c *ChunkInfo) error { if r.chunks[c.UploadId] == nil { r.chunks[c.UploadId] = map[int32]struct{}{} } r.chunks[c.UploadId][c.ChunkIndex] = struct{}{} return nil } func (r *memRepo) ListChunkIndexes(uploadID string) ([]int32, error) { var out []int32 for i := range r.chunks[uploadID] { out = append(out, i) } sort.Slice(out, func(a, b int) bool { return out[a] < out[b] }) return out, nil } func (r *memRepo) DeleteChunks(uploadID string) error { delete(r.chunks, uploadID) return nil } func (r *memRepo) DeleteTaskByUploadID(uploadID string) error { id, ok := r.byUID[uploadID] if !ok { return nil } delete(r.tasks, id) delete(r.byUID, uploadID) return nil } // ── 测试工具 ───────────────────────────────────────────────────────── func newTestSvc(t *testing.T) (*service, *memRepo, string) { t.Helper() repo := newMemRepo() cloud := t.TempDir() svc := newService(Options{ ChunkRoot: t.TempDir(), CloudDir: cloud, WebPathPrefix: "cloud", URLPrefix: "/uploads", Envelope: func(c *gin.Context, resp any, err error) {}, }, repo) return svc, repo, cloud } func mustMD5(b []byte) string { return fmt.Sprintf("%x", md5.Sum(b)) } func seedDoneTask(t *testing.T, svc *service, repo *memRepo, md5Str, targetDir string) *TaskInfo { t.Helper() task := TaskInfo{ UploadId: newUploadID(), FileName: "old.bin", FileMd5: md5Str, Status: StatusDone, TargetDir: targetDir, StoragePath: buildStoragePath("cloud", targetDir, "old.bin"), } if err := repo.CreateTask(&task); err != nil { t.Fatal(err) } // 物理文件落盘 phys := svc.physicalPath(task.StoragePath) if err := os.MkdirAll(filepath.Dir(phys), 0o766); err != nil { t.Fatal(err) } if err := os.WriteFile(phys, []byte("OLD-CONTENT"), 0o644); err != nil { t.Fatal(err) } return &task } // ── SanitizeDir ───────────────────────────────────────────────────── func TestSanitizeDir(t *testing.T) { ok := map[string]string{ "": "", // 根目录(固定模式) "a": "a", "a/": "a", // 尾随 / 归一 "a/b": "a/b", `a\b`: "a/b", "/abs/": "abs", // 前导 / 剥离,按相对处理 " docs / reports ": "docs / reports", "云盘/资料": "云盘/资料", "a\x01b": "ab", // 控制字符剔除 } for in, want := range ok { got, err := SanitizeDir(in) if err != nil || got != want { t.Errorf("SanitizeDir(%q) = %q, %v; want %q", in, got, err, want) } } bad := []string{ "..", "a/../b", "a/./b", "a//b", strings.Repeat("a/", 20) + "b", // 超深 strings.Repeat("a", MaxDirLength+1), // 超长 "a:b", "a*b", `a"b`, "ab", "a|b", // Windows 非法字符 } for _, in := range bad { if got, err := SanitizeDir(in); err == nil { t.Errorf("SanitizeDir(%q) = %q, want error", in, got) } } } // ── init:秒传双模式 ────────────────────────────────────────────────── func initReq(name, md5Str string, targetDir string) *InitReq { return &InitReq{ FileName: name, FileSize: 10, FileMD5: md5Str, ChunkSize: 10, TotalChunk: 1, TargetDir: targetDir, } } func TestInitInstantHitFixedMode(t *testing.T) { svc, repo, cloud := newTestSvc(t) sum := mustMD5([]byte("OLD-CONTENT")) seedDoneTask(t, svc, repo, sum, "") resp, err := svc.init(initReq("old.bin", sum, ""), "op", 1) if err != nil { t.Fatal(err) } if resp.(map[string]any)["skipUpload"] != true { t.Fatalf("固定模式应秒传: %v", resp) } if got := resp.(map[string]any)["fileUrl"]; got != "/uploads/cloud/old.bin" { t.Fatalf("fileUrl = %v, 与 v1.0.x 行为不一致", got) } // 不应产生复制:cloud 根下仍只有 old.bin entries, _ := os.ReadDir(cloud) if len(entries) != 1 { t.Fatalf("固定模式秒传不应复制文件, cloud 根条目数 = %d", len(entries)) } } func TestInitInstantHitCrossDirCopy(t *testing.T) { svc, repo, cloud := newTestSvc(t) sum := mustMD5([]byte("OLD-CONTENT")) seedDoneTask(t, svc, repo, sum, "") var hit TaskInfo svc.opts.OnInstantHit = func(ti TaskInfo) error { hit = ti; return nil } resp, err := svc.init(initReq("old.bin", sum, "docs/reports"), "op", 1) if err != nil { t.Fatal(err) } if got := resp.(map[string]any)["fileUrl"]; got != "/uploads/cloud/docs/reports/old.bin" { t.Fatalf("fileUrl = %v", got) } if hit.StoragePath != "cloud/docs/reports/old.bin" || hit.TargetDir != "docs/reports" { t.Fatalf("OnInstantHit 携带路径错误: %+v", hit) } // 物理复制存在且内容一致 data, err := os.ReadFile(filepath.Join(cloud, "docs", "reports", "old.bin")) if err != nil || string(data) != "OLD-CONTENT" { t.Fatalf("目标目录物理副本缺失或不一致: %v, %q", err, data) } } func TestInitInstantHitFallbackWhenSourceMissing(t *testing.T) { svc, repo, _ := newTestSvc(t) sum := mustMD5([]byte("OLD-CONTENT")) done := seedDoneTask(t, svc, repo, sum, "") if err := os.Remove(svc.physicalPath(done.StoragePath)); err != nil { t.Fatal(err) } resp, err := svc.init(initReq("old.bin", sum, "docs"), "op", 1) if err != nil { t.Fatal(err) } m := resp.(map[string]any) if m["skipUpload"] != false || m["uploadId"] == "" { t.Fatalf("旧物理文件缺失应回退正常上传: %v", m) } } // ── init:断点续传目录隔离 ──────────────────────────────────────────── func TestInitResumeDirIsolation(t *testing.T) { svc, repo, _ := newTestSvc(t) sum := mustMD5([]byte("OLD-CONTENT")) first, err := svc.init(initReq("old.bin", sum, "a"), "op", 1) if err != nil { t.Fatal(err) } oldUID := first.(map[string]any)["uploadId"].(string) same, err := svc.init(initReq("old.bin", sum, "a"), "op", 1) if err != nil { t.Fatal(err) } if got := same.(map[string]any)["uploadId"]; got != oldUID { t.Fatalf("同目录续传应复用任务: %v != %v", got, oldUID) } diff, err := svc.init(initReq("old.bin", sum, "b"), "op", 1) if err != nil { t.Fatal(err) } newUID := diff.(map[string]any)["uploadId"].(string) if newUID == oldUID { t.Fatal("目录不同应另起新任务") } if task, _ := repo.GetTaskByUploadID(newUID); task.TargetDir != "b" { t.Fatalf("新任务 TargetDir 应为 b: %+v", task) } fixed, err := svc.init(initReq("old.bin", sum, ""), "op", 1) if err != nil { t.Fatal(err) } if got := fixed.(map[string]any)["uploadId"]; got == oldUID { t.Fatal("固定模式与目录 a 的在途任务也应隔离") } } // ── merge:落盘目标目录 ────────────────────────────────────────────── func mergeTask(t *testing.T, svc *service, repo *memRepo, targetDir, content string) string { t.Helper() sum := mustMD5([]byte(content)) resp, err := svc.init(initReq("new.bin", sum, targetDir), "op", 1) if err != nil { t.Fatal(err) } uid := resp.(map[string]any)["uploadId"].(string) dir := svc.chunkDir(uid) if err := os.MkdirAll(dir, 0o766); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "0"), []byte(content), 0o644); err != nil { t.Fatal(err) } if err := repo.CreateChunk(&ChunkInfo{UploadId: uid, ChunkIndex: 0}); err != nil { t.Fatal(err) } out, err := svc.merge(uid) if err != nil { t.Fatal(err) } if got := out.(map[string]any)["fileUrl"]; got != "/uploads/"+repo.tasks[repo.byUID[uid]].StoragePath { t.Fatalf("fileUrl 与 storagePath 不对应: %v vs %s", got, repo.tasks[repo.byUID[uid]].StoragePath) } return repo.tasks[repo.byUID[uid]].StoragePath } func TestMergeIntoTargetDir(t *testing.T) { svc, repo, cloud := newTestSvc(t) sp := mergeTask(t, svc, repo, "docs/reports", "HELLO") if sp != "cloud/docs/reports/"+filepath.Base(sp) { t.Fatalf("storagePath 形态错误: %s", sp) } data, err := os.ReadFile(filepath.Join(cloud, "docs", "reports", filepath.Base(sp))) if err != nil || string(data) != "HELLO" { t.Fatalf("目标目录物理文件缺失或不一致: %v", err) } } func TestMergeFixedModeRegression(t *testing.T) { svc, repo, cloud := newTestSvc(t) sp := mergeTask(t, svc, repo, "", "HELLO") // 与 v1.0.x 逐字节一致:cloud/_,物理在 cloud 根 if !strings.HasPrefix(sp, "cloud/") || strings.Contains(strings.TrimPrefix(sp, "cloud/"), "/") { t.Fatalf("固定模式 storagePath 应平铺在 cloud 根: %s", sp) } if _, err := os.Stat(filepath.Join(cloud, filepath.Base(sp))); err != nil { t.Fatalf("固定模式物理文件应在 cloud 根: %v", err) } } // ── check:目录过滤 ────────────────────────────────────────────────── func TestCheckUploadingDirFilter(t *testing.T) { svc, repo, _ := newTestSvc(t) sum := mustMD5([]byte("OLD-CONTENT")) if _, err := svc.init(initReq("old.bin", sum, "a"), "op", 1); err != nil { t.Fatal(err) } uploading, err := svc.check(sum, "a") if err != nil { t.Fatal(err) } if uploading.(map[string]any)["status"] != StatusUploading { t.Fatalf("同目录 check 应报 uploading: %v", uploading) } noneResp, err := svc.check(sum, "b") if err != nil { t.Fatal(err) } if noneResp.(map[string]any)["status"] != "none" { t.Fatalf("异目录 check 应报 none: %v", noneResp) } // done 命中不受目录影响(init 会 copy-on-hit) seedDoneTask(t, svc, repo, mustMD5([]byte("OTHER")), "") doneResp, err := svc.check(mustMD5([]byte("OTHER")), "zzz") if err != nil { t.Fatal(err) } if doneResp.(map[string]any)["status"] != StatusDone { t.Fatalf("done 命中应报 done: %v", doneResp) } }