Skip to content
quarry Artifact [7db345ef76]
Login

Artifact [7db345ef76]

Artifact 7db345ef766c1791211f4702effda8af91730dfe28b66d24d9eb91e9e7b762f9:


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 := noHooks.template(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", Key: "form-key-1"}})
	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"`,
		`name="idempotencyKey" value="form-key-1"`,
	} {
		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 := noHooks.template(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"`
	Key       string `json:"idempotencyKey"`
}

// 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) {
	freshIntake(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"},
		"idempotencyKey": {"form-key-1"}})
	if code != http.StatusSeeOther || last.Comment != "it broke\n\n---\nSeen on https://forge.example/r/tk/timeline ยท zit 0.3.28" || last.Key != "form-key-1" {
		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")
}

// Field commands typed as whole lines of the description set the field
// and leave the text: matched to the vocabulary's own spelling, never
// inside a code block, and only as a whole line.
func TestQuickActionsSetFieldsFromTheDescription(t *testing.T) {
	vocab := map[string][]string{"severity": {"Critical", "Minor"}, "type": {"Code_Defect"}}
	set, rest := applyQuickActions("It broke.\n/severity critical\n/type code defect\n```\n/priority High\n```\nsee /subsystem in the docs", vocab)
	if set["severity"] != "Critical" || set["type"] != "Code_Defect" || set["priority"] != "" || len(set) != 2 {
		t.Fatalf("FAIL: set %v", set)
	}
	if rest != "It broke.\n```\n/priority High\n```\nsee /subsystem in the docs" {
		t.Fatalf("FAIL: text left %q", rest)
	}
	t.Log("ok: severity and type set in the vocabulary's spelling; the fenced line and the sentence stay text")
}

// On the form, a command typed into the description wins over the chip.
func TestAQuickActionBeatsTheChip(t *testing.T) {
	freshIntake(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 }}
	res, err := client.PostForm(srv.URL+"/r/tk/ticket/new", url.Values{"title": {"broken"}, "severity": {"Minor"},
		"comment": {"It broke.\n/severity Critical\n/subsystem web"}})
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	if last.Severity != "Critical" || last.Subsystem != "web" || last.Comment != "It broke." {
		t.Fatalf("FAIL: filed %+v", last)
	}
	t.Log("ok: /severity Critical beat the Minor chip, /subsystem set, the lines left the description")
}

type similarStubIn struct {
	Repo  string `json:"repo"`
	Title string `json:"title"`
}

type similarStubOut struct {
	Tickets []similarRow `json:"tickets"`
}

// Typing a title asks for its likely duplicates; the fragment lists
// them as links, and nothing at all when there are none.
func TestTheTitleAsksForItsLikelyDuplicates(t *testing.T) {
	r := viewRegistry(t, nil)
	if err := ops.Register(r, ops.Op[similarStubIn, similarStubOut]{
		Name: "ticket.similar", Caps: "r", Doc: "test stub", Kind: ops.Query, Auth: ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/stub/similar",
		Run: func(_ context.Context, in similarStubIn) (similarStubOut, error) {
			if strings.Contains(in.Title, "export") {
				return similarStubOut{Tickets: []similarRow{{ID: "4b8508f3aa", Title: "Settings: configuration export", Status: "Open"}}}, nil
			}
			return similarStubOut{}, nil
		},
	}); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
	defer srv.Close()
	get := func(title string) string {
		res, err := http.Get(srv.URL + "/r/tk/ticket/similar.frag?title=" + url.QueryEscape(title))
		if err != nil {
			t.Fatal(err)
		}
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		return string(b)
	}
	if hit := get("export fails"); !strings.Contains(hit, `id="tn-similar"`) || !strings.Contains(hit, `href="/r/tk/ticket/4b8508f3aa" data-hook="tn-similar-row"`) {
		t.Fatalf("FAIL: the fragment does not link the match:\n%s", hit)
	}
	if miss := get("something else"); strings.Contains(miss, "tn-similar-row") || !strings.Contains(miss, `id="tn-similar"`) {
		t.Fatalf("FAIL: no match should still patch an empty box:\n%s", miss)
	}
	t.Log("ok: a match is linked under the title; no match patches the box empty")
}