package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// fakeChecks answers RFC 0009's policy and rules from maps.
type fakeChecks struct {
required map[string][]string
premerge map[string]bool
}
func (f fakeChecks) RequiredChecks(_, branch string) []string { return f.required[branch] }
func (f fakeChecks) PremergeMode(_, branch string) bool { return f.premerge[branch] }
// fakeCI serves runs for whatever check-in is asked.
type fakeCI struct{ runs []api.CIRun }
func (f fakeCI) CIRuns(string, int) ([]api.CIRun, error) { return f.runs, nil }
func (f fakeCI) CIRunsFor(string, string) ([]api.CIRun, error) { return f.runs, nil }
func (f fakeCI) CIRunsForPair(string, string) ([]api.CIRun, error) { return f.runs, nil }
func checksServer(t *testing.T, ix *index.Index, required map[string][]string, runs []api.CIRun, owner string) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
Checks: fakeChecks{required: required},
CI: fakeCI{runs: runs}}
if owner != "" {
deps.Protect = stubPolicy{owner: owner}
}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}))
t.Cleanup(srv.Close)
return srv
}
// RFC 0009 §verdicts: the panel shows a row per run, required checks
// are chipped, and a required check with no run reads waiting.
func TestChecksPanelShowsRunsAndWaiting(t *testing.T) {
ix := landingIndex(t, false)
srv := checksServer(t, ix,
map[string][]string{"trunk": {"test", "sign"}},
[]api.CIRun{{RunID: "run-1", Workflow: "test", Pipeline: "ci/test.json", Status: "success", Started: "2026-08-28"}},
"")
body := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
for _, want := range []string{`id="l-checks"`, "ci/test.json", ">pass<", ">waiting<", "<code>sign</code>", `class="chip">required`} {
if !strings.Contains(body, want) {
t.Fatalf("panel lacks %q", want)
}
}
}
// A red required set closes the accept with the names in words; the
// override control appears only for who may use it.
func TestUnmetChecksCloseTheAccept(t *testing.T) {
ix := landingIndex(t, false)
srv := checksServer(t, ix,
map[string][]string{"trunk": {"test"}},
[]api.CIRun{{RunID: "run-1", Workflow: "test", Status: "failed"}},
"")
body := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if !strings.Contains(body, "required checks not green") || !strings.Contains(body, "<code>test</code>") {
t.Fatal("the closed accept does not name the red check")
}
if strings.Contains(body, "Accept anyway") {
t.Fatal("override offered to someone who may not use it")
}
// The op refuses too — the button is honesty, not the gate.
resp := postAsDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6)+"/accept", url.Values{})
if resp.StatusCode == http.StatusSeeOther {
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "landing") {
t.Fatalf("accept redirected oddly: %s", loc)
}
}
}
// A branch owner gets the override control, and the accept carries the
// stated reason.
func TestOwnerMayOverrideWithAReason(t *testing.T) {
ix := landingIndex(t, false)
srv := checksServer(t, ix,
map[string][]string{"trunk": {"test"}},
nil, "dan")
body := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if !strings.Contains(body, "Accept anyway") || !strings.Contains(body, `name="override"`) {
t.Fatal("owner does not get the override control")
}
}
var _ = oltp.CheckRule{} // settings-side types live in oltp
// RFC 0009 §external-ci: a reporter token posts a state, the panel
// shows the row with its own link, and a green external check
// satisfies the required set.
func TestExternalCheckReportsAndSatisfies(t *testing.T) {
ix := landingIndex(t, false)
odb, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer odb.Close()
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
Checks: fakeChecks{required: map[string][]string{"trunk": {"jenkins/test"}}},
External: odb}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}))
t.Cleanup(srv.Close)
landing := "/r/alpha/landing/" + strings.Repeat("a1", 6)
if body := asDan(t, srv, landing); !strings.Contains(body, "required checks not green") {
t.Fatal("accept open before any report")
}
tok, err := odb.MintReporterToken("alpha", "jenkins")
if err != nil {
t.Fatal(err)
}
// The subject is the landing branch's tip; the fixture's branch has
// one check-in — read it off the page's commits? Simpler: report on
// every commit hash the landing lists by scraping is brittle; use
// the index directly.
cs, err := ix.BranchCommits("fossil-caps", 50)
if err != nil || len(cs) == 0 {
t.Fatalf("fixture branch: %v %v", cs, err)
}
tip := cs[len(cs)-1].Hash
// The op is the contract; the HTTP route is the standard router's
// wiring, covered where every other op's is.
if _, err := invoke(context.Background(), r, "check.report", url.Values{
"repo": {"alpha"}, "checkin": {tip}, "context": {"jenkins/test"},
"state": {"success"}, "url": {"https://ci.example/build/9"}, "token": {tok}}); err != nil {
t.Fatalf("report: %v", err)
}
body := asDan(t, srv, landing)
for _, want := range []string{"jenkins/test", "https://ci.example/build/9", ">details<"} {
if !strings.Contains(body, want) {
t.Fatalf("panel lacks %q", want)
}
}
if strings.Contains(body, "required checks not green") {
t.Fatal("a green external check did not satisfy the required set")
}
// A wrong token is refused.
if _, err := invoke(context.Background(), r, "check.report", url.Values{
"repo": {"alpha"}, "checkin": {tip}, "context": {"jenkins/test"},
"state": {"success"}, "token": {"qcr_wrong"}}); err == nil {
t.Fatal("a wrong token reported")
}
}
// The re-run button posts through ci.trigger.
func TestReRunButtonTriggers(t *testing.T) {
ix := landingIndex(t, false)
var got []string
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
CITrigger: func(repo, checkin string) error { got = append(got, repo+"@"+checkin); return nil }}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}))
t.Cleanup(srv.Close)
resp := postAsDan(t, srv, "/r/alpha/ci/trigger", url.Values{"back": {"/r/alpha/landing/x"}})
if resp.StatusCode != http.StatusSeeOther || len(got) != 1 || got[0] != "alpha@" {
t.Fatalf("trigger: %d %v", resp.StatusCode, got)
}
}
// RFC 0009 §premerge: in premerge mode the subject is the pair of
// tips — tip runs do not count, pair runs do, and the page says which
// tree the checks are about.
func TestPremergeModeKeysThePair(t *testing.T) {
ix := landingIndex(t, false)
tipRun := []api.CIRun{{RunID: "tip-1", Workflow: "test", Status: "success"}}
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
Checks: fakeChecks{required: map[string][]string{"trunk": {"test"}},
premerge: map[string]bool{"trunk": true}},
CI: pairAwareCI{tip: tipRun, pair: nil}}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}))
t.Cleanup(srv.Close)
body := asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if !strings.Contains(body, "premerge: these checks test the MERGED tree") {
t.Fatal("the page does not say the subject is the merge")
}
if !strings.Contains(body, "required checks not green") {
t.Fatal("a green TIP run satisfied a PREMERGE requirement")
}
// The same server with a green pair run is satisfied.
r2 := ops.NewRegistry()
deps.CI = pairAwareCI{pair: []api.CIRun{{RunID: "pm-1", Workflow: "test", Status: "success"}}}
if err := api.RegisterAll(r2, deps); err != nil {
t.Fatal(err)
}
srv2 := httptest.NewServer(HandlerWith(r2, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}))
t.Cleanup(srv2.Close)
body2 := asDan(t, srv2, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if strings.Contains(body2, "required checks not green") {
t.Fatal("a green pair run did not satisfy")
}
}
// pairAwareCI serves different runs for tip and pair subjects.
type pairAwareCI struct{ tip, pair []api.CIRun }
func (f pairAwareCI) CIRuns(string, int) ([]api.CIRun, error) { return nil, nil }
func (f pairAwareCI) CIRunsFor(string, string) ([]api.CIRun, error) { return f.tip, nil }
func (f pairAwareCI) CIRunsForPair(string, string) ([]api.CIRun, error) { return f.pair, nil }