package web
import (
"bytes"
"context"
"html/template"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// The paste-and-drop control under a composer follows the same answer
// the section's form does — the family's letter and a bound bucket —
// and is absent for anyone else. It names the repository and the
// family the script begins deferred tickets with at submit, the
// pending list the script fills, and the error line it reports to.
func TestComposerControlFollowsTheLetterAndTheBinding(t *testing.T) {
if attachComposerFor(nil, "") != nil {
t.Errorf("FAIL: a viewer with nothing to offer got a composer control")
}
if attachComposerFor(&attachWrite{Repo: "tk", Family: "forum", CanMod: true}, "") != nil {
t.Errorf("FAIL: a moderator without the upload letter got a composer control")
}
if attachComposerFor(&attachWrite{Repo: "tk", Family: "forum", Unbound: true}, "") != nil {
t.Errorf("FAIL: an administrator with no bucket got a composer control")
}
c := attachComposerFor(&attachWrite{Repo: "tk", Family: "wiki", CanAttach: true, Limit: "2.0 MB"}, "Design")
if c == nil || c.Repo != "tk" || c.Family != "wiki" || c.Target != "Design" || c.Limit != "2.0 MB" {
t.Fatalf("FAIL: control = %+v", c)
}
render := func(page *template.Template, data pageData) string {
var buf bytes.Buffer
if err := noHooks.template(page).ExecuteTemplate(&buf, "content", data); err != nil {
t.Fatal(err)
}
return buf.String()
}
forum := &attachComposer{Repo: "tk", Family: "forum", Limit: "2.0 MB"}
hooks := []string{`data-hook="attach-composer"`, `data-repo="tk"`, `data-family="forum"`,
`data-hook="attach-pending"`, `data-hook="attach-error"`, `data-drop="ready"`}
newThread := render(forumNewPage, pageData{Repo: "tk", AttachComposer: forum})
for _, want := range hooks {
if !strings.Contains(newThread, want) {
t.Errorf("FAIL: the new-thread composer lacks %s", want)
}
}
// Drawn hidden: with no script there is nothing a person can do
// with it, and the script reveals it once it can hash and PUT.
if !strings.Contains(newThread, `data-drop="ready" hidden>`) {
t.Errorf("FAIL: the control is not drawn hidden")
}
if without := render(forumNewPage, pageData{Repo: "tk"}); strings.Contains(without, `data-hook="attach-composer"`) {
t.Errorf("FAIL: without the letter, the new-thread page carries the control")
}
thread := render(forumThreadPage, pageData{Repo: "tk", ForumRoot: "abcd", AttachComposer: forum})
if !strings.Contains(thread, `data-hook="attach-composer"`) || !strings.Contains(thread, `data-family="forum"`) {
t.Errorf("FAIL: the reply composer lacks the control")
}
wiki := render(wikiEditPage, pageData{Repo: "tk", Wiki: wikiDetail{Title: "Design"}, AttachComposer: c})
if !strings.Contains(wiki, `data-hook="attach-composer"`) || !strings.Contains(wiki, `data-family="wiki"`) || !strings.Contains(wiki, `data-target="Design"`) {
t.Errorf("FAIL: the wiki editor's control does not name its page")
}
if strings.Contains(newThread, `data-target=`) {
t.Errorf("FAIL: a composer for an object not yet written names a target")
}
// The ticket page's description and comment boxes both take a paste.
page := render(ticketPage, pageData{Repo: "tk", Ticket: ticketDetail{ID: tkID}, CanWrite: true, AttachTarget: tkID,
AttachComposer: &attachComposer{Repo: "tk", Family: "ticket", Target: tkID, Limit: "2.0 MB"}})
if n := strings.Count(page, `data-hook="attach-composer" data-repo="tk" data-family="ticket" data-target="`+tkID+`"`); n != 2 {
t.Errorf("FAIL: %d controls on the ticket page, wanted the description and the comment", n)
}
ticket := render(ticketNewPage, pageData{Repo: "tk", CanAdmin: true, Vocab: map[string][]string{},
AttachComposer: &attachComposer{Repo: "tk", Family: "ticket", Limit: "2.0 MB"}})
if !strings.Contains(ticket, `data-hook="attach-composer"`) || !strings.Contains(ticket, `data-family="ticket"`) {
t.Errorf("FAIL: the new-ticket form lacks the control")
}
if plain := render(ticketNewPage, pageData{Repo: "tk", Vocab: map[string][]string{}}); strings.Contains(plain, `data-hook="attach-composer"`) {
t.Errorf("FAIL: a filer without the control's answer sees it")
}
t.Log("ok: the composer control follows the letter and the binding on every composer")
}
// A section a viewer may upload to says so for the script's drop
// handling; one they may not does not.
func TestSectionIsADropTarget(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
cards := attachCards(context.Background(), r, "tk", mixedRows())
with := renderTicketAttachments(t, cards, &attachWrite{Repo: "tk", Family: "ticket", CanAttach: true, Limit: "1 MB", Back: "/r/tk/ticket/abab#attachments"})
if !strings.Contains(with, `data-target="`+tkID+`" data-family="ticket" data-drop="ready">`) {
t.Errorf("FAIL: the section a holder of the letter sees is not a drop target")
}
without := renderTicketAttachments(t, cards, nil)
if strings.Contains(without, `data-drop=`) {
t.Errorf("FAIL: a reader's section is a drop target")
}
t.Log("ok: the section carries the script's drop hook for a holder of the letter only")
}
type composerStubIn struct {
Repo string `json:"repo"`
Root string `json:"root"`
Name string `json:"name"`
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Attach []string `json:"attach"`
}
type composerStubOut struct {
ID string `json:"id"`
Hash string `json:"hash"`
}
// The composer POSTs hand every `attach` value to their op; a new
// ticket filed into the local tier is refused with files rather than
// filed without them, since only a fossil ticket carries an A card.
func TestComposerPostsForwardAttachTickets(t *testing.T) {
freshIntake(t)
// The refusal page reads the version through the registry like
// every page; the view registry carries that stub.
r := viewRegistry(t, nil)
got := map[string][]string{}
for _, name := range []string{"forum.post", "forum.reply", "ticket.new", "ticket.intake", "ticket.set", "wiki.save"} {
name := name
if err := ops.Register(r, ops.Op[composerStubIn, composerStubOut]{
Name: name, Caps: "3n", Doc: "test stub", Kind: ops.Command, Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/stub/" + name,
Run: func(_ context.Context, in composerStubIn) (composerStubOut, error) {
got[name] = in.Attach
return composerStubOut{ID: "t1", Hash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}, 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(path string, form url.Values) int {
res, err := client.PostForm(srv.URL+path, form)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
return res.StatusCode
}
tickets := url.Values{"attach": {"tk1", "tk2"}}
thread := url.Values{"title": {"hello"}, "body": {"x"}, "attach": tickets["attach"]}
if code := post("/r/tk/forum-new", thread); code != http.StatusSeeOther || strings.Join(got["forum.post"], ",") != "tk1,tk2" {
t.Errorf("FAIL: forum-new = %d, attach %v", code, got["forum.post"])
}
reply := url.Values{"body": {"x"}, "attach": tickets["attach"]}
if code := post("/r/tk/forum/abcd/reply", reply); code != http.StatusSeeOther || strings.Join(got["forum.reply"], ",") != "tk1,tk2" {
t.Errorf("FAIL: reply = %d, attach %v", code, got["forum.reply"])
}
fossil := url.Values{"title": {"broken"}, "fossil": {"1"}, "attach": tickets["attach"]}
if code := post("/r/tk/ticket/new", fossil); code != http.StatusSeeOther || strings.Join(got["ticket.new"], ",") != "tk1,tk2" {
t.Errorf("FAIL: ticket/new into the fossil = %d, attach %v", code, got["ticket.new"])
}
local := url.Values{"title": {"broken"}, "attach": tickets["attach"]}
if code := post("/r/tk/ticket/new", local); code != http.StatusBadRequest || got["ticket.intake"] != nil {
t.Errorf("FAIL: a local ticket with files = %d, intake saw %v", code, got["ticket.intake"])
}
if code := post("/r/tk/ticket/new", url.Values{"title": {"broken"}}); code != http.StatusSeeOther || len(got["ticket.intake"]) != 0 {
t.Errorf("FAIL: a local ticket without files = %d, attach %v", code, got["ticket.intake"])
}
if code := post("/r/tk/wiki/Design/edit", url.Values{"body": {"x"}, "attach": tickets["attach"]}); code != http.StatusSeeOther || strings.Join(got["wiki.save"], ",") != "tk1,tk2" {
t.Errorf("FAIL: wiki edit = %d, attach %v", code, got["wiki.save"])
}
t.Log("ok: the composer POSTs forward the tickets; a local ticket refuses them")
}
// Somebody who simply has a file on their computer needs a control, not
// a shortcut: paste is a keystroke and drop needs a mouse, and a phone
// has neither. The composer offers a file input under a label.
func TestComposerOffersAFilePicker(t *testing.T) {
ctl := attachComposerFor(&attachWrite{Family: "ticket", CanAttach: true, Limit: "100.0 MB"}, tkID)
var b bytes.Buffer
if err := noHooks.template(ticketPage).ExecuteTemplate(&b, "attach-composer", ctl); err != nil {
t.Fatal(err)
}
got := b.String()
for _, want := range []string{`data-hook="attach-choose"`, `<input type="file" multiple>`} {
if !strings.Contains(got, want) {
t.Errorf("FAIL: the composer has no way to choose a file: missing %s", want)
}
}
if !strings.Contains(sheet, ".attach-choose .attach-pick") {
t.Error("FAIL: the picker has no styling in the served sheet")
}
t.Log("ok: a file can be attached to a comment without a keyboard or a mouse")
}