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)
}