package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/ops"
)
type dashFeedIn struct {
Kind string `json:"kind"`
Find string `json:"find"`
Repo string `json:"repo"`
Offset int `json:"offset"`
}
type dashReposIn struct {
Find string `json:"find"`
}
type dashAcrossIn struct {
Awaiting bool `json:"awaiting"`
Mine bool `json:"mine"`
Yours bool `json:"yours"`
Brief bool `json:"brief"`
State string `json:"state"`
Limit int `json:"limit"`
}
type dashTeamsIn struct {
Mine bool `json:"mine"`
}
// homeHarness is the home page over test doubles that count what the
// page asks for. yours is how many tickets ticket.across answers for the
// reader's own; every other ticket question answers one open ticket.
type homeHarness struct {
h http.Handler
mu sync.Mutex
feeds int
across []dashAcrossIn
}
func newHomeHarness(t *testing.T, yours int) *homeHarness {
t.Helper()
hh := &homeHarness{}
r := ops.NewRegistry()
type empty struct{}
type tickets struct {
Tickets []map[string]any `json:"tickets"`
}
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
must(ops.Register(r, ops.Op[dashFeedIn, struct {
Total int `json:"total"`
}]{Name: "me.feed", Doc: "test double", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/me/feed",
Run: func(context.Context, dashFeedIn) (struct {
Total int `json:"total"`
}, error) {
hh.mu.Lock()
hh.feeds++
hh.mu.Unlock()
return struct {
Total int `json:"total"`
}{}, nil
}}))
must(ops.Register(r, ops.Op[dashReposIn, struct {
Repos []map[string]any `json:"repos"`
}]{Name: "me.repos", Doc: "test double", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/me/repos",
Run: func(context.Context, dashReposIn) (struct {
Repos []map[string]any `json:"repos"`
}, error) {
return struct {
Repos []map[string]any `json:"repos"`
}{[]map[string]any{{"name": "alpha", "role": "admin"}}}, nil
}}))
must(ops.Register(r, ops.Op[dashAcrossIn, tickets]{Name: "ticket.across", Doc: "test double", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/me/tickets",
Run: func(_ context.Context, in dashAcrossIn) (tickets, error) {
hh.mu.Lock()
hh.across = append(hh.across, in)
hh.mu.Unlock()
n := 1
if in.Yours {
n = yours
}
var out tickets
for i := 0; i < n; i++ {
out.Tickets = append(out.Tickets, map[string]any{"repo": "alpha", "id": "abcdef0123456789",
"title": "a ticket", "status": "Open", "number": i + 1})
}
return out, nil
}}))
must(ops.Register(r, ops.Op[dashTeamsIn, struct {
Teams []struct{} `json:"teams"`
}]{Name: "team.list", Doc: "test double", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/teams",
Run: func(context.Context, dashTeamsIn) (struct {
Teams []struct{} `json:"teams"`
}, error) {
return struct {
Teams []struct{} `json:"teams"`
}{}, nil
}}))
must(ops.Register(r, ops.Op[empty, struct {
Version string `json:"version"`
}]{Name: "meta.version", Doc: "test double", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/version",
Run: func(context.Context, empty) (struct {
Version string `json:"version"`
}, error) {
return struct {
Version string `json:"version"`
}{"t"}, nil
}}))
hh.h = HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "jan", roles: map[string]ops.Auth{"": ops.Token}})})
return hh
}
// get answers path as the signed-in reader, with what it asked for
// since the last call.
func (hh *homeHarness) get(t *testing.T, path string) (int, string, int, []dashAcrossIn) {
t.Helper()
hh.mu.Lock()
hh.feeds, hh.across = 0, nil
hh.mu.Unlock()
req := httptest.NewRequest("GET", path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
w := httptest.NewRecorder()
hh.h.ServeHTTP(w, req)
hh.mu.Lock()
defer hh.mu.Unlock()
return w.Code, w.Body.String(), hh.feeds, hh.across
}
// The home page is where the reader works — repositories, teams,
// tickets — and none of it waits on the activity feed, which reads every
// event of every tracker the reader reaches. The feed follows the page
// in, behind a skeleton only script can show; without script the page
// links to the activity page instead.
func TestTheHomePageLeavesTheFeedForLater(t *testing.T) {
hh := newHomeHarness(t, 0)
code, body, feeds, across := hh.get(t, "/")
if code != http.StatusOK {
t.Fatalf("FAIL: / answered %d", code)
}
if feeds != 0 {
t.Errorf("FAIL: the home page asked for the feed %d times", feeds)
}
for _, want := range []string{`data-hook="dash-repos"`, `data-hook="dash-tickets"`,
`data-init="@get('/activity?frag=recent')"`, `<noscript><p class="dash-more"><a href="/activity">`} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the home page has no %s", want)
}
}
// The skeleton is hidden until Datastar shows it.
if !strings.Contains(body, `style="display: none" data-show="true" aria-hidden="true" data-hook="dash-skeleton"`) {
t.Errorf("FAIL: the skeleton shows without script")
}
for _, in := range across {
if !in.Brief || in.Awaiting {
t.Errorf("FAIL: the home page asked ticket.across for more than a brief list: %+v", in)
}
}
// The rows arrive as one patch of the section, from the feed alone.
code, body, feeds, across = hh.get(t, "/activity?frag=recent")
if code != http.StatusOK || feeds != 1 || len(across) != 0 || !strings.Contains(body, `data-hook="dash-activity"`) {
t.Errorf("FAIL: ?frag=recent: %d, %d feeds, %d ticket asks", code, feeds, len(across))
}
// The band of tickets waiting on you leads the activity page's ticket
// changes, and is asked for there only.
if _, _, _, across = hh.get(t, "/activity?kind=t"); len(across) != 1 || !across[0].Awaiting {
t.Errorf("FAIL: ticket changes did not ask for the waiting band: %+v", across)
}
if _, _, _, across = hh.get(t, "/activity?kind=c"); len(across) != 0 {
t.Errorf("FAIL: check-ins asked for the waiting band")
}
}
// The feed as it was addressed on the home page arrives at the activity
// page, its filters kept.
func TestTheOldFeedAddressesArriveAtTheActivityPage(t *testing.T) {
hh := newHomeHarness(t, 0)
for path, want := range map[string]string{
"/?kind=t": "/activity?kind=t",
"/?offset=10&repo=x": "/activity?offset=10&repo=x",
"/?frag=1&kind=w": "/activity?kind=w",
} {
req := httptest.NewRequest("GET", path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
w := httptest.NewRecorder()
hh.h.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != want {
t.Errorf("FAIL: %s answered %d to %q, want %s", path, w.Code, w.Header().Get("Location"), want)
}
}
}
// The tickets are the reader's own — assigned to them, or filed by them
// or their agents — and never the tracker at large when they have none.
func TestTheHomeTicketsAreOnlyYours(t *testing.T) {
_, body, _, across := newHomeHarness(t, 2).get(t, "/")
if strings.Count(body, `data-hook="dash-ticket"`) != 2 {
t.Errorf("FAIL: the reader's two tickets are not the list")
}
for _, in := range across {
if !in.Yours {
t.Errorf("FAIL: the home page asked for tickets that are not the reader's: %+v", in)
}
}
if _, body, _, _ = newHomeHarness(t, 0).get(t, "/"); strings.Contains(body, `data-hook="dash-ticket"`) {
t.Errorf("FAIL: with none of the reader's own, the home page listed somebody else's")
}
}