mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
* feat(refresh): refresh a single repository with POST /refresh?repo= The endpoint refreshes one configured repository and returns the full state plus the repo name. Each repository has a 60-second cooldown that is separate from the full-refresh limit. A running full refresh or a refresh of the same repository returns 409. An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same. The collector reuses its fetch path and replaces only that repository. * feat(ui): add a refresh button beside each repository The button appears in the project list and in each attention group header. It stays hidden when the server disables manual refresh. The button shows a spinner while its repository refreshes and reports refusals in the notice line. The store swaps in the returned state, so no extra fetch follows.
460 lines
17 KiB
Go
460 lines
17 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, 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, 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, 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, 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, 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 := 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateDisabledThresholds(t *testing.T) {
|
|
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, 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 := 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, CreatedAt: t0.Add(-40 * time.Hour)}}}
|
|
cfg := th()
|
|
cfg.DataStaleAfter = -1
|
|
if a := Evaluate(&r, t0, cfg); len(a) != 0 {
|
|
t.Fatalf("unexpected %+v", a)
|
|
}
|
|
if a := 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, 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)
|
|
}
|
|
if len(v.Alerts) != 2 || v.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)
|
|
}
|
|
}
|