Files
ghtracker/internal/collector/collector_test.go
T
lukaszraczylo 66ed84bdbf fix(collector): publish each repository as its scan finishes
A rescan held every result until the last repository finished. The
dashboard therefore showed new and resolved alerts only at the end.
Each repository now enters the view when its fetch completes. The
last scan time, loaded flag and refresh hooks still update at the end.
2026-10-05 14:05:12 +01:00

587 lines
21 KiB
Go

package collector
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
func th() config.Thresholds {
return config.Thresholds{
PRWarnAfter: 48 * time.Hour, PRCritAfter: 7 * 24 * time.Hour,
IssueStaleAfter: 30 * 24 * time.Hour, WorkflowStuck: 6 * time.Hour, WorkflowDormant: 30 * 24 * time.Hour, DataStaleAfter: 13 * time.Hour,
}
}
func kinds(a []model.Alert) map[string]model.Severity {
m := map[string]model.Severity{}
for _, x := range a {
m[x.Kind] = max(m[x.Kind], x.Severity)
}
return m
}
func TestEvaluate(t *testing.T) {
fresh := func() model.Repo {
return model.Repo{FullName: "o/r", URL: "https://github.com/o/r", Up: true, RefreshedAt: t0}
}
failedRun := &model.Run{Status: "completed", Conclusion: "failure", URL: "run"}
tests := map[string]struct {
mutate func(*model.Repo)
want map[string]model.Severity
health model.Severity
}{
"healthy": {func(*model.Repo) {}, map[string]model.Severity{}, model.OK},
"refresh failed": {func(r *model.Repo) { r.Err = "boom"; r.Up = false }, map[string]model.Severity{KindRefreshFailed: model.Crit}, model.Crit},
"data stale": {func(r *model.Repo) { r.RefreshedAt = t0.Add(-14 * time.Hour) }, map[string]model.Severity{KindDataStale: model.Warn}, model.Warn},
"partial data": {func(r *model.Repo) { r.Warnings = []string{"workflows: 403"} }, map[string]model.Severity{KindPartialData: model.Warn}, model.Warn},
"workflow failed": {func(r *model.Repo) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: *failedRun, LastDone: failedRun}}
}, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit},
"workflow rerun in progress after failure still critical": {func(r *model.Repo) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "in_progress", CreatedAt: t0}, LastDone: failedRun}}
}, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit},
"dormant workflow failure muted": {func(r *model.Repo) {
old := &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: t0.Add(-40 * 24 * time.Hour)}
r.Workflows = []model.Workflow{{Name: "Old", Latest: *old, LastDone: old}}
}, map[string]model.Severity{}, model.OK},
"workflow success": {func(r *model.Repo) {
ok := &model.Run{Status: "completed", Conclusion: "success"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *ok, LastDone: ok}}
}, map[string]model.Severity{}, model.OK},
"cancelled is not failure": {func(r *model.Repo) {
c := &model.Run{Status: "completed", Conclusion: "cancelled"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
}, map[string]model.Severity{}, model.OK},
"workflow stuck": {func(r *model.Repo) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}}
}, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn},
"pr fresh": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-47 * time.Hour)}}
}, map[string]model.Severity{}, model.OK},
"pr waiting warn": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-49 * time.Hour)}}
}, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn},
"pr waiting crit": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-8 * 24 * time.Hour)}}
}, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit},
"draft pr ignored": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}}
}, map[string]model.Severity{}, model.OK},
"pr checks failing": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
}, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
"issues stale aggregated": {func(r *model.Repo) {
old := t0.Add(-40 * 24 * time.Hour)
r.Issues = []model.Issue{{Number: 1, UpdatedAt: old}, {Number: 2, UpdatedAt: old}, {Number: 3, UpdatedAt: t0}}
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
"archived repo skips work alerts": {func(r *model.Repo) {
r.Archived = true
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-30 * 24 * time.Hour)}}
}, map[string]model.Severity{}, model.OK},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
r := fresh()
tt.mutate(&r)
alerts := withoutPROpen(Evaluate(&r, t0, th()))
got := kinds(alerts)
if len(got) != len(tt.want) {
t.Fatalf("alerts = %+v, want kinds %v", alerts, tt.want)
}
for k, sev := range tt.want {
if got[k] != sev {
t.Fatalf("kind %s severity = %v, want %v", k, got[k], sev)
}
}
if r.Health != tt.health {
t.Fatalf("health = %v, want %v", r.Health, tt.health)
}
})
}
}
// withoutPROpen drops the informational pr_open alerts so tests of other kinds stay focused.
func withoutPROpen(in []model.Alert) []model.Alert {
var out []model.Alert
for _, a := range in {
if a.Kind != KindPROpen {
out = append(out, a)
}
}
return out
}
func TestEvaluateDisabledThresholds(t *testing.T) {
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-100 * 24 * time.Hour)}},
Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}}
off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}
if a := withoutPROpen(Evaluate(&r, t0, off)); len(a) != 0 {
t.Fatalf("disabled checks produced %+v", a)
}
}
func TestAgeAdvancesWithoutRefresh(t *testing.T) {
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-40 * time.Hour)}}}
cfg := th()
cfg.DataStaleAfter = -1
if a := withoutPROpen(Evaluate(&r, t0, cfg)); len(a) != 0 {
t.Fatalf("unexpected %+v", a)
}
if a := withoutPROpen(Evaluate(&r, t0.Add(10*time.Hour), cfg)); len(a) != 1 || a[0].Severity != model.Warn {
t.Fatalf("PR must cross the warn threshold as time passes: %+v", a)
}
}
func TestAge(t *testing.T) {
cases := map[time.Duration]string{-time.Minute: "0m", 5 * time.Minute: "5m", 90 * time.Minute: "1h", 47 * time.Hour: "47h", 49 * time.Hour: "2d", 10 * 24 * time.Hour: "10d"}
for d, want := range cases {
if got := Age(d); got != want {
t.Errorf("Age(%s) = %s, want %s", d, got, want)
}
}
}
type fakeFetcher struct {
fn func(name string) (model.Repo, error)
calls []string
mu sync.Mutex
}
func (f *fakeFetcher) FetchRepo(_ context.Context, name string) (model.Repo, error) {
f.mu.Lock()
f.calls = append(f.calls, name)
f.mu.Unlock()
return f.fn(name)
}
func newCollector(f Fetcher, repos ...string) *Collector {
cfg := &config.Config{Repos: repos, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th()}
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
return c
}
func TestRefreshKeepsLastGoodDataOnFailure(t *testing.T) {
fail := false
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if fail {
return model.Repo{}, errors.New("boom")
}
return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil
}}
c := newCollector(f, "o/a")
c.Refresh(context.Background())
if v := c.View(); !v.Loaded || len(v.Repos) != 1 || !v.Repos[0].Up || len(v.Repos[0].Issues) != 1 {
t.Fatalf("first view: %+v", v)
}
fail = true
c.Refresh(context.Background())
v := c.View()
r := v.Repos[0]
if r.Up || r.Err != "boom" || len(r.Issues) != 1 {
t.Fatalf("must keep old data and flag error: %+v", r)
}
if v.Crit != 1 || r.Health != model.Crit {
t.Fatalf("crit=%d health=%v", v.Crit, r.Health)
}
if s := c.Stats(); s.RepoErrorsTotal != 1 || s.RefreshTotal != 2 {
t.Fatalf("stats %+v", s)
}
}
func TestFirstRefreshFailureStillListsRepo(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
c.Refresh(context.Background())
v := c.View()
if len(v.Repos) != 1 || v.Repos[0].Up || v.Repos[0].URL != "https://github.com/o/a" {
t.Fatalf("%+v", v.Repos)
}
}
func TestRateLimitStopsRemainingRepos(t *testing.T) {
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if name == "o/b" {
return model.Repo{}, model.ErrRateLimited
}
return model.Repo{FullName: name}, nil
}}
c := newCollector(f, "o/a", "o/b", "o/c", "o/d")
c.Refresh(context.Background())
if len(f.calls) != 2 {
t.Fatalf("calls after limit = %v, want only a and b", f.calls)
}
v := c.View()
if len(v.Repos) != 4 {
t.Fatalf("all repos must stay listed: %d", len(v.Repos))
}
// Next cycle starts with a clean limit flag.
f.fn = func(name string) (model.Repo, error) { return model.Repo{FullName: name}, nil }
f.calls = nil
c.Refresh(context.Background())
if len(f.calls) != 4 {
t.Fatalf("limit flag must reset, calls=%v", f.calls)
}
}
func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
r := model.Repo{FullName: name}
switch name {
case "o/crit":
c := &model.Run{Status: "completed", Conclusion: "failure"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
case "o/warn":
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-72 * time.Hour)}}
}
return r, nil
}}
c := newCollector(f, "o/ok", "o/warn", "o/crit")
c.Refresh(context.Background())
v := c.View()
if v.Repos[0].FullName != "o/crit" || v.Repos[1].FullName != "o/warn" || v.Repos[2].FullName != "o/ok" {
t.Fatalf("order: %s %s %s", v.Repos[0].FullName, v.Repos[1].FullName, v.Repos[2].FullName)
}
alerts := withoutPROpen(v.Alerts)
if len(alerts) != 2 || alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 {
t.Fatalf("alerts: %+v", v.Alerts)
}
}
func TestRequestRefreshCoalesces(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
if !c.RequestRefresh() {
t.Fatal("first request must queue")
}
if c.RequestRefresh() {
t.Fatal("second request must coalesce while one is queued")
}
}
func TestRunRefreshesOnStartAndStopsOnCancel(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
deadline := time.After(2 * time.Second)
for !c.Stats().Loaded {
select {
case <-deadline:
t.Fatal("no initial refresh")
case <-time.After(5 * time.Millisecond):
}
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not stop")
}
}
func TestSummarizeChecks(t *testing.T) {
tests := map[string]struct {
want string
in model.CheckSummary
}{
"few distinct": {in: model.CheckSummary{Failed: 2, Failing: []string{"build", "lint"}}, want: "2 checks failing: build, lint"},
"duplicates collapse and extras count": {
in: model.CheckSummary{Failed: 6, Failing: []string{"pr-checks / Tests", "pr-checks / Tests", "pr-checks / Lint", "pr-checks / Scan", "pr-checks / Gosec"}},
want: "6 checks failing: Gosec, Lint +2 more",
},
"mixed prefixes stay intact": {in: model.CheckSummary{Failed: 2, Failing: []string{"a / x", "b / y"}}, want: "2 checks failing: a / x, b / y"},
"no names": {in: model.CheckSummary{Failed: 1}, want: "1 checks failing"},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := summarizeChecks(tt.in); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
got := summarizeWarnings([]string{
"issues: github: HTTP 403: Resource not accessible by integration",
"workflows: github: HTTP 403: Resource not accessible by integration",
"releases: github: HTTP 500: boom",
})
want := []string{
"issues, workflows: github: HTTP 403: Resource not accessible by integration",
"releases: github: HTTP 500: boom",
}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %q", got)
}
}
func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{}, 4)
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
started <- struct{}{}
<-release
return model.Repo{FullName: name}, nil
}}
c := newCollector(f, "o/a", "o/b", "o/c")
if v := c.View(); v.ScanDone != 0 || v.ScanTotal != 0 {
t.Fatalf("before any scan: %+v", v)
}
done := make(chan struct{})
go func() { c.Refresh(context.Background()); close(done) }()
<-started
if v := c.View(); v.ScanTotal != 3 || v.ScanDone != 0 || !v.Refreshing {
t.Fatalf("during scan: done=%d total=%d refreshing=%v", v.ScanDone, v.ScanTotal, v.Refreshing)
}
close(release)
<-done
if v := c.View(); v.ScanDone != 3 || v.ScanTotal != 3 {
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
}
}
func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
fail := &fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}
cfg := &config.Config{
Repos: []string{"o/a"}, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th(),
GitHub: config.GitHub{APIURL: "https://ghe.example.com/api/v3"},
}
c := New(fail, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
c.Refresh(context.Background())
if got := c.View().Repos[0].URL; got != "https://ghe.example.com/o/a" {
t.Fatalf("repo link = %q", got)
}
}
func TestRefreshRepoReplacesOnlyThatRepo(t *testing.T) {
gen := 0
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, gen)}, nil
}}
c := newCollector(f, "o/a", "o/b")
c.Refresh(context.Background())
before := c.View()
gen = 2
v, err := c.RefreshRepo(context.Background(), "O/B")
if err != nil {
t.Fatal(err)
}
issues := map[string]int{}
for _, r := range v.Repos {
issues[r.FullName] = len(r.Issues)
}
if issues["o/a"] != 0 || issues["o/b"] != 2 {
t.Fatalf("only o/b must change: %v", issues)
}
if !v.LastRefresh.Equal(before.LastRefresh) || c.Stats().RefreshTotal != 1 {
t.Fatalf("full-scan bookkeeping must not move: %v %d", v.LastRefresh, c.Stats().RefreshTotal)
}
if len(f.calls) != 3 || f.calls[2] != "o/b" {
t.Fatalf("calls %v", f.calls)
}
}
func TestRefreshRepoFailureKeepsLastGoodDataAndAlerts(t *testing.T) {
fail := false
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if fail {
return model.Repo{}, errors.New("boom")
}
return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil
}}
c := newCollector(f, "o/a")
c.Refresh(context.Background())
fail = true
v, err := c.RefreshRepo(context.Background(), "o/a")
if err != nil {
t.Fatal(err)
}
if r := v.Repos[0]; r.Up || r.Err != "boom" || len(r.Issues) != 1 || v.Crit != 1 {
t.Fatalf("%+v crit=%d", r, v.Crit)
}
if c.Stats().RepoErrorsTotal != 1 {
t.Fatalf("stats %+v", c.Stats())
}
}
func TestRefreshRepoIgnoresScanRateLimitFlag(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
c.limited.Store(true)
v, err := c.RefreshRepo(context.Background(), "o/a")
if err != nil || !v.Repos[0].Up {
t.Fatalf("err=%v %+v", err, v.Repos)
}
}
func TestRefreshRepoRejections(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
if _, err := c.RefreshRepo(context.Background(), "x/y"); !errors.Is(err, ErrUnknownRepo) {
t.Fatalf("unknown: %v", err)
}
c.refreshing.Store(true)
if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
t.Fatalf("full refresh running: %v", err)
}
c.refreshing.Store(false)
if !c.claim("o/a") {
t.Fatal("claim")
}
if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
t.Fatalf("same repo running: %v", err)
}
}
func TestRefreshRepoCancelledKeepsState(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
f := &fakeFetcher{fn: func(string) (model.Repo, error) { cancel(); return model.Repo{}, context.Canceled }}
c := newCollector(f, "o/a")
if _, err := c.RefreshRepo(ctx, "o/a"); !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
if len(c.View().Repos) != 0 || c.Stats().RepoErrorsTotal != 0 {
t.Fatal("a cancelled request must not mark the repo down")
}
}
func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) {
f := &fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}
c := newCollector(f, "o/a", "o/b")
var wg sync.WaitGroup
for range 8 {
wg.Add(3)
go func() { defer wg.Done(); c.Refresh(context.Background()) }()
go func() { defer wg.Done(); _, _ = c.RefreshRepo(context.Background(), "o/a") }()
go func() { defer wg.Done(); _ = c.View(); _ = c.Stats() }()
}
wg.Wait()
if len(c.View().Repos) != 2 {
t.Fatalf("%+v", c.View().Repos)
}
}
func TestEvaluatePROpen(t *testing.T) {
pr := func(mut func(*model.PullRequest)) model.PullRequest {
p := model.PullRequest{Number: 7, Title: "fix", URL: "https://github.com/o/r/pull/7", Author: "alice", HeadSHA: "abc123", CreatedAt: t0.Add(-5 * time.Hour)}
mut(&p)
return p
}
tests := map[string]struct {
pr model.PullRequest
archived bool
want bool
}{
"open": {pr(func(*model.PullRequest) {}), false, true},
"fork": {pr(func(p *model.PullRequest) { p.Fork = true }), false, true},
"draft": {pr(func(p *model.PullRequest) { p.Draft = true }), false, false},
"archived": {pr(func(*model.PullRequest) {}), true, false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
r := model.Repo{FullName: "o/r", Up: true, RefreshedAt: t0, Archived: tc.archived, PRs: []model.PullRequest{tc.pr}}
alerts := Evaluate(&r, t0, th())
if r.Health != model.OK {
t.Fatalf("health = %v, want ok", r.Health)
}
if !tc.want {
if len(alerts) != 0 {
t.Fatalf("alerts = %+v, want none", alerts)
}
return
}
want := model.Alert{Severity: model.OK, Repo: "o/r", Kind: KindPROpen, Subject: "#7 fix", Detail: "open for 5h",
URL: tc.pr.URL, Ref: "abc123", Author: "alice", Since: tc.pr.CreatedAt}
if len(alerts) != 1 || alerts[0] != want {
t.Fatalf("alerts = %+v, want [%+v]", alerts, want)
}
})
}
}
func TestPROpenDoesNotChangeCounts(t *testing.T) {
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
return model.Repo{FullName: name, PRs: []model.PullRequest{{Number: 1, HeadSHA: "s", CreatedAt: t0}}}, nil
}}
c := newCollector(f, "o/a")
c.Refresh(context.Background())
v := c.View()
if len(v.Alerts) != 1 || v.Alerts[0].Kind != KindPROpen || v.Crit != 0 || v.Warn != 0 || v.Repos[0].Health != model.OK {
t.Fatalf("view = %+v", v)
}
}
func TestRefreshRepoRunsRefreshHooksWithTheNewView(t *testing.T) {
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, 3)}, nil
}}
c := newCollector(f, "o/a")
got := make(chan View, 1)
c.OnRefresh(func(_ context.Context, v View) { got <- v })
if _, err := c.RefreshRepo(context.Background(), "o/a"); err != nil {
t.Fatal(err)
}
select {
case v := <-got:
if len(v.Repos) != 1 || len(v.Repos[0].Issues) != 3 {
t.Fatalf("hook saw a stale view: %+v", v.Repos)
}
case <-time.After(2 * time.Second):
t.Fatal("refresh hook did not run after a repository refresh")
}
}
func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
called := make(chan struct{}, 1)
c.OnRefresh(func(context.Context, View) { called <- struct{}{} })
if _, err := c.RefreshRepo(context.Background(), "unknown/x"); err == nil {
t.Fatal("unknown repository must fail")
}
select {
case <-called:
t.Fatal("hook ran for a refused refresh")
case <-time.After(200 * time.Millisecond):
}
}
func TestRefreshPublishesEachRepoBeforeTheScanEnds(t *testing.T) {
release := make(chan struct{})
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if name == "o/slow" {
<-release
}
return model.Repo{FullName: name, Err: ""}, nil
}}
cfg := &config.Config{Repos: []string{"o/fast", "o/slow"}, RefreshInterval: time.Hour, Concurrency: 2, Thresholds: th()}
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
done := make(chan struct{})
go func() { c.Refresh(context.Background()); close(done) }()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if v := c.View(); len(v.Repos) == 1 && v.Repos[0].FullName == "o/fast" {
break
}
time.Sleep(5 * time.Millisecond)
}
v := c.View()
if len(v.Repos) != 1 || v.Repos[0].FullName != "o/fast" || !v.Refreshing || v.Loaded {
t.Fatalf("mid-scan view: repos=%d refreshing=%v loaded=%v", len(v.Repos), v.Refreshing, v.Loaded)
}
close(release)
<-done
if v := c.View(); len(v.Repos) != 2 || !v.Loaded {
t.Fatalf("after scan: repos=%d loaded=%v", len(v.Repos), v.Loaded)
}
}