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.
450 lines
16 KiB
Go
450 lines
16 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
|
"github.com/lukaszraczylo/ghtracker/internal/config"
|
|
"github.com/lukaszraczylo/ghtracker/internal/model"
|
|
)
|
|
|
|
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
|
|
|
type fakeSource struct {
|
|
repoFn func(name string) (collector.View, error)
|
|
repoCalls []string
|
|
st collector.Stats
|
|
v collector.View
|
|
refreshed int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (f *fakeSource) View() collector.View { return f.v }
|
|
func (f *fakeSource) Stats() collector.Stats { return f.st }
|
|
func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true }
|
|
|
|
func (f *fakeSource) RefreshRepo(_ context.Context, name string) (collector.View, error) {
|
|
f.mu.Lock()
|
|
f.repoCalls = append(f.repoCalls, name)
|
|
f.mu.Unlock()
|
|
if f.repoFn != nil {
|
|
return f.repoFn(name)
|
|
}
|
|
return f.v, nil
|
|
}
|
|
|
|
func (f *fakeSource) calls() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.repoCalls)
|
|
}
|
|
|
|
func testView() collector.View {
|
|
repo := model.Repo{
|
|
FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true,
|
|
Health: model.Crit, RefreshedAt: now.Add(-time.Hour), Description: "desc",
|
|
Release: &model.Release{Tag: "v1.2.3", URL: "https://github.com/o/r/releases/tag/v1.2.3", PublishedAt: now.Add(-48 * time.Hour)},
|
|
PRs: []model.PullRequest{
|
|
{Number: 5, CreatedAt: now.Add(-72 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}},
|
|
{Number: 6, Draft: true, CreatedAt: now.Add(-900 * time.Hour)},
|
|
},
|
|
Issues: []model.Issue{{Number: 9, UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {Number: 10, UpdatedAt: now}},
|
|
Workflows: []model.Workflow{
|
|
{Name: "CI", Latest: model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}, LastDone: &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}},
|
|
{Name: "Nightly", Latest: model.Run{Status: "in_progress", CreatedAt: now}},
|
|
},
|
|
}
|
|
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
|
|
return collector.View{
|
|
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, ScanDone: 5, ScanTotal: 9,
|
|
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
|
|
Repos: []model.Repo{repo, bare},
|
|
Alerts: []model.Alert{
|
|
{Severity: model.Crit, Repo: "o/r", Kind: "workflow_failed", Subject: "CI", Detail: "failure on main (push)", URL: "https://github.com/o/r/actions/runs/1", Since: now.Add(-2 * time.Hour)},
|
|
{Severity: model.Warn, Repo: "o/r", Kind: "pr_waiting", Subject: "#5 fix", Detail: "open 3d", URL: "https://github.com/o/r/pull/5", Since: now.Add(-72 * time.Hour)},
|
|
},
|
|
}
|
|
}
|
|
|
|
func testUI() fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"index.html": {Data: []byte("<!doctype html><title>ui</title>")},
|
|
"assets/app.js": {Data: []byte("console.log(1)")},
|
|
"secret.txt": {Data: []byte("nope")},
|
|
}
|
|
}
|
|
|
|
func newServer(src *fakeSource, ui fstest.MapFS, manual bool) *Server {
|
|
return New(src, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("metrics")) }),
|
|
ui, manual, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
}
|
|
|
|
func do(h http.Handler, method, path string) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
|
return rec
|
|
}
|
|
|
|
func TestIndexServesBuiltUIWithSecurityHeaders(t *testing.T) {
|
|
rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/")
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>ui</title>") {
|
|
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("Cache-Control") != "no-cache" || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
|
|
t.Fatalf("headers: %v", rec.Header())
|
|
}
|
|
csp := rec.Header().Get("Content-Security-Policy")
|
|
for _, want := range []string{"default-src 'none'", "script-src 'self'", "connect-src 'self'"} {
|
|
if !strings.Contains(csp, want) {
|
|
t.Errorf("CSP %q missing %q", csp, want)
|
|
}
|
|
}
|
|
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Fatal("nosniff missing")
|
|
}
|
|
}
|
|
|
|
func TestIndexWithoutBuiltUIIs503(t *testing.T) {
|
|
rec := do(newServer(&fakeSource{}, fstest.MapFS{}, false).Handler(), "GET", "/")
|
|
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "pnpm build") {
|
|
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAssetsCachedAndScopedToAssetsDir(t *testing.T) {
|
|
h := newServer(&fakeSource{}, testUI(), false).Handler()
|
|
rec := do(h, "GET", "/assets/app.js")
|
|
if rec.Code != 200 || rec.Header().Get("Cache-Control") != immutableCache || rec.Body.String() != "console.log(1)" {
|
|
t.Fatalf("%d %v", rec.Code, rec.Header())
|
|
}
|
|
for _, p := range []string{"/assets/missing.js", "/secret.txt", "/assets/../secret.txt", "/nope"} {
|
|
if rec := do(h, "GET", p); rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusTemporaryRedirect {
|
|
t.Errorf("%s -> %d, want 404", p, rec.Code)
|
|
} else if rec.Body.Len() > 0 && strings.Contains(rec.Body.String(), "nope") {
|
|
t.Errorf("%s leaked a file outside assets", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStateJSONContract(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
rec := do(newServer(src, testUI(), true).Handler(), "GET", "/api/state")
|
|
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("%d %v", rec.Code, rec.Header())
|
|
}
|
|
var got struct {
|
|
Repos []map[string]any
|
|
Alerts []map[string]any
|
|
Counts struct{ Crit, Warn int }
|
|
Scan struct{ Done, Total int }
|
|
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
|
|
IntervalSeconds int64
|
|
Loaded bool
|
|
ManualRefresh bool
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Scan.Done != 5 || got.Scan.Total != 9 {
|
|
t.Fatalf("scan: %+v", got.Scan)
|
|
}
|
|
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
|
|
t.Fatalf("top level: %+v", got)
|
|
}
|
|
if got.Thresholds.PRWarnSeconds != 172800 || got.Thresholds.PRCritSeconds != 604800 {
|
|
t.Fatalf("thresholds: %+v", got.Thresholds)
|
|
}
|
|
r := got.Repos[0]
|
|
if r["fullName"] != "o/r" || r["health"] != "crit" {
|
|
t.Fatalf("repo: %v", r)
|
|
}
|
|
prs, issues, wf := r["prs"].(map[string]any), r["issues"].(map[string]any), r["workflows"].(map[string]any)
|
|
if prs["open"] != float64(2) || prs["waiting"] != float64(1) || prs["failingChecks"] != float64(1) || prs["oldestSeconds"] != float64(72*3600) {
|
|
t.Fatalf("prs: %v", prs)
|
|
}
|
|
if issues["open"] != float64(2) || issues["stale"] != float64(1) {
|
|
t.Fatalf("issues: %v", issues)
|
|
}
|
|
if wf["total"] != float64(2) || wf["failing"] != float64(1) || wf["running"] != float64(1) {
|
|
t.Fatalf("workflows: %v", wf)
|
|
}
|
|
if rel := r["release"].(map[string]any); rel["tag"] != "v1.2.3" || rel["url"] == "" {
|
|
t.Fatalf("release: %v", rel)
|
|
}
|
|
if got.Repos[1]["release"] != nil || got.Repos[1]["error"] != "boom" || got.Repos[1]["health"] != "ok" {
|
|
t.Fatalf("bare repo: %v", got.Repos[1])
|
|
}
|
|
if len(got.Alerts) != 2 || got.Alerts[0]["severity"] != "crit" || got.Alerts[0]["subject"] != "CI" {
|
|
t.Fatalf("alerts: %v", got.Alerts)
|
|
}
|
|
}
|
|
|
|
func TestStateJSONEmptyListsAreArrays(t *testing.T) {
|
|
rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/api/state")
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"repos":[]`) || !strings.Contains(body, `"alerts":[]`) {
|
|
t.Fatalf("lists must encode as [], got %s", body)
|
|
}
|
|
}
|
|
|
|
func TestHealthzAndMetrics(t *testing.T) {
|
|
src := &fakeSource{}
|
|
h := newServer(src, testUI(), false).Handler()
|
|
if rec := do(h, "GET", "/healthz"); rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("before first refresh: %d", rec.Code)
|
|
}
|
|
src.st.Loaded = true
|
|
if rec := do(h, "GET", "/healthz"); rec.Code != 200 {
|
|
t.Fatalf("after refresh: %d", rec.Code)
|
|
}
|
|
if rec := do(h, "GET", "/metrics"); rec.Body.String() != "metrics" {
|
|
t.Fatal("metrics handler not mounted")
|
|
}
|
|
}
|
|
|
|
func TestManualRefreshDisabledByDefault(t *testing.T) {
|
|
src := &fakeSource{}
|
|
rec := do(newServer(src, testUI(), false).Handler(), "POST", "/refresh")
|
|
if rec.Code == http.StatusAccepted || src.refreshed != 0 {
|
|
t.Fatalf("refresh must not be routed when disabled: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestManualRefreshRateLimited(t *testing.T) {
|
|
src := &fakeSource{}
|
|
s := newServer(src, testUI(), true)
|
|
clock := now
|
|
s.now = func() time.Time { return clock }
|
|
h := s.Handler()
|
|
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted {
|
|
t.Fatalf("first: %d", rec.Code)
|
|
}
|
|
clock = clock.Add(time.Minute)
|
|
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("second within gap: %d", rec.Code)
|
|
}
|
|
clock = clock.Add(5 * time.Minute)
|
|
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted || src.refreshed != 2 {
|
|
t.Fatalf("after gap: %d refreshed=%d", rec.Code, src.refreshed)
|
|
}
|
|
if rec := do(h, "GET", "/refresh"); rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("GET /refresh: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) {
|
|
src := &fakeSource{}
|
|
h := newServer(src, testUI(), true).Handler()
|
|
for _, site := range []string{"cross-site", "same-site"} {
|
|
req := httptest.NewRequest("POST", "/refresh", nil)
|
|
req.Header.Set("Sec-Fetch-Site", site)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden || src.refreshed != 0 {
|
|
t.Fatalf("%s: %d refreshed=%d", site, rec.Code, src.refreshed)
|
|
}
|
|
}
|
|
req := httptest.NewRequest("POST", "/refresh", nil)
|
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("same-origin: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func post(h http.Handler, target string, hdr map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("POST", target, nil)
|
|
for k, v := range hdr {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func repoServer(src *fakeSource, manual bool) (*Server, *time.Time) {
|
|
srv := newServer(src, testUI(), manual)
|
|
clock := now
|
|
srv.now = func() time.Time { return clock }
|
|
return srv, &clock
|
|
}
|
|
|
|
func TestRepoRefreshReturnsStateAndRepoName(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, _ := repoServer(src, true)
|
|
rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" {
|
|
t.Fatalf("%d %v", rec.Code, rec.Header())
|
|
}
|
|
var got struct {
|
|
Repo string `json:"repo"`
|
|
Repos []struct {
|
|
FullName string `json:"fullName"`
|
|
} `json:"repos"`
|
|
Counts struct{ Crit int } `json:"counts"`
|
|
Loaded bool `json:"loaded"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Repo != "o/r" || !got.Loaded || len(got.Repos) != 2 || got.Counts.Crit != 1 {
|
|
t.Fatalf("body: %s", rec.Body.String())
|
|
}
|
|
if len(src.repoCalls) != 1 || src.repoCalls[0] != "o/r" || src.refreshed != 0 {
|
|
t.Fatalf("calls=%v full=%d", src.repoCalls, src.refreshed)
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshUnknownRepoIs404JSON(t *testing.T) {
|
|
src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, collector.ErrUnknownRepo }}
|
|
srv, _ := repoServer(src, true)
|
|
h := srv.Handler()
|
|
rec := post(h, "/refresh?repo=x/y", nil)
|
|
var body map[string]string
|
|
if rec.Code != http.StatusNotFound || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body["error"] == "" {
|
|
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := post(h, "/refresh?repo=x/y", nil); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("a rejected name must not start a cooldown: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshCooldownPerRepo(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, clock := repoServer(src, true)
|
|
h := srv.Handler()
|
|
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("first: %d", rec.Code)
|
|
}
|
|
*clock = clock.Add(20 * time.Second)
|
|
rec := post(h, "/refresh?repo=O/R", nil)
|
|
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "40" {
|
|
t.Fatalf("cooldown: %d retry-after=%q", rec.Code, rec.Header().Get("Retry-After"))
|
|
}
|
|
if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("other repo must be independent: %d", rec.Code)
|
|
}
|
|
*clock = clock.Add(repoRefreshCooldown)
|
|
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK || src.calls() != 3 {
|
|
t.Fatalf("after cooldown: %d calls=%d", rec.Code, src.calls())
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshDoesNotUseFullRefreshBudget(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, _ := repoServer(src, true)
|
|
h := srv.Handler()
|
|
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("repo: %d", rec.Code)
|
|
}
|
|
if rec := post(h, "/refresh", nil); rec.Code != http.StatusAccepted {
|
|
t.Fatalf("full refresh after repo refresh: %d", rec.Code)
|
|
}
|
|
if rec := post(h, "/refresh", nil); rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("full refresh gap must still apply: %d", rec.Code)
|
|
}
|
|
if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("repo refresh must not be blocked by the full gap: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshEmptyParamKeepsFullRefresh(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, _ := repoServer(src, true)
|
|
rec := post(srv.Handler(), "/refresh?repo=", nil)
|
|
if rec.Code != http.StatusAccepted || rec.Body.Len() != 0 || src.refreshed != 1 || src.calls() != 0 {
|
|
t.Fatalf("%d body=%q full=%d repo=%d", rec.Code, rec.Body.String(), src.refreshed, src.calls())
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshBusyIs409AndKeepsCooldownFree(t *testing.T) {
|
|
busy := true
|
|
src := &fakeSource{v: testView(), repoFn: func(string) (collector.View, error) {
|
|
if busy {
|
|
return collector.View{}, collector.ErrRefreshBusy
|
|
}
|
|
return testView(), nil
|
|
}}
|
|
srv, _ := repoServer(src, true)
|
|
h := srv.Handler()
|
|
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusConflict {
|
|
t.Fatalf("busy: %d", rec.Code)
|
|
}
|
|
busy = false
|
|
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("retry after busy: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshUnexpectedErrorIs500(t *testing.T) {
|
|
src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, errors.New("x") }}
|
|
srv, _ := repoServer(src, true)
|
|
if rec := post(srv.Handler(), "/refresh?repo=o/r", nil); rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("%d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshRefusesCrossSite(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, _ := repoServer(src, true)
|
|
h := srv.Handler()
|
|
for _, site := range []string{"cross-site", "same-site"} {
|
|
if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": site}); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("%s: %d", site, rec.Code)
|
|
}
|
|
}
|
|
if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": "same-origin"}); rec.Code != http.StatusOK || src.calls() != 1 {
|
|
t.Fatalf("same-origin: %d calls=%d", rec.Code, src.calls())
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshDisabledByConfig(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, _ := repoServer(src, false)
|
|
rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
|
|
if rec.Code == http.StatusOK || src.calls() != 0 {
|
|
t.Fatalf("%d calls=%d", rec.Code, src.calls())
|
|
}
|
|
}
|
|
|
|
func TestRepoRefreshConcurrentRequests(t *testing.T) {
|
|
src := &fakeSource{v: testView()}
|
|
srv, _ := repoServer(src, true)
|
|
h := srv.Handler()
|
|
var wg sync.WaitGroup
|
|
codes := make(chan int, 16)
|
|
for range 16 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
codes <- post(h, "/refresh?repo=o/r", nil).Code
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(codes)
|
|
ok, limited := 0, 0
|
|
for c := range codes {
|
|
switch c {
|
|
case http.StatusOK:
|
|
ok++
|
|
case http.StatusTooManyRequests:
|
|
limited++
|
|
}
|
|
}
|
|
if ok != 1 || limited != 15 || src.calls() != 1 {
|
|
t.Fatalf("ok=%d limited=%d calls=%d", ok, limited, src.calls())
|
|
}
|
|
}
|