package review import ( "context" "errors" "testing" ) // mockFetcher implements PersonaFetcher for testing. type mockFetcher struct { contents map[string][]ContentEntry // path -> entries files map[string]string // path -> content listErr error // error to return from ListContents getFileErr map[string]error // path -> error for GetFileContent listNotFound bool // return 404-style error } func newMockFetcher() *mockFetcher { return &mockFetcher{ contents: make(map[string][]ContentEntry), files: make(map[string]string), getFileErr: make(map[string]error), } } func (m *mockFetcher) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) { if m.listNotFound { return nil, errors.New("HTTP 404: not found") } if m.listErr != nil { return nil, m.listErr } entries, ok := m.contents[path] if !ok { return nil, errors.New("HTTP 404: not found") } return entries, nil } func (m *mockFetcher) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) { if err, ok := m.getFileErr[filepath]; ok { return "", err } content, ok := m.files[filepath] if !ok { return "", errors.New("HTTP 404: file not found") } return content, nil } func TestLoadRemotePersonas_NoDirectory(t *testing.T) { fetcher := newMockFetcher() fetcher.listNotFound = true result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("expected no error for missing directory, got: %v", err) } if len(result) != 0 { t.Errorf("expected empty map, got %d personas", len(result)) } } func TestLoadRemotePersonas_EmptyDirectory(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{} result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 0 { t.Errorf("expected empty map, got %d personas", len(result)) } } func TestLoadRemotePersonas_SinglePersona(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"}, } fetcher.files[".review-bot/personas/trading.yaml"] = ` name: trading display_name: Trading Expert identity: You are a trading systems expert. focus: - order execution - market data ` result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 1 { t.Fatalf("expected 1 persona, got %d", len(result)) } if result["trading"] == nil { t.Fatal("expected 'trading' persona") } if result["trading"].DisplayName != "Trading Expert" { t.Errorf("expected display name 'Trading Expert', got %q", result["trading"].DisplayName) } } func TestLoadRemotePersonas_MultiplePersonas(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "one.yaml", Path: ".review-bot/personas/one.yaml", Type: "file"}, {Name: "two.yml", Path: ".review-bot/personas/two.yml", Type: "file"}, } fetcher.files[".review-bot/personas/one.yaml"] = ` name: one identity: First persona. ` fetcher.files[".review-bot/personas/two.yml"] = ` name: two identity: Second persona. ` result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 2 { t.Fatalf("expected 2 personas, got %d", len(result)) } if result["one"] == nil || result["two"] == nil { t.Error("expected both personas to be loaded") } } func TestLoadRemotePersonas_SkipsNonYAML(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"}, {Name: "readme.md", Path: ".review-bot/personas/readme.md", Type: "file"}, {Name: "config.json", Path: ".review-bot/personas/config.json", Type: "file"}, } fetcher.files[".review-bot/personas/valid.yaml"] = ` name: valid identity: Valid persona. ` result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 1 { t.Fatalf("expected 1 persona (skipping non-YAML), got %d", len(result)) } } func TestLoadRemotePersonas_SkipsDirectories(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"}, {Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"}, } fetcher.files[".review-bot/personas/valid.yaml"] = ` name: valid identity: Valid persona. ` result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 1 { t.Fatalf("expected 1 persona (skipping dir), got %d", len(result)) } } func TestLoadRemotePersonas_SkipsInvalidYAML(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"}, {Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"}, } fetcher.files[".review-bot/personas/valid.yaml"] = ` name: valid identity: Valid persona. ` fetcher.files[".review-bot/personas/invalid.yaml"] = ` this is not valid yaml: [unclosed bracket ` result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 1 { t.Fatalf("expected 1 persona (skipping invalid), got %d", len(result)) } if result["valid"] == nil { t.Error("expected valid persona to be loaded") } } func TestLoadRemotePersonas_SkipsOversizedFiles(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"}, } // Create content larger than MaxPersonaFileSize (64KB) fetcher.files[".review-bot/personas/huge.yaml"] = ` name: huge identity: ` + string(make([]byte, MaxPersonaFileSize+1000)) result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 0 { t.Errorf("expected 0 personas (oversized file skipped), got %d", len(result)) } } func TestLoadRemotePersonas_SkipsFetchErrors(t *testing.T) { fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"}, {Name: "error.yaml", Path: ".review-bot/personas/error.yaml", Type: "file"}, } fetcher.files[".review-bot/personas/valid.yaml"] = ` name: valid identity: Valid persona. ` fetcher.getFileErr[".review-bot/personas/error.yaml"] = errors.New("network error") result, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(result) != 1 { t.Fatalf("expected 1 persona (skipping error), got %d", len(result)) } } func TestLoadRemotePersonas_ListContentsError(t *testing.T) { fetcher := newMockFetcher() fetcher.listErr = errors.New("server error") _, err := LoadRemotePersonas(context.Background(), fetcher, "owner", "repo") if err == nil { t.Fatal("expected error for list contents failure") } } func TestLoadRemotePersonas_ContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately fetcher := newMockFetcher() fetcher.contents[DefaultPersonasPath] = []ContentEntry{ {Name: "one.yaml", Path: ".review-bot/personas/one.yaml", Type: "file"}, } fetcher.files[".review-bot/personas/one.yaml"] = ` name: one identity: One. ` _, err := LoadRemotePersonas(ctx, fetcher, "owner", "repo") if err == nil { t.Fatal("expected context cancellation error") } } func TestMergePersonas_NoOverlap(t *testing.T) { remote := map[string]*Persona{ "trading": {Name: "trading", Identity: "Trading expert."}, } builtin := map[string]*Persona{ "security": {Name: "security", Identity: "Security expert."}, } merged, names := MergePersonas(remote, builtin) if len(merged) != 2 { t.Fatalf("expected 2 personas, got %d", len(merged)) } if len(names) != 2 { t.Fatalf("expected 2 names, got %d", len(names)) } // Names should be sorted if names[0] != "security" || names[1] != "trading" { t.Errorf("expected sorted names [security, trading], got %v", names) } } func TestMergePersonas_RemoteOverridesBuiltin(t *testing.T) { remote := map[string]*Persona{ "security": {Name: "security", Identity: "Custom security expert."}, } builtin := map[string]*Persona{ "security": {Name: "security", Identity: "Default security expert."}, } merged, _ := MergePersonas(remote, builtin) if merged["security"].Identity != "Custom security expert." { t.Errorf("expected remote to override builtin, got identity: %q", merged["security"].Identity) } } func TestMergePersonas_EmptyRemote(t *testing.T) { remote := map[string]*Persona{} builtin := map[string]*Persona{ "security": {Name: "security", Identity: "Security."}, } merged, names := MergePersonas(remote, builtin) if len(merged) != 1 { t.Fatalf("expected 1 persona, got %d", len(merged)) } if names[0] != "security" { t.Errorf("expected 'security', got %q", names[0]) } } func TestMergePersonas_EmptyBuiltin(t *testing.T) { remote := map[string]*Persona{ "trading": {Name: "trading", Identity: "Trading."}, } builtin := map[string]*Persona{} merged, names := MergePersonas(remote, builtin) if len(merged) != 1 { t.Fatalf("expected 1 persona, got %d", len(merged)) } if names[0] != "trading" { t.Errorf("expected 'trading', got %q", names[0]) } } func TestLoadAllBuiltinPersonas(t *testing.T) { personas := LoadAllBuiltinPersonas() // Should load at least the known built-in personas expected := []string{"architect", "docs", "security"} for _, name := range expected { if personas[name] == nil { t.Errorf("expected built-in persona %q to be loaded", name) } } } func TestIsYAMLFile(t *testing.T) { tests := []struct { name string expected bool }{ {"test.yaml", true}, {"test.yml", true}, {"test.YAML", true}, {"test.YML", true}, {"test.json", false}, {"test.md", false}, {"yaml", false}, {"", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := isYAMLFile(tc.name); got != tc.expected { t.Errorf("isYAMLFile(%q) = %v, want %v", tc.name, got, tc.expected) } }) } } func TestIsNotFoundError(t *testing.T) { tests := []struct { name string err error expected bool }{ {"nil error", nil, false}, {"HTTP 404", errors.New("HTTP 404: not found"), true}, {"not found text", errors.New("path not found"), false}, {"server error", errors.New("server error"), false}, {"HTTP 500", errors.New("HTTP 500: internal error"), false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := isNotFoundError(tc.err); got != tc.expected { t.Errorf("isNotFoundError(%v) = %v, want %v", tc.err, got, tc.expected) } }) } }