package slidestest import ( "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" ) // otherAddress is a second person on the instance, for the tests whose // whole subject is what one person can see of another's deck. const otherAddress = "sam@example.test" // sampleDeckTitle is the deck every new person's first sign-in seeds. const sampleDeckTitle = "Welcome to Slides" // deckPath finds /d/{sqid} in a Location header or a rendered page. var deckPath = regexp.MustCompile(`/d/[23456789abcdefghjkmnpqrstuvwxyz]{10}`) // newSharedApp is newApp with both test addresses on the allowlist, so // two people can sign in to the same instance. func newSharedApp(t *testing.T) *testApp { t.Helper() cfg, mailer := newConfig(t) cfg.AllowedAddresses = []string{allowedAddress, otherAddress} return newAppWith(t, cfg, mailer) } // createDeck posts the new-deck form and returns the sqid path the app // redirected to. func createDeck(t *testing.T, a *testApp, cookies []*http.Cookie, title string) string { t.Helper() rec := post(t, a, "/decks", url.Values{"title": {title}, "theme": {"ink"}}, cookies...) if rec.Code != http.StatusSeeOther { t.Fatalf("POST /decks: status %d, want %d\n%s", rec.Code, http.StatusSeeOther, rec.Body) } loc := rec.Header().Get("Location") if !deckPath.MatchString(loc) { t.Fatalf("POST /decks: Location = %q, want a /d/{sqid}", loc) } return loc } // mustGet fetches target and fails unless the status is want. func mustGet(t *testing.T, a *testApp, target string, want int, cookies []*http.Cookie) *httptest.ResponseRecorder { t.Helper() rec := get(t, a, target, cookies...) if rec.Code != want { t.Fatalf("GET %s: status %d, want %d", target, rec.Code, want) } return rec } // TestNewDeckAppearsInList: the create flow end to end — the form // screen, the POST, the redirect to the deck, and the row in the list. func TestNewDeckAppearsInList(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) mustGet(t, a, "/decks/new", http.StatusOK, cookies) path := createDeck(t, a, cookies, "Roadmap") deckPage := mustGet(t, a, path, http.StatusOK, cookies).Body.String() if !strings.Contains(deckPage, "Roadmap") { t.Errorf("GET %s does not name the deck:\n%s", path, deckPage) } list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() if !strings.Contains(list, "Roadmap") { t.Errorf("GET / does not list the new deck:\n%s", list) } if !strings.Contains(list, path) { t.Errorf("GET / does not link %s:\n%s", path, list) } } // TestSampleDeckSeededOnFirstSignin: a person's very first authenticated // request builds their sample deck, so the app is never a blank screen // with a button on it. func TestSampleDeckSeededOnFirstSignin(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() if !strings.Contains(list, sampleDeckTitle) { t.Fatalf("GET / after first sign-in does not list %q:\n%s", sampleDeckTitle, list) } path := deckPath.FindString(list) if path == "" { t.Fatalf("GET / lists no deck link:\n%s", list) } page := mustGet(t, a, path, http.StatusOK, cookies).Body.String() // Eight slides, every flow template plus two canvas slides, and // notes on each: the sample is what teaches the model, so an empty // one would pass every other assertion here. // // Counted off the filmstrip and the counter rather than off the // rendered sections, because the viewer is paged (task 5): one // slide is on the page at a time, and the filmstrip is where the // whole deck is named. if !strings.Contains(page, "1 of 8") { t.Errorf("the sample deck does not say it has eight slides:\n%s", page) } if n := strings.Count(page, `?slide=`); n < 8 { t.Errorf("the sample deck's filmstrip links %d slides, want at least 8:\n%s", n, page) } // Seeding happens once, not on every request past the gate. again := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() if n := strings.Count(again, sampleDeckTitle); n != 1 { t.Errorf("%q appears %d times on the list, want once", sampleDeckTitle, n) } } // TestForeignPrivateDeckIs404: a deck that is not yours does not exist. // Not 403 — a 403 tells a stranger the sqid is real. func TestForeignPrivateDeckIs404(t *testing.T) { a := newSharedApp(t) mine := signedIn(t, a, allowedAddress) theirs := signedIn(t, a, otherAddress) path := createDeck(t, a, mine, "Board update") mustGet(t, a, path, http.StatusNotFound, theirs) mustGet(t, a, path+"/settings", http.StatusNotFound, theirs) list := mustGet(t, a, "/", http.StatusOK, theirs).Body.String() if strings.Contains(list, "Board update") { t.Errorf("another person's private deck is on the list:\n%s", list) } } // TestTeamDeckVisibleToOthers: raising a deck to the team rung opens it // to everyone signed in to this instance, and only the owner may raise // it. func TestTeamDeckVisibleToOthers(t *testing.T) { a := newSharedApp(t) mine := signedIn(t, a, allowedAddress) theirs := signedIn(t, a, otherAddress) path := createDeck(t, a, mine, "All hands") mustGet(t, a, path, http.StatusNotFound, theirs) // Someone who cannot see the deck cannot raise its rung either. if rec := post(t, a, path+"/settings", url.Values{ "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"}, }, theirs...); rec.Code != http.StatusNotFound { t.Fatalf("POST %s/settings as a stranger: status %d, want 404", path, rec.Code) } if rec := post(t, a, path+"/settings", url.Values{ "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"}, }, mine...); rec.Code != http.StatusSeeOther { t.Fatalf("POST %s/settings: status %d, want 303\n%s", path, rec.Code, rec.Body) } mustGet(t, a, path, http.StatusOK, theirs) list := mustGet(t, a, "/", http.StatusOK, theirs).Body.String() if !strings.Contains(list, "All hands") { t.Errorf("a team deck is missing from another person's list:\n%s", list) } } // TestRenameAndDelete: the settings screen writes the title through to // both the list and the document, and delete is a confirm page whose // POST takes the deck away for good. func TestRenameAndDelete(t *testing.T) { a := newSharedApp(t) mine := signedIn(t, a, allowedAddress) theirs := signedIn(t, a, otherAddress) path := createDeck(t, a, mine, "Draft") form := mustGet(t, a, path+"/settings", http.StatusOK, mine).Body.String() if !strings.Contains(form, `value="Draft"`) { t.Errorf("the settings form does not seed the current title:\n%s", form) } rec := post(t, a, path+"/settings", url.Values{ "title": {"Final"}, "theme": {"sunset"}, "rung": {"private"}, }, mine...) if rec.Code != http.StatusSeeOther { t.Fatalf("POST %s/settings: status %d, want 303\n%s", path, rec.Code, rec.Body) } list := mustGet(t, a, "/", http.StatusOK, mine).Body.String() if !strings.Contains(list, "Final") || strings.Contains(list, "Draft") { t.Errorf("the rename did not reach the list:\n%s", list) } // The document carries the title too, not only the row: the deck // page renders what the core holds. page := mustGet(t, a, path, http.StatusOK, mine).Body.String() if !strings.Contains(page, "Final") { t.Errorf("the rename did not reach the document:\n%s", page) } // Deleting is its own URL with its own page, and it is the owner's // alone. mustGet(t, a, path+"/delete", http.StatusOK, mine) if rec := post(t, a, path+"/delete", nil, theirs...); rec.Code != http.StatusNotFound { t.Fatalf("POST %s/delete as a stranger: status %d, want 404", path, rec.Code) } if rec := post(t, a, path+"/delete", nil, mine...); rec.Code != http.StatusSeeOther { t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body) } mustGet(t, a, path, http.StatusNotFound, mine) gone := mustGet(t, a, "/", http.StatusOK, mine).Body.String() if strings.Contains(gone, "Final") { t.Errorf("the deleted deck is still on the list:\n%s", gone) } // Delete what is left — the sample deck — and the list falls back // to its empty state. Worth reaching: it is the one branch of the // list screen nothing else in this suite renders, and a person who // has cleared their decks out is exactly who needs the screen to // say what it is for. rest := deckPath.FindString(gone) if rest == "" { t.Fatalf("expected the sample deck still on the list:\n%s", gone) } if rec := post(t, a, rest+"/delete", nil, mine...); rec.Code != http.StatusSeeOther { t.Fatalf("POST %s/delete: status %d, want 303", rest, rec.Code) } empty := mustGet(t, a, "/", http.StatusOK, mine).Body.String() if !strings.Contains(empty, "No decks yet") { t.Errorf("an emptied list does not render its empty state:\n%s", empty) } } // TestDeleteDeckTakesItsDerivedRowsToo: a deck's snapshots and its // verified-asset rows go with it. Neither is the deck — the log is — // but a snapshot outliving its deck is a whole copy of the document // nothing can reach and nothing cleans up, and an asset row outliving // its deck is a verification for a deck that no longer exists. Both // are written directly here: a snapshot takes two hundred updates to // earn, and an asset row a bound store. func TestDeleteDeckTakesItsDerivedRowsToo(t *testing.T) { a := newApp(t) mine := signedIn(t, a, allowedAddress) path := createDeck(t, a, mine, "Derived") sqid := sqidOf(path) for _, q := range []string{ "INSERT INTO deck_snapshots (deck_id, seq, bytes, created_at) VALUES ((SELECT id FROM decks WHERE sqid = ?), 1, x'00', CURRENT_TIMESTAMP)", "INSERT INTO deck_assets (deck_id, ref, created_at) VALUES ((SELECT id FROM decks WHERE sqid = ?), 'abc', CURRENT_TIMESTAMP)", } { if err := a.db.G.Exec(q, sqid).Error; err != nil { t.Fatalf("%s: %v", q, err) } } if rec := post(t, a, path+"/delete", nil, mine...); rec.Code != http.StatusSeeOther { t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body) } // Orphans, not totals: the sample deck is still there with rows of // its own. for _, table := range []string{"deck_snapshots", "deck_assets", "deck_updates"} { if got := scalar(t, a, "SELECT count(*) FROM "+table+" WHERE deck_id NOT IN (SELECT id FROM decks)"); got != "0" { t.Errorf("%s holds %s rows for a deck that no longer exists", table, got) } } }