oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "regexp"
8 "strings"
9 "testing"
10)
11
12// otherAddress is a second person on the instance, for the tests whose
13// whole subject is what one person can see of another's deck.
14const otherAddress = "sam@example.test"
15
16// sampleDeckTitle is the deck every new person's first sign-in seeds.
17const sampleDeckTitle = "Welcome to Slides"
18
19// deckPath finds /d/{sqid} in a Location header or a rendered page.
20var deckPath = regexp.MustCompile(`/d/[23456789abcdefghjkmnpqrstuvwxyz]{10}`)
21
22// newSharedApp is newApp with both test addresses on the allowlist, so
23// two people can sign in to the same instance.
24func newSharedApp(t *testing.T) *testApp {
25 t.Helper()
26 cfg, mailer := newConfig(t)
27 cfg.AllowedAddresses = []string{allowedAddress, otherAddress}
28 return newAppWith(t, cfg, mailer)
29}
30
31// createDeck posts the new-deck form and returns the sqid path the app
32// redirected to.
33func createDeck(t *testing.T, a *testApp, cookies []*http.Cookie, title string) string {
34 t.Helper()
35 rec := post(t, a, "/decks", url.Values{"title": {title}, "theme": {"ink"}}, cookies...)
36 if rec.Code != http.StatusSeeOther {
37 t.Fatalf("POST /decks: status %d, want %d\n%s", rec.Code, http.StatusSeeOther, rec.Body)
38 }
39 loc := rec.Header().Get("Location")
40 if !deckPath.MatchString(loc) {
41 t.Fatalf("POST /decks: Location = %q, want a /d/{sqid}", loc)
42 }
43 return loc
44}
45
46// mustGet fetches target and fails unless the status is want.
47func mustGet(t *testing.T, a *testApp, target string, want int, cookies []*http.Cookie) *httptest.ResponseRecorder {
48 t.Helper()
49 rec := get(t, a, target, cookies...)
50 if rec.Code != want {
51 t.Fatalf("GET %s: status %d, want %d", target, rec.Code, want)
52 }
53 return rec
54}
55
56// TestNewDeckAppearsInList: the create flow end to end — the form
57// screen, the POST, the redirect to the deck, and the row in the list.
58func TestNewDeckAppearsInList(t *testing.T) {
59 a := newApp(t)
60 cookies := signedIn(t, a, allowedAddress)
61
62 mustGet(t, a, "/decks/new", http.StatusOK, cookies)
63
64 path := createDeck(t, a, cookies, "Roadmap")
65
66 deckPage := mustGet(t, a, path, http.StatusOK, cookies).Body.String()
67 if !strings.Contains(deckPage, "Roadmap") {
68 t.Errorf("GET %s does not name the deck:\n%s", path, deckPage)
69 }
70
71 list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
72 if !strings.Contains(list, "Roadmap") {
73 t.Errorf("GET / does not list the new deck:\n%s", list)
74 }
75 if !strings.Contains(list, path) {
76 t.Errorf("GET / does not link %s:\n%s", path, list)
77 }
78}
79
80// TestSampleDeckSeededOnFirstSignin: a person's very first authenticated
81// request builds their sample deck, so the app is never a blank screen
82// with a button on it.
83func TestSampleDeckSeededOnFirstSignin(t *testing.T) {
84 a := newApp(t)
85 cookies := signedIn(t, a, allowedAddress)
86
87 list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
88 if !strings.Contains(list, sampleDeckTitle) {
89 t.Fatalf("GET / after first sign-in does not list %q:\n%s", sampleDeckTitle, list)
90 }
91
92 path := deckPath.FindString(list)
93 if path == "" {
94 t.Fatalf("GET / lists no deck link:\n%s", list)
95 }
96 page := mustGet(t, a, path, http.StatusOK, cookies).Body.String()
97 // Eight slides, every flow template plus two canvas slides, and
98 // notes on each: the sample is what teaches the model, so an empty
99 // one would pass every other assertion here.
100 //
101 // Counted off the filmstrip and the counter rather than off the
102 // rendered sections, because the viewer is paged (task 5): one
103 // slide is on the page at a time, and the filmstrip is where the
104 // whole deck is named.
105 if !strings.Contains(page, "1 of 8") {
106 t.Errorf("the sample deck does not say it has eight slides:\n%s", page)
107 }
108 if n := strings.Count(page, `?slide=`); n < 8 {
109 t.Errorf("the sample deck's filmstrip links %d slides, want at least 8:\n%s", n, page)
110 }
111
112 // Seeding happens once, not on every request past the gate.
113 again := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
114 if n := strings.Count(again, sampleDeckTitle); n != 1 {
115 t.Errorf("%q appears %d times on the list, want once", sampleDeckTitle, n)
116 }
117}
118
119// TestForeignPrivateDeckIs404: a deck that is not yours does not exist.
120// Not 403 — a 403 tells a stranger the sqid is real.
121func TestForeignPrivateDeckIs404(t *testing.T) {
122 a := newSharedApp(t)
123 mine := signedIn(t, a, allowedAddress)
124 theirs := signedIn(t, a, otherAddress)
125
126 path := createDeck(t, a, mine, "Board update")
127
128 mustGet(t, a, path, http.StatusNotFound, theirs)
129 mustGet(t, a, path+"/settings", http.StatusNotFound, theirs)
130
131 list := mustGet(t, a, "/", http.StatusOK, theirs).Body.String()
132 if strings.Contains(list, "Board update") {
133 t.Errorf("another person's private deck is on the list:\n%s", list)
134 }
135}
136
137// TestTeamDeckVisibleToOthers: raising a deck to the team rung opens it
138// to everyone signed in to this instance, and only the owner may raise
139// it.
140func TestTeamDeckVisibleToOthers(t *testing.T) {
141 a := newSharedApp(t)
142 mine := signedIn(t, a, allowedAddress)
143 theirs := signedIn(t, a, otherAddress)
144
145 path := createDeck(t, a, mine, "All hands")
146 mustGet(t, a, path, http.StatusNotFound, theirs)
147
148 // Someone who cannot see the deck cannot raise its rung either.
149 if rec := post(t, a, path+"/settings", url.Values{
150 "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"},
151 }, theirs...); rec.Code != http.StatusNotFound {
152 t.Fatalf("POST %s/settings as a stranger: status %d, want 404", path, rec.Code)
153 }
154
155 if rec := post(t, a, path+"/settings", url.Values{
156 "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"},
157 }, mine...); rec.Code != http.StatusSeeOther {
158 t.Fatalf("POST %s/settings: status %d, want 303\n%s", path, rec.Code, rec.Body)
159 }
160
161 mustGet(t, a, path, http.StatusOK, theirs)
162 list := mustGet(t, a, "/", http.StatusOK, theirs).Body.String()
163 if !strings.Contains(list, "All hands") {
164 t.Errorf("a team deck is missing from another person's list:\n%s", list)
165 }
166}
167
168// TestRenameAndDelete: the settings screen writes the title through to
169// both the list and the document, and delete is a confirm page whose
170// POST takes the deck away for good.
171func TestRenameAndDelete(t *testing.T) {
172 a := newSharedApp(t)
173 mine := signedIn(t, a, allowedAddress)
174 theirs := signedIn(t, a, otherAddress)
175
176 path := createDeck(t, a, mine, "Draft")
177
178 form := mustGet(t, a, path+"/settings", http.StatusOK, mine).Body.String()
179 if !strings.Contains(form, `value="Draft"`) {
180 t.Errorf("the settings form does not seed the current title:\n%s", form)
181 }
182
183 rec := post(t, a, path+"/settings", url.Values{
184 "title": {"Final"}, "theme": {"sunset"}, "rung": {"private"},
185 }, mine...)
186 if rec.Code != http.StatusSeeOther {
187 t.Fatalf("POST %s/settings: status %d, want 303\n%s", path, rec.Code, rec.Body)
188 }
189
190 list := mustGet(t, a, "/", http.StatusOK, mine).Body.String()
191 if !strings.Contains(list, "Final") || strings.Contains(list, "Draft") {
192 t.Errorf("the rename did not reach the list:\n%s", list)
193 }
194 // The document carries the title too, not only the row: the deck
195 // page renders what the core holds.
196 page := mustGet(t, a, path, http.StatusOK, mine).Body.String()
197 if !strings.Contains(page, "Final") {
198 t.Errorf("the rename did not reach the document:\n%s", page)
199 }
200
201 // Deleting is its own URL with its own page, and it is the owner's
202 // alone.
203 mustGet(t, a, path+"/delete", http.StatusOK, mine)
204 if rec := post(t, a, path+"/delete", nil, theirs...); rec.Code != http.StatusNotFound {
205 t.Fatalf("POST %s/delete as a stranger: status %d, want 404", path, rec.Code)
206 }
207
208 if rec := post(t, a, path+"/delete", nil, mine...); rec.Code != http.StatusSeeOther {
209 t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body)
210 }
211 mustGet(t, a, path, http.StatusNotFound, mine)
212 gone := mustGet(t, a, "/", http.StatusOK, mine).Body.String()
213 if strings.Contains(gone, "Final") {
214 t.Errorf("the deleted deck is still on the list:\n%s", gone)
215 }
216
217 // Delete what is left — the sample deck — and the list falls back
218 // to its empty state. Worth reaching: it is the one branch of the
219 // list screen nothing else in this suite renders, and a person who
220 // has cleared their decks out is exactly who needs the screen to
221 // say what it is for.
222 rest := deckPath.FindString(gone)
223 if rest == "" {
224 t.Fatalf("expected the sample deck still on the list:\n%s", gone)
225 }
226 if rec := post(t, a, rest+"/delete", nil, mine...); rec.Code != http.StatusSeeOther {
227 t.Fatalf("POST %s/delete: status %d, want 303", rest, rec.Code)
228 }
229 empty := mustGet(t, a, "/", http.StatusOK, mine).Body.String()
230 if !strings.Contains(empty, "No decks yet") {
231 t.Errorf("an emptied list does not render its empty state:\n%s", empty)
232 }
233}
234
235// TestDeleteDeckTakesItsDerivedRowsToo: a deck's snapshots and its
236// verified-asset rows go with it. Neither is the deck — the log is —
237// but a snapshot outliving its deck is a whole copy of the document
238// nothing can reach and nothing cleans up, and an asset row outliving
239// its deck is a verification for a deck that no longer exists. Both
240// are written directly here: a snapshot takes two hundred updates to
241// earn, and an asset row a bound store.
242func TestDeleteDeckTakesItsDerivedRowsToo(t *testing.T) {
243 a := newApp(t)
244 mine := signedIn(t, a, allowedAddress)
245 path := createDeck(t, a, mine, "Derived")
246 sqid := sqidOf(path)
247
248 for _, q := range []string{
249 "INSERT INTO deck_snapshots (deck_id, seq, bytes, created_at) VALUES ((SELECT id FROM decks WHERE sqid = ?), 1, x'00', CURRENT_TIMESTAMP)",
250 "INSERT INTO deck_assets (deck_id, ref, created_at) VALUES ((SELECT id FROM decks WHERE sqid = ?), 'abc', CURRENT_TIMESTAMP)",
251 } {
252 if err := a.db.G.Exec(q, sqid).Error; err != nil {
253 t.Fatalf("%s: %v", q, err)
254 }
255 }
256 if rec := post(t, a, path+"/delete", nil, mine...); rec.Code != http.StatusSeeOther {
257 t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body)
258 }
259 // Orphans, not totals: the sample deck is still there with rows of
260 // its own.
261 for _, table := range []string{"deck_snapshots", "deck_assets", "deck_updates"} {
262 if got := scalar(t, a, "SELECT count(*) FROM "+table+" WHERE deck_id NOT IN (SELECT id FROM decks)"); got != "0" {
263 t.Errorf("%s holds %s rows for a deck that no longer exists", table, got)
264 }
265 }
266}
267