Skip to content
quarry Artifact [975d5c8fa3]
Login

Artifact [975d5c8fa3]

Artifact 975d5c8fa35de8af2f864c7154abb673bf7087712c57248c7ecaf3d4c34d88c2:


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 := 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 := 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")
	}
}