Skip to content
quarry Artifact [816de5a8cd]
Login

Artifact [816de5a8cd]

Artifact 816de5a8cd714b5c27f8a1ccc4f5d876f3b229555ae317462fe913b8dbd11414:


package web

import (
	"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)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil,
		[]Option{WithSessions(fakeSessions{user: "dan"})}))
	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)
	}
}