package web
import (
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/index"
)
// The layouts are what real corpora actually contain — fossil writes
// millisecond UTC with no zone, and an imported store carries the
// second-precision form too. A layout this misses renders the raw stamp,
// which is ugly but honest; a layout it MISPARSES would silently date
// every page wrong, so the parse is worth pinning.
func TestParseArtifactTimeCoversRealCorpusShapes(t *testing.T) {
want := time.Date(2025, 8, 25, 21, 25, 59, 0, time.UTC)
for _, s := range []string{
"2025-08-25T21:25:59.000", // fossil forum post
"2025-08-25T21:25:59", // second precision
"2025-08-25T21:25:59Z", // RFC3339, quarry's own stamps
"2025-08-25 21:25:59", // space-separated
} {
got, ok := parseArtifactTime(s)
if !ok {
t.Errorf("%q did not parse", s)
continue
}
if !got.Equal(want) {
t.Errorf("%q parsed to %v, want %v", s, got, want)
}
}
if _, ok := parseArtifactTime("last tuesday"); ok {
t.Error("nonsense parsed as a time")
}
}
// An unparseable stamp must be shown, not swallowed. A blank cell where
// a date belongs reads as "no date recorded", which is a different and
// wrong claim about the artifact.
func TestAgoShowsWhatItCannotParse(t *testing.T) {
if got := string(ago("not a date")); got != "not a date" {
t.Errorf("ago(unparseable) = %q, want it passed through", got)
}
if got := string(ago("")); got != "" {
t.Errorf("ago(empty) = %q, want empty", got)
}
// A parseable one keeps the exact value reachable, because a
// relative time is not something you can quote in a bug report.
got := string(ago("2025-08-25T21:25:59.000"))
if !strings.Contains(got, `datetime="2025-08-25T21:25:59Z"`) {
t.Errorf("ago dropped the machine-readable value: %s", got)
}
if !strings.Contains(got, `title="2025-08-25 21:25:59 UTC"`) {
t.Errorf("ago dropped the absolute value: %s", got)
}
}
func TestRelativeWording(t *testing.T) {
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
at time.Time
want string
}{
{now.Add(-30 * time.Second), "just now"},
{now.Add(-time.Minute), "1 minute ago"},
{now.Add(-5 * time.Minute), "5 minutes ago"},
{now.Add(-time.Hour), "1 hour ago"},
{now.Add(-3 * time.Hour), "3 hours ago"},
{now.Add(-24 * time.Hour), "1 day ago"},
{now.Add(-13 * 24 * time.Hour), "13 days ago"},
// Past a month a date beats a day count; past a year the year is
// the part that carries the information.
{now.Add(-60 * 24 * time.Hour), "18 Jun"},
{now.Add(-400 * 24 * time.Hour), "13 Jul 2025"},
// Clock skew: a future stamp must not read as "in 3 minutes".
{now.Add(3 * time.Minute), "2026-08-17 12:03"},
} {
if got := relative(tc.at, now); got != tc.want {
t.Errorf("relative(%v) = %q, want %q", tc.at, got, tc.want)
}
}
}
// The nav's job is orientation, so the two states that carry it — where
// you are, and where there is nothing to find — must both survive into
// the markup, and must not collide into one attribute.
func TestNavlinkStates(t *testing.T) {
current := string(navlink("alpha", "forum", "forum", "forum", true, 12))
if !strings.Contains(current, `class="here"`) || !strings.Contains(current, `aria-current="page"`) {
t.Errorf("current section unmarked: %s", current)
}
if !strings.Contains(current, `>12</span>`) {
t.Errorf("count missing: %s", current)
}
empty := string(navlink("alpha", "wiki", "wiki", "forum", true, 0))
if !strings.Contains(empty, `class="empty"`) {
t.Errorf("empty section unmarked: %s", empty)
}
if !strings.Contains(empty, `href="/r/alpha/wiki"`) {
t.Errorf("empty section stopped being a link: %s", empty)
}
// Current AND empty is one class attribute, not two — a browser
// honours only the first, so the second state would vanish.
both := string(navlink("alpha", "wiki", "wiki", "wiki", true, 0))
// Count within the ANCHOR tag only; the count badge carries its own
// class and would otherwise be mistaken for a duplicate.
anchor, _, _ := strings.Cut(both, ">")
if strings.Count(anchor, "class=") != 1 {
t.Errorf("expected exactly one class attribute on the anchor: %s", both)
}
if !strings.Contains(anchor, "here") || !strings.Contains(anchor, "empty") {
t.Errorf("both states not preserved: %s", both)
}
// A section with nothing countable shows no badge rather than "0".
files := string(navlink("alpha", "tree", "files", "tree", false, false))
if strings.Contains(files, "<span") {
t.Errorf("uncountable section rendered a badge: %s", files)
}
}
// The marker has to follow a reader INTO a section, not just light up on
// its index page — otherwise it goes dark exactly when orientation
// matters most.
func TestSectionOfFollowsDeepPages(t *testing.T) {
for path, want := range map[string]string{
"/r/alpha": "tree",
"/r/alpha/tree": "tree",
"/r/alpha/file/trunk/README.md": "tree",
"/r/alpha/history/trunk/README.md": "tree",
"/r/alpha/timeline": "timeline",
"/r/alpha/checkin/abc123": "timeline",
"/r/alpha/diff": "timeline",
"/r/alpha/wiki": "wiki",
"/r/alpha/wiki/Home/history": "wiki",
"/r/alpha/ticket/abc/edit": "ticket",
"/r/alpha/forum/abc": "forum",
"/fleet": "",
} {
if got := sectionOf(path); got != want {
t.Errorf("sectionOf(%q) = %q, want %q", path, got, want)
}
}
}
// Badges exist so the numbers line up down the column, and a compact
// duration is what makes that possible — "34 minutes ago" is a different
// width in every row.
func TestAgoShortIsCompact(t *testing.T) {
for _, tc := range []struct{ d, want string }{
{"30s", "now"}, {"34m", "34m"}, {"12h", "12h"},
{"72h", "3d"}, {"9000h", "1y"},
} {
dur, err := time.ParseDuration(tc.d)
if err != nil {
t.Fatal(err)
}
stamp := time.Now().UTC().Add(-dur).Format("2006-01-02T15:04:05")
got := string(agoShort(stamp))
if !strings.Contains(got, ">"+tc.want+"<") {
t.Errorf("agoShort(-%s) = %q, want %q", tc.d, got, tc.want)
}
// The exact instant stays reachable; a badge is a summary, not a
// replacement for the value.
if !strings.Contains(got, "datetime=") || !strings.Contains(got, "title=") {
t.Errorf("agoShort dropped the precise value: %q", got)
}
}
if agoShort("not a date") != "" {
t.Error("agoShort invented a duration for an unparseable stamp")
}
}
// The nav's width was a function of its numbers: a repository with
// 25,557 check-ins rendered a visibly different nav from one with none,
// and on a phone it wrapped to three lines. Four characters is the
// budget; the exact figure lives in the badge's title.
func TestCompactCountIsBounded(t *testing.T) {
for _, tc := range []struct {
n int
want string
}{
{0, "0"}, {19, "19"}, {503, "503"}, {999, "999"},
{1000, "1K"}, {1100, "1.1K"}, {5809, "5.8K"}, {9999, "10K"},
{25557, "25K"}, {42118, "42K"}, {999999, "999K"},
{1000000, "1M"}, {1234567, "1.2M"}, {25000000, "25M"},
{-5, "0"},
} {
if got := compactCount(tc.n); got != tc.want {
t.Errorf("compactCount(%d) = %q, want %q", tc.n, got, tc.want)
}
if len(compactCount(tc.n)) > 4 {
t.Errorf("compactCount(%d) = %q, over the four-character budget", tc.n, compactCount(tc.n))
}
}
}
// Fossil technote titles carry HTML — "<b>Design Note:</b> About the
// Irreversible Schema Change" is a real one from fossil's own repo.
// Escaping it is safe but reads as literal markup in a list.
func TestPlainTitleStripsMarkup(t *testing.T) {
got := plainTitle(`<b>Design Note:</b> About the "Irreversible Schema Change"`)
want := `Design Note: About the "Irreversible Schema Change"`
if got != want {
t.Errorf("plainTitle = %q, want %q", got, want)
}
// The caller still escapes, so this is a readability pass and not a
// security boundary — but it must not leave a usable tag behind.
if strings.Contains(plainTitle(`<script>alert(1)</script>hi`), "<") {
t.Error("a tag survived stripping")
}
if plainTitle("no markup here") != "no markup here" {
t.Error("plain text was altered")
}
}
// Fossil's ticket status vocabulary is per-repository, so this cannot be
// exhaustive — it must degrade to "open" for words it doesn't know
// rather than mislabel them, and it must not care about case or spacing.
func TestStatusClass(t *testing.T) {
for status, want := range map[string]string{
"Open": "st-open", "Review": "st-open", "Tested": "st-open",
"Closed": "st-done", "Fixed": "st-done", "Verified": "st-done",
" DEFERRED ": "st-done", "duplicate": "st-done",
"Needs Triage": "st-open", "": "st-open",
} {
if got := statusClass(status); got != want {
t.Errorf("statusClass(%q) = %q, want %q", status, got, want)
}
}
}
// A Referer is attacker-controllable, so the theme switcher must never
// bounce a reader off this origin. Only a same-site path survives.
func TestLocalRefRefusesOffsiteReferers(t *testing.T) {
// Only the PATH survives, so an off-origin Referer is reduced to a
// local path rather than refused — what must never happen is a
// return value a browser would read as another origin.
for ref, want := range map[string]string{
"": "/",
"http://evil.example/steal": "/steal",
"//evil.example/steal": "/steal",
"https://quarry.test/r/x/tree?v=ab": "/r/x/tree?v=ab",
"/r/x/wiki": "/r/x/wiki",
"javascript:alert(1)": "/",
"https://evil.example": "/",
} {
req := httptest.NewRequest("POST", "/theme", nil)
if ref != "" {
req.Header.Set("Referer", ref)
}
got := localRef(req)
if got != want {
t.Errorf("localRef(%q) = %q, want %q", ref, got, want)
}
if !strings.HasPrefix(got, "/") || strings.HasPrefix(got, "//") {
t.Errorf("localRef(%q) = %q, which a browser may read as another origin", ref, got)
}
}
}
// Three states in a fixed cycle: a reader who picks light must be able
// to get back to "follow my phone", which a two-way toggle cannot offer.
func TestThemeCycleReturnsToSystem(t *testing.T) {
theme := ""
var seen []string
for range 3 {
next, _, name := themeState(theme)
seen = append(seen, name)
theme = next
}
if want := []string{"system", "light", "dark"}; !slices.Equal(seen, want) {
t.Errorf("cycle = %v, want %v", seen, want)
}
if theme != "" {
t.Errorf("cycle ended at %q, want back at system", theme)
}
// An unrecognized stored value must not strand the control.
if next, _, _ := themeState("chartreuse"); next != "light" {
t.Errorf("unknown theme advanced to %q, want light", next)
}
}
// The search box means "in this section". A section with nothing indexed
// must fall through to everything rather than to an empty scope, which
// would silently find nothing and look like a broken search.
func TestSearchContextPerSection(t *testing.T) {
for section, wantKind := range map[string]string{
"timeline": "checkin", "wiki": "wiki", "ticket": "ticket",
"forum": "forum", "technote": "technote",
"tree": "", "ci": "", "chat": "", "": "",
} {
kind, scope := searchContext(section)
if kind != wantKind {
t.Errorf("searchContext(%q) kind = %q, want %q", section, kind, wantKind)
}
if scope == "" {
t.Errorf("searchContext(%q) has no scope word", section)
}
if kind == "" && scope != "everything" {
t.Errorf("searchContext(%q) unindexed section scoped to %q", section, scope)
}
}
// Every scope the results page offers must be a kind the index
// actually knows, or the pill leads to a 404.
for _, sc := range searchScopes {
if sc.Kind != "" && !slices.Contains(index.SearchKinds, sc.Kind) {
t.Errorf("scope %q is not an index kind", sc.Kind)
}
}
}
// The avatar is initials because quarry keeps no avatar images and
// fetching a gravatar would send every reader's identity to a third
// party on every page. It must never render empty or panic on the
// shapes a login name actually takes.
func TestInitials(t *testing.T) {
for name, want := range map[string]string{
"dmestas": "DM",
"dan": "DA",
"d": "D",
"Dan Mestas": "DM",
"dan.mestas": "DM",
"dan-mestas": "DM",
"dan_mestas": "DM",
"[email protected]": "DE",
"": "?",
" ": "?",
"drh": "DR",
"Ægir": "ÆG",
} {
got := initials(name)
if got != want {
t.Errorf("initials(%q) = %q, want %q", name, got, want)
}
if got == "" {
t.Errorf("initials(%q) is empty; the button would render blank", name)
}
}
}
// Templates wrap their tables in .tw, which scrolls. Tables that arrive
// from RENDERED CONTENT — the API doc, a wiki page, a ticket body, a
// forum post — have no wrapper to be put in, and a wide one pushed the
// whole page sideways: /docs overflowed a 320px phone by 208px. Such a
// table has to be its own scroll container, and the ones already in a
// wrapper must stay real tables or their columns stop lining up.
func TestContentTablesScrollThemselves(t *testing.T) {
if !strings.Contains(baseHTML, "main :not(.tw) > table { display: block; overflow-x: auto;") {
t.Error("an unwrapped table would push the page sideways")
}
if !strings.Contains(baseHTML, ".tw > table { display: table; }") {
t.Error("wrapped tables lost table layout, so their columns will not align")
}
}