carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1// Package todotest tests the app from the outside: real HTTP requests
2// against the real router, wired exactly as cmd/todo/main.go wires it.
3package todotest
4
5import (
6 "io"
7 "log/slog"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "os"
12 "regexp"
13 "strings"
14 "testing"
15
16 "amadan.net/rastrillo/rastrillo/budget"
17 "amadan.net/rastrillo/rastrillo/db"
18 "amadan.net/rastrillo/rastrillo/dbtest"
19
20 todo "todo/internal/todo"
21)
22
23// testOrigin is the origin newApp wires: the app's own.
24const testOrigin = "http://app.test"
25
26// schema is migrated once per test binary and copied for each test.
27var schema = dbtest.FromSet(todo.BootSchema)
28
29func TestMain(m *testing.M) {
30 code := budget.Main(m)
31 schema.Remove()
32 os.Exit(code)
33}
34
35// newApp builds the whole app over its own copy of the migrated
36// database, exactly as main.go does.
37func newApp(t *testing.T) http.Handler {
38 t.Helper()
39 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
40 d, err := db.Open(schema.Path(t), logger)
41 if err != nil {
42 t.Fatalf("db.Open: %v", err)
43 }
44 t.Cleanup(func() { d.Close() })
45 mux, err := todo.App(d, testOrigin, logger)
46 if err != nil {
47 t.Fatalf("App: %v", err)
48 }
49 return mux
50}
51
52// browser is one visitor: a cookie jar in front of the app.
53type browser struct {
54 t *testing.T
55 h http.Handler
56 cookies map[string]*http.Cookie
57}
58
59func newBrowser(t *testing.T, h http.Handler) *browser {
60 return &browser{t: t, h: h, cookies: map[string]*http.Cookie{}}
61}
62
63func (b *browser) do(req *http.Request) *httptest.ResponseRecorder {
64 b.t.Helper()
65 for _, c := range b.cookies {
66 req.AddCookie(c)
67 }
68 rec := httptest.NewRecorder()
69 b.h.ServeHTTP(rec, req)
70 for _, c := range rec.Result().Cookies() {
71 if c.MaxAge < 0 {
72 delete(b.cookies, c.Name)
73 } else {
74 b.cookies[c.Name] = c
75 }
76 }
77 return rec
78}
79
80func (b *browser) get(target string) *httptest.ResponseRecorder {
81 b.t.Helper()
82 return b.do(httptest.NewRequest(http.MethodGet, target, nil))
83}
84
85// post sends a form the way the shared suite does: no Origin header.
86func (b *browser) post(target string, form url.Values, headers ...string) *httptest.ResponseRecorder {
87 b.t.Helper()
88 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
89 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
90 for i := 0; i+1 < len(headers); i += 2 {
91 req.Header.Set(headers[i], headers[i+1])
92 }
93 return b.do(req)
94}
95
96// add posts a new todo and fails the test unless it 303s.
97func (b *browser) add(title string) {
98 b.t.Helper()
99 rec := b.post("/todos", url.Values{"title": {title}, "filter": {"all"}})
100 if rec.Code != http.StatusSeeOther {
101 b.t.Fatalf("POST /todos: status %d, want 303", rec.Code)
102 }
103}
104
105var itemRE = regexp.MustCompile(`(?s)<li data-id="(\d+)"(?: class="([^"]*)")?>.*?<label>(.*?)</label>`)
106
107type shown struct{ ID, Class, Title string }
108
109// items lists what a page shows, in order.
110func items(body string) []shown {
111 var out []shown
112 for _, m := range itemRE.FindAllStringSubmatch(body, -1) {
113 out = append(out, shown{m[1], m[2], m[3]})
114 }
115 return out
116}
117
118func titles(body string) []string {
119 var out []string
120 for _, it := range items(body) {
121 out = append(out, it.Title)
122 }
123 return out
124}
125