package web
import (
"bytes"
"context"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"todo-go/internal/todo"
)
// client is one visitor: a cookie jar that doesn't follow redirects.
type client struct {
t *testing.T
srv *httptest.Server
hc *http.Client
}
func newServer(t *testing.T) *httptest.Server {
t.Helper()
store, err := todo.Open(context.Background(), filepath.Join(t.TempDir(), "todo.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
srv := httptest.NewUnstartedServer(nil)
srv.Config.Handler = New(store, Config{Origin: "http://" + srv.Listener.Addr().String(), Version: "test"})
srv.Start()
t.Cleanup(srv.Close)
return srv
}
func visitor(t *testing.T, srv *httptest.Server) *client {
jar, _ := cookiejar.New(nil)
hc := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
return &client{t: t, srv: srv, hc: hc}
}
func (c *client) get(path string) (*http.Response, string) {
c.t.Helper()
res, err := c.hc.Get(c.srv.URL + path)
if err != nil {
c.t.Fatal(err)
}
defer res.Body.Close()
var b bytes.Buffer
b.ReadFrom(res.Body)
return res, b.String()
}
func (c *client) post(path string, form url.Values, header map[string]string) (*http.Response, string) {
c.t.Helper()
req, _ := http.NewRequest("POST", c.srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range header {
req.Header.Set(k, v)
}
res, err := c.hc.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer res.Body.Close()
var b bytes.Buffer
b.ReadFrom(res.Body)
return res, b.String()
}
var idRE = regexp.MustCompile(`data-id="(\d+)"`)
func ids(body string) []string {
var out []string
for _, m := range idRE.FindAllStringSubmatch(body, -1) {
out = append(out, m[1])
}
return out
}
func TestActionsRedirectToTheirFilter(t *testing.T) {
c := visitor(t, newServer(t))
for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} {
res, _ := c.post("/todos", url.Values{"title": {"x"}, "filter": {filter}}, nil)
if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != want {
t.Errorf("filter %q: %d to %q, want 303 to %q", filter, res.StatusCode, res.Header.Get("Location"), want)
}
}
}
func TestPageShowsFilterAndCounts(t *testing.T) {
c := visitor(t, newServer(t))
_, body := c.get("/")
if strings.Contains(body, `class="main"`) || strings.Contains(body, `class="footer"`) {
t.Fatal("empty list should hide .main and .footer")
}
c.post("/todos", url.Values{"title": {"a"}}, nil)
c.post("/todos", url.Values{"title": {"b"}}, nil)
_, body = c.get("/")
id := ids(body)[0]
c.post("/todos/"+id+"/toggle", url.Values{"filter": {"all"}}, nil)
_, body = c.get("/active")
if got := ids(body); len(got) != 1 {
t.Fatalf("/active shows %v", got)
}
for _, want := range []string{
`1 item left`,
`Active`,
`class="clear-completed"`,
`href="/active?edit=`,
} {
if !strings.Contains(body, want) {
t.Errorf("/active is missing %q", want)
}
}
_, body = c.get("/completed?edit=" + id)
if !strings.Contains(body, `class="completed editing" data-id="`+id+`"`) ||
!strings.Contains(body, `action="/todos/`+id+`/edit"`) {
t.Errorf("edit view not rendered:\n%s", body)
}
}
func TestEscapesTitles(t *testing.T) {
c := visitor(t, newServer(t))
c.post("/todos", url.Values{"title": {`
& "q"`}}, nil)
_, body := c.get("/")
if strings.Contains(body, "
`) ||
strings.Contains(body, " 300 {
t.Fatalf("app.js is %d lines; the cap is 300", n)
}
}
// A well-formed cookie the server never issued is replaced, so two
// visitors can't share a list by choosing the same value.
func TestUnissuedCookieIsReplaced(t *testing.T) {
srv := newServer(t)
forged := strings.Repeat("A", 43) // decodes to 32 bytes
post := func() *http.Response {
req, _ := http.NewRequest("POST", srv.URL+"/todos", strings.NewReader("title=shared"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", cookieName+"="+forged)
res, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res
}
for range 2 {
res := post()
var got string
for _, c := range res.Cookies() {
if c.Name == cookieName {
got = c.Value
}
}
if got == "" || got == forged {
t.Fatalf("forged cookie kept (Set-Cookie %q)", res.Header.Get("Set-Cookie"))
}
}
req, _ := http.NewRequest("GET", srv.URL+"/", nil)
req.Header.Set("Cookie", cookieName+"="+forged)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var b bytes.Buffer
b.ReadFrom(res.Body)
res.Body.Close()
if len(ids(b.String())) != 0 {
t.Fatal("forged cookie sees a shared list")
}
}