Skip to content
quarry Artifact [aab65f4323]
Login

Artifact [aab65f4323]

Artifact aab65f4323c3b244a57aa73662afaf6be25d5b88b13ddb06e29fb302254a43d4:


package web

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ops"
)

func testRegistry(t *testing.T) *ops.Registry {
	t.Helper()
	// An empty (but real) index so entity lookups answer not-found
	// rather than "no store configured".
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	return r
}

func TestHomePageRendersThroughVersionOp(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "quarry 9.9.9-test") {
		t.Fatalf("footer missing op-served version:\n%s", body)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Fatalf("content-type %q", ct)
	}
	// Principle 3: the ONLY script is the vendored Datastar module (the
	// one allowed framework, pure enhancement); no inline code.
	if n := strings.Count(body, "<script"); n != 1 || !strings.Contains(body, `src="/assets/datastar.js"`) {
		t.Fatalf("script policy violated (%d script tags):\n%s", n, body)
	}
	if strings.Contains(body, "<script>") {
		t.Fatal("inline script on the base page")
	}
	if !strings.Contains(body, "prefers-color-scheme") {
		t.Fatal("dark theme missing")
	}
}

func TestUnknownPathIs404(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/nope", nil))
	if rec.Code != 404 {
		t.Fatalf("status %d", rec.Code)
	}
}

func TestThemed404Page(t *testing.T) {
	for _, path := range []string{"/nope", "/r/test/wiki/NoSuchPage", "/r/test/checkin/deadbeefdead", "/r/test/ticket/deadbeef"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 404 {
			t.Errorf("%s: status %d", path, rec.Code)
			continue
		}
		body := rec.Body.String()
		if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
			t.Errorf("%s: content-type %q — bare default 404?", path, rec.Header().Get("Content-Type"))
		}
		if !strings.Contains(body, "quarry 9.9.9-test") {
			t.Errorf("%s: 404 page missing version footer", path)
		}
		if !strings.Contains(body, `href="/"`) {
			t.Errorf("%s: 404 page has no way back into the site", path)
		}
	}
}

func TestPersistentNavOnEveryPage(t *testing.T) {
	// Ticket 010: every repo page carries that repo's nav and search
	// form; every page (fleet and 404 included) carries the site link.
	for _, path := range []string{"/r/test/timeline", "/r/test/wiki", "/r/test/ticket", "/r/test/search"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		body := rec.Body.String()
		for _, want := range []string{`<nav>`, `href="/r/test/timeline"`, `href="/r/test/wiki"`, `href="/r/test/ticket"`, `action="/r/test/search"`} {
			if !strings.Contains(body, want) {
				t.Errorf("%s: missing %s in header", path, want)
			}
		}
	}
	for _, path := range []string{"/", "/nope"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if !strings.Contains(rec.Body.String(), `class="site" href="/"`) {
			t.Errorf("%s: missing site link", path)
		}
	}
}

func TestFleetPageListsRepos(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), `href="/r/test/timeline"`) {
		t.Fatalf("fleet page missing repo link:\n%s", rec.Body.String())
	}
}

func TestTablesScrollInWrapper(t *testing.T) {
	// Ticket 120: wide tables scroll inside .tw, not the page.
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil))
	body := rec.Body.String()
	if strings.Contains(body, "<table>") && !strings.Contains(body, `<div class="tw"><table>`) {
		t.Fatalf("timeline table not wrapped:\n%s", body)
	}
}

func TestTimelineRendersAllRowsWithBranches(t *testing.T) {
	// Regression: a template field mismatch once truncated the page at
	// the first row — Execute's error was discarded. Render a POPULATED
	// timeline and assert every row, branch labels, and a complete page.
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	var batch []index.Parsed
	for i := 0; i < 5; i++ {
		h := strings.Repeat(fmt.Sprintf("%02d", i+1), 32)
		art := &artifact.Artifact{Type: artifact.Manifest, User: "dan",
			Date:    fmt.Sprintf("2026-08-15T0%d:00:00", i),
			Comment: fmt.Sprintf("commit %d", i)}
		if i == 0 {
			art.Tags = []artifact.Tag{{Name: "*branch", Target: "*", Value: "trunk"}}
		}
		if i > 0 {
			art.Parents = []string{strings.Repeat(fmt.Sprintf("%02d", i), 32)}
		}
		batch = append(batch, index.Parsed{Hash: h, Art: art})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	Handler(r, nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil))
	body := rec.Body.String()
	if got := strings.Count(body, `class="tl-card"`); got != 5 {
		t.Fatalf("rendered %d rows, want 5:\n%s", got, body)
	}
	// Count the GRAPH's layers specifically. A bare "<svg " count also
	// catches the file-listing icons, which live as data URIs in the
	// base stylesheet and have nothing to do with the timeline.
	if got := strings.Count(body, `<svg class="tl-`); got != 10 { // head + tail per row
		t.Fatalf("rendered %d graph layers, want 10", got)
	}
	if got := strings.Count(body, `<svg class="tl-head`); got != 5 {
		t.Fatalf("rendered %d head layers, want 5", got)
	}
	if !strings.Contains(body, `<span class="tl-pill">2026-08-15</span>`) {
		t.Fatal("date pill missing")
	}
	if !strings.Contains(body, `href="/r/test/timeline?branch=trunk"><b>trunk</b></a>`) {
		t.Fatalf("branch label missing:\n%s", body)
	}
	if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
		t.Fatal("page truncated — template error swallowed")
	}
}

func TestTimelineGraphRails(t *testing.T) {
	// DAG:  m (merge of c2+b1) ← newest
	//       b1 (branch dev, parent c1)
	//       c2 (trunk, parent c1)
	//       c1 (trunk root)
	c1, c2, b1, m := strings.Repeat("aa", 32), strings.Repeat("bb", 32), strings.Repeat("cc", 32), strings.Repeat("dd", 32)
	entries := []timelineEntry{
		{Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge", IsMerge: true, Parents: []string{c2, b1}},
		{Hash: b1, Date: "2026-08-15T03:00:00", Branch: "dev", Comment: "dev work", Parents: []string{c1}},
		{Hash: c2, Date: "2026-08-15T02:00:00", Branch: "trunk", Comment: "trunk work", Parents: []string{c1}},
		{Hash: c1, Date: "2026-08-15T01:00:00", Branch: "trunk", Comment: "root"},
	}
	days := buildTimeline("test", entries)
	if len(days) != 1 || len(days[0].Rows) != 4 {
		t.Fatalf("days = %+v", days)
	}
	svg := func(i int) string { return string(days[0].Rows[i].Head) + string(days[0].Rows[i].Tail) }
	// The merge row curves out to a second rail.
	if !strings.Contains(svg(0), "<path") {
		t.Fatalf("merge row has no curve:\n%s", svg(0))
	}
	// The dev row sits on a different x than the trunk rail and carries
	// the dev branch color.
	if !strings.Contains(svg(1), branchStroke("dev")) {
		t.Fatalf("dev row not dev-colored:\n%s", svg(1))
	}
	if !strings.Contains(string(days[0].Rows[1].Head), `cx="21"`) {
		t.Fatalf("dev node not on second rail:\n%s", days[0].Rows[1].Head)
	}
	// The trunk row between branch point and merge shows the dev rail
	// passing through.
	if !strings.Contains(svg(2), `x1="21"`) {
		t.Fatalf("dev rail not passing through trunk row:\n%s", svg(2))
	}
	// The root row converges both rails (a join curve).
	if !strings.Contains(svg(3), "<path") {
		t.Fatalf("root row has no join curve:\n%s", svg(3))
	}
	// Root has nothing below: no line from cy to unitH on the node rail.
	if strings.Contains(svg(3), `y1="13" x2="7" y2="26"`) {
		t.Fatalf("root row continues below:\n%s", svg(3))
	}
}

func TestTimelineGraphBranchColorContinuity(t *testing.T) {
	// The dev branch line must be dev-colored end to end: the merge
	// curve out of m, the rail between m and b1, b1's own segments, and
	// b1's join curve at the fork commit c1. No empty-branch hash hues.
	c1, c2, b1, m := strings.Repeat("aa", 32), strings.Repeat("bb", 32), strings.Repeat("cc", 32), strings.Repeat("dd", 32)
	entries := []timelineEntry{
		{Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge", IsMerge: true, Parents: []string{c2, b1}},
		{Hash: b1, Date: "2026-08-15T03:00:00", Branch: "dev", Comment: "dev work", Parents: []string{c1}},
		{Hash: c2, Date: "2026-08-15T02:00:00", Branch: "trunk", Comment: "trunk work", Parents: []string{c1}},
		{Hash: c1, Date: "2026-08-15T01:00:00", Branch: "trunk", Comment: "root"},
	}
	days := buildTimeline("test", entries)
	rows := days[0].Rows
	dev, unknown := branchStroke("dev"), branchStroke("")

	// Merge row: the curve AND the tail rail toward b1 are dev-colored.
	mergeHead, mergeTail := string(rows[0].Head), string(rows[0].Tail)
	if !strings.Contains(mergeHead, `Q`) || !strings.Contains(mergeHead, dev) {
		t.Fatalf("merge curve not dev-colored:\n%s", mergeHead)
	}
	if !strings.Contains(mergeTail, dev) {
		t.Fatalf("rail below merge not dev-colored:\n%s", mergeTail)
	}
	// b1's node and below-rail are dev; the fork row's join curve is dev.
	if !strings.Contains(string(rows[1].Head), dev) || !strings.Contains(string(rows[1].Tail), dev) {
		t.Fatalf("dev commit segments not dev-colored")
	}
	rootHead := string(rows[3].Head)
	if !strings.Contains(rootHead, `Q`) || !strings.Contains(rootHead, dev) {
		t.Fatalf("join curve at fork not dev-colored:\n%s", rootHead)
	}
	// The pass-through row (c2) shows the dev rail in dev color.
	if !strings.Contains(string(rows[2].Head), dev) {
		t.Fatalf("pass-through dev rail not dev-colored:\n%s", rows[2].Head)
	}
	// Nothing anywhere uses the unknown-branch neutral (all on-page).
	for i, r := range rows {
		if strings.Contains(string(r.Head), unknown) || strings.Contains(string(r.Tail), unknown) {
			t.Fatalf("row %d uses unknown-branch color", i)
		}
	}
	// Uniform stroke width.
	for i, r := range rows {
		all := string(r.Head) + string(r.Tail)
		if strings.Contains(all, `stroke-width="2"`) || strings.Contains(all, "vector-effect") {
			t.Fatalf("row %d has non-uniform stroke setup", i)
		}
	}
}

func TestTimelineOffPageParentIsNeutral(t *testing.T) {
	// A merge whose second parent is beyond the page: its rail renders
	// neutral, never a hashed hue for the empty branch name.
	m := strings.Repeat("ee", 32)
	off := strings.Repeat("ff", 32)
	days := buildTimeline("test", []timelineEntry{
		{Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge far", IsMerge: true,
			Parents: []string{strings.Repeat("aa", 32), off}},
	})
	head := string(days[0].Rows[0].Head)
	if !strings.Contains(head, branchStroke("")) {
		t.Fatalf("off-page merge rail not neutral:\n%s", head)
	}
}

type fakeNotifier struct{ ch chan struct{} }

func (f *fakeNotifier) Watch(string) (<-chan struct{}, func()) { return f.ch, func() {} }

func TestLiveTimelineSSE(t *testing.T) {
	// The live endpoint streams a Datastar element patch containing the
	// re-rendered #tl fragment when the notifier ticks.
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	h := strings.Repeat("99", 32)
	if err := ix.FoldNow([]index.Parsed{{Hash: h, Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "live one"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	// The page advertises the live hookup.
	page, _ := srv.Client().Get(srv.URL + "/r/test/timeline")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	if !strings.Contains(string(body), `data-init="@get('/r/test/timeline.sse?`) {
		t.Fatalf("page missing live hookup:\n%s", body)
	}

	resp, err := srv.Client().Get(srv.URL + "/r/test/timeline.sse")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
		t.Fatalf("content-type %q", ct)
	}
	fn.ch <- struct{}{}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "live one") {
		lineCh := make(chan string, 1)
		go func() {
			l, _ := rd.ReadString('\n')
			lineCh <- l
		}()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			t.Fatalf("no patch within deadline; got:\n%s", got)
		}
	}
	if !strings.Contains(got, `data: elements <div id="tl">`) {
		t.Fatalf("patch not a #tl morph:\n%s", got)
	}
}

// TestWikiHistoryPages covers the wiki history list, revision view with
// its banner, and the timeline linking wiki rows to their revision.
func TestWikiHistoryPages(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("a1", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Guide", Date: "2026-08-15T09:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("first draft\n")}},
		{Hash: strings.Repeat("b2", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Guide", Date: "2026-08-15T10:00:00", User: "eve",
			Mimetype: "text/x-markdown", Body: []byte("polished\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)

	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
		return rec.Body.String()
	}

	// Latest view: history link, no revision banner.
	body := get("/r/test/wiki/Guide")
	if !strings.Contains(body, "polished") || !strings.Contains(body, `/r/test/wiki/Guide/history`) {
		t.Fatalf("latest view:\n%s", body)
	}
	if strings.Contains(body, "Historical revision") {
		t.Fatal("banner on latest view")
	}

	// History lists both edits, newest first, old one linked with ?rev=.
	body = get("/r/test/wiki/Guide/history")
	if !strings.Contains(body, "?rev="+strings.Repeat("a1", 6)) {
		t.Fatalf("old revision not linked:\n%s", body)
	}
	if i, j := strings.Index(body, strings.Repeat("b2", 6)), strings.Index(body, strings.Repeat("a1", 6)); i < 0 || j < 0 || i > j {
		t.Fatalf("history order wrong (b2 at %d, a1 at %d):\n%s", i, j, body)
	}

	// Revision view: old content plus the banner back to latest.
	body = get("/r/test/wiki/Guide?rev=" + strings.Repeat("a1", 6))
	if !strings.Contains(body, "first draft") || strings.Contains(body, "polished") {
		t.Fatalf("revision content:\n%s", body)
	}
	if !strings.Contains(body, "Historical revision") {
		t.Fatalf("banner missing:\n%s", body)
	}

	// Timeline wiki rows link the revision they represent.
	body = get("/r/test/timeline")
	if !strings.Contains(body, "/r/test/wiki/Guide?rev="+strings.Repeat("a1", 6)) {
		t.Fatalf("timeline wiki row not revision-linked:\n%s", body)
	}
}

// Real stores carry wiki titles with slashes (tcl's "(Initi/Fin)alize");
// every generated link must escape them so the mux sees one segment.
func TestWikiTitleWithSlash(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("c3", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "(Initi/Fin)alize", Date: "2026-08-15T09:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("lifecycle\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/wiki", nil))
	if !strings.Contains(rec.Body.String(), `/r/test/wiki/%28Initi%2FFin%29alize`) {
		t.Fatalf("wiki list link not escaped:\n%s", rec.Body.String())
	}
	for _, path := range []string{
		"/r/test/wiki/%28Initi%2FFin%29alize",
		"/r/test/wiki/%28Initi%2FFin%29alize/history",
	} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 || !strings.Contains(rec.Body.String(), "(Initi/Fin)alize") {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
	}
}

// TestLiveFleetSSE: the home page advertises /fleet.sse and a tick on
// any repo streams a #fleet morph with fresh repo.list data.
func TestLiveFleetSSE(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("77", 32), Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "fleet tip"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	page, _ := srv.Client().Get(srv.URL + "/")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	if !strings.Contains(string(body), `data-init="@get('/fleet.sse')"`) {
		t.Fatalf("home missing live hookup:\n%s", body)
	}

	resp, err := srv.Client().Get(srv.URL + "/fleet.sse")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	fn.ch <- struct{}{}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	// The listing shows the repository and its counts, not the last
	// commit message — an aggregator page is not the place for one.
	for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "/r/test/timeline") {
		lineCh := make(chan string, 1)
		go func() {
			l, _ := rd.ReadString('\n')
			lineCh <- l
		}()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			t.Fatalf("no patch within deadline; got:\n%s", got)
		}
	}
	if !strings.Contains(got, `data: elements <div id="fleet">`) {
		t.Fatalf("patch not a #fleet morph:\n%s", got)
	}
}

// TestForumTechnotePages renders the forum and technote read surfaces.
func TestForumTechnotePages(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	root := strings.Repeat("aa", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadTitle: "First topic", Date: "2026-08-16T09:00:00", User: "dan",
			Body: []byte("opening post body\n")}},
		{Hash: strings.Repeat("bb", 20), Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadRoot: root, InReplyTo: root, Date: "2026-08-16T10:00:00", User: "eve",
			Body: []byte("a considered reply\n")}},
		{Hash: strings.Repeat("cc", 20), Art: &artifact.Artifact{Type: artifact.Technote,
			EventID: strings.Repeat("dd", 20), EventTime: "2026-08-16T08:00:00",
			Date: "2026-08-16T08:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("# Note\n\ntechnote body text\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)
	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
		return rec.Body.String()
	}

	if b := get("/r/test/forum"); !strings.Contains(b, "First topic") || !strings.Contains(b, "2 posts") {
		t.Fatalf("forum list:\n%s", b)
	}
	if b := get("/r/test/forum/" + root[:16]); !strings.Contains(b, "opening post body") || !strings.Contains(b, "a considered reply") {
		t.Fatalf("forum thread:\n%s", b)
	}
	if b := get("/r/test/technote"); !strings.Contains(b, "/r/test/technote/"+strings.Repeat("dd", 8)) {
		t.Fatalf("technote list:\n%s", b)
	}
	if b := get("/r/test/technote/" + strings.Repeat("dd", 8)); !strings.Contains(b, "technote body text") {
		t.Fatalf("technote page:\n%s", b)
	}
	// Nav links present on every repo page.
	if b := get("/r/test/timeline"); !strings.Contains(b, `/r/test/forum`) || !strings.Contains(b, `/r/test/technote`) {
		t.Fatal("nav missing forum/technote links")
	}
}

// fakeChatLive delivers preset messages to the SSE handler.
type fakeChatLive struct{ ch chan ChatMessage }

func (f *fakeChatLive) SubscribeChat(string) (<-chan ChatMessage, func()) {
	return f.ch, func() {}
}

func TestChatPage(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	fc := &webChatStub{msgs: []chat.Message{{User: "dan", Text: "hello world", At: "2026-08-16T12:00:00Z"}}}
	if err := api.RegisterAll(r, api.Deps{Version: "t",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: fc}); err != nil {
		t.Fatal(err)
	}
	live := &fakeChatLive{ch: make(chan ChatMessage, 1)}
	h := Handler(r, nil, live)

	// The page shows history + the post form + the live hookup + nav link.
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat", nil))
	body := rec.Body.String()
	if !strings.Contains(body, "hello world") || !strings.Contains(body, `action="/r/test/chat"`) {
		t.Fatalf("chat page:\n%s", body)
	}
	if !strings.Contains(body, `data-init="@get('/r/test/chat.sse')"`) {
		t.Fatal("chat page missing live hookup")
	}
	if !strings.Contains(body, `/r/test/chat`) || !strings.Contains(rec.Body.String(), `id="chatlog"`) {
		t.Fatal("chat log container missing")
	}

	// The SSE stream appends a new message as a datastar patch. Close
	// the response before the server so Close doesn't block on the
	// still-open stream.
	srv := httptest.NewServer(h)
	resp, err := srv.Client().Get(srv.URL + "/r/test/chat.sse")
	if err != nil {
		srv.Close()
		t.Fatal(err)
	}
	live.ch <- ChatMessage{User: "eve", Text: "live line", At: "now"}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "live line") || !strings.Contains(got, "mode append") {
		lineCh := make(chan string, 1)
		go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			resp.Body.Close()
			srv.Close()
			t.Fatalf("no chat patch:\n%s", got)
		}
	}
	resp.Body.Close()
	srv.Close()
	if !strings.Contains(got, "selector #chatlog") {
		t.Fatalf("patch not targeting #chatlog:\n%s", got)
	}
}

type webChatStub struct{ msgs []chat.Message }

func (s *webChatStub) Post(code string, m chat.Message) error                 { s.msgs = append(s.msgs, m); return nil }
func (s *webChatStub) History(code string, limit int) ([]chat.Message, error) { return s.msgs, nil }

// The live stream re-renders the whole listing, so it has to know what
// the reader is looking at. A stream that does not carry the filter
// replaces the filtered listing with the default about a second after
// it appears — which reads as the filter simply not working.
func TestLiveFleetStreamCarriesTheFilter(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(
		map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	// The page must point the stream at its own view, not at the bare URL.
	page, _ := srv.Client().Get(srv.URL + "/?q=alpha")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	if !strings.Contains(string(body), "/fleet.sse?q") {
		t.Fatalf("filtered home page hooks the unfiltered stream:\n%s", body)
	}

	// And the stream must actually apply it.
	req, _ := http.NewRequest("GET", srv.URL+"/fleet.sse?q=alpha", nil)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	resp, err := srv.Client().Do(req.WithContext(ctx))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	// The preamble and the first repaint arrive in separate writes, so
	// read until the patch shows up rather than trusting one Read.
	var frag string
	rd := bufio.NewReader(resp.Body)
	for !strings.Contains(frag, "</div>") {
		line, err := rd.ReadString('\n')
		if err != nil {
			break
		}
		frag += line
	}
	if !strings.Contains(frag, "alpha") {
		t.Errorf("filtered stream dropped the match:\n%s", frag)
	}
	if strings.Contains(frag, "beta") {
		t.Errorf("filtered stream includes a repo the filter excludes:\n%s", frag)
	}
	// The controls come back with the repaint, so they must carry the
	// filter too or the box empties itself while the listing stays filtered.
	if !strings.Contains(frag, `value="alpha"`) {
		t.Errorf("repaint cleared the filter box:\n%s", frag)
	}
}

// The first SSE repaint arrives immediately on connect and REPLACES the
// server-rendered listing, so the fragment must be given everything the
// full page gave the same template. Any field it omits renders on load
// and then vanishes a moment later — which is what happened to the
// "23 of 23" count and the New repository link, and before them to the
// filter and sort.
//
// The admin control is per-connection: it must appear for an admin's
// stream and for nobody else's.
func TestFleetFragmentCarriesEverythingThePageDoes(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(
		map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}

	anon, err := fleetFragment(r, url.Values{}, false)
	if err != nil {
		t.Fatal(err)
	}
	// The total is what makes the count render at all.
	if !strings.Contains(anon, "of 2") {
		t.Errorf("the fragment dropped the fleet total, so the count vanishes on repaint:\n%s", anon)
	}
	if strings.Contains(anon, "New repository") {
		t.Error("an anonymous stream is offered the admin-only create control")
	}

	admin, err := fleetFragment(r, url.Values{}, true)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(admin, "New repository") {
		t.Error("an admin's stream loses the create control on repaint")
	}
}