package review import ( "context" "errors" "os" "path/filepath" "strings" "testing" ) // fakeDocFetcher is a mock DocFetcher for tests. type fakeDocFetcher struct { files map[string]string // path -> content dirs map[string]map[string]string // dir path -> (file path -> content) } func (f *fakeDocFetcher) GetFileContent(_ context.Context, _, _, path string) (string, error) { if content, ok := f.files[path]; ok { return content, nil } return "", errors.New("file not found: " + path) } func (f *fakeDocFetcher) GetAllFilesInPath(_ context.Context, _, _, path string) (map[string]string, error) { if files, ok := f.dirs[path]; ok { return files, nil } // Return empty (not an error) for unknown directories. return nil, nil } // ============================================================ // ParseDocMapConfig // ============================================================ func TestParseDocMapConfig_Valid(t *testing.T) { yaml := ` mappings: - paths: - "lib/foo/**" docs: - docs/foo.md - paths: - "lib/bar/**" - "lib/baz.go" docs: - docs/bar.md - docs/shared/ ` f := writeTempYAML(t, yaml) cfg, err := ParseDocMapConfig(f) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(cfg.Mappings) != 2 { t.Fatalf("expected 2 mappings, got %d", len(cfg.Mappings)) } if cfg.Mappings[0].Paths[0] != "lib/foo/**" { t.Errorf("unexpected path: %q", cfg.Mappings[0].Paths[0]) } if cfg.Mappings[1].Docs[1] != "docs/shared/" { t.Errorf("unexpected doc: %q", cfg.Mappings[1].Docs[1]) } } func TestParseDocMapConfig_InvalidYAML(t *testing.T) { f := writeTempYAML(t, "mappings: [{{invalid") _, err := ParseDocMapConfig(f) if err == nil { t.Fatal("expected error for invalid YAML, got nil") } } func TestParseDocMapConfig_EmptyMappings(t *testing.T) { f := writeTempYAML(t, "mappings: []\n") cfg, err := ParseDocMapConfig(f) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(cfg.Mappings) != 0 { t.Errorf("expected 0 mappings, got %d", len(cfg.Mappings)) } } func TestParseDocMapConfig_UnknownKeys(t *testing.T) { // Unknown keys should produce a warning but not fail. yaml := ` mappings: - paths: ["lib/foo/**"] docs: ["docs/foo.md"] extra_key: ignored ` f := writeTempYAML(t, yaml) // Should succeed (lenient parsing). cfg, err := ParseDocMapConfig(f) if err != nil { t.Fatalf("unexpected error for unknown keys: %v", err) } if len(cfg.Mappings) != 1 { t.Errorf("expected 1 mapping, got %d", len(cfg.Mappings)) } } func TestParseDocMapConfig_FileNotFound(t *testing.T) { _, err := ParseDocMapConfig("/nonexistent/path/doc-map.yml") if err == nil { t.Fatal("expected error for missing file, got nil") } } // ============================================================ // MatchDocs // ============================================================ func TestMatchDocs_NoMatch(t *testing.T) { cfg := &DocMapConfig{ Mappings: []DocMapping{ {Paths: []string{"lib/foo/**"}, Docs: []string{"docs/foo.md"}}, }, } got := MatchDocs(cfg, []string{"lib/bar/baz.go"}) if len(got) != 0 { t.Errorf("expected no matches, got %v", got) } } func TestMatchDocs_SingleMatch(t *testing.T) { cfg := &DocMapConfig{ Mappings: []DocMapping{ {Paths: []string{"lib/foo/**"}, Docs: []string{"docs/foo.md"}}, }, } got := MatchDocs(cfg, []string{"lib/foo/bar.go"}) if len(got) != 1 || got[0] != "docs/foo.md" { t.Errorf("expected [docs/foo.md], got %v", got) } } func TestMatchDocs_MultipleMatchesDeduplicated(t *testing.T) { cfg := &DocMapConfig{ Mappings: []DocMapping{ {Paths: []string{"lib/foo/**"}, Docs: []string{"docs/shared.md", "docs/foo.md"}}, {Paths: []string{"lib/bar/**"}, Docs: []string{"docs/shared.md", "docs/bar.md"}}, }, } got := MatchDocs(cfg, []string{"lib/foo/a.go", "lib/bar/b.go"}) // Both match; docs/shared.md should appear only once. wantSet := map[string]bool{ "docs/shared.md": true, "docs/foo.md": true, "docs/bar.md": true, } if len(got) != 3 { t.Errorf("expected 3 docs, got %d: %v", len(got), got) } for _, d := range got { if !wantSet[d] { t.Errorf("unexpected doc: %q", d) } } } func TestMatchDocs_EmptyPaths(t *testing.T) { // Mapping with empty paths list should not match anything. cfg := &DocMapConfig{ Mappings: []DocMapping{ {Paths: []string{}, Docs: []string{"docs/foo.md"}}, }, } got := MatchDocs(cfg, []string{"lib/foo/bar.go"}) if len(got) != 0 { t.Errorf("expected no matches for empty paths, got %v", got) } } func TestMatchDocs_EmptyDocs(t *testing.T) { // Mapping with empty docs list should produce nothing. cfg := &DocMapConfig{ Mappings: []DocMapping{ {Paths: []string{"lib/foo/**"}, Docs: []string{}}, }, } got := MatchDocs(cfg, []string{"lib/foo/bar.go"}) if len(got) != 0 { t.Errorf("expected no docs for empty docs list, got %v", got) } } func TestMatchDocs_ExactMatch(t *testing.T) { cfg := &DocMapConfig{ Mappings: []DocMapping{ {Paths: []string{"lib/baz.go"}, Docs: []string{"docs/baz.md"}}, }, } got := MatchDocs(cfg, []string{"lib/baz.go"}) if len(got) != 1 || got[0] != "docs/baz.md" { t.Errorf("expected [docs/baz.md], got %v", got) } } // ============================================================ // globMatch // ============================================================ func TestGlobMatch(t *testing.T) { tests := []struct { name string pattern string path string want bool }{ {"exact match", "lib/foo/bar.go", "lib/foo/bar.go", true}, {"exact no match", "lib/foo/bar.go", "lib/foo/baz.go", false}, {"star wildcard", "lib/foo/*.go", "lib/foo/bar.go", true}, {"star no match cross-dir", "lib/foo/*.go", "lib/foo/sub/bar.go", false}, {"trailing doublestar", "lib/foo/**", "lib/foo/bar.go", true}, {"trailing doublestar nested", "lib/foo/**", "lib/foo/sub/deep/bar.go", true}, // Note: trailing ** matches the parent path too; PR file lists contain file paths // (not directories), so this corner case does not arise in practice. {"trailing doublestar matches parent", "lib/foo/**", "lib/foo", true}, {"doublestar in middle", "lib/**/bar.go", "lib/foo/sub/bar.go", true}, {"doublestar in middle no match", "lib/**/bar.go", "lib/foo/sub/baz.go", false}, {"leading doublestar", "**/bar.go", "lib/foo/bar.go", true}, {"leading doublestar top-level", "**/bar.go", "bar.go", true}, {"question mark", "lib/foo/ba?.go", "lib/foo/bar.go", true}, {"question mark no match", "lib/foo/ba?.go", "lib/foo/ba.go", false}, {"star matches none in segment", "lib/*/bar.go", "lib/bar.go", false}, {"star single segment", "lib/*/bar.go", "lib/foo/bar.go", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := globMatch(tc.pattern, tc.path) if got != tc.want { t.Errorf("globMatch(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want) } }) } } // ============================================================ // LoadMatchingDocs // ============================================================ func TestLoadMatchingDocs_FileInjection(t *testing.T) { fetcher := &fakeDocFetcher{ files: map[string]string{ "docs/foo.md": "# Foo Design\n\nThis is the foo doc.", }, } content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"docs/foo.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(content, "# Foo Design") { t.Errorf("expected doc content, got: %q", content) } if !strings.Contains(content, "### docs/foo.md") { t.Errorf("expected heading with path, got: %q", content) } } func TestLoadMatchingDocs_MissingFileSkipped(t *testing.T) { fetcher := &fakeDocFetcher{ files: map[string]string{ "docs/present.md": "present", }, } content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"docs/missing.md", "docs/present.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(content, "present") { t.Errorf("expected present doc content, got: %q", content) } // Missing file should be skipped, not cause a failure. } func TestLoadMatchingDocs_DirectoryExpansion(t *testing.T) { fetcher := &fakeDocFetcher{ dirs: map[string]map[string]string{ "docs/domain/": { "docs/domain/a.md": "# A", "docs/domain/b.md": "# B", "docs/domain/c.go": "package domain", // should be skipped (not .md) }, }, } content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"docs/domain/"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(content, "# A") { t.Errorf("expected doc A content, got: %q", content) } if !strings.Contains(content, "# B") { t.Errorf("expected doc B content, got: %q", content) } if strings.Contains(content, "package domain") { t.Errorf("non-.md file should not be injected, got: %q", content) } } func TestLoadMatchingDocs_DirectoryNoMDFiles(t *testing.T) { fetcher := &fakeDocFetcher{ dirs: map[string]map[string]string{ "src/": { "src/main.go": "package main", }, }, } content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"src/"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected error: %v", err) } if content != "" { t.Errorf("expected empty content for dir with no .md files, got: %q", content) } } func TestLoadMatchingDocs_NoMatchingPaths(t *testing.T) { fetcher := &fakeDocFetcher{} content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected error: %v", err) } if content != "" { t.Errorf("expected empty content for no paths, got: %q", content) } } func TestLoadMatchingDocs_ContextSizeGuard(t *testing.T) { bigContent := strings.Repeat("x", 200) fetcher := &fakeDocFetcher{ files: map[string]string{ "docs/a.md": bigContent, "docs/b.md": bigContent, "docs/c.md": bigContent, }, } // Limit to 350 bytes — enough for a.md fully and part of b.md. content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"docs/a.md", "docs/b.md", "docs/c.md"}, DocMapOptions{MaxBytes: 350}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(content) > 600 { t.Errorf("content too large, expected ≤600 bytes total, got %d", len(content)) } if !strings.Contains(content, "truncated") { t.Errorf("expected truncation notice, got: %q", content) } } func TestLoadMatchingDocs_Deduplication(t *testing.T) { fetcher := &fakeDocFetcher{ files: map[string]string{ "docs/shared.md": "shared content", }, } // MatchDocs deduplicates before calling LoadMatchingDocs, but test it with // duplicates in input too. content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"docs/shared.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(content, "shared content") { t.Errorf("expected shared content, got: %q", content) } } func TestValidateDocPath(t *testing.T) { valid := []string{ "docs/design.md", "docs/domain/contexts/risk/risk-controls.md", "README.md", "a/b/c", } for _, p := range valid { if err := ValidateDocPath(p); err != nil { t.Errorf("expected valid path %q to pass, got error: %v", p, err) } } invalid := []string{ "/etc/passwd", "/docs/design.md", "docs/../../../etc/passwd", "../sibling-repo/file.md", "a/b/../c", // Backslashes must be rejected (Finding #3 — Windows platform edge cases). `docs\foo.md`, `docs\..\secret`, `\absolute`, } for _, p := range invalid { if err := ValidateDocPath(p); err == nil { t.Errorf("expected path %q to be rejected, but it was accepted", p) } } } func TestLoadMatchingDocs_PathTraversalRejected(t *testing.T) { fetcher := &fakeDocFetcher{ files: map[string]string{ "../secret.md": "should not be fetched", }, } content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo", []string{"../secret.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes}) if err != nil { t.Fatalf("unexpected hard error: %v", err) } // Bad path should be skipped (warned), not injected. if strings.Contains(content, "should not be fetched") { t.Errorf("path traversal doc was injected, expected it to be skipped") } } // TestValidateDocPath_Backslash verifies that backslash-bearing paths are // rejected to prevent Windows platform edge cases where a path separator // could be normalised differently by the host OS or VCS backend. func TestValidateDocPath_Backslash(t *testing.T) { backslashPaths := []string{ `docs\foo.md`, `docs\subdir\file.md`, `\absolute`, } for _, p := range backslashPaths { if err := ValidateDocPath(p); err == nil { t.Errorf("expected backslash path %q to be rejected, but it was accepted", p) } } // Sanity: forward-slash path must still be accepted. if err := ValidateDocPath("docs/foo.md"); err != nil { t.Errorf("expected forward-slash path to be accepted, got: %v", err) } } // ============================================================ // Helpers // ============================================================ func writeTempYAML(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "doc-map-*.yml") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer f.Close() if _, err := f.WriteString(content); err != nil { t.Fatalf("failed to write temp file: %v", err) } return filepath.Clean(f.Name()) } // ============================================================ // FileCoveredByDocMap // ============================================================ func TestFileCoveredByDocMap(t *testing.T) { cfg := &DocMapConfig{ Mappings: []DocMapping{ { Paths: []string{"lib/foo/**", "lib/bar/*.go"}, Docs: []string{"docs/foo.md"}, }, { Paths: []string{"cmd/**"}, Docs: []string{"docs/cmd.md"}, }, }, } cases := []struct { file string covered bool }{ {"lib/foo/baz.ex", true}, {"lib/foo/sub/deep.ex", true}, {"lib/bar/util.go", true}, {"lib/bar/sub/util.go", false}, // *.go only matches one level {"cmd/main.go", true}, {"cmd/sub/main.go", true}, {"internal/secret.go", false}, {"", false}, } for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { got := FileCoveredByDocMap(cfg, tc.file) if got != tc.covered { t.Errorf("FileCoveredByDocMap(%q) = %v, want %v", tc.file, got, tc.covered) } }) } } func TestFileCoveredByDocMap_EmptyConfig(t *testing.T) { cfg := &DocMapConfig{} if FileCoveredByDocMap(cfg, "lib/foo/bar.go") { t.Error("expected false for empty config, got true") } }