package web
import (
"errors"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// The freshness line: handleView's Head on the envelope, the shared beadsHead
// partial, and the ago func the two of them render. The line qualifies every
// number on the page, so the tests that matter most here are the ones where it
// is absent: a store with no history, and a log that cannot be read, must still
// serve the board.
// testNow is the instant the clock is pinned to in these tests. Nothing about
// it is special beyond being fixed.
var testNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
// pinClock replaces the package clock ago reads for the duration of a test, so
// a relative time is a function of the fixture rather than of when the suite
// ran.
func pinClock(t *testing.T, at time.Time) {
t.Helper()
prev := timeNow
timeNow = func() time.Time { return at }
t.Cleanup(func() { timeNow = prev })
}
// headHash is a dolt-shaped object id; its first 8 characters are what
// shortsha renders.
const headHash = "qk9j2n4bh0ktbfjrn3f7f8m3s9d1p5v2"
// withHead gives a fixture session a head commit four minutes old.
func withHead(s *fakeSession) *fakeSession {
s.commits = []browse.CommitInfo{{
Hash: headHash,
Author: "Eugene Blikh",
Date: testNow.Add(-4 * time.Minute),
Message: "bd: create sr-ht-dolt-44n.2 (auto-commit)",
}}
return s
}
func TestAgoGranularity(t *testing.T) {
pinClock(t, testNow)
cases := []struct {
name string
d time.Duration // how long before the pinned now the instant is
want string
}{
{"the instant itself", 0, "just now"},
{"seconds", 45 * time.Second, "just now"},
{"one second short of a minute", time.Minute - time.Second, "just now"},
{"exactly a minute", time.Minute, "1 minute ago"},
{"minutes", 4 * time.Minute, "4 minutes ago"},
{"one second short of an hour", time.Hour - time.Second, "59 minutes ago"},
{"exactly an hour", time.Hour, "1 hour ago"},
{"hours", 3 * time.Hour, "3 hours ago"},
{"one second short of a day", 24*time.Hour - time.Second, "23 hours ago"},
{"exactly a day", 24 * time.Hour, "1 day ago"},
{"days", 2 * 24 * time.Hour, "2 days ago"},
{"one second short of a month", 30*24*time.Hour - time.Second, "29 days ago"},
{"exactly a month", 30 * 24 * time.Hour, "1 month ago"},
{"months", 71 * 24 * time.Hour, "2 months ago"},
{"one second short of a year", 365*24*time.Hour - time.Second, "12 months ago"},
{"exactly a year", 365 * 24 * time.Hour, "1 year ago"},
{"years", 3 * 365 * 24 * time.Hour, "3 years ago"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, ago(testNow.Add(-c.d)))
})
}
}
// A commit stamped in the future is clock skew between whoever wrote it and
// this host, not a scheduled event: it reads as "just now", never as a
// forward-facing phrase and never as a negated count.
func TestAgoOnAFutureInstant(t *testing.T) {
pinClock(t, testNow)
for _, ahead := range []time.Duration{time.Second, 3 * time.Minute, 48 * time.Hour} {
got := ago(testNow.Add(ahead))
assert.Equal(t, "just now", got, "an instant %s ahead of the clock", ahead)
assert.NotContains(t, got, "-", "a future instant must not render a negative count")
assert.NotContains(t, got, "in ", "the freshness line is past-facing")
}
}
func TestBeadsHeaderShowsFreshness(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = withHead(beadsFixture())
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "board: %s", rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, `class="beads-freshness"`)
// The branch being rendered, the relative age, and the short hash linking to
// the commit page — the three things the line is for.
assert.Contains(t, body, "main · last commit")
assert.Contains(t, body, "4 minutes ago")
assert.Contains(t, body, `href="/~alice/db/commit/`+headHash+`"`)
assert.Contains(t, body, "qk9j2n4b")
// The exact stamp stays one hover away.
assert.Contains(t, body, `title="2026-08-12 11:56:00 UTC"`)
// The board itself is unchanged around it.
assert.Contains(t, body, "Rolling")
}
func TestMilestonesHeaderShowsFreshness(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = withHead(milestoneFixture())
setViews(t, h, &beadsView{}, &milestonesView{})
rec := h.do("GET", "/~alice/db/view/milestones", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "milestones: %s", rec.Body.String())
body := rec.Body.String()
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/db/commit/`+headHash+`"`)
assert.Contains(t, body, "qk9j2n4b")
assert.Contains(t, body, "ms-title\">m1<")
}
// A database with no commits renders the board without the line. This is the
// one that protects the rule: the freshness line is decoration on top of an
// answer and may never be the reason a reader gets a 500 instead of a board.
func TestViewHeaderOmittedWithoutHistory(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
sess := beadsFixture()
sess.commits = nil // an initialised store nobody has committed to
h.browse.sess = sess
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "board: %s", rec.Body.String())
body := rec.Body.String()
// The class name still appears in the page's scoped stylesheet, which is
// static; what must be absent is an element carrying it.
assert.NotContains(t, body, `class="beads-freshness"`)
assert.NotContains(t, body, "last commit")
// The page is otherwise the page.
assert.Contains(t, body, "Rolling")
assert.Contains(t, body, "Ready to roll")
}
// A log that cannot be read degrades the same way, rather than failing the
// request.
func TestViewHeaderOmittedWhenLogFails(t *testing.T) {
pinClock(t, testNow)
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
sess := withHead(beadsFixture())
sess.logErr = errors.New("browse: walk commits: corrupt chunk")
h.browse.sess = sess
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "board: %s", rec.Body.String())
body := rec.Body.String()
// The class name still appears in the page's scoped stylesheet, which is
// static; what must be absent is an element carrying it.
assert.NotContains(t, body, `class="beads-freshness"`)
assert.NotContains(t, body, "last commit")
assert.Contains(t, body, "Rolling")
}
// headCommit is the envelope's one read, and returns nil on both failure arms
// so the template has nothing to render.
func TestHeadCommitDegradesToNil(t *testing.T) {
ctx := t.Context()
empty := &fakeSession{branches: []browse.Branch{{Name: "main"}}}
assert.Nil(t, headCommit(ctx, empty, "main"), "no commits")
failing := &fakeSession{logErr: errors.New("boom")}
assert.Nil(t, headCommit(ctx, failing, "main"), "log error")
ok := withHead(&fakeSession{})
got := headCommit(ctx, ok, "main")
require.NotNil(t, got)
assert.Equal(t, headHash, got.Hash)
}