package web import ( "errors" "fmt" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-dolt/beads" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // The cross-database ready page. What matters here is not the projection — that // is beads' and is tested there — but the two things only this layer can get // wrong: which databases a caller is shown, and how many stores one request is // allowed to open. // readyStore builds a fake session for one tracker: a beads schema, a head // commit four minutes old, and the given issue rows. func readyStore(head string, issues [][]string) *fakeSession { return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: head}}, tables: beadsTables(), commits: []browse.CommitInfo{{ Hash: head, Author: "Eugene Blikh", Date: testNow.Add(-4 * time.Minute), Message: "bd: create (auto-commit)", }}, rowsByTable: map[string]*browse.RowPage{ "issues": { Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked"}, Rows: issues, Total: len(issues), }, "dependencies": { Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0, }, }, } } // addTracker registers a database with the metadata store and wires its store // path to a fake session, the way the production pair (repo.Path → // BrowseOpener) is wired. func addTracker(h *harness, owner string, ownerID int, name string, vis core.Visibility, sess *fakeSession) *core.Repo { repo := h.store.add(&core.Repo{ Name: name, OwnerID: ownerID, OwnerName: owner, Path: "/var/lib/dolt/~" + owner + "/" + name, Visibility: vis, }) if h.browse.byPath == nil { h.browse.byPath = map[string]*fakeSession{} } h.browse.byPath[repo.Path] = sess return repo } // readyHarness is two public trackers: alpha with two ready issues among four, // beta with one. func readyHarness(t *testing.T) (*harness, *fakeSession, *fakeSession) { t.Helper() h := newHarness(t) alpha := readyStore("h-alpha", [][]string{ {"a-1", "Ready, top priority", "open", "0", "task", "alice", "0"}, {"a-2", "Ready, lower", "open", "2", "bug", "bob", "0"}, {"a-3", "Under way", "in_progress", "0", "task", "alice", "0"}, {"a-4", "Waiting on something", "open", "0", "task", "bob", "1"}, }) beta := readyStore("h-beta", [][]string{ {"b-1", "The one ready thing", "open", "1", "task", "carol", "0"}, {"b-2", "Finished", "closed", "0", "task", "carol", "0"}, }) addTracker(h, "alice", 1, "alpha", core.VisibilityPublic, alpha) addTracker(h, "bob", 2, "beta", core.VisibilityPublic, beta) return h, alpha, beta } func TestReadyPageRendersGroupsInOrder(t *testing.T) { pinClock(t, testNow) h, _, _ := readyHarness(t) rec := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) body := rec.Body.String() // Both databases, the busier one first, each with its own count. assert.Contains(t, body, "~alice/alpha") assert.Contains(t, body, "~bob/beta") assert.Less(t, strings.Index(body, "~alice/alpha"), strings.Index(body, "~bob/beta"), "groups are ordered by ready count desc") assert.Contains(t, body, "2 ready") assert.Contains(t, body, "1 ready") assert.Contains(t, body, "3 ready issues") // The ready ones, linked into their own database's board. assert.Contains(t, body, `href="/~alice/alpha/view/beads?issue=a-1"`) assert.Contains(t, body, `href="/~alice/alpha/view/beads?issue=a-2"`) assert.Contains(t, body, `href="/~bob/beta/view/beads?issue=b-1"`) // …and only those: in-progress, blocked and closed issues are not ready. assert.NotContains(t, body, "issue=a-3") assert.NotContains(t, body, "issue=a-4") assert.NotContains(t, body, "issue=b-2") // Each group carries its own database's freshness, so "ready" from a store // that stopped receiving pushes is not claimed silently. assert.Contains(t, body, `class="beads-freshness"`) assert.Contains(t, body, "main · last commit") assert.Contains(t, body, "4 minutes ago") assert.Contains(t, body, `href="/~alice/alpha/commit/h-alpha"`) assert.Contains(t, body, `href="/~bob/beta/commit/h-beta"`) } // A PRIVATE tracker the caller may not browse is absent. Not a 403, not a // counted-but-unnamed group: nothing on the page may hint that it exists. func TestReadyPageHidesADatabaseTheCallerMayNotBrowse(t *testing.T) { pinClock(t, testNow) h, _, _ := readyHarness(t) secret := readyStore("h-secret", [][]string{ {"s-1", "The secret thing", "open", "0", "task", "dave", "0"}, }) addTracker(h, "dave", 9, "secrets", core.VisibilityPrivate, secret) rec := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, rec.Code, "a hidden database is not a refusal") body := rec.Body.String() assert.NotContains(t, body, "secrets") assert.NotContains(t, body, "s-1") assert.NotContains(t, body, "The secret thing") assert.NotContains(t, body, "dave") assert.NotContains(t, body, "h-secret") assert.Zero(t, secret.opens, "a database the caller may not browse is never opened") // The visible ones are unaffected. assert.Contains(t, body, "~alice/alpha") assert.Contains(t, body, "3 ready issues") // Its owner sees it, which is what makes the absence above a visibility rule // and not a broken page. owner := h.do("GET", "/ready", testCaller(9, "dave"), nil) require.Equal(t, http.StatusOK, owner.Code) assert.Contains(t, owner.Body.String(), "~dave/secrets") assert.Contains(t, owner.Body.String(), "s-1") } // The head-hash gate: a second request whose heads have not moved reads no rows // at all. Counted on the fake, never timed. func TestReadyPageHeadHashGateReadsNothingTwice(t *testing.T) { pinClock(t, testNow) h, alpha, beta := readyHarness(t) first := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, first.Code) reads := alpha.rowReads require.Greater(t, reads, 0, "the first request must read rows") second := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, second.Code) assert.Equal(t, reads, alpha.rowReads, "an unmoved head must cost no row reads") assert.Equal(t, 1, alpha.tableReads, "nor a second table listing") assert.Equal(t, 1, alpha.logReads, "nor a second log read") assert.Equal(t, 1, beta.tableReads, "the sibling database is gated too") // Both stores are still opened and their branches listed — that is what the // gate is gated on, and it is the cheap half. assert.Equal(t, 2, alpha.opens) // The page is the same page, cache or not. assert.Equal(t, first.Body.String(), second.Body.String()) } // The TTL: past it the projection is read again even though nothing moved, so a // cache can never be the reason a reader sees yesterday's answer. func TestReadyPageTTLExpiryForcesAReread(t *testing.T) { pinClock(t, testNow) h, alpha, _ := readyHarness(t) require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code) reads := alpha.rowReads // One tick short of the TTL: still cached. pinClock(t, testNow.Add(beads.ReadyCacheTTL-time.Second)) require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code) assert.Equal(t, reads, alpha.rowReads, "inside the TTL the projection stands") // Past it: read again. pinClock(t, testNow.Add(beads.ReadyCacheTTL+time.Second)) require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code) assert.Greater(t, alpha.rowReads, reads, "past the TTL the projection is re-read") } // The ceiling: more candidate databases than beads.ReadyMaxDatabases and the // page says so. A silent cap reads as "that is everything". func TestReadyPageCeilingSaysItWasCapped(t *testing.T) { pinClock(t, testNow) h := newHarness(t) var overflow []*fakeSession for i := 1; i <= beads.ReadyMaxDatabases+2; i++ { sess := readyStore(fmt.Sprintf("h%02d", i), [][]string{ {fmt.Sprintf("t%02d-1", i), "Ready", "open", "1", "task", "alice", "0"}, }) addTracker(h, "alice", 1, fmt.Sprintf("db%02d", i), core.VisibilityPublic, sess) if i > beads.ReadyMaxDatabases { overflow = append(overflow, sess) } } rec := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.Contains(t, body, fmt.Sprintf("only the first %d were read", beads.ReadyMaxDatabases)) assert.Contains(t, body, fmt.Sprintf("%d ready issues", beads.ReadyMaxDatabases)) for _, sess := range overflow { assert.Zero(t, sess.opens, "a database past the ceiling is never opened") } } // A database that cannot be opened costs itself only: the rest of the page // renders, and the browse error reaches the log and not the reader. func TestReadyPageSurvivesADatabaseThatCannotBeOpened(t *testing.T) { pinClock(t, testNow) h, _, _ := readyHarness(t) broken := h.store.add(&core.Repo{ Name: "broken", OwnerID: 4, OwnerName: "erin", Path: "/var/lib/dolt/~erin/broken", Visibility: core.VisibilityPublic, }) h.browse.errByPath = map[string]error{ broken.Path: errors.New("browse: open /var/lib/dolt/~erin/broken: manifest is corrupt"), } rec := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() // The rest of the page is the page. assert.Contains(t, body, "~alice/alpha") assert.Contains(t, body, "~bob/beta") assert.Contains(t, body, "3 ready issues") // The gap is admitted; the error is not printed. assert.Contains(t, body, "could not be read") assert.NotContains(t, body, "manifest is corrupt") assert.NotContains(t, body, "/var/lib/dolt") } // A database that is not a beads tracker is skipped silently: no group, no // note, no mention. func TestReadyPageSkipsNonBeadsDatabases(t *testing.T) { pinClock(t, testNow) h, _, _ := readyHarness(t) plain := &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "h-plain"}}, tables: []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}}, } addTracker(h, "frank", 5, "sensors", core.VisibilityPublic, plain) rec := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.NotContains(t, body, "sensors") assert.NotContains(t, body, "could not be read") assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows") assert.Contains(t, body, "3 ready issues") } func TestReadyPageFilters(t *testing.T) { pinClock(t, testNow) get := func(t *testing.T, target string) string { t.Helper() h, _, _ := readyHarness(t) rec := h.do("GET", target, nil, nil) require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) return rec.Body.String() } t.Run("q narrows over id and title", func(t *testing.T) { body := get(t, "/ready?q=one+ready+thing") assert.Contains(t, body, "issue=b-1") assert.NotContains(t, body, "issue=a-1") assert.Contains(t, body, "1 ready issue") }) t.Run("assignee narrows", func(t *testing.T) { body := get(t, "/ready?assignee=bob") assert.Contains(t, body, "issue=a-2") assert.NotContains(t, body, "issue=a-1") assert.NotContains(t, body, "issue=b-1") }) t.Run("priority narrows", func(t *testing.T) { body := get(t, "/ready?priority=0") assert.Contains(t, body, "issue=a-1") assert.NotContains(t, body, "issue=a-2") }) t.Run("db narrows to one database", func(t *testing.T) { body := get(t, "/ready?db=bob%2Fbeta") assert.Contains(t, body, "~bob/beta") assert.NotContains(t, body, "~alice/alpha") assert.Contains(t, body, `href="/ready"`, "and offers a way back to all of them") }) t.Run("a filter matching nothing says so", func(t *testing.T) { body := get(t, "/ready?assignee=nobody") assert.Contains(t, body, "Nothing ready matches these filters") }) } // An instance with no beads databases at all still answers, and says what it // found rather than nothing. func TestReadyPageEmptyInstance(t *testing.T) { pinClock(t, testNow) h := newHarness(t) rec := h.do("GET", "/ready", nil, nil) require.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "Nothing is ready to work") } // The page is reachable from the dashboard — the only place a reader would look // for a question that belongs to no single database. func TestDashboardLinksToTheReadyPage(t *testing.T) { h := newHarness(t) signedIn := h.do("GET", "/", testCaller(3, "bob"), nil) require.Equal(t, http.StatusOK, signedIn.Code) assert.Contains(t, signedIn.Body.String(), `href="/ready"`) anon := h.do("GET", "/", nil, nil) require.Equal(t, http.StatusOK, anon.Code) assert.Contains(t, anon.Body.String(), `href="/ready"`) } // HEAD is registered for this route like every other read route, and answers // the same status as the GET. func TestReadyPageAnswersHead(t *testing.T) { pinClock(t, testNow) h, _, _ := readyHarness(t) rec := h.do("HEAD", "/ready", nil, nil) assert.Equal(t, http.StatusOK, rec.Code) }