08749df050
Swap modernc.org/sqlite for jackc/pgx/v5 with no observable change: same endpoints, same wire format, same updated_at ordering rule. The schema now comes from numbered SQL embedded in the binary and applied on startup, one transaction each, recorded in schema_migrations. That replaces two pieces of SQLite-era machinery, both deleted rather than ported: the column probing (Postgres has ADD COLUMN IF NOT EXISTS, and there is no legacy database left to probe) and the Asura key rewrite, which has run clean on every start for months now that the userscripts strip build hashes before writing. Its regexp survives as latest.asuraBuildHash, where the poller still needs it to scope chapter links to a series whose slug carries a rotating hash. Types get real: favorite is a boolean, chapter numbers double precision, timestamps stay unix-ms bigint. SQLite's null-safe IS NOT becomes IS DISTINCT FROM, which is what implements the rule that only reading progress reorders a list. Inside COALESCE/NULLIF the status and kind parameters need an explicit ::text -- there is no target column to infer from and Postgres refuses to guess. Tests lose their free t.TempDir() database, so Docker is now a hard prerequisite for `go test ./...`: internal/pgtest starts one postgres:17-alpine per test binary and hands each test a database of its own. Also lands CONTEXT.md and the four ADRs written while scoping #18. BREAKING CHANGE: DB_PATH is retired for DATABASE_URL, which is required and has no default. Compose gains a postgres service on an internal network with its own volume; POSTGRES_PASSWORD joins .env. The old bookmarks-data volume is deliberately left undeclared so `docker compose down -v` cannot take the pre-migration database with it. main is not deployable until #25 and #26 land. Closes #20 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
503 lines
15 KiB
Go
503 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/pgtest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
const testToken = "s3cret-token"
|
|
|
|
func testConfig() Config {
|
|
return Config{
|
|
Token: testToken,
|
|
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
|
Port: "8080",
|
|
}
|
|
}
|
|
|
|
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
|
|
|
func newTestServer(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
return newRouter(newTestStore(t), testConfig())
|
|
}
|
|
|
|
func newTestStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
s, err := store.Open(pgtest.URL(t))
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s
|
|
}
|
|
|
|
func auth(req *http.Request) *http.Request {
|
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
return req
|
|
}
|
|
|
|
func floatPtr(f float64) *float64 { return &f }
|
|
|
|
// seedForCheck inserts a bookmark and forces its latest_checked_at.
|
|
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
|
t.Helper()
|
|
if _, err := s.Upsert(store.Bookmark{
|
|
Key: key,
|
|
Site: "asura",
|
|
SeriesID: key,
|
|
SeriesURL: seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed %q: %v", key, err)
|
|
}
|
|
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
|
t.Fatalf("seed mark %q: %v", key, err)
|
|
}
|
|
}
|
|
|
|
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
|
t.Helper()
|
|
ts, err := s.LatestCheckedAt(key)
|
|
if err != nil {
|
|
t.Fatalf("LatestCheckedAt %q: %v", key, err)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
func TestHealthzNoAuth(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("healthz status = %d, want 200", rr.Code)
|
|
}
|
|
if rr.Body.String() != "ok" {
|
|
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthRequired(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
cases := []struct {
|
|
name string
|
|
header string
|
|
}{
|
|
{"no header", ""},
|
|
{"bad token", "Bearer wrong"},
|
|
{"not bearer", "Basic " + testToken},
|
|
{"empty bearer", "Bearer "},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
|
if tc.header != "" {
|
|
req.Header.Set("Authorization", tc.header)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rr.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthAccepted(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); got != "[]\n" {
|
|
t.Fatalf("empty list body = %q, want []", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSPreflight(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
|
|
req.Header.Set("Origin", "https://asuracomic.net")
|
|
req.Header.Set("Access-Control-Request-Method", "PUT")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("preflight status = %d, want 204", rr.Code)
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
|
|
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
t.Fatal("Allow-Methods missing")
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
|
t.Fatal("Allow-Headers missing")
|
|
}
|
|
}
|
|
|
|
func TestCORSDisallowedOrigin(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
|
|
}
|
|
}
|
|
|
|
func TestBookmarkRoundTrip(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
key := "asura:solo-leveling-123"
|
|
in := store.Bookmark{
|
|
Title: "Solo Leveling",
|
|
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
|
Cover: "https://asuracomic.net/cover.jpg",
|
|
LastChapter: "Chapter 10",
|
|
LastChapterNum: 10,
|
|
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
|
|
}
|
|
body, _ := json.Marshal(in)
|
|
|
|
// PUT
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
|
}
|
|
var stored store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
|
|
t.Fatalf("decode PUT response: %v", err)
|
|
}
|
|
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
|
|
t.Fatalf("derived fields wrong: %+v", stored)
|
|
}
|
|
if stored.UpdatedAt == 0 {
|
|
t.Fatal("server did not set updated_at")
|
|
}
|
|
|
|
// GET
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
var list []store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
|
|
t.Fatalf("GET list wrong: %+v", list)
|
|
}
|
|
|
|
// PUT again (upsert, progress advance)
|
|
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
|
|
body, _ = json.Marshal(in)
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("second PUT status = %d", rr.Code)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
json.Unmarshal(rr.Body.Bytes(), &list)
|
|
if len(list) != 1 || list[0].LastChapterNum != 11 {
|
|
t.Fatalf("upsert did not update in place: %+v", list)
|
|
}
|
|
|
|
// DELETE
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE status = %d, want 204", rr.Code)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
json.Unmarshal(rr.Body.Bytes(), &list)
|
|
if len(list) != 0 {
|
|
t.Fatalf("after delete list = %+v, want empty", list)
|
|
}
|
|
}
|
|
|
|
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
|
|
// which is the row as actually stored (not the request payload).
|
|
func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark {
|
|
t.Helper()
|
|
body, _ := json.Marshal(b)
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
|
|
}
|
|
var out store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode PUT response: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark {
|
|
t.Helper()
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET status = %d", rr.Code)
|
|
}
|
|
var list []store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
return list
|
|
}
|
|
|
|
// updated_at drives list ordering, so it must move only on a real progress
|
|
// advance — never on a favorite toggle or a latest-chapter capture.
|
|
func TestUpsertConditionalUpdatedAt(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mutate func(store.Bookmark) store.Bookmark
|
|
wantBumped bool
|
|
}{
|
|
{
|
|
name: "unchanged progress",
|
|
mutate: func(b store.Bookmark) store.Bookmark { return b },
|
|
wantBumped: false,
|
|
},
|
|
{
|
|
name: "changed progress",
|
|
mutate: func(b store.Bookmark) store.Bookmark {
|
|
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
|
|
return b
|
|
},
|
|
wantBumped: true,
|
|
},
|
|
{
|
|
name: "favorite only",
|
|
mutate: func(b store.Bookmark) store.Bookmark {
|
|
b.Favorite = true
|
|
return b
|
|
},
|
|
wantBumped: false,
|
|
},
|
|
{
|
|
name: "latest chapter only",
|
|
mutate: func(b store.Bookmark) store.Bookmark {
|
|
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
|
|
return b
|
|
},
|
|
wantBumped: false,
|
|
},
|
|
{
|
|
name: "unrelated metadata only",
|
|
mutate: func(b store.Bookmark) store.Bookmark {
|
|
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
|
|
return b
|
|
},
|
|
wantBumped: false,
|
|
},
|
|
}
|
|
|
|
for i, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
key := fmt.Sprintf("asura:cond-%d", i)
|
|
|
|
first := putBookmark(t, srv, key, store.Bookmark{
|
|
Title: "Test",
|
|
LastChapter: "Chapter 10",
|
|
LastChapterNum: 10,
|
|
})
|
|
if first.UpdatedAt == 0 {
|
|
t.Fatal("new bookmark did not get updated_at set")
|
|
}
|
|
|
|
// Guarantee a later wall-clock ms so a real bump is observable.
|
|
time.Sleep(2 * time.Millisecond)
|
|
|
|
second := putBookmark(t, srv, key, tc.mutate(first))
|
|
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
|
|
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
|
|
}
|
|
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
|
|
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
|
|
}
|
|
|
|
// The PUT response must match what a subsequent GET reports.
|
|
list := getBookmarks(t, srv)
|
|
if len(list) != 1 {
|
|
t.Fatalf("list = %+v, want 1 item", list)
|
|
}
|
|
if list[0].UpdatedAt != second.UpdatedAt {
|
|
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFavoriteRoundTrip(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
key := "demonic:some-series"
|
|
|
|
stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true})
|
|
if !stored.Favorite {
|
|
t.Fatalf("PUT response favorite = false, want true")
|
|
}
|
|
|
|
list := getBookmarks(t, srv)
|
|
if len(list) != 1 || !list[0].Favorite {
|
|
t.Fatalf("favorite did not round-trip: %+v", list)
|
|
}
|
|
|
|
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
|
|
stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false})
|
|
if stored.Favorite {
|
|
t.Fatal("PUT response favorite = true after unfavorite")
|
|
}
|
|
list = getBookmarks(t, srv)
|
|
if len(list) != 1 || list[0].Favorite {
|
|
t.Fatalf("unfavorite did not round-trip: %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestLatestChapterNullable(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
key := "asura:latest-test"
|
|
|
|
// Never captured: latest_chapter_num must serialize as JSON null.
|
|
body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
|
|
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
|
|
}
|
|
|
|
list := getBookmarks(t, srv)
|
|
if len(list) != 1 || list[0].LatestChapterNum != nil {
|
|
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
|
|
}
|
|
|
|
// Once captured it round-trips as a value.
|
|
stored := putBookmark(t, srv, key, store.Bookmark{
|
|
Title: "No latest yet",
|
|
LatestChapter: "Chapter 162",
|
|
LatestChapterNum: floatPtr(162),
|
|
})
|
|
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
|
|
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
|
|
}
|
|
list = getBookmarks(t, srv)
|
|
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
|
|
t.Fatalf("latest chapter did not round-trip: %+v", list)
|
|
}
|
|
if list[0].LatestChapter != "Chapter 162" {
|
|
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigWebPassword(t *testing.T) {
|
|
t.Setenv("API_TOKEN", "token-abc")
|
|
t.Setenv("WEB_PASSWORD", "hunter2")
|
|
if got := loadConfig().WebPassword; got != "hunter2" {
|
|
t.Fatalf("WebPassword = %q, want hunter2", got)
|
|
}
|
|
|
|
t.Setenv("WEB_PASSWORD", "")
|
|
if got := loadConfig().WebPassword; got != "" {
|
|
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
|
|
}
|
|
}
|
|
|
|
// A userscript PUT body has no latest_checked_at field. If the column is ever
|
|
// moved into bookmarkColumns, this test catches it: the PUT would reset the
|
|
// cooldown and the poller would re-fetch that series on every single tick.
|
|
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
|
s := newTestStore(t)
|
|
srv := newRouter(s, testConfig())
|
|
|
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
|
|
|
|
// Exactly what the userscript sends: no latest_checked_at key at all.
|
|
body := `{"key":"asura:x","site":"asura","series_id":"x",
|
|
"series_url":"https://asurascans.com/comics/x",
|
|
"last_chapter":"Chapter 5","last_chapter_num":5}`
|
|
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 {
|
|
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
|
}
|
|
}
|
|
|
|
// The userscript route is registered outside the `if cfg.WebPassword != ""`
|
|
// block in newRouter, so it must keep working on a deployment that never set
|
|
// WEB_PASSWORD — see internal/userscript for the handler's own behaviour.
|
|
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
|
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil {
|
|
t.Fatalf("write script: %v", err)
|
|
}
|
|
|
|
s := newTestStore(t)
|
|
cfg := testConfig() // WebPassword empty
|
|
cfg.UserscriptPath = path
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil)
|
|
newRouter(s, cfg).ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// Both scripts are served from the same handler on the same token, outside the
|
|
// WEB_PASSWORD gate — a wrong token is a 404, never a 401.
|
|
func TestNovelUserscriptServed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
|
|
if err := os.WriteFile(novelPath, []byte("// novel\n"), 0o644); err != nil {
|
|
t.Fatalf("write script: %v", err)
|
|
}
|
|
|
|
s := newTestStore(t)
|
|
|
|
cfg := testConfig()
|
|
cfg.NovelUserscriptPath = novelPath
|
|
srv := newRouter(s, cfg)
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
|
"/u/"+testToken+"/novel-bookmark.user.js", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
|
t.Fatalf("Content-Type = %q, want text/javascript", ct)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
|
"/u/wrong-token/novel-bookmark.user.js", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("wrong token status = %d, want 404", rr.Code)
|
|
}
|
|
}
|