package web
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// The filing form reads as the mockup does: type pills from the
// tracker's own words, a title, the Markdown composer, the triage
// fields as chips whose unset state says what it would set, the
// context the bug button brought, and one primary submit.
func TestTheFilingFormIsQuickToRead(t *testing.T) {
vocab := map[string][]string{
"status": {"Open", "Fixed"},
"type": {"Code_Defect", "Feature_Request"},
"severity": {"Critical", "Minor"},
"priority": {"High", "Low"},
}
var b strings.Builder
err := ticketNewPage.ExecuteTemplate(&b, "content", pageData{Repo: "alpha", CanAdmin: true, Vocab: vocab,
ZitVersion: "0.3.28", Origin: "https://forge.example",
Prefill: ticketPrefill{Type: "Code_Defect", Severity: "Minor", Seen: "https://forge.example/r/alpha/timeline"}})
if err != nil {
t.Fatal(err)
}
got := b.String()
for _, want := range []string{
`data-hook="tn-types"`, `value="Code_Defect" checked>Code defect`, `value="Feature_Request">Feature request`,
`class="tn-title" name="title" required`,
`<textarea name="comment"`,
`data-hook="tn-chips"`, `<select name="severity"`, `<option value="Minor" selected>Minor</option>`,
`<select name="priority"`, `<option value="">+ Priority</option>`,
`data-field="foundin"`,
`data-hook="tn-context"`, `name="seen" value="https://forge.example/r/alpha/timeline"`, `name="seen_zit" value="0.3.28"`,
`data-hook="tn-dest"`, `name="another"`, `data-hook="tn-submit"`, `data-hook="tn-curl"`,
} {
if !strings.Contains(got, want) {
t.Errorf("FAIL: the form lacks %s", want)
}
}
if strings.Contains(got, `<select name="status"><option value="">`) {
t.Error("FAIL: status offers no status, which a ticket cannot hold")
}
var plain strings.Builder
if err := ticketNewPage.ExecuteTemplate(&plain, "content", pageData{Repo: "alpha", Vocab: vocab}); err != nil {
t.Fatal(err)
}
if strings.Contains(plain.String(), `data-hook="tn-context"`) || strings.Contains(plain.String(), `data-hook="tn-dest"`) {
t.Error("FAIL: a plain visit shows a context row, or a non-admin is offered the permanent history")
}
t.Log("ok: type pills, title, composer, chips with + Priority, context, destination for an admin, one submit")
}
// "+name" is fossil's append syntax, never a column: a tracker that
// recorded one must not grow a "+comment" box on the form.
func TestAnAppendFieldIsNotOffered(t *testing.T) {
for name, want := range map[string]bool{"assignee": true, "+comment": false, "comment": false, "severity": false} {
if offeredField(name) != want {
t.Errorf("FAIL: offeredField(%q) = %v", name, !want)
}
}
}
type ticketStubIn struct {
Repo string `json:"repo"`
Title string `json:"title"`
Comment string `json:"comment"`
Type string `json:"type"`
Severity string `json:"severity"`
Status string `json:"status"`
Priority string `json:"priority"`
Subsystem string `json:"subsystem"`
FoundIn string `json:"foundin"`
}
// Filing: the context the form showed is a footer under what was
// written, only while ticked; "Create another" comes back to a fresh
// form that keeps the fields and says the last one landed.
func TestFilingKeepsContextAndCanComeBackForMore(t *testing.T) {
r := viewRegistry(t, nil)
var last ticketStubIn
if err := ops.Register(r, ops.Op[ticketStubIn, composerStubOut]{
Name: "ticket.intake", Caps: "n", Doc: "test stub", Kind: ops.Command, Auth: ops.Anonymous,
Route: "POST /api/v1/r/{repo}/stub/intake",
Run: func(_ context.Context, in ticketStubIn) (composerStubOut, error) {
last = in
return composerStubOut{ID: "abc123"}, nil
},
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
defer srv.Close()
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
post := func(form url.Values) (int, string) {
res, err := client.PostForm(srv.URL+"/r/tk/ticket/new", form)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
return res.StatusCode, res.Header.Get("Location")
}
code, loc := post(url.Values{"title": {"broken"}, "comment": {"it broke"}, "type": {"Code_Defect"}, "severity": {"Minor"},
"seen": {"https://forge.example/r/tk/timeline"}, "seen_zit": {"0.3.28"}, "ctx": {"1"}, "another": {"1"}})
if code != http.StatusSeeOther || last.Comment != "it broke\n\n---\nSeen on https://forge.example/r/tk/timeline ยท zit 0.3.28" {
t.Fatalf("FAIL: %d, comment %q", code, last.Comment)
}
u, _ := url.Parse(loc)
if u.Path != "/r/tk/ticket/new" || u.Query().Get("opened") != "abc123" || u.Query().Get("type") != "Code_Defect" || u.Query().Get("severity") != "Minor" || u.Query().Get("title") != "" {
t.Fatalf("FAIL: create another went to %q", loc)
}
_, loc = post(url.Values{"title": {"broken"}, "comment": {"it broke"}, "seen": {"https://forge.example/x"}})
if last.Comment != "it broke" || loc != "/r/tk/ticket/abc123" {
t.Fatalf("FAIL: unticked context was filed (%q), or a single ticket did not open (%q)", last.Comment, loc)
}
t.Log("ok: ticked context filed as a footer, unticked left out; create another keeps type and severity; a single ticket opens")
}
type configStubIn struct {
Repo string `json:"repo"`
Templates bool `json:"templates"`
}
type configStubOut struct {
Vocab []struct {
Field string `json:"field"`
Words []string `json:"words"`
} `json:"vocab"`
Templates []struct {
Type string `json:"type"`
Body string `json:"body"`
} `json:"templates"`
}
// The chosen type's template starts an empty description, the default
// one when no type is chosen; a comment handed in is never replaced;
// and every template rides the page for the script to swap.
func TestTheChosenTypeStartsTheDescription(t *testing.T) {
r := viewRegistry(t, nil)
if err := ops.Register(r, ops.Op[configStubIn, configStubOut]{
Name: "ticket.config", Caps: "r", Doc: "test stub", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/stub/ticket-config",
Run: func(_ context.Context, in configStubIn) (configStubOut, error) {
var out configStubOut
if in.Templates {
out.Templates = append(out.Templates,
struct {
Type string `json:"type"`
Body string `json:"body"`
}{"Code_Defect", "### What happened"},
struct {
Type string `json:"type"`
Body string `json:"body"`
}{"", "### Describe it"})
}
return out, nil
},
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
defer srv.Close()
get := func(q string) string {
res, err := http.Get(srv.URL + "/r/tk/ticket/new" + q)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
return string(b)
}
box := func(page string) string {
i := strings.Index(page, `<textarea name="comment"`)
if i < 0 {
return ""
}
j := strings.Index(page[i:], ">") + i + 1
return page[j : strings.Index(page[j:], "</textarea>")+j]
}
if got := box(get("?type=Code_Defect")); got != "### What happened" {
t.Errorf("FAIL: a defect starts from %q", got)
}
if got := box(get("")); got != "### Describe it" {
t.Errorf("FAIL: no type starts from %q", got)
}
if got := box(get("?type=Code_Defect&comment=mine")); got != "mine" {
t.Errorf("FAIL: a handed-in comment became %q", got)
}
page := get("")
if !strings.Contains(page, `<template data-tn-template="Code_Defect">### What happened</template>`) || !strings.Contains(page, `<template data-tn-template="">`) {
t.Error("FAIL: the templates do not ride the page for the script")
}
t.Log("ok: defect template, default template, a handed-in comment kept; templates on the page")
}