package web import ( "errors" "html" "net/http" "net/url" "regexp" "strings" "testing" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // The projection these tests drive — the fingerprint, the lanes, the ready rule, // the detail/epic assembly — is tested in the beads package. What is left here // is what only this package can answer: that beads.html renders what the // projection produced, over the real router and template set. // --- fixtures ---------------------------------------------------------------- // beadsTables is a schema fingerprint the beads view should accept: issues (with // id + status) + dependencies both present. func beadsTables() []browse.TableInfo { return []browse.TableInfo{ {Name: "issues", Columns: []browse.ColumnInfo{ {Name: "id", PrimaryKey: true}, {Name: "status"}, }}, {Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}}, {Name: "labels"}, } } // beadsFixture wires a fakeSession whose per-table Rows model a small parade: // - i-open : open, ready → Lined Up // - i-prog : in_progress → Rolling // - i-done : closed → Past Stand // - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target) // and also carries is_blocked=1 → Stalled func beadsFixture() *fakeSession { issues := &browse.RowPage{ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"}, Rows: [][]string{ {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"}, {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"}, {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"}, {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"}, }, Total: 4, } // i-blocked depends on i-open (blocks, target open → keeps it Stalled). deps := &browse.RowPage{ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Rows: [][]string{ {"d1", "i-blocked", "i-open", "blocks"}, }, Total: 1, } labels := &browse.RowPage{ Columns: []string{"issue_id", "label"}, Rows: [][]string{ {"i-open", "backend"}, {"i-open", "urgent"}, }, Total: 2, } statuses := &browse.RowPage{ Columns: []string{"name", "category"}, Rows: [][]string{ {"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"}, }, Total: 3, } comments := &browse.RowPage{ Columns: []string{"issue_id", "author", "text", "created_at"}, Rows: [][]string{ {"i-open", "alice", "first!", "2024-01-05"}, {"i-prog", "bob", "not this one", "2024-01-06"}, }, Total: 2, } // i-done's closure is recorded as a `closed` audit event carrying the reason // (the only place the reason now surfaces — there is no standalone block). events := &browse.RowPage{ Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, Rows: [][]string{ {"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"}, }, Total: 1, } return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": issues, "dependencies": deps, "labels": labels, "custom_statuses": statuses, "comments": comments, "events": events, }, } } // beadsEpicFixture models an epic (i-epic) with three parent-child children — // one closed, one open, one in-progress — plus a comment and audit events on the // epic, so both the subtask rollup and the merged history reach the template. func beadsEpicFixture() *fakeSession { issues := &browse.RowPage{ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"}, Rows: [][]string{ {"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"}, {"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"}, {"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"}, {"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"}, }, Total: 4, } deps := &browse.RowPage{ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"}, Rows: [][]string{ {"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"}, {"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"}, {"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"}, }, Total: 3, } statuses := &browse.RowPage{ Columns: []string{"name", "category"}, Rows: [][]string{ {"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"}, }, Total: 3, } comments := &browse.RowPage{ Columns: []string{"issue_id", "author", "text", "created_at"}, Rows: [][]string{ {"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"}, {"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"}, }, Total: 2, } events := &browse.RowPage{ Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, Rows: [][]string{ {"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"}, {"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"}, {"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"}, {"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"}, {"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"}, }, Total: 5, } return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": issues, "dependencies": deps, "custom_statuses": statuses, "comments": comments, "events": events, }, } } // --- end to end -------------------------------------------------------------- func TestBeadsHandleViewBoard(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{"Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "beads-summary"} { if !strings.Contains(body, want) { t.Errorf("board body missing %q", want) } } // The Tables tab must remain reachable from the view. if !strings.Contains(body, "/~alice/db/tree/") { t.Errorf("board missing Tables tab link; body=%s", body) } // The filter bar renders with option lists drawn from the data, plus the // Ready-only toggle; i-open is ready, so a ready dot renders on the board. for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities", "Ready only", "ready-dot"} { if !strings.Contains(body, want) { t.Errorf("board missing control %q", want) } } } func TestBeadsDepTreeRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": {Columns: []string{"id", "title", "status"}, Rows: [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "open"}}, Total: 3}, "dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Rows: [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}}, Total: 2}, }, } setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=a", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The transitive chain section appears with the depth-1 node c and an indent. for _, want := range []string{"Prerequisite chain", "dep-tree", "--depth: 1", ">c<"} { if !strings.Contains(body, want) { t.Errorf("dep-tree render missing %q; body=%s", want, body) } } } func TestBeadsBoardFilterRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?type=feature", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The active type is preselected and a Clear link appears. if !strings.Contains(body, `value="feature" selected`) { t.Errorf("type filter not preselected; body=%s", body) } if !strings.Contains(body, "beads-filter-clear") { t.Errorf("Clear link missing when a filter is active") } // Only feature issues on the board; the bug (i-prog) is filtered out. if !strings.Contains(body, "i-open") || strings.Contains(body, "i-prog") { t.Errorf("filtered board should show features only; body=%s", body) } } func TestBeadsHandleViewDetail(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=i-blocked", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Waiting") { t.Errorf("detail missing issue title; body=%s", body) } if !strings.Contains(body, "Depends on") || !strings.Contains(body, "i-open") { t.Errorf("detail missing dependency edge; body=%s", body) } if !strings.Contains(body, "Back to the parade") { t.Errorf("detail missing back link; body=%s", body) } } // A closed issue's detail pane must surface its close reason (and the closed // timestamp), so the resolution recorded by `bd close -r` is not lost. // TestBeadsDetailShowsCloseReason: the reason appears twice by design — once in // the Comments tab (which has no closed event) as a Close reason block, and once // in the History tab as the humanized `closed` event. func TestBeadsDetailShowsCloseReason(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=i-done", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // Comments-tab block. if !strings.Contains(body, "

Close reason

") { t.Errorf("Comments tab should show a Close reason block; body=%s", body) } // History-tab closed event. if !strings.Contains(body, "closed the issue") { t.Errorf("History should carry the closed event; body=%s", body) } // The reason text itself is present (both places). if !strings.Contains(body, "Fixed in commit abc123") { t.Errorf("close reason text missing; body=%s", body) } } // --- the stream layout ------------------------------------------------------- func TestBeadsStreamRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?layout=stream", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("stream: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // One column of sections, the board's lanes gone, and the same cards in it. for _, want := range []string{ `class="beads-stream"`, `class="stream-head"`, `class="stream-body"`, "Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "ready-dot", `class="bead-row"`, } { if !strings.Contains(body, want) { t.Errorf("stream body missing %q", want) } } if strings.Contains(body, `class="beads-lanes"`) { t.Errorf("stream body should not render the board's lane row; body=%s", body) } // Past Stand is the one collapsed section: a
with no open attribute. if !strings.Contains(body, `
`) { t.Errorf("Past Stand should render as a collapsed
; body=%s", body) } if strings.Contains(body, "
; body=%s", body) } // The filter form must carry the layout, or filtering would drop back to the // board; the toggle marks Stream as current and links to the board. if !strings.Contains(body, ``) { t.Errorf("stream form missing the layout carrier; body=%s", body) } if !strings.Contains(body, `Stream`) { t.Errorf("toggle should mark Stream as current; body=%s", body) } if !strings.Contains(body, `href="/~alice/db/view/beads">Board`) { t.Errorf("toggle should link back to an unfiltered board; body=%s", body) } // No JavaScript is added by this layout. if strings.Contains(body, "Clear`) { t.Errorf("Clear should keep ref and layout; body=%s", body) } } // ?issue= wins over any layout: a link to a card is a link to a card. func TestBeadsStreamIssueDetailWins(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?layout=stream&issue=i-blocked", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail under layout=stream: got %d; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Waiting") || !strings.Contains(body, "Back to the parade") { t.Errorf("layout=stream should not disturb the detail pane; body=%s", body) } if strings.Contains(body, `class="beads-stream"`) { t.Errorf("the detail pane rendered a stream; body=%s", body) } } // --- copy-ready bd commands --------------------------------------------------- // The commands offered follow the issue's status, and nothing is offered that bd // would refuse: an open issue can be claimed or closed, an in-progress one closed // or put back to open, a closed one only reopened. func TestBeadsDetailCommandsFollowStatus(t *testing.T) { for _, tc := range []struct { name string issue string want []string notWant []string }{ { name: "open", issue: "i-open", want: []string{"bd update i-open --claim", "bd close i-open"}, notWant: []string{"bd reopen", "--status=open"}, }, { name: "in progress", issue: "i-prog", want: []string{"bd close i-prog", "bd update i-prog --status=open"}, notWant: []string{"--claim", "bd reopen"}, }, { name: "closed", issue: "i-done", want: []string{"bd reopen i-done"}, notWant: []string{"--claim", "bd close i-done", "--status=open"}, }, } { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue="+tc.issue, nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, `class="bead-commands"`) { t.Fatalf("detail pane missing the command block; body=%s", body) } for _, want := range tc.want { if !strings.Contains(body, want) { t.Errorf("command block missing %q; body=%s", want, body) } } for _, notWant := range tc.notWant { if strings.Contains(body, notWant) { t.Errorf("command block offers %q for a %s issue; body=%s", notWant, tc.name, body) } } // One click selects one command, and nothing here needs a script. if !strings.Contains(body, "user-select: all") { t.Errorf("command lines are not user-select: all; body=%s", body) } if strings.Contains(body, ""'` h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": {Columns: []string{"id", "title", "status"}, Rows: [][]string{{id, "Odd id", "open"}}, Total: 1}, "dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0}, }, } setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue="+url.QueryEscape(id), nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() want := `bd update i-a&b<x>"' --claim` if !strings.Contains(body, want) { t.Errorf("command missing the escaped id %q; body=%s", want, body) } if strings.Contains(body, "bd update "+id) { t.Errorf("the id reached the page unescaped; body=%s", body) } // Escaped, the command still says exactly the stored id. if got := html.UnescapeString(want); got != "bd update "+id+" --claim" { t.Errorf("escaped command decodes to %q, want the stored id verbatim", got) } } // The epic pane is the detail pane with a rollup, so it carries the block too. func TestBeadsEpicCommandsRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsEpicFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("epic: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{`class="bead-commands"`, "bd update i-epic --claim", "bd close i-epic"} { if !strings.Contains(body, want) { t.Errorf("epic pane missing %q; body=%s", want, body) } } } // The block belongs to one issue, so the board must not carry it. func TestBeadsBoardHasNoCommandBlock(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, notWant := range []string{`class="bead-commands"`, "bd update ", "bd close ", "bd reopen "} { if strings.Contains(body, notWant) { t.Errorf("the board rendered the command block (%q); body=%s", notWant, body) } } } // --- cross-database issue links ------------------------------------------------ // The database whose detail pane these tests render: prefix "alpha", one issue // whose four bodies, one comment and history name issues in other databases — // one visible, one this caller may not browse, one belonging to nobody — plus a // script tag stored in the description. func beadsLinkFixture() *fakeSession { sess := linkTracker("h-alpha", "alpha") sess.rowsByTable["issues"] = &browse.RowPage{ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "description", "design", "acceptance_criteria", "notes"}, Rows: [][]string{{ "alpha-1", "The one with references", "open", "1", "task", "alice", "2024-01-01", "blocked by beta-46c.2, and by alpha-2.\nnosuch-9z is nobody's.\n", "the shape is beta-nex's", "beta-46c.2 is closed", "secret-9a1 stays a secret", }}, Total: 1, } sess.rowsByTable["comments"] = &browse.RowPage{ Columns: []string{"issue_id", "author", "text", "created_at"}, Rows: [][]string{ {"alpha-1", "alice", "landing with beta-46c.2", "2024-01-05 09:00:00"}, }, Total: 1, } sess.rowsByTable["events"] = &browse.RowPage{ Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, Rows: [][]string{ {"e1", "alpha-1", "updated", "alice", "NULL", `{"design":"the shape is beta-nex's"}`, "NULL", "2024-01-06 09:00:00"}, }, Total: 1, } return sess } // linkTracker is a beads database whose config names an issue prefix, with no // issues in it. It stands for the other databases on the instance: what the // index reads out of one is the prefix and nothing else. func linkTracker(head, prefix string) *fakeSession { return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: head}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": {Columns: []string{"id", "title", "status"}, Total: 0}, "dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0}, "config": { Columns: []string{"key", "value"}, Rows: [][]string{ {"compact_tier2_days", "30"}, {"issue_prefix", prefix}, }, Total: 2, }, }, } } // linkHarness is the instance these tests read: the current database // (alice/alpha), a second public one whose prefix is "beta", and a PRIVATE one // whose prefix is "secret" and which the caller may not browse. func linkHarness(t *testing.T) (h *harness, beta, secret *fakeSession) { t.Helper() h = newHarness(t) beta = linkTracker("h-beta", "beta") secret = linkTracker("h-secret", "secret") addTracker(h, "alice", 1, "alpha", core.VisibilityPublic, beadsLinkFixture()) addTracker(h, "bob", 2, "beta", core.VisibilityPublic, beta) addTracker(h, "dave", 9, "secrets", core.VisibilityPrivate, secret) setViews(t, h, &beadsView{}) return h, beta, secret } // An id whose prefix names a database this caller may browse becomes a link to // that database's detail pane; an id in the current database keeps linking to // this one; an id whose prefix matches nothing is left as it was written. func TestBeadsDetailLinksIdsToTheDatabaseThatOwnsThem(t *testing.T) { h, _, _ := linkHarness(t) rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The sibling database, in each of the long-text bodies that name it. want := `beta-46c.2` for _, field := range []string{"Description", "Acceptance criteria"} { if got := fieldBody(t, body, field); !strings.Contains(got, want) { t.Errorf("the %s body did not link the sibling's id: %s", field, got) } } // The design body's id, which is another database's too. if got := fieldBody(t, body, "Design"); !strings.Contains(got, `beta-nex`) { t.Errorf("the design body's id did not link: %s", got) } // This database's own id links where it has always linked: here. if !strings.Contains(body, `alpha-2`) { t.Errorf("an id in the current database did not link to it; body=%s", body) } // A prefix no database on this instance claims is not an id. if !strings.Contains(body, "nosuch-9z is nobody's.") { t.Errorf("an unknown prefix should be left alone; body=%s", body) } if strings.Contains(body, "issue=nosuch-9z") { t.Errorf("an unknown prefix was linked; body=%s", body) } } // A database this caller may not browse is not in the index, so the ids it owns // render as plain text — with nothing at all to distinguish them from an id that // matches nothing. No tooltip, no class, no marker: each of those would publish // the existence of a database this caller is not allowed to know about. func TestBeadsDetailLeavesAnInvisibleDatabaseUnlinked(t *testing.T) { h, _, secret := linkHarness(t) rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The text says what it said. if !strings.Contains(body, "secret-9a1 stays a secret") { t.Errorf("the notes body lost its text; body=%s", body) } // And says nothing else: not a link, not a marker, not the database's name, // its owner, or its head. for _, notWant := range []string{ "issue=secret-9a1", ">secret-9a1", "secrets", "dave", "h-secret", "unknown tracker", } { if strings.Contains(body, notWant) { t.Errorf("the page revealed %q about a database the caller may not browse; body=%s", notWant, body) } } if secret.opens != 0 { t.Errorf("a database the caller may not browse was opened %d times", secret.opens) } // Its owner, who may browse it, gets the link — which is what makes the // absence above a visibility rule and not a broken index. owner := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", testCaller(9, "dave"), nil) if owner.Code != http.StatusOK { t.Fatalf("owner detail: got %d; body=%s", owner.Code, owner.Body.String()) } if !strings.Contains(owner.Body.String(), `secret-9a1`) { t.Errorf("the owner of the private tracker should see the link; body=%s", owner.Body.String()) } } // Stored text is escaped first and linked second. A description carrying a // script tag reaches the page as text, and the anchors this rendering generated // are the only markup in the body it produced. func TestBeadsDetailEscapesBeforeItLinks(t *testing.T) { h, _, _ := linkHarness(t) rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if strings.Contains(body, "|)$`) for _, tag := range regexp.MustCompile(`<[^>]*>`).FindAllString(desc, -1) { if !anchor.MatchString(tag) { t.Errorf("unexpected markup %q in the rendered description: %s", tag, desc) } } // Escaped, the description still says exactly what was stored. if got := html.UnescapeString(regexp.MustCompile(`]*>`).ReplaceAllString(desc, "")); got != "blocked by beta-46c.2, and by alpha-2.\nnosuch-9z is nobody's.\n" { t.Errorf("the rendered description does not decode to the stored text: %q", got) } } // The ids in a comment body and in a history summary are linked too: they are // where a hand-off between two trackers is usually written. func TestBeadsDetailLinksCommentsAndHistory(t *testing.T) { h, _, _ := linkHarness(t) rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() comment := `
landing with beta-46c.2
` if !strings.Contains(body, comment) { t.Errorf("a comment body's id did not link; body=%s", body) } summary := `updated design to the shape is beta-nex's` if !strings.Contains(body, summary) { t.Errorf("a history summary's id did not link; body=%s", body) } } // The index is bounded exactly as /ready is: a second render whose heads have // not moved reads no rows again. Counted on the fake, never timed. func TestBeadsDetailPrefixIndexIsBounded(t *testing.T) { h, beta, _ := linkHarness(t) first := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if first.Code != http.StatusOK { t.Fatalf("first detail: got %d", first.Code) } reads := beta.rowReads if reads == 0 { t.Fatalf("the first render must read the sibling's config") } second := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if second.Code != http.StatusOK { t.Fatalf("second detail: got %d", second.Code) } if beta.rowReads != reads { t.Errorf("an unmoved head must cost no row reads: %d then %d", reads, beta.rowReads) } // The store is still opened and its branches listed — that is what the gate // is gated on, and it is the cheap half. if beta.opens != 2 { t.Errorf("the sibling should be opened once per render, got %d", beta.opens) } // And the page is the same page, cache or not. if first.Body.String() != second.Body.String() { t.Errorf("the cached render differs from the first one") } } // The board carries no stored prose, so it builds no index and opens no other // database. The index is paid for by the one rendering that needs it. func TestBeadsBoardBuildsNoLinkIndex(t *testing.T) { h, beta, _ := linkHarness(t) rec := h.do("GET", "/~alice/alpha/view/beads", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("board: got %d; body=%s", rec.Code, rec.Body.String()) } if beta.opens != 0 { t.Errorf("the board opened another database %d times", beta.opens) } if strings.Contains(rec.Body.String(), "/~bob/beta/view/beads") { t.Errorf("the board linked into another database; body=%s", rec.Body.String()) } } // An index that could not be built at all costs the page its links and not the // page: the ids are text, exactly as they were before this existed. func TestBeadsDetailSurvivesAnUnbuildableIndex(t *testing.T) { h, _, _ := linkHarness(t) h.store.listErr = errors.New("db: list repositories: connection refused") rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "blocked by beta-46c.2, and by alpha-2.") { t.Errorf("the description is missing; body=%s", body) } if strings.Contains(body, "issue=beta-46c.2") { t.Errorf("an index that was never built linked something; body=%s", body) } if strings.Contains(body, "connection refused") { t.Errorf("the listing error reached the reader; body=%s", body) } } // fieldBody returns the contents of the
 that follows
// the named field label.
func fieldBody(t *testing.T, body, label string) string {
	t.Helper()
	head := `
` + label + `
`
	i := strings.Index(body, head)
	if i < 0 {
		t.Fatalf("no %s body in the page: %s", label, body)
	}
	rest := body[i+len(head):]
	j := strings.Index(rest, "
") if j < 0 { t.Fatalf("unterminated %s body", label) } return rest[:j] } func TestBeadsEpicViewRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsEpicFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("epic view: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{"Subtasks", "1 of 3 done", "epic-progress", "Child three", "History", "changed status to in_progress"} { if !strings.Contains(body, want) { t.Errorf("epic render missing %q", want) } } }