package web
import (
"bytes"
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// A tab for a plane that cannot be used here is not an empty section
// but a wrong one: Chat shows only where the forge runs a chat plane
// and the repository's chat setting is on, and the page behind a hidden
// tab is a 404 rather than an empty channel.
func TestChatTabFollowsThePlaneAndTheSetting(t *testing.T) {
ix := landingIndex(t, false)
serve := func(chat api.ChatPlane, meta func() map[string]api.RepoMeta) *httptest.Server {
r := ops.NewRegistry()
deps := api.Deps{Version: "v", Chat: chat, RepoMeta: meta,
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
t.Cleanup(srv.Close)
return srv
}
on := func() map[string]api.RepoMeta { return map[string]api.RepoMeta{"alpha": {Chat: true}} }
off := func() map[string]api.RepoMeta { return map[string]api.RepoMeta{"alpha": {Chat: false}} }
cases := []struct {
name string
srv *httptest.Server
tab bool
}{
{"no chat plane", serve(nil, on), false},
{"plane and setting on", serve(&webChatStub{}, on), true},
{"plane, setting off", serve(&webChatStub{}, off), false},
{"plane, no settings at all", serve(&webChatStub{}, nil), true},
}
for _, c := range cases {
page := landingGet(t, c.srv, "/r/alpha/timeline")
if got := strings.Contains(page, `href="/r/alpha/chat"`); got != c.tab {
t.Errorf("%s: chat tab shown=%v, want %v", c.name, got, c.tab)
}
// Landings and CI stay: this store is its own source of truth.
for _, tab := range []string{"/r/alpha/landing", "/r/alpha/ci"} {
if !strings.Contains(page, `href="`+tab+`"`) {
t.Errorf("%s: %s tab missing", c.name, tab)
}
}
res, err := http.Get(c.srv.URL + "/r/alpha/chat")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if want := map[bool]int{true: 200, false: 404}[c.tab]; res.StatusCode != want {
t.Errorf("%s: GET /chat = %d, want %d", c.name, res.StatusCode, want)
}
}
}
// renderBase draws the chrome — header, repo bar, tabs — around an
// empty page, which is all a tab test needs.
func renderBase(t *testing.T, d pageData) string {
t.Helper()
page := template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{end}}`))
var buf bytes.Buffer
if err := page.Execute(&buf, d); err != nil {
t.Fatal(err)
}
return buf.String()
}
// A repository that tracks an upstream is not the source of truth:
// nothing lands, builds or is discussed here, so those tabs go and
// the rest stay.
func TestTrackingMirrorHidesLandingsCIAndChat(t *testing.T) {
var d pageData
d.Repo, d.Section = "alpha", "timeline"
d.Posture.Mirror.Tracking, d.Posture.Mirror.URL, d.Posture.Chat = true, "https://x/y", true
html := renderBase(t, d)
for _, tab := range []string{"/r/alpha/landing", "/r/alpha/ci", "/r/alpha/chat"} {
if strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a tracking mirror shows %s", tab)
}
}
for _, tab := range []string{"/r/alpha/timeline", "/r/alpha/ticket", "/r/alpha/forum"} {
if !strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a tracking mirror lost %s", tab)
}
}
// Detached: quarry is the source of truth again.
d.Posture.Mirror.Tracking, d.Posture.DetachedFrom = false, "https://x/y"
html = renderBase(t, d)
for _, tab := range []string{"/r/alpha/landing", "/r/alpha/ci", "/r/alpha/chat"} {
if !strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a detached store lacks %s", tab)
}
}
}
// The chat rail lists only rooms the viewer can enter: a repository
// with chat off is not a channel, and neither is one where the viewer
// holds no chat capability.
func TestChatRailListsOnlyUsableChannels(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
deps := api.Deps{Version: "v", Chat: &webChatStub{},
RepoMeta: func() map[string]api.RepoMeta {
return map[string]api.RepoMeta{"alpha": {Chat: true}, "beta": {Chat: false}, "gamma": {Chat: true}}
},
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}, "gamma": {Index: ix}})}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
rail := func(caps Caps) string {
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(caps)},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })))
t.Cleanup(srv.Close)
page := asDan(t, srv, "/r/alpha/chat")
i := strings.Index(page, `id="rail-channels"`)
if i < 0 {
t.Fatalf("no rail on the chat page")
}
return page[i : i+strings.Index(page[i:], "</ul>")]
}
// Chat everywhere the viewer is allowed: alpha and gamma, not beta.
got := rail(stubCaps{letters: "C"})
for name, want := range map[string]bool{"alpha": true, "beta": false, "gamma": true} {
if strings.Contains(got, "/r/"+name+"/chat") != want {
t.Errorf("with C, rail lists %s = %v, want %v", name, !want, want)
}
}
// No chat capability anywhere: no channels, even where chat is on.
if got := rail(stubCaps{letters: "o"}); strings.Contains(got, "/r/alpha/chat") || strings.Contains(got, "/r/gamma/chat") {
t.Errorf("without C the rail still lists channels: %s", got)
}
}
// Accepting a landing merges on the store and can take a while: with
// script the decision form goes busy when a control is pressed, so
// nothing takes a second press and the pressed control says what it is
// doing. The hook must be on the click, never on the form's submit:
// Datastar prevents the default of a submit it binds on a form, which
// cancelled the real accept. Read from the template source, which is
// what the page is made of.
func TestLandingDecisionFormGoesBusyOnSubmit(t *testing.T) {
src := landingSource
for _, want := range []string{
`data-on:click="$pressed = 'accept'; $busy = true"`,
`data-class:busy="$busy"`,
`data-class:is-busy="$busy && $pressed == 'accept'"`,
`<span class="b-busy">Accepting…</span>`,
`data-class:show="$busy"`,
} {
if !strings.Contains(src, want) {
t.Errorf("landing decision form lacks %s", want)
}
}
if strings.Contains(src, `data-on:submit`) {
t.Errorf("a data-on:submit on the decision form cancels the real submission")
}
}