Files
dingtalk-workspace-cli/internal/helpers/drive_status_test.go

401 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"os"
"path/filepath"
"testing"
)
// ──────────────────────────────────────────────────────────
// validateLocalDirAbs — 本地路径校验(必须为绝对路径)
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageValidateLocalDirAbs_empty(t *testing.T) {
if _, err := validateLocalDirAbs(""); err == nil {
t.Fatal("expected error for empty --local-folder")
}
}
func TestCrossPlatformCoverageValidateLocalDirAbs_relative(t *testing.T) {
if _, err := validateLocalDirAbs(filepath.Join(".", "repo")); err == nil {
t.Fatal("expected error for relative path")
}
}
func TestCrossPlatformCoverageValidateLocalDirAbs_ok(t *testing.T) {
abs := filepath.Join(t.TempDir(), "repo")
got, err := validateLocalDirAbs(abs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != filepath.Clean(abs) {
t.Errorf("got %q, want %q", got, filepath.Clean(abs))
}
}
// ──────────────────────────────────────────────────────────
// walkLocalTree — 本地遍历
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageWalkLocalTree_collectsRegularFiles(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.txt"), "hello")
mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world")
files, err := walkLocalTree(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d: %v", len(files), files)
}
// rel_path 用 / 分隔(跨平台一致)
if _, ok := files["sub/b.txt"]; !ok {
t.Errorf("expected key sub/b.txt, got keys %v", keys(files))
}
// walk 阶段不计算 hash(惰性策略),但记录 AbsPath 与 mtime
if files["a.txt"].Hash != "" {
t.Error("walkLocalTree should not compute MD5 eagerly")
}
if files["a.txt"].AbsPath == "" {
t.Error("walkLocalTree should record AbsPath")
}
if files["a.txt"].ModTimeMillis == 0 {
t.Error("walkLocalTree should record mtime")
}
}
// ──────────────────────────────────────────────────────────
// compareTrees — 四类差异分类
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageCompareTrees_exact(t *testing.T) {
local := map[string]*localFile{
"same.txt": {RelPath: "same.txt", Hash: "h1"},
"diff.txt": {RelPath: "diff.txt", Hash: "local"},
"onlyme.txt": {RelPath: "onlyme.txt", Hash: "h3"},
}
remote := map[string]*remoteFile{
"same.txt": {RelPath: "same.txt", Hash: "h1"},
"diff.txt": {RelPath: "diff.txt", Hash: "remote"},
"onlythem.txt": {RelPath: "onlythem.txt", Hash: "h4"},
}
res, err := compareTrees(local, remote, "exact", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPaths(t, "unchanged", res.Unchanged, "same.txt")
assertPaths(t, "modified", res.Modified, "diff.txt")
assertPaths(t, "new_local", res.NewLocal, "onlyme.txt")
assertPaths(t, "new_remote", res.NewRemote, "onlythem.txt")
if res.Detection != "exact" {
t.Errorf("detection = %q, want exact", res.Detection)
}
}
func TestCrossPlatformCoverageCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) {
local := map[string]*localFile{"a.txt": {RelPath: "a.txt", Hash: "h1"}}
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: ""}}
res, err := compareTrees(local, remote, "exact", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 远端无 md5 → 无法核对内容,归入 unknown(既非 unchanged 也非 modified)。
assertPaths(t, "unknown", res.Unknown, "a.txt")
if len(res.Unchanged) != 0 {
t.Error("unknown remote hash must not be judged unchanged")
}
if len(res.Modified) != 0 {
t.Error("unknown remote hash must not be judged modified")
}
}
// TestCompareTrees_exact_hashesOnlyIntersection 验证本地 hash 只在双端都存在时才
// 计算:local-only 文件即使 AbsPath 指向不存在的文件也不会触发 hash(不报错)。
func TestCrossPlatformCoverageCompareTrees_exact_hashesOnlyIntersection(t *testing.T) {
root := t.TempDir()
bothPath := filepath.Join(root, "both.txt")
mustWrite(t, bothPath, "same-content")
h, err := md5File(bothPath)
if err != nil {
t.Fatalf("md5File: %v", err)
}
local := map[string]*localFile{
"both.txt": {RelPath: "both.txt", AbsPath: bothPath},
"local.txt": {RelPath: "local.txt", AbsPath: filepath.Join(root, "does-not-exist.txt")},
}
remote := map[string]*remoteFile{
"both.txt": {RelPath: "both.txt", Hash: h},
}
res, err := compareTrees(local, remote, "exact", false)
if err != nil {
t.Fatalf("local-only file must not be hashed, got error: %v", err)
}
assertPaths(t, "unchanged", res.Unchanged, "both.txt")
assertPaths(t, "new_local", res.NewLocal, "local.txt")
// 双端都存在的 both.txt 被惰性 hash 后应回填 Hash
if local["both.txt"].Hash == "" {
t.Error("intersection file should have been hashed lazily")
}
if local["local.txt"].Hash != "" {
t.Error("local-only file should never be hashed")
}
}
// TestCompareTrees_exact_noRemoteMD5AllUnknown 验证 exact 模式在远端无 md5 时
// 一律归入 unknown——不再用 fileSize + modified_time 近似判 unchanged(否则大小与
// mtime 恰好相同但内容不同的文件会被误报为未变更),无论大小/时间是否相同。
func TestCrossPlatformCoverageCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) {
local := map[string]*localFile{
"sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModTimeMillis: 1000},
"diffsize.txt": {RelPath: "diffsize.txt", Size: 100, ModTimeMillis: 1000},
"difftime.txt": {RelPath: "difftime.txt", Size: 100, ModTimeMillis: 1000},
}
remote := map[string]*remoteFile{
// 远端均无 md5(Hash 空)→ 全部 unknown,绝不判 unchanged。
"sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModifiedTime: 1000, ModifiedTimeValid: true},
"diffsize.txt": {RelPath: "diffsize.txt", Size: 200, ModifiedTime: 1000, ModifiedTimeValid: true},
"difftime.txt": {RelPath: "difftime.txt", Size: 100, ModifiedTime: 2000, ModifiedTimeValid: true},
}
res, err := compareTrees(local, remote, "exact", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPaths(t, "unknown", res.Unknown, "diffsize.txt", "difftime.txt", "sameSizeTime.txt")
if len(res.Unchanged) != 0 {
t.Errorf("远端无 md5 时不应有 unchanged, got %v", entryPaths(res.Unchanged))
}
if len(res.Modified) != 0 {
t.Errorf("远端无 md5 时不应有 modified, got %v", entryPaths(res.Modified))
}
}
// TestCompareTrees_exact_base64RemoteMD5 验证 exact 用 base64 md5 比对:本地按
// base64 算 md5,与钉盘返回的 base64 md5 直接相等 → unchanged。
func TestCrossPlatformCoverageCompareTrees_exact_base64RemoteMD5(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, "a.txt")
mustWrite(t, p, "hello root\n") // 11 字节,md5 base64 = 6y9K7B0b2K0AInNZZV/70A==
remoteB64 := "6y9K7B0b2K0AInNZZV/70A=="
local := map[string]*localFile{"a.txt": {RelPath: "a.txt", AbsPath: p}}
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: remoteB64}}
res, err := compareTrees(local, remote, "exact", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPaths(t, "unchanged", res.Unchanged, "a.txt")
if len(res.Modified) != 0 {
t.Errorf("同一 md5(base64) 不应判 modified, got modified=%d", len(res.Modified))
}
// 本地按需 hash 后应回填为 base64
if local["a.txt"].Hash != remoteB64 {
t.Errorf("本地 md5 应回填为 base64 %q, got %q", remoteB64, local["a.txt"].Hash)
}
}
func TestCrossPlatformCoverageCompareTrees_quick(t *testing.T) {
local := map[string]*localFile{
"eq.txt": {RelPath: "eq.txt", ModTimeMillis: 1000},
"ne.txt": {RelPath: "ne.txt", ModTimeMillis: 1000},
"bad.txt": {RelPath: "bad.txt", ModTimeMillis: 1000},
}
remote := map[string]*remoteFile{
"eq.txt": {RelPath: "eq.txt", ModifiedTime: 1000, ModifiedTimeValid: true},
"ne.txt": {RelPath: "ne.txt", ModifiedTime: 2000, ModifiedTimeValid: true},
"bad.txt": {RelPath: "bad.txt", ModifiedTime: 1000, ModifiedTimeValid: false},
}
res, err := compareTrees(local, remote, "quick", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPaths(t, "unchanged", res.Unchanged, "eq.txt")
// ne.txt 时间不等、bad.txt 远端时间不可信 → 都记为 modified
assertPaths(t, "modified", res.Modified, "bad.txt", "ne.txt")
}
func TestCrossPlatformCoverageCompareTrees_emptyRemote_allNewLocal(t *testing.T) {
local := map[string]*localFile{
"a.txt": {RelPath: "a.txt", Hash: "h1"},
"b.txt": {RelPath: "b.txt", Hash: "h2"},
}
res, err := compareTrees(local, map[string]*remoteFile{}, "exact", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPaths(t, "new_local", res.NewLocal, "a.txt", "b.txt")
if len(res.NewRemote) != 0 || len(res.Modified) != 0 || len(res.Unchanged) != 0 {
t.Error("with empty remote, only new_local should be populated")
}
}
// ──────────────────────────────────────────────────────────
// helpers
// ──────────────────────────────────────────────────────────
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func keys(m map[string]*localFile) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func assertPaths(t *testing.T, label string, entries []driveStatusEntry, want ...string) {
t.Helper()
if len(entries) != len(want) {
t.Fatalf("%s: got %d entries %v, want %v", label, len(entries), entryPaths(entries), want)
}
for i, w := range want {
if entries[i].RelPath != w {
t.Errorf("%s[%d] = %q, want %q", label, i, entries[i].RelPath, w)
}
}
}
func entryPaths(entries []driveStatusEntry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.RelPath
}
return out
}
// ──────────────────────────────────────────────────────────
// parseDriveList — list_files 返回解析
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageParseDriveList_resultItems(t *testing.T) {
text := `{"result":{"items":[{"name":"a.txt","type":"file"},{"name":"sub","type":"folder"}],"nextToken":"tok"}}`
items, token, err := parseDriveList(text)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
if token != "tok" {
t.Errorf("token = %q, want tok", token)
}
}
func TestCrossPlatformCoverageParseDriveList_topLevelArray(t *testing.T) {
text := `{"result":[{"name":"a.txt","type":"file"}]}`
items, token, err := parseDriveList(text)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 1 || items[0].name() != "a.txt" {
t.Errorf("unexpected items: %v", items)
}
if token != "" {
t.Errorf("expected empty token, got %q", token)
}
}
func TestCrossPlatformCoverageParseDriveList_invalid(t *testing.T) {
if _, _, err := parseDriveList("not json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
// ──────────────────────────────────────────────────────────
// driveItem — 字段访问器(带 fallback)
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageDriveItem_accessors(t *testing.T) {
it := driveItem{
"name": "报告.pdf",
"type": "file",
"fileId": "d-123",
"md5": "abc",
"path": "/dir/报告.pdf",
"modifyTime": float64(1700000000000),
}
if it.name() != "报告.pdf" {
t.Errorf("name() = %q", it.name())
}
if it.id() != "d-123" {
t.Errorf("id() = %q", it.id())
}
if it.typ() != "file" {
t.Errorf("typ() = %q, want file", it.typ())
}
if it.hash() != "abc" {
t.Errorf("hash() = %q", it.hash())
}
if it.path() != "/dir/报告.pdf" {
t.Errorf("path() = %q", it.path())
}
if it.isFolder() {
t.Error("type=file should not be a folder")
}
if !it.isFile() {
t.Error("type=file should be a file")
}
ms, ok := it.modifiedMillis()
if !ok || ms != 1700000000000 {
t.Errorf("modifiedMillis() = %d,%v", ms, ok)
}
}
func TestCrossPlatformCoverageDriveItem_folderAndFile(t *testing.T) {
folder := driveItem{"name": "sub", "type": "folder"}
if !folder.isFolder() || folder.isFile() {
t.Error("type=folder should be folder, not file")
}
file := driveItem{"name": "a.txt", "type": "file"}
if !file.isFile() || file.isFolder() {
t.Error("type=file should be file, not folder")
}
// 非 file / 非 folder 的类型(在线文档、快捷方式等)都不纳入比对。
doc := driveItem{"name": "设计稿", "type": "docx"}
if doc.isFile() {
t.Error("docx should not be treated as file")
}
shortcut := driveItem{"name": "快捷方式", "type": "shortcut"}
if shortcut.isFile() {
t.Error("shortcut should not be treated as file")
}
}
// ──────────────────────────────────────────────────────────
// toMillis — 时间字段归一化
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageToMillis(t *testing.T) {
cases := []struct {
name string
in any
want int64
ok bool
}{
{"epoch-ms-number", float64(1700000000000), 1700000000000, true},
{"epoch-ms-string", "1700000000000", 1700000000000, true},
{"rfc3339", "2023-11-14T22:13:20Z", 1700000000000, true},
{"zero", float64(0), 0, false},
{"empty", "", 0, false},
{"garbage", "not-a-time", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := toMillis(c.in)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("toMillis(%v) = %d,%v; want %d,%v", c.in, got, ok, c.want, c.ok)
}
})
}
}