cli/internal/bugseti/fetcher_test.go
Athena d13565df4c fix(bugseti): add comprehensive tests for FetcherService (#60)
Add fetcher_test.go covering: service creation, start/pause lifecycle,
calculatePriority scoring for all label types, label query construction
with custom and default labels, gh CLI JSON parsing for both list and
single-issue endpoints, channel backpressure when issuesCh is full,
fetchAll with no repos configured, and missing binary error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00

407 lines
11 KiB
Go

package bugseti
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testConfigService creates a ConfigService with in-memory config for testing.
func testConfigService(t *testing.T, repos []string, labels []string) *ConfigService {
t.Helper()
dir := t.TempDir()
cs := &ConfigService{
path: dir + "/config.json",
config: &Config{
WatchedRepos: repos,
Labels: labels,
FetchInterval: 15,
DataDir: dir,
},
}
return cs
}
// TestHelperProcess is invoked by the test binary when GO_TEST_HELPER_PROCESS
// is set. It prints the value of GO_TEST_HELPER_OUTPUT and optionally exits
// with a non-zero code. Kept for future exec.Command mocking.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
return
}
fmt.Fprint(os.Stdout, os.Getenv("GO_TEST_HELPER_OUTPUT"))
if os.Getenv("GO_TEST_HELPER_EXIT_ERROR") == "1" {
os.Exit(1)
}
os.Exit(0)
}
// ---- NewFetcherService ----
func TestNewFetcherService_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify)
require.NotNil(t, f)
assert.Equal(t, "FetcherService", f.ServiceName())
assert.False(t, f.IsRunning())
assert.NotNil(t, f.Issues())
}
// ---- Start / Pause / IsRunning lifecycle ----
func TestStartPause_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify)
require.NoError(t, f.Start())
assert.True(t, f.IsRunning())
// Starting again is a no-op.
require.NoError(t, f.Start())
assert.True(t, f.IsRunning())
f.Pause()
assert.False(t, f.IsRunning())
// Pausing again is a no-op.
f.Pause()
assert.False(t, f.IsRunning())
}
// ---- calculatePriority ----
func TestCalculatePriority_Good(t *testing.T) {
tests := []struct {
name string
labels []string
expected int
}{
{"no labels", nil, 50},
{"good first issue", []string{"good first issue"}, 80},
{"help wanted", []string{"Help Wanted"}, 70},
{"beginner", []string{"beginner-friendly"}, 75},
{"easy", []string{"Easy"}, 70},
{"bug", []string{"bug"}, 60},
{"documentation", []string{"Documentation"}, 55},
{"priority", []string{"high-priority"}, 65},
{"combined", []string{"good first issue", "bug"}, 90},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, calculatePriority(tt.labels))
})
}
}
func TestCalculatePriority_Bad(t *testing.T) {
// Unknown labels should not change priority from default.
assert.Equal(t, 50, calculatePriority([]string{"unknown-label", "something-else"}))
}
// ---- Label query construction ----
func TestLabelQuery_Good(t *testing.T) {
// When config has custom labels, fetchFromRepo should use them.
cfg := testConfigService(t, []string{"owner/repo"}, []string{"custom-label", "another"})
labels := cfg.GetLabels()
labelQuery := strings.Join(labels, ",")
assert.Equal(t, "custom-label,another", labelQuery)
}
func TestLabelQuery_Bad(t *testing.T) {
// When config has empty labels, fetchFromRepo falls back to defaults.
cfg := testConfigService(t, []string{"owner/repo"}, nil)
labels := cfg.GetLabels()
if len(labels) == 0 {
labels = []string{"good first issue", "help wanted", "beginner-friendly"}
}
labelQuery := strings.Join(labels, ",")
assert.Equal(t, "good first issue,help wanted,beginner-friendly", labelQuery)
}
// ---- fetchFromRepo with mocked gh CLI output ----
func TestFetchFromRepo_Good(t *testing.T) {
ghIssues := []struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}{
{
Number: 42,
Title: "Fix login bug",
Body: "The login page crashes",
URL: "https://github.com/test/repo/issues/42",
CreatedAt: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
},
}
ghIssues[0].Author.Login = "octocat"
ghIssues[0].Labels = []struct {
Name string `json:"name"`
}{
{Name: "good first issue"},
{Name: "bug"},
}
output, err := json.Marshal(ghIssues)
require.NoError(t, err)
// We can't easily intercept exec.CommandContext in the production code
// without refactoring, so we test the JSON parsing path by directly
// calling json.Unmarshal the same way fetchFromRepo does.
var parsed []struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
require.NoError(t, json.Unmarshal(output, &parsed))
require.Len(t, parsed, 1)
gi := parsed[0]
labels := make([]string, len(gi.Labels))
for i, l := range gi.Labels {
labels[i] = l.Name
}
issue := &Issue{
ID: fmt.Sprintf("%s#%d", "test/repo", gi.Number),
Number: gi.Number,
Repo: "test/repo",
Title: gi.Title,
Body: gi.Body,
URL: gi.URL,
Labels: labels,
Author: gi.Author.Login,
CreatedAt: gi.CreatedAt,
Priority: calculatePriority(labels),
}
assert.Equal(t, "test/repo#42", issue.ID)
assert.Equal(t, 42, issue.Number)
assert.Equal(t, "Fix login bug", issue.Title)
assert.Equal(t, "octocat", issue.Author)
assert.Equal(t, []string{"good first issue", "bug"}, issue.Labels)
assert.Equal(t, 90, issue.Priority) // 50 + 30 (good first issue) + 10 (bug)
}
func TestFetchFromRepo_Bad_InvalidJSON(t *testing.T) {
// Simulate gh returning invalid JSON.
var ghIssues []struct {
Number int `json:"number"`
}
err := json.Unmarshal([]byte(`not json at all`), &ghIssues)
assert.Error(t, err, "invalid JSON should produce an error")
}
func TestFetchFromRepo_Bad_GhNotInstalled(t *testing.T) {
// Verify that a missing executable produces an exec error.
cmd := exec.Command("gh-nonexistent-binary-12345")
_, err := cmd.Output()
assert.Error(t, err, "missing binary should produce an error")
}
// ---- fetchAll: no repos configured ----
func TestFetchAll_Bad_NoRepos(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify)
// fetchAll with no repos should not panic and should not send to channel.
f.fetchAll()
// Channel should be empty.
select {
case <-f.issuesCh:
t.Fatal("expected no issues on channel when no repos configured")
default:
// expected
}
}
// ---- Channel backpressure ----
func TestChannelBackpressure_Ugly(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify)
// Fill the channel to capacity (buffer size is 10).
for i := 0; i < 10; i++ {
f.issuesCh <- []*Issue{{ID: fmt.Sprintf("test#%d", i)}}
}
// Now try to send via the select path (same logic as fetchAll).
// This should be a non-blocking drop, not a deadlock.
done := make(chan struct{})
go func() {
defer close(done)
issues := []*Issue{{ID: "overflow#1"}}
select {
case f.issuesCh <- issues:
// Shouldn't happen — channel is full.
t.Error("expected channel send to be skipped due to backpressure")
default:
// This is the expected path — channel full, message dropped.
}
}()
select {
case <-done:
// success — did not deadlock
case <-time.After(time.Second):
t.Fatal("backpressure test timed out — possible deadlock")
}
}
// ---- FetchIssue single-issue parsing ----
func TestFetchIssue_Good_Parse(t *testing.T) {
// Test the JSON parsing and Issue construction for FetchIssue.
ghIssue := struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Comments []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"comments"`
}{
Number: 99,
Title: "Add dark mode",
Body: "Please add dark mode support",
URL: "https://github.com/test/repo/issues/99",
CreatedAt: time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC),
}
ghIssue.Author.Login = "contributor"
ghIssue.Labels = []struct {
Name string `json:"name"`
}{
{Name: "help wanted"},
}
ghIssue.Comments = []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
}{
{Body: "I can work on this"},
}
ghIssue.Comments[0].Author.Login = "volunteer"
data, err := json.Marshal(ghIssue)
require.NoError(t, err)
// Re-parse as the function would.
var parsed struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Comments []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"comments"`
}
require.NoError(t, json.Unmarshal(data, &parsed))
labels := make([]string, len(parsed.Labels))
for i, l := range parsed.Labels {
labels[i] = l.Name
}
comments := make([]Comment, len(parsed.Comments))
for i, c := range parsed.Comments {
comments[i] = Comment{Author: c.Author.Login, Body: c.Body}
}
issue := &Issue{
ID: fmt.Sprintf("%s#%d", "test/repo", parsed.Number),
Number: parsed.Number,
Repo: "test/repo",
Title: parsed.Title,
Body: parsed.Body,
URL: parsed.URL,
Labels: labels,
Author: parsed.Author.Login,
CreatedAt: parsed.CreatedAt,
Priority: calculatePriority(labels),
Comments: comments,
}
assert.Equal(t, "test/repo#99", issue.ID)
assert.Equal(t, "contributor", issue.Author)
assert.Equal(t, 70, issue.Priority) // 50 + 20 (help wanted)
require.Len(t, issue.Comments, 1)
assert.Equal(t, "volunteer", issue.Comments[0].Author)
assert.Equal(t, "I can work on this", issue.Comments[0].Body)
}
// ---- Issues() channel accessor ----
func TestIssuesChannel_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify)
ch := f.Issues()
require.NotNil(t, ch)
// Send and receive through the channel.
go func() {
f.issuesCh <- []*Issue{{ID: "test#1", Title: "Test issue"}}
}()
select {
case issues := <-ch:
require.Len(t, issues, 1)
assert.Equal(t, "test#1", issues[0].ID)
case <-time.After(time.Second):
t.Fatal("timed out waiting for issues on channel")
}
}