package vcs_test import ( "context" "strings" "fmt" "testing" "gitea.weiker.me/rodin/review-bot/vcs" ) // mockFileReader implements vcs.FileReader for testing. type mockFileReader struct { contents map[string][]vcs.ContentEntry // path -> entries files map[string]string // path -> content } func (m *mockFileReader) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) { content, ok := m.files[path] if !ok { return "", fmt.Errorf("HTTP 404: file not found: %s", path) } return content, nil } func (m *mockFileReader) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) { entries, ok := m.contents[path] if !ok { return nil, fmt.Errorf("HTTP 404: path not found: %s", path) } return entries, nil } func TestGetAllFilesInPath(t *testing.T) { ctx := context.Background() t.Run("empty directory", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "src": {}, }, } result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 0 { t.Errorf("expected empty map, got %d entries", len(result)) } }) t.Run("flat directory", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "src": { {Name: "main.go", Path: "src/main.go", Type: "file"}, {Name: "util.go", Path: "src/util.go", Type: "file"}, }, }, files: map[string]string{ "src/main.go": "package main", "src/util.go": "package main\n// util", }, } result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 2 { t.Fatalf("expected 2 files, got %d", len(result)) } if result["src/main.go"] != "package main" { t.Errorf("main.go content = %q", result["src/main.go"]) } if result["src/util.go"] != "package main\n// util" { t.Errorf("util.go content = %q", result["src/util.go"]) } }) t.Run("nested directories", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "src": { {Name: "main.go", Path: "src/main.go", Type: "file"}, {Name: "pkg", Path: "src/pkg", Type: "dir"}, }, "src/pkg": { {Name: "lib.go", Path: "src/pkg/lib.go", Type: "file"}, {Name: "sub", Path: "src/pkg/sub", Type: "dir"}, }, "src/pkg/sub": { {Name: "deep.go", Path: "src/pkg/sub/deep.go", Type: "file"}, }, }, files: map[string]string{ "src/main.go": "package main", "src/pkg/lib.go": "package pkg", "src/pkg/sub/deep.go": "package sub", }, } result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 3 { t.Fatalf("expected 3 files, got %d", len(result)) } if result["src/main.go"] != "package main" { t.Errorf("main.go content = %q", result["src/main.go"]) } if result["src/pkg/lib.go"] != "package pkg" { t.Errorf("lib.go content = %q", result["src/pkg/lib.go"]) } if result["src/pkg/sub/deep.go"] != "package sub" { t.Errorf("deep.go content = %q", result["src/pkg/sub/deep.go"]) } }) t.Run("mixed files and dirs", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "root": { {Name: "README.md", Path: "root/README.md", Type: "file"}, {Name: "docs", Path: "root/docs", Type: "dir"}, {Name: "config.yaml", Path: "root/config.yaml", Type: "file"}, }, "root/docs": { {Name: "guide.md", Path: "root/docs/guide.md", Type: "file"}, }, }, files: map[string]string{ "root/README.md": "# Hello", "root/config.yaml": "key: value", "root/docs/guide.md": "## Guide", }, } result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "root") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 3 { t.Fatalf("expected 3 files, got %d", len(result)) } if result["root/README.md"] != "# Hello" { t.Errorf("README content = %q", result["root/README.md"]) } if result["root/docs/guide.md"] != "## Guide" { t.Errorf("guide content = %q", result["root/docs/guide.md"]) } }) } func TestBuildLineToPositionMap(t *testing.T) { t.Run("single hunk", func(t *testing.T) { diff := "diff --git a/file.go b/file.go\nindex abc..def 100644\n--- a/file.go\n+++ b/file.go\n@@ -1,3 +1,4 @@\n package main\n \n+// new comment\n func main() {}\n" result := vcs.BuildLineToPositionMap(diff) fileMap, ok := result["file.go"] if !ok { t.Fatal("expected file.go in result") } // Hunk header @@ is position 1 // Line 1: " package main" -> position 2 if fileMap[1] != 2 { t.Errorf("line 1 position = %d, want 2", fileMap[1]) } // Line 2: " " (context) -> position 3 if fileMap[2] != 3 { t.Errorf("line 2 position = %d, want 3", fileMap[2]) } // Line 3: "+// new comment" -> position 4 if fileMap[3] != 4 { t.Errorf("line 3 position = %d, want 4", fileMap[3]) } // Line 4: " func main() {}" -> position 5 if fileMap[4] != 5 { t.Errorf("line 4 position = %d, want 5", fileMap[4]) } }) t.Run("multi hunk", func(t *testing.T) { diff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1,3 +1,3 @@\n package main\n \n-// old\n+// new\n@@ -10,3 +10,4 @@\n func foo() {\n+\t// added\n \treturn\n }\n" result := vcs.BuildLineToPositionMap(diff) fileMap, ok := result["file.go"] if !ok { t.Fatal("expected file.go in result") } // First hunk: @@ is position 1 // Line 1: " package main" -> position 2 if fileMap[1] != 2 { t.Errorf("line 1 position = %d, want 2", fileMap[1]) } // Line 3: "+// new" -> position 5 (after " ", "-// old" at pos 3,4) if fileMap[3] != 5 { t.Errorf("line 3 position = %d, want 5", fileMap[3]) } // Second hunk: @@ is position 6 // Line 10: " func foo() {" -> position 7 if fileMap[10] != 7 { t.Errorf("line 10 position = %d, want 7", fileMap[10]) } // Line 11: "+\t// added" -> position 8 if fileMap[11] != 8 { t.Errorf("line 11 position = %d, want 8", fileMap[11]) } }) t.Run("deletion lines not in map", func(t *testing.T) { diff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1,4 +1,3 @@\n package main\n \n-// deleted line\n func main() {}\n" result := vcs.BuildLineToPositionMap(diff) fileMap, ok := result["file.go"] if !ok { t.Fatal("expected file.go in result") } // Line 1: " package main" -> position 2 if fileMap[1] != 2 { t.Errorf("line 1 position = %d, want 2", fileMap[1]) } // Line 3 in new file: " func main() {}" -> position 5 (after deletion at pos 4) if fileMap[3] != 5 { t.Errorf("line 3 position = %d, want 5", fileMap[3]) } // Should only have 3 entries (lines 1, 2, 3 of new file) if len(fileMap) != 3 { t.Errorf("expected 3 mapped lines, got %d: %v", len(fileMap), fileMap) } }) t.Run("multiple files", func(t *testing.T) { diff := "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1,2 +1,3 @@\n package a\n \n+// file a\ndiff --git a/b.go b/b.go\n--- a/b.go\n+++ b/b.go\n@@ -1,2 +1,3 @@\n package b\n \n+// file b\n" result := vcs.BuildLineToPositionMap(diff) if len(result) != 2 { t.Fatalf("expected 2 files, got %d", len(result)) } aMap, ok := result["a.go"] if !ok { t.Fatal("expected a.go in result") } bMap, ok := result["b.go"] if !ok { t.Fatal("expected b.go in result") } // a.go line 3: "+// file a" -> position 4 if aMap[3] != 4 { t.Errorf("a.go line 3 position = %d, want 4", aMap[3]) } // b.go line 3: "+// file b" -> position 4 if bMap[3] != 4 { t.Errorf("b.go line 3 position = %d, want 4", bMap[3]) } }) } func TestGetAllFilesInPath_ErrorPropagation(t *testing.T) { ctx := context.Background() t.Run("ListContents error propagates", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ // "src" not in map, so ListContents will fail }, } _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "list contents") { t.Errorf("expected error about list contents, got: %v", err) } }) t.Run("GetFileContent error propagates", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "src": { {Name: "main.go", Path: "src/main.go", Type: "file"}, }, }, files: map[string]string{ // "src/main.go" not in files map, so GetFileContent will fail }, } _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "get file") { t.Errorf("expected error about get file, got: %v", err) } }) t.Run("nested ListContents error propagates", func(t *testing.T) { client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "src": { {Name: "pkg", Path: "src/pkg", Type: "dir"}, }, // "src/pkg" not in map, so recursive ListContents will fail }, } _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "list contents") { t.Errorf("expected error about list contents, got: %v", err) } }) t.Run("cancelled context propagates", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately client := &mockFileReader{ contents: map[string][]vcs.ContentEntry{ "src": { {Name: "main.go", Path: "src/main.go", Type: "file"}, }, }, files: map[string]string{ "src/main.go": "package main", }, } _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src") if err == nil { t.Fatal("expected error from cancelled context, got nil") } if !strings.Contains(err.Error(), "context cancelled") { t.Errorf("expected context cancellation error, got: %v", err) } }) }