package web
import (
"context"
"fmt"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
const (
sgOpen = "aaaa000000000000000000000000000000000000000000000000000000000001"
sgClosed = "bbbb000000000000000000000000000000000000000000000000000000000002"
)
// suggestIndex is a repository with two stylesheet tickets, #1 open and
// #2 closed, and three check-ins, two of which mention the stylesheet.
func suggestIndex(t *testing.T) *index.Index {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
ticket := func(id, date, title, status string) index.Parsed {
return index.Parsed{Hash: id, Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id, Date: date, User: "dan",
Fields: []artifact.Field{{Name: "title", Value: title}, {Name: "status", Value: status}}}}
}
batch := []index.Parsed{
ticket(sgOpen, "2026-09-01T10:00:00", "the stylesheet breaks on a phone", "Open"),
ticket(sgClosed, "2026-09-02T10:00:00", "stylesheet colours fail contrast", "Closed"),
}
var prev string
for i, c := range []string{"plain work", "the stylesheet gets its own file", "stylesheet ships lean"} {
art := &artifact.Artifact{Type: artifact.Manifest,
Date: fmt.Sprintf("2026-09-17T10:%02d:00", i), User: "dan", Comment: c}
if prev != "" {
art.Parents = []string{prev}
}
prev = fmt.Sprintf("c%d", i) + strings.Repeat("0", 39)
batch = append(batch, index.Parsed{Hash: prev, Art: art})
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
return ix
}
// suggestServer serves "alpha" and a private "secret" holding the same
// words, which no signed-out reader may see.
func suggestServer(t *testing.T) *httptest.Server {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: suggestIndex(t)}, "secret": {Index: suggestIndex(t)}}),
Local: db, Idem: db, Numbers: db,
CanRead: func(ctx context.Context, repo string) bool { return repo != "secret" }}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithPrivateRepos(func(repo string) bool { return repo == "secret" }),
}))
t.Cleanup(srv.Close)
return srv
}
var sgHref = regexp.MustCompile(`<a class="sg-it" href="([^"]+)" data-hook="suggest-item"`)
func suggestHrefs(body string) []string {
var out []string
for _, m := range sgHref.FindAllStringSubmatch(body, -1) {
out = append(out, strings.ReplaceAll(m[1], "&", "&"))
}
return out
}
// The repository box offers what its section's search would find, under
// that section's scope, then the full results and everything; an empty
// box offers nothing.
func TestRepoBoxSuggestsWithinItsScope(t *testing.T) {
srv := suggestServer(t)
code, body := getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet&kind=ticket")
if code != 200 || !strings.Contains(body, `data-hook="search-suggest"`) {
t.Fatalf("FAIL: %d, no list", code)
}
hrefs := suggestHrefs(body)
if len(hrefs) != 2 {
t.Fatalf("FAIL: %d items, want the 2 tickets: %v", len(hrefs), hrefs)
}
for _, h := range hrefs {
if !strings.HasPrefix(h, "/r/alpha/ticket/") {
t.Errorf("FAIL: a ticket-scoped list offers %s", h)
}
}
if n := strings.Count(body, `data-hook="suggest-more"`); n != 2 {
t.Errorf("FAIL: %d ways on, want the scoped results and everything", n)
}
if !strings.Contains(body, `href="/r/alpha/search?q=stylesheet&kind=ticket"`) ||
!strings.Contains(body, `href="/r/alpha/search?q=stylesheet"`) {
t.Error("FAIL: the ways on do not open the full results")
}
if !strings.Contains(body, `data-count="2"`) || !strings.Contains(body, `id="find-suggest-n"`) {
t.Error("FAIL: the list does not carry its count")
}
_, body = getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet")
var tickets, checkins int
for _, h := range suggestHrefs(body) {
switch {
case strings.Contains(h, "/ticket/"):
tickets++
case strings.Contains(h, "/checkin/"):
checkins++
}
}
if tickets != 2 || checkins != 2 {
t.Errorf("FAIL: everything offers %d tickets and %d check-ins, want 2 and 2", tickets, checkins)
}
if n := strings.Count(body, `data-hook="suggest-more"`); n != 1 {
t.Errorf("FAIL: %d ways on when already searching everything, want 1", n)
}
_, empty := getPage(t, srv, "/r/alpha/search.suggest?q=")
if len(suggestHrefs(empty)) != 0 || strings.Contains(empty, "suggest-more") || strings.Contains(empty, "suggest-none") {
t.Error("FAIL: an empty box suggests things")
}
_, none := getPage(t, srv, "/r/alpha/search.suggest?q=zebra")
if len(suggestHrefs(none)) != 0 || !strings.Contains(none, `data-hook="suggest-none"`) {
t.Error("FAIL: words matching nothing do not say so")
}
if code, _ := getPage(t, srv, "/r/alpha/search.suggest?q=x&kind=bogus"); code != 404 {
t.Errorf("FAIL: an unknown scope answered %d", code)
}
}
// A ticket's icon says whether it is open, as on every ticket link.
func TestSuggestedTicketsWearTheirState(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/r/alpha/search.suggest?q=stylesheet&kind=ticket")
for _, c := range []struct{ id, icon, num string }{{sgOpen, "i-tkt-open", "#1"}, {sgClosed, "i-tkt-done", "#2"}} {
i := strings.Index(body, "/ticket/"+c.id)
if i < 0 {
t.Fatalf("FAIL: ticket %.4s not offered", c.id)
}
end := strings.Index(body[i:], "</a>")
if !strings.Contains(body[i:i+end], `href="#`+c.icon+`"`) {
t.Errorf("FAIL: ticket %.4s does not wear %s", c.id, c.icon)
}
if !strings.Contains(body[i:i+end], "<code>"+c.num+"</code>") {
t.Errorf("FAIL: ticket %.4s is not offered by its number %s", c.id, c.num)
}
}
}
// A ticket number or a hash prefix goes straight to what it names,
// listed first.
func TestBoxJumpsToWhatANumberOrHashNames(t *testing.T) {
srv := suggestServer(t)
for _, q := range []string{"2", "%232"} {
_, body := getPage(t, srv, "/r/alpha/search.suggest?q="+q)
hrefs := suggestHrefs(body)
if len(hrefs) == 0 || hrefs[0] != "/r/alpha/ticket/"+sgClosed {
t.Errorf("FAIL: q=%s first offers %v, want ticket #2", q, hrefs)
}
}
_, body := getPage(t, srv, "/r/alpha/search.suggest?q=c100")
if hrefs := suggestHrefs(body); len(hrefs) == 0 || hrefs[0] != "/r/alpha/info/c100" {
t.Errorf("FAIL: a hash prefix first offers %v, want the check-in it names", hrefs)
}
_, body = getPage(t, srv, "/r/alpha/search.suggest?q=9")
for _, h := range suggestHrefs(body) {
if strings.Contains(h, "/ticket/") {
t.Errorf("FAIL: a number naming no ticket jumped to %s", h)
}
}
}
// Off a repository, the box searches every repository the reader may
// read and says which one each item is in. A private repository is
// never asked.
func TestFleetBoxSuggestsAcrossReadableRepositories(t *testing.T) {
srv := suggestServer(t)
_, body := getPage(t, srv, "/search.suggest?q=stylesheet")
hrefs := suggestHrefs(body)
if len(hrefs) == 0 {
t.Fatal("FAIL: the fleet box offers nothing")
}
for _, h := range hrefs {
if !strings.HasPrefix(h, "/r/alpha/") {
t.Errorf("FAIL: the fleet box offers %s", h)
}
}
if !strings.Contains(body, "<code>#1</code>") {
t.Error("FAIL: a ticket across the fleet is not offered by its number")
}
if !strings.Contains(body, `class="sg-repo"`) {
t.Error("FAIL: items do not say which repository they are in")
}
if !strings.Contains(body, `href="/search?q=stylesheet"`) {
t.Error("FAIL: no way to the full results")
}
if strings.Contains(body, "secret") {
t.Error("FAIL: a private repository answered the fleet box")
}
}
// The boxes are still forms: with scripting off, Enter submits them as
// before. Scripted, each names its list, and the / shortcut uses the
// event spelling Datastar fires.
func TestSearchBoxesKeepTheirForms(t *testing.T) {
srv := suggestServer(t)
for _, c := range []struct{ path, action, suggest string }{
{"/r/alpha/ticket", `action="/r/alpha/search"`, "/r/alpha/search.suggest"},
{"/r/alpha/wiki", `action="/r/alpha/search"`, "/r/alpha/search.suggest"},
{"/search", `action="/search"`, "/search.suggest"},
} {
_, body := getPage(t, srv, c.path)
i := strings.Index(body, `data-hook="search-find"`)
if i < 0 {
t.Errorf("FAIL %s: no search box", c.path)
continue
}
form := body[strings.LastIndex(body[:i], "<form"):]
form = form[:strings.Index(form, "</form>")]
if !strings.Contains(form, c.action) || !strings.Contains(form, `name="q"`) {
t.Errorf("FAIL %s: the box is no longer a plain form", c.path)
}
if !strings.Contains(form, `aria-controls="find-suggest"`) || !strings.Contains(form, `id="find-suggest"`) ||
!strings.Contains(strings.ReplaceAll(form, `\/`, "/"), "@get('"+c.suggest+"'") {
t.Errorf("FAIL %s: the box does not name and fetch its list", c.path)
}
if strings.Contains(body, "data-on-keydown") {
t.Errorf("FAIL %s: a dash-spelled event Datastar never fires", c.path)
}
}
}