e3fb19fa1b
CI / test (push) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
444 lines
12 KiB
Go
444 lines
12 KiB
Go
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParsePersonaBytes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data string
|
|
source string
|
|
wantName string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "valid yaml",
|
|
data: `name: test
|
|
identity: test identity
|
|
focus:
|
|
- testing
|
|
`,
|
|
source: "test.yaml",
|
|
wantName: "test",
|
|
},
|
|
{
|
|
name: "missing name",
|
|
data: "identity: test\n",
|
|
source: "test.yaml",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "invalid yaml",
|
|
data: "not: valid:\n yaml: [broken",
|
|
source: "test.yaml",
|
|
wantErr: "parse",
|
|
},
|
|
{
|
|
name: "json format by extension",
|
|
data: `{"name": "jsontest", "identity": "json identity"}`,
|
|
source: "test.json",
|
|
wantName: "jsontest",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p, err := ParsePersonaBytes([]byte(tt.data), tt.source)
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if p.Name != tt.wantName {
|
|
t.Errorf("Name = %q, want %q", p.Name, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockGiteaClient implements GiteaClient for testing.
|
|
type mockGiteaClient struct {
|
|
contents map[string][]ContentEntry // path -> entries
|
|
files map[string]string // path -> content
|
|
listErr error
|
|
fileErr map[string]error // path -> error
|
|
}
|
|
|
|
func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
|
if m.listErr != nil {
|
|
return nil, m.listErr
|
|
}
|
|
entries, ok := m.contents[path]
|
|
if !ok {
|
|
return nil, errors.New("list contents .review-bot/personas: HTTP 404: not found")
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
|
if m.fileErr != nil {
|
|
if err, ok := m.fileErr[filepath]; ok {
|
|
return "", err
|
|
}
|
|
}
|
|
content, ok := m.files[filepath]
|
|
if !ok {
|
|
return "", errors.New("HTTP 404: file not found")
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func TestLoadRepoPersonas(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("directory not found returns empty map", func(t *testing.T) {
|
|
client := &mockGiteaClient{} // No contents configured -> 404
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if personas == nil {
|
|
t.Error("expected empty map, got nil")
|
|
}
|
|
if len(personas) != 0 {
|
|
t.Errorf("expected 0 personas, got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("empty directory returns empty map", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {},
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 0 {
|
|
t.Errorf("expected 0 personas, got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("loads valid personas", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"},
|
|
{Name: "crypto.yaml", Path: ".review-bot/personas/crypto.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/trading.yaml": `name: trading
|
|
display_name: Trading Expert
|
|
identity: You are a trading expert.
|
|
focus:
|
|
- order handling
|
|
- risk management
|
|
`,
|
|
".review-bot/personas/crypto.yaml": `name: crypto
|
|
display_name: Crypto Expert
|
|
identity: You are a cryptography expert.
|
|
focus:
|
|
- key management
|
|
- encryption
|
|
`,
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 2 {
|
|
t.Fatalf("expected 2 personas, got %d", len(personas))
|
|
}
|
|
if personas["trading"] == nil {
|
|
t.Error("expected trading persona")
|
|
}
|
|
if personas["crypto"] == nil {
|
|
t.Error("expected crypto persona")
|
|
}
|
|
if personas["trading"].DisplayName != "Trading Expert" {
|
|
t.Errorf("trading display name = %q, want %q", personas["trading"].DisplayName, "Trading Expert")
|
|
}
|
|
})
|
|
|
|
t.Run("skips invalid persona files", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
|
|
{Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/valid.yaml": `name: valid
|
|
identity: Valid persona
|
|
`,
|
|
".review-bot/personas/invalid.yaml": "not valid yaml: [broken",
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should have the valid one, skip the invalid
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (skipped invalid), got %d", len(personas))
|
|
}
|
|
if personas["valid"] == nil {
|
|
t.Error("expected valid persona")
|
|
}
|
|
})
|
|
|
|
t.Run("skips non-yaml files", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
|
|
{Name: "README.md", Path: ".review-bot/personas/README.md", Type: "file"},
|
|
{Name: "notes.txt", Path: ".review-bot/personas/notes.txt", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/persona.yaml": `name: test
|
|
identity: Test persona
|
|
`,
|
|
".review-bot/personas/README.md": "# Personas\n\nPut your personas here.",
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (yaml only), got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("skips subdirectories", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
|
|
{Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/persona.yaml": `name: test
|
|
identity: Test persona
|
|
`,
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (files only), got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("propagates auth errors", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
listErr: errors.New("HTTP 401: unauthorized"),
|
|
}
|
|
_, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err == nil {
|
|
t.Fatal("expected error for auth failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") {
|
|
t.Errorf("error = %q, want containing '401'", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("skips files that fail to fetch", func(t *testing.T) {
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "good.yaml", Path: ".review-bot/personas/good.yaml", Type: "file"},
|
|
{Name: "bad.yaml", Path: ".review-bot/personas/bad.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/good.yaml": `name: good
|
|
identity: Good persona
|
|
`,
|
|
},
|
|
fileErr: map[string]error{
|
|
".review-bot/personas/bad.yaml": errors.New("HTTP 500: internal server error"),
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (skipped failed fetch), got %d", len(personas))
|
|
}
|
|
})
|
|
|
|
t.Run("skips oversized files", func(t *testing.T) {
|
|
// Create a content string that exceeds MaxPersonaFileSize (64KB)
|
|
oversizedContent := strings.Repeat("a", MaxPersonaFileSize+1)
|
|
client := &mockGiteaClient{
|
|
contents: map[string][]ContentEntry{
|
|
RepoPersonaPath: {
|
|
{Name: "normal.yaml", Path: ".review-bot/personas/normal.yaml", Type: "file"},
|
|
{Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
".review-bot/personas/normal.yaml": `name: normal
|
|
identity: Normal sized persona
|
|
`,
|
|
".review-bot/personas/huge.yaml": oversizedContent,
|
|
},
|
|
}
|
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should have the normal one, skip the oversized
|
|
if len(personas) != 1 {
|
|
t.Fatalf("expected 1 persona (skipped oversized), got %d", len(personas))
|
|
}
|
|
if personas["normal"] == nil {
|
|
t.Error("expected normal persona")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMergePersonas(t *testing.T) {
|
|
builtin := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Built-in security"},
|
|
"docs": {Name: "docs", Identity: "Built-in docs"},
|
|
}
|
|
repo := map[string]*Persona{
|
|
"security": {Name: "security", Identity: "Repo security override"},
|
|
"trading": {Name: "trading", Identity: "Repo trading"},
|
|
}
|
|
|
|
merged := MergePersonas(builtin, repo)
|
|
|
|
t.Run("repo overrides builtin on collision", func(t *testing.T) {
|
|
if merged["security"].Identity != "Repo security override" {
|
|
t.Errorf("security identity = %q, want repo override", merged["security"].Identity)
|
|
}
|
|
})
|
|
|
|
t.Run("builtin preserved when no collision", func(t *testing.T) {
|
|
if merged["docs"].Identity != "Built-in docs" {
|
|
t.Errorf("docs identity = %q, want built-in", merged["docs"].Identity)
|
|
}
|
|
})
|
|
|
|
t.Run("repo-only persona added", func(t *testing.T) {
|
|
if merged["trading"] == nil {
|
|
t.Error("expected trading persona from repo")
|
|
}
|
|
if merged["trading"].Identity != "Repo trading" {
|
|
t.Errorf("trading identity = %q, want repo", merged["trading"].Identity)
|
|
}
|
|
})
|
|
|
|
t.Run("original maps not modified", func(t *testing.T) {
|
|
if builtin["trading"] != nil {
|
|
t.Error("builtin map was modified")
|
|
}
|
|
if len(repo) != 2 {
|
|
t.Error("repo map was modified")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetBuiltinPersonasMap(t *testing.T) {
|
|
personas := GetBuiltinPersonasMap()
|
|
|
|
if len(personas) == 0 {
|
|
t.Fatal("expected at least one built-in persona")
|
|
}
|
|
|
|
// Verify expected personas exist
|
|
expected := []string{"security", "architect", "docs"}
|
|
for _, name := range expected {
|
|
if personas[name] == nil {
|
|
t.Errorf("expected built-in persona %q", name)
|
|
}
|
|
}
|
|
|
|
// Verify personas are valid
|
|
for name, p := range personas {
|
|
if p.Name != name {
|
|
t.Errorf("persona %q has mismatched name %q", name, p.Name)
|
|
}
|
|
if p.Identity == "" {
|
|
t.Errorf("persona %q has empty identity", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsYAMLFile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
want bool
|
|
}{
|
|
{"test.yaml", true},
|
|
{"test.yml", true},
|
|
{"test.YAML", true},
|
|
{"test.YML", true},
|
|
{"test.json", false},
|
|
{"test.md", false},
|
|
{"test.txt", false},
|
|
{"yaml", false},
|
|
{"yaml.md", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isYAMLFile(tt.name); got != tt.want {
|
|
t.Errorf("isYAMLFile(%q) = %v, want %v", tt.name, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNotFoundError(t *testing.T) {
|
|
tests := []struct {
|
|
err error
|
|
want bool
|
|
}{
|
|
{nil, false},
|
|
{errors.New("HTTP 404: not found"), true},
|
|
{errors.New("HTTP 404"), true},
|
|
// Intentionally false: generic "not found" could mask auth/transport errors.
|
|
// Only explicit HTTP 404 responses should be treated as "directory doesn't exist".
|
|
{errors.New("something not found"), false},
|
|
{errors.New("HTTP 401: unauthorized"), false},
|
|
{errors.New("connection refused"), false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
name := "nil"
|
|
if tt.err != nil {
|
|
name = tt.err.Error()
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
if got := isNotFoundError(tt.err); got != tt.want {
|
|
t.Errorf("isNotFoundError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|