package web
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// A landing is a forum thread with a marker body (zit#127), so the pages are
// tested the way the store actually presents one: fold the artifacts, then
// read the surfaces. No zit CLI is involved, because quarry reads its index.
func landingIndex(t *testing.T, withTransition bool) *index.Index {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
root := strings.Repeat("a1", 20)
batch := []index.Parsed{
// RFC 0007: the merge is the fact. A record that reads
// "accepted" needs the merge it announces in the DAG, or it is
// a claim and the page says so. base -> tip on fossil-caps ->
// merge on trunk, dated after the landing opened.
{Hash: strings.Repeat("00", 20), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-19T09:00:00", User: "dan", Comment: "base"}},
{Hash: strings.Repeat("11", 20), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-20T08:00:00", User: "dan", Comment: "the caps work",
Parents: []string{strings.Repeat("00", 20)},
Tags: []artifact.Tag{{Name: "*branch", Target: "*", Value: "fossil-caps"},
{Name: "*sym-fossil-caps", Target: "*"}}}},
// A second commit on the branch, so the check-in count is three:
// TestLandingsAreNotForumContent asserts no title="2" appears
// anywhere on the forum page, and two check-ins would put one
// in the nav that has nothing to do with the forum.
{Hash: strings.Repeat("33", 20), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-20T08:30:00", User: "dan", Comment: "and its tests",
Parents: []string{strings.Repeat("11", 20)}}},
{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
ThreadTitle: "land the caps work", Date: "2026-08-20T09:00:00", User: "dan",
Mimetype: "text/x-markdown",
Body: []byte("zit-landing: 1\nbranch: fossil-caps\ntarget: trunk\n\n" +
"Capability model, phases one through five.\n")}},
{Hash: strings.Repeat("b2", 20), Art: &artifact.Artifact{Type: artifact.Forum,
ThreadRoot: root, InReplyTo: root, Date: "2026-08-20T10:00:00", User: "eve",
Mimetype: "text/x-markdown", Body: []byte("Reads well. One question about `e`.\n")}},
}
if withTransition {
// The merge, and only with the transition: a merge IS acceptance
// (RFC 0007), so an open fixture must not carry one.
batch = append(batch, index.Parsed{Hash: strings.Repeat("22", 20),
Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-20T10:00:00", User: "dan", Comment: "land fossil-caps",
Parents: []string{strings.Repeat("00", 20), strings.Repeat("33", 20)}}})
batch = append(batch, index.Parsed{Hash: strings.Repeat("c3", 20),
Art: &artifact.Artifact{Type: artifact.Forum, ThreadRoot: root, InReplyTo: root,
Date: "2026-08-20T11:00:00", User: "dan", Mimetype: "text/x-markdown",
Body: []byte("zit-landing-state: accepted\n")}})
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
// Branches, then the refresh that reads the merge (index.RecomputeBranches).
if err := ix.RecomputeBranches(); err != nil {
t.Fatal(err)
}
return ix
}
func landingServer(t *testing.T, ix *index.Index, caps string) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
opts := []Option{WithSessions(fakeSessions{user: "dan"})}
// caps was declared and never wired, so no test could reach the
// decide controls — they render only for a caller who may settle
// the record. Wiring it is what lets the retarget control below be
// tested at all.
if caps != "" {
opts = append(opts, WithCaps(&memCaps{stored: map[string]string{"dan": caps}}))
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, opts))
t.Cleanup(srv.Close)
return srv
}
func landingGet(t *testing.T, srv *httptest.Server, path string) string {
t.Helper()
req, _ := http.NewRequest("GET", srv.URL+path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("GET %s: %d", path, resp.StatusCode)
}
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// The listing answers one question — what is awaiting a decision — and a
// landing's identity is the pair of branch names, so both must be on the row.
func TestLandingListShowsWhatAwaitsDecision(t *testing.T) {
srv := landingServer(t, landingIndex(t, false), "")
html := landingGet(t, srv, "/r/alpha/landing")
for _, want := range []string{"land the caps work", "fossil-caps", "trunk",
`href="/r/alpha/landing/`} {
if !strings.Contains(html, want) {
t.Errorf("the listing does not show %q:\n%s", want, html)
}
}
// A settled record is history; the default view is the queue.
settled := landingGet(t, landingServer(t, landingIndex(t, true), ""), "/r/alpha/landing")
if strings.Contains(settled, "land the caps work") {
t.Error("an accepted landing is still listed as awaiting decision")
}
if !strings.Contains(landingGet(t, landingServer(t, landingIndex(t, true), ""),
"/r/alpha/landing?state=accepted"), "land the caps work") {
t.Error("an accepted landing cannot be found under accepted")
}
}
// zit records a state change as a reply, because a reply is an artifact
// fossil understands. A reader wants it as an EVENT — "dan accepted this" —
// not as a comment reading "zit-landing-state: accepted", which is the
// encoding leaking through the interface that exists to hide it.
func TestLandingDetailLiftsTransitionsOutOfTheDiscussion(t *testing.T) {
srv := landingServer(t, landingIndex(t, true), "")
html := landingGet(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if strings.Contains(html, "zit-landing-state") || strings.Contains(html, "zit-landing: 1") {
t.Errorf("the record's encoding reached the page:\n%s", html)
}
if !strings.Contains(html, `class="l-events"`) || !strings.Contains(html, "accepted this") {
t.Errorf("the state change is not on the record's timeline:\n%s", html)
}
// The opener is the proposal, not a comment on it.
if !strings.Contains(html, "Capability model") {
t.Error("the proposal's own body is missing")
}
// The remark IS a comment.
if !strings.Contains(html, "One question about") {
t.Error("the discussion is missing")
}
// A settled record offers no decision controls.
if strings.Contains(html, "Accept and land") {
t.Error("an already-accepted landing still offers to be accepted")
}
}
// The accept control is the one that merges, so it appears only for a viewer
// who could actually land it. With no capability plane configured there is no
// caps answer at all, and a control that cannot work must not be drawn.
func TestLandingAcceptControlFollowsCapability(t *testing.T) {
srv := landingServer(t, landingIndex(t, false), "")
html := landingGet(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if strings.Contains(html, "Accept and land") {
t.Errorf("the accept control was drawn without the check-in capability:\n%s", html)
}
// Rejecting writes no check-in and rides the forum capability, so an
// ungated forge — which has no strangers — may still settle a record.
if !strings.Contains(html, "Reject") {
t.Error("no way to settle a landing without merging it")
}
}
// A landing IS a forum thread — that is what lets a stock fossil clone read
// one — but it is not forum CONTENT. It has its own surface, its own controls
// and its own listing, and a reader scanning discussions has no use for a
// thread whose first line is a marker.
//
// So: absent from the forum listing, absent from the count above it (a number
// that promises a thread the list refuses to show is the same confirmation
// leak in miniature), and an existing link redirected rather than refused.
func TestLandingsAreNotForumContent(t *testing.T) {
ix := landingIndex(t, false)
// An ordinary thread, so the forum has something to show and the
// test cannot pass by everything being empty.
if err := ix.FoldNow([]index.Parsed{
{Hash: strings.Repeat("d4", 20), Art: &artifact.Artifact{Type: artifact.Forum,
ThreadTitle: "an ordinary question", Date: "2026-08-21T09:00:00", User: "eve",
Mimetype: "text/x-markdown", Body: []byte("how does this work?\n")}},
}); err != nil {
t.Fatal(err)
}
srv := landingServer(t, ix, "")
list := landingGet(t, srv, "/r/alpha/forum")
if !strings.Contains(list, "an ordinary question") {
t.Fatalf("the forum lost its ordinary threads:\n%s", list)
}
if strings.Contains(list, "land the caps work") {
t.Error("a landing record was listed as a forum thread")
}
// The tab's count must agree with the list beneath it.
counts := landingGet(t, srv, "/r/alpha/forum")
if strings.Contains(counts, `title="2"`) {
t.Error("the forum count includes a thread the listing hides")
}
// An existing link still resolves — to the surface that owns it.
req, _ := http.NewRequest("GET", srv.URL+"/r/alpha/forum/"+strings.Repeat("a1", 20), nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
srv.Client().CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("a landing's forum URL returned %d, want a redirect", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "/landing/") {
t.Errorf("redirected to %q, want the landing record", loc)
}
}
// A record whose author wrote a paragraph before the blank line has no
// headline: the paragraph is prose, so it renders as the opener's body
// and the visible h1 goes, exactly as when there is no blank line at all.
func TestAParagraphTitleIsProseNotAHeading(t *testing.T) {
ix := landingIndex(t, false)
root := strings.Repeat("d4", 20)
para := "CI never ran on the day's check-ins because the poll lane was saturated by stores that had not changed, so the polls that mattered were shed and nothing downstream of ingest ever fired."
if err := ix.FoldNow([]index.Parsed{{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
ThreadTitle: para, Date: "2026-08-21T09:00:00", User: "dan", Mimetype: "text/x-markdown",
Body: []byte("zit-landing: 1\nbranch: fossil-caps\ntarget: trunk\n\n" + para + "\n\nThe loop now stamps the store.\n")}}}); err != nil {
t.Fatal(err)
}
srv := landingServer(t, ix, "")
html := landingGet(t, srv, "/r/alpha/landing/"+root[:12])
if strings.Contains(html, "<h1>"+para) || strings.Contains(html, "<h1>CI never ran") {
t.Error("FAIL: the paragraph is drawn as the visible heading")
}
if !strings.Contains(html, `<h1 class="sr-only">`) {
t.Error("FAIL: the page lost its name")
}
if !strings.Contains(html, "<p>CI never ran") && !strings.Contains(html, "<p>"+para[:20]) {
t.Errorf("FAIL: the paragraph is not in the opener's body:\n%s", html)
}
if !strings.Contains(html, "The loop now stamps the store.") {
t.Error("FAIL: the rest of the message is missing")
}
if !index.IsSubjectLine("land the caps work") || index.IsSubjectLine(para) || index.IsSubjectLine("two\nlines") {
t.Error("FAIL: IsSubjectLine misjudges a headline or a paragraph")
}
t.Log("ok: a paragraph title renders as prose under an sr-only heading")
}
// A landing page is live: with a notifier in play it subscribes to the
// repository's revision stream and re-fetches its body on a change, and
// the fragment it fetches is the same record a reload would draw.
func TestLandingPageFollowsTheRecord(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, &fakeNotifier{ch: make(chan struct{}, 1)}, nil,
[]Option{WithSessions(fakeSessions{user: "dan"})}))
t.Cleanup(srv.Close)
id := strings.Repeat("a1", 6)
html := landingGet(t, srv, "/r/alpha/landing/"+id)
if !strings.Contains(html, "@get('/r/alpha/timeline.rev')") ||
!strings.Contains(html, "@get('/r/alpha/landing.frag/"+id+"')") {
t.Error("FAIL: the page does not follow the repository's revision stream")
}
if !strings.Contains(html, `id="l-page"`) {
t.Error("FAIL: the page has no body to patch")
}
frag := landingGet(t, srv, "/r/alpha/landing.frag/"+id)
if !strings.HasPrefix(frag, "event: datastar-patch-elements\n") ||
!strings.Contains(frag, `data: elements <div id="l-page">`) {
t.Errorf("FAIL: the fragment is not a patch of the body:\n%.300s", frag)
}
if !strings.Contains(frag, "Capability model") || !strings.Contains(frag, "fossil-caps") {
t.Error("FAIL: the fragment does not carry the record")
}
if strings.Contains(frag, "timeline.rev") {
t.Error("FAIL: the fragment re-subscribes on every patch")
}
// The tabs move with the page: the same revision bump re-fetches
// the nav, whose counts the page's own fragment does not carry.
if !strings.Contains(html, "@get('/r/alpha/nav.frag?s=landing')") {
t.Error("FAIL: the page does not refresh its tabs on change")
}
nav := landingGet(t, srv, "/r/alpha/nav.frag?s=landing")
if !strings.HasPrefix(nav, "event: datastar-patch-elements\n") ||
!strings.Contains(nav, `data: elements <nav id="repo-tabs">`) {
t.Errorf("FAIL: nav.frag is not a patch of the tabs:\n%.300s", nav)
}
if !strings.Contains(nav, `href="/r/alpha/landing" class="here`) {
t.Errorf("FAIL: nav.frag does not mark the page's own section current:\n%.400s", nav)
}
t.Log("ok: landing page subscribes once and re-fetches its body and its tabs on change")
}
// A stack strands itself when its base lands on its own: zit then
// refuses the merge and says to retarget. The instruction is only
// useful if there is something on the page that can carry it out, so
// an open record offers the control.
func TestOpenLandingOffersRetarget(t *testing.T) {
srv := landingServer(t, landingIndex(t, false), "i")
html := landingGet(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
// Assert the PAGE first. A misplaced {{end}} put this control
// outside its own {{define}}, and the landing page then rendered
// as nothing but the control -- 350 bytes -- while a test looking
// only for the control passed happily. Presence of a fragment is
// not evidence the page around it survived.
if len(html) < 4000 || !strings.Contains(html, `id="landing-body"`) && !strings.Contains(html, "fossil-caps") {
t.Fatalf("FAIL: the landing page did not render around the control (%d bytes)", len(html))
}
for _, hook := range []string{`data-hook="retarget-to"`, `data-hook="retarget-go"`} {
if !strings.Contains(html, hook) {
t.Errorf("FAIL: an open record does not offer %s", hook)
continue
}
t.Logf("ok: an open record offers %s", hook)
}
if !strings.Contains(html, `/retarget"`) {
t.Error("FAIL: the control does not post to the retarget route")
}
}
// A settled record does not move, so it must not offer to.
func TestSettledLandingDoesNotOfferRetarget(t *testing.T) {
srv := landingServer(t, landingIndex(t, true), "i")
html := landingGet(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
if strings.Contains(html, `data-hook="retarget-go"`) {
t.Error("FAIL: a settled record offers to retarget")
return
}
t.Log("ok: a settled record does not offer to retarget")
}
// Every state AcceptState can return must have a rendering.
//
// "protected" did not. The function returns it deliberately, with a
// comment explaining why a protection outranks a conflict, and the
// template had no branch for it — so that case rendered no control AND
// no reason, on the one page whose job is to say why the control is
// closed. A non-owner looking at a protected target saw a blank.
//
// This asserts the SET rather than the one case, because the bug was
// not a wrong branch, it was a missing one: a state added later with no
// branch fails here instead of silently rendering nothing.
func TestEveryAcceptStateRendersSomething(t *testing.T) {
// The states the function can return, and a landing that produces
// each. "" is the deliberate no-control case and is excluded.
base := func() landingDetail {
return landingDetail{
landingRow: landingRow{ID: strings.Repeat("a", 64), Branch: "work",
Target: "trunk", State: "open"},
CanAccept: true, Mergeable: "clean",
}
}
cases := map[string]func() landingDetail{
"go": base,
"protected": func() landingDetail {
d := base()
d.Protected, d.MayLand = true, false
d.Owners = []string{"dmestas", "lou"}
return d
},
"conflict": func() landingDetail {
d := base()
d.Mergeable = "conflict"
return d
},
"landed": func() landingDetail {
d := base()
d.Mergeable = "up-to-date"
return d
},
"stacked": func() landingDetail {
d := base()
d.Mergeable = "stacked"
return d
},
"checks": func() landingDetail {
d := base()
d.Unmet = []string{"quarry"}
return d
},
}
for want, mk := range cases {
d := mk()
if got := d.AcceptState(); got != want {
t.Errorf("FAIL: fixture for %q produced state %q — the test no longer covers what it names", want, got)
continue
}
var buf bytes.Buffer
if err := noHooks.template(landingPage).Execute(&buf, pageData{Title: "Landing", Repo: "quarry",
Section: "landings", Landing: &d}); err != nil {
t.Errorf("FAIL: %s: %v", want, err)
continue
}
html := buf.String()
// Asserted on the control's own data hook, not on the word
// "Accept". The first version of this test grepped for that
// word and PASSED with the protected branch deleted, because
// the page says "accepts itself" elsewhere — a guard that
// cannot fail is worse than none, since it reads as coverage.
hook := `data-accept="` + want + `"`
if !strings.Contains(html, hook) {
t.Errorf("FAIL: state %q renders no accept control (no %s)", want, hook)
continue
}
if want != "go" {
// The closed states must say WHY, not just be dead. Checked
// on the control's own tag so another disabled element on
// the page cannot stand in for it.
tag := html[strings.Index(html, hook):]
if end := strings.Index(tag, ">"); end > 0 {
tag = tag[:end]
}
if !strings.Contains(tag, "disabled") {
t.Errorf("FAIL: state %q renders an enabled control: %s", want, tag)
}
if !strings.Contains(tag, "title=") {
t.Errorf("FAIL: state %q gives no reason on the control: %s", want, tag)
}
}
}
// And the protected case names who CAN land it, because "you
// cannot" is a dead end and "ask these people" is not.
d := cases["protected"]()
var buf bytes.Buffer
if err := noHooks.template(landingPage).Execute(&buf, pageData{Title: "Landing", Repo: "quarry",
Section: "landings", Landing: &d}); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "dmestas") {
t.Error("FAIL: a protected target does not name its owners")
}
}
// The panel and the accept must answer "is this required check green"
// the same way, because they are the same question.
//
// They did not. Required-check names stopped being the workflow's
// registered name — that carries a repository and a digest, so nobody
// should type it — and the accept path learned to match the pipeline's
// stem instead. MissingChecks kept matching only the workflow name and
// the full pipeline path, and the stem helper was unexported so the
// page could not have shared the rule. The result was one panel
// contradicting itself: "Waiting for 1 required check — quarry … no run
// yet" printed directly above the green run for ci/quarry.json, beside
// an Accept that was enabled because the op knew better.
func TestThePanelAndTheAcceptAgreeAboutRequiredChecks(t *testing.T) {
// A run named the way the engine names them now.
run := ciRunRow{Workflow: "quarry.quarry.574cf8", Pipeline: "ci/quarry.json",
Status: "completed"}
for _, required := range []string{
"quarry", // the stem: what a person types
"ci/quarry.json", // the full path
"quarry.quarry.574cf8", // the engine's own name
} {
d := landingDetail{
landingRow: landingRow{ID: strings.Repeat("a", 64), Branch: "work",
Target: "trunk", State: "open"},
CanAccept: true, Mergeable: "clean",
Required: []string{required}, Checks: []ciRunRow{run},
}
// The op would report nothing unmet for any of the three, so
// the page must not report one missing.
if got := d.MissingChecks(); len(got) != 0 {
t.Errorf("FAIL: required %q is green for the accept and missing for the panel: %v",
required, got)
}
if !api.CheckNameMatches(required, run.Workflow, run.Pipeline) {
t.Errorf("FAIL: the shared rule does not match %q", required)
}
}
// And a required check with genuinely no run stays missing — the
// fix must not make everything green.
d := landingDetail{
landingRow: landingRow{ID: strings.Repeat("a", 64), Branch: "work",
Target: "trunk", State: "open"},
CanAccept: true, Mergeable: "clean",
Required: []string{"nightly"}, Checks: []ciRunRow{run},
}
if got := d.MissingChecks(); len(got) != 1 || got[0] != "nightly" {
t.Errorf("FAIL: a required check with no run is not reported missing: %v", got)
}
}
// zit writes a landing record with no N card unless the opener passes
// --mimetype, and fossil reads an absent card as its own wiki markup —
// so a record written in the Markdown box every authoring surface here
// offers came out with literal asterisks and backticks on the page.
// A landing is quarry's artifact, never fossil-born, so the absent card
// is read as markdown rather than as fossil's default.
func TestALandingWithNoMimetypeStillRendersAsMarkdown(t *testing.T) {
ix := landingIndex(t, false)
root := strings.Repeat("e5", 20)
if err := ix.FoldNow([]index.Parsed{{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
ThreadTitle: "land the render fix", Date: "2026-08-22T09:00:00", User: "dan",
// No Mimetype: exactly what `zit landing open` writes.
Body: []byte("zit-landing: 1\nbranch: fossil-caps\ntarget: trunk\n\n" +
"**Not decided here, deliberately.** The gate calls `Refused`.\n")}}}); err != nil {
t.Fatal(err)
}
html := landingGet(t, landingServer(t, ix, ""), "/r/alpha/landing/"+root[:12])
if !strings.Contains(html, "<strong>Not decided here, deliberately.</strong>") {
t.Errorf("FAIL: emphasis is not rendered:\n%s", html)
}
if !strings.Contains(html, "<code>Refused</code>") {
t.Error("FAIL: the code span is not rendered")
}
if strings.Contains(html, "**Not decided") {
t.Error("FAIL: the asterisks reached the reader")
}
t.Log("ok: a landing with no N card renders as markdown")
}