package api
import (
"context"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/health"
"craftdesign.group/quarry/internal/obs"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// Every rule the operations page, the API and the alerts share lives
// in one list. A lane that is full and has refused, a store whose
// index stopped, a dependency that is down and a disk that is nearly
// full each become one row with a key, worst first.
func TestFindingsAreOneRuleSet(t *testing.T) {
resetFindings()
defer resetFindings()
out := opsStatusOut{
Lanes: []zitcli.Lane{
{Name: "store subprocesses", Bound: 8, Running: 3, Refused: 0},
{Name: "archives", Bound: 2, Running: 2, Refused: 37, Waiting: 3, WaitedMs: 88_000},
// Refused once at startup, idle now: not a finding.
{Name: "rebuilds", Bound: 2, Running: 0, Refused: 1},
},
Stores: []opsStore{
{Name: "tcl", State: "current"},
{Name: "sqlite", State: "behind", Why: "last successful round 4m10s ago"},
{Name: "netbsd-src", State: "stalled", Why: "ingest stopped retrying after 9 failures"},
},
DependsOn: []opsDep{{Name: "dagnats", Up: true}, {Name: "notify", Up: false, DownFor: "3h2m", Why: "no alerts are being delivered"}},
Disk: &opsDisk{Path: "/srv/quarry", Total: 100 << 30, Free: 8 << 30, Used: 92 << 30},
Store: obs.Health{Configured: true, Dropped: 12},
}
fs := Deps{}.opsFindings(context.Background(), &out, obs.Snapshot{})
keys := make([]string, 0, len(fs))
for _, f := range fs {
keys = append(keys, f.Severity+" "+f.Key)
}
got := strings.Join(keys, ", ")
want := "bad dep:notify, bad disk, bad lane:archives, bad store:netbsd-src, warn history, warn store:sqlite"
if got != want {
t.Fatalf("FAIL: findings\n got %s\nwant %s", got, want)
}
for _, f := range fs {
if f.Link == "" || f.Text == "" {
t.Errorf("FAIL: a finding without a link or text: %+v", f)
}
}
if !strings.Contains(fs[2].Text, "37 callers") || !strings.Contains(fs[2].Text, "3 waiting") {
t.Errorf("FAIL: the lane finding reads %q", fs[2].Text)
}
}
// A finding says when this process first saw it, which a ring of
// renders cannot. The time holds across snapshots and the last finding
// to close is kept, so a quiet page can say what the last trouble was.
func TestFindingsRememberWhenTheyWereFirstSeen(t *testing.T) {
resetFindings()
defer resetFindings()
t1 := time.Date(2026, 10, 24, 14, 2, 0, 0, time.UTC)
fs := []opsFinding{{Key: "slow:GET /r/{repo}/ci", About: "slow", Text: "ci over budget"}}
if closed := findings.stamp(t1, fs); closed != nil {
t.Errorf("FAIL: nothing has closed yet, got %+v", closed)
}
again := []opsFinding{{Key: "slow:GET /r/{repo}/ci", About: "slow", Text: "ci over budget"}, {Key: "errors", About: "errors"}}
findings.stamp(t1.Add(10*time.Minute), again)
if again[0].Since != "2026-10-24T14:02:00Z" {
t.Errorf("FAIL: first seen moved to %s", again[0].Since)
}
if again[1].Since != "2026-10-24T14:12:00Z" {
t.Errorf("FAIL: the new finding is first seen at %s", again[1].Since)
}
closed := findings.stamp(t1.Add(39*time.Minute), []opsFinding{{Key: "errors", About: "errors"}})
if closed == nil || closed.Key != "slow:GET /r/{repo}/ci" || closed.Since != "2026-10-24T14:02:00Z" || closed.Until != "2026-10-24T14:41:00Z" {
t.Fatalf("FAIL: the closed finding reads %+v", closed)
}
// It reopens as new: a fresh first-seen, not the old one.
reopened := []opsFinding{{Key: "slow:GET /r/{repo}/ci", About: "slow"}}
findings.stamp(t1.Add(50*time.Minute), reopened)
if reopened[0].Since != "2026-10-24T14:52:00Z" {
t.Errorf("FAIL: a reopened finding kept its old first-seen: %s", reopened[0].Since)
}
}
// Each store's index is current, behind, rebuilding, starting, failing
// or stalled from what the ingest pipeline reports, the ones needing
// attention first.
func TestStoresAreListedWorstFirst(t *testing.T) {
now := time.Date(2026, 10, 24, 14, 30, 0, 0, time.UTC)
fresh := now.Add(-5 * time.Second).Format(time.RFC3339)
stale := now.Add(-4 * time.Minute).Format(time.RFC3339)
repos := map[string]*Repo{
"a-current": {Health: func() RepoHealth { return RepoHealth{LastOK: fresh} }},
"b-behind": {Health: func() RepoHealth { return RepoHealth{LastOK: stale} }},
"c-stalled": {Health: func() RepoHealth {
return RepoHealth{LastOK: stale, Stalled: true, Failures: 9, LastErr: "zit: timeout"}
}},
"d-rebuilding": {Indexing: func() bool { return true }, Health: func() RepoHealth { return RepoHealth{LastOK: fresh} }},
"e-starting": {Health: func() RepoHealth { return RepoHealth{} }},
"f-failing": {Health: func() RepoHealth { return RepoHealth{LastOK: fresh, Failures: 3, LastErr: "disk full"} }},
"g-gaps": {Health: func() RepoHealth { return RepoHealth{LastOK: fresh, Gaps: 4} }},
}
got := Deps{Repos: NewRepos(repos)}.opsStores(now)
var order []string
for _, s := range got {
order = append(order, s.Name+"="+s.State)
}
want := "c-stalled=stalled, f-failing=failing, b-behind=behind, g-gaps=gaps, d-rebuilding=rebuilding, e-starting=starting, a-current=current"
if strings.Join(order, ", ") != want {
t.Fatalf("FAIL: stores\n got %s\nwant %s", strings.Join(order, ", "), want)
}
if !strings.Contains(got[0].Why, "9 failures") || !strings.Contains(got[0].Why, "zit: timeout") {
t.Errorf("FAIL: the stalled store's reason reads %q", got[0].Why)
}
if got[2].Why != "last successful round 4m ago" {
t.Errorf("FAIL: the lagging store's reason reads %q", got[2].Why)
}
}
// ops.status carries the process identity and the hour of signals, so
// the page's status line and strip read the same op the panels do.
func TestOpsStatusNamesTheProcessAndItsHour(t *testing.T) {
resetFindings()
defer resetFindings()
started := time.Date(2026, 10, 24, 3, 5, 0, 0, time.UTC)
d := Deps{Version: "0.2.1-dev+abc", Started: started, Repos: NewRepos(nil),
DepStates: func() []health.State {
return []health.State{{Dep: health.Dep{Name: "notify", Why: "no alerts"}, Up: false, Since: started, Err: "connection refused"}}
},
ErrorRates: func(buckets int, span time.Duration) (map[string][]int, error) {
b := make([]int, buckets)
b[buckets-1] = 2
return map[string][]int{"web GET /x": b}, nil
}}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
res, err := r.Invoke(ops.WithUser(context.Background(), "admin"), "ops.status", opsStatusIn{Limit: 5})
if err != nil {
t.Fatal(err)
}
out := res.(opsStatusOut)
if out.Version != "0.2.1-dev+abc" || out.Started != "2026-10-24T03:05:00Z" {
t.Errorf("FAIL: process identity reads %s at %s", out.Version, out.Started)
}
if len(out.Signals.Errors) != obs.SignalMinutes || out.Signals.Errors[obs.SignalMinutes-1] != 2 {
t.Errorf("FAIL: the error signal reads %v", out.Signals.Errors)
}
if len(out.DependsOn) != 1 || out.DependsOn[0].Up || out.DependsOn[0].DownFor == "" {
t.Errorf("FAIL: depends-on reads %+v", out.DependsOn)
}
var keys []string
for _, f := range out.Findings {
keys = append(keys, f.Key)
if f.Since == "" {
t.Errorf("FAIL: %s has no first-seen", f.Key)
}
}
for _, want := range []string{"dep:notify", "errors"} {
found := false
for _, k := range keys {
found = found || k == want
}
if !found {
t.Errorf("FAIL: findings %v lack %s", keys, want)
}
}
}