handler_test.go
| 1 | package server |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "net/url" |
| 7 | "regexp" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // getWith issues a GET carrying the given cookies (so a flash set by a prior |
| 14 | // POST is delivered to the page that renders it). |
| 15 | func getWith(router http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder { |
| 16 | req := httptest.NewRequest("GET", path, nil) |
| 17 | for _, c := range cookies { |
| 18 | req.AddCookie(c) |
| 19 | } |
| 20 | w := httptest.NewRecorder() |
| 21 | router.ServeHTTP(w, req) |
| 22 | return w |
| 23 | } |
| 24 | |
| 25 | // flash returns the (kind, message) of a flash cookie set on the response. |
| 26 | func flash(w *httptest.ResponseRecorder) (kind, message string, ok bool) { |
| 27 | for _, c := range w.Result().Cookies() { |
| 28 | if c.Name == "flash" && c.Value != "" { |
| 29 | if v, err := url.QueryUnescape(c.Value); err == nil { |
| 30 | if k, m, found := strings.Cut(v, "|"); found { |
| 31 | return k, m, true |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | return "", "", false |
| 37 | } |
| 38 | |
| 39 | var presetIDRe = regexp.MustCompile(`/settings/presets/(\d+)"`) |
| 40 | |
| 41 | // newestPresetID returns the highest preset id referenced on the settings page, |
| 42 | // which (ids autoincrement) is the most recently created preset. |
| 43 | func newestPresetID(t *testing.T, router http.Handler) string { |
| 44 | t.Helper() |
| 45 | body := getWith(router, "/settings", nil).Body.String() |
| 46 | max := int64(-1) |
| 47 | for _, m := range presetIDRe.FindAllStringSubmatch(body, -1) { |
| 48 | if id, err := strconv.ParseInt(m[1], 10, 64); err == nil && id > max { |
| 49 | max = id |
| 50 | } |
| 51 | } |
| 52 | if max < 0 { |
| 53 | t.Fatal("no preset id found on settings page") |
| 54 | } |
| 55 | return strconv.FormatInt(max, 10) |
| 56 | } |
| 57 | |
| 58 | func validPresetForm(name string) url.Values { |
| 59 | return url.Values{ |
| 60 | "name": {name}, |
| 61 | "format_mode": {"default"}, |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // A created preset shows a success flash exactly once, then the message is gone. |
| 66 | func TestPresetCreateFlashShownOnce(t *testing.T) { |
| 67 | srv, _, cleanup := setupTestServer(t) |
| 68 | defer cleanup() |
| 69 | router := srv.Router() |
| 70 | |
| 71 | w := postForm(router, "/settings/presets", validPresetForm("Archival")) |
| 72 | if w.Code != http.StatusSeeOther { |
| 73 | t.Fatalf("create preset: expected 303, got %d", w.Code) |
| 74 | } |
| 75 | kind, msg, ok := flash(w) |
| 76 | if !ok || kind != "success" || !strings.Contains(msg, "created") { |
| 77 | t.Fatalf("expected success flash, got kind=%q msg=%q ok=%v", kind, msg, ok) |
| 78 | } |
| 79 | |
| 80 | // The redirected page carries the flash cookie and shows the message. |
| 81 | page := getWith(router, "/settings", w.Result().Cookies()) |
| 82 | if !strings.Contains(page.Body.String(), msg) { |
| 83 | t.Error("settings page did not render the flash message") |
| 84 | } |
| 85 | if !strings.Contains(page.Body.String(), "Archival") { |
| 86 | t.Error("created preset not shown on settings page") |
| 87 | } |
| 88 | // ...and it clears the cookie, so a subsequent load is clean. |
| 89 | if _, _, stillSet := flash(page); stillSet { |
| 90 | t.Error("flash cookie should have been cleared after being shown") |
| 91 | } |
| 92 | clean := getWith(router, "/settings", page.Result().Cookies()) |
| 93 | if strings.Contains(clean.Body.String(), msg) { |
| 94 | t.Error("flash message should not persist across loads") |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestPresetUpdateAndDelete(t *testing.T) { |
| 99 | srv, _, cleanup := setupTestServer(t) |
| 100 | defer cleanup() |
| 101 | router := srv.Router() |
| 102 | |
| 103 | if w := postForm(router, "/settings/presets", validPresetForm("Original")); w.Code != http.StatusSeeOther { |
| 104 | t.Fatalf("create: got %d", w.Code) |
| 105 | } |
| 106 | id := newestPresetID(t, router) |
| 107 | |
| 108 | // Update renames it. |
| 109 | if w := postForm(router, "/settings/presets/"+id, validPresetForm("Renamed")); w.Code != http.StatusSeeOther { |
| 110 | t.Fatalf("update: got %d", w.Code) |
| 111 | } |
| 112 | if body := getWith(router, "/settings", nil).Body.String(); !strings.Contains(body, "Renamed") { |
| 113 | t.Error("updated preset name not reflected") |
| 114 | } |
| 115 | |
| 116 | // Delete removes it. |
| 117 | w := postForm(router, "/settings/presets/"+id+"/delete", nil) |
| 118 | if w.Code != http.StatusSeeOther { |
| 119 | t.Fatalf("delete: got %d", w.Code) |
| 120 | } |
| 121 | if k, _, ok := flash(w); !ok || k != "success" { |
| 122 | t.Error("expected success flash on delete") |
| 123 | } |
| 124 | if body := getWith(router, "/settings", nil).Body.String(); strings.Contains(body, "Renamed") { |
| 125 | t.Error("deleted preset still present") |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // The create and edit preset forms share one partial; both must render. |
| 130 | func TestSettingsRendersSharedPresetForm(t *testing.T) { |
| 131 | srv, _, cleanup := setupTestServer(t) |
| 132 | defer cleanup() |
| 133 | router := srv.Router() |
| 134 | |
| 135 | if w := postForm(router, "/settings/presets", validPresetForm("Editable")); w.Code != http.StatusSeeOther { |
| 136 | t.Fatalf("create: got %d", w.Code) |
| 137 | } |
| 138 | body := getWith(router, "/settings", nil).Body.String() |
| 139 | for _, want := range []string{"Create Preset", "Update Preset", `name="format_mode"`} { |
| 140 | if !strings.Contains(body, want) { |
| 141 | t.Errorf("settings page missing %q", want) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestCreateDownloadRequiresURL(t *testing.T) { |
| 147 | srv, _, cleanup := setupTestServer(t) |
| 148 | defer cleanup() |
| 149 | router := srv.Router() |
| 150 | |
| 151 | w := postForm(router, "/download", url.Values{"url": {""}}) |
| 152 | if w.Code != http.StatusSeeOther { |
| 153 | t.Fatalf("expected 303 redirect, got %d", w.Code) |
| 154 | } |
| 155 | if loc := w.Header().Get("Location"); loc != "/download" { |
| 156 | t.Errorf("expected redirect to /download, got %q", loc) |
| 157 | } |
| 158 | if k, _, ok := flash(w); !ok || k != "error" { |
| 159 | t.Error("expected an error flash for the missing URL") |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestCreateDownloadQueues(t *testing.T) { |
| 164 | srv, _, cleanup := setupTestServer(t) |
| 165 | defer cleanup() |
| 166 | router := srv.Router() |
| 167 | |
| 168 | w := postForm(router, "/download", url.Values{"url": {"https://example.com/clip"}}) |
| 169 | if w.Code != http.StatusSeeOther { |
| 170 | t.Fatalf("expected 303, got %d", w.Code) |
| 171 | } |
| 172 | if loc := w.Header().Get("Location"); loc != "/queue" { |
| 173 | t.Errorf("expected redirect to /queue, got %q", loc) |
| 174 | } |
| 175 | if k, _, ok := flash(w); !ok || k != "success" { |
| 176 | t.Error("expected a success flash") |
| 177 | } |
| 178 | if body := getWith(router, "/queue", nil).Body.String(); !strings.Contains(body, "example.com/clip") { |
| 179 | t.Error("queued download not shown on the queue page") |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestUpdateSettingsFlash(t *testing.T) { |
| 184 | srv, _, cleanup := setupTestServer(t) |
| 185 | defer cleanup() |
| 186 | router := srv.Router() |
| 187 | |
| 188 | w := postForm(router, "/settings", url.Values{ |
| 189 | "refresh_interval": {"7"}, |
| 190 | "auto_refresh_library": {"1"}, |
| 191 | "auto_refresh_downloads": {"1"}, |
| 192 | }) |
| 193 | if w.Code != http.StatusSeeOther { |
| 194 | t.Fatalf("expected 303, got %d", w.Code) |
| 195 | } |
| 196 | k, msg, ok := flash(w) |
| 197 | if !ok || k != "success" || !strings.Contains(msg, "saved") { |
| 198 | t.Errorf("expected success flash, got kind=%q msg=%q ok=%v", k, msg, ok) |
| 199 | } |
| 200 | // The saved interval is reflected in the form. |
| 201 | if body := getWith(router, "/settings", nil).Body.String(); !strings.Contains(body, `value="7"`) { |
| 202 | t.Error("saved refresh interval not reflected in settings form") |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // A subscription's edit form is rendered by the shared partial with its values |
| 207 | // populated. |
| 208 | func TestSubscriptionEditFormRenders(t *testing.T) { |
| 209 | srv, _, cleanup := setupTestServer(t) |
| 210 | defer cleanup() |
| 211 | router := srv.Router() |
| 212 | |
| 213 | if w := postForm(router, "/subscriptions", url.Values{ |
| 214 | "name": {"News"}, |
| 215 | "url": {"https://example.com/news"}, |
| 216 | "output_dir": {"subscriptions/news"}, |
| 217 | "refresh_mode": {"skip"}, |
| 218 | "schedule_kind": {"weekly"}, |
| 219 | }); w.Code != http.StatusSeeOther { |
| 220 | t.Fatalf("create sub: got %d", w.Code) |
| 221 | } |
| 222 | |
| 223 | body := getWith(router, "/subscriptions", nil).Body.String() |
| 224 | for _, want := range []string{ |
| 225 | "Update Subscription", // edit form present |
| 226 | `value="https://example.com/news"`, // URL populated in edit form |
| 227 | `value="subscriptions/news"`, // folder populated |
| 228 | } { |
| 229 | if !strings.Contains(body, want) { |
| 230 | t.Errorf("subscriptions page missing %q", want) |
| 231 | } |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // findCookie returns the value of a cookie set on the response. |
| 236 | func findCookie(w *httptest.ResponseRecorder, name string) (string, bool) { |
| 237 | for _, c := range w.Result().Cookies() { |
| 238 | if c.Name == name { |
| 239 | return c.Value, true |
| 240 | } |
| 241 | } |
| 242 | return "", false |
| 243 | } |
| 244 | |
| 245 | // An explicit ?sort= is remembered, and a later request without one restores it. |
| 246 | // The pages auto-refresh, so a sort order that did not persist would be reset |
| 247 | // every few seconds. |
| 248 | func TestSortOrderPersistsInCookie(t *testing.T) { |
| 249 | srv, _, cleanup := setupTestServer(t) |
| 250 | defer cleanup() |
| 251 | router := srv.Router() |
| 252 | |
| 253 | cases := []struct { |
| 254 | name, path, cookie, sort, selected string |
| 255 | }{ |
| 256 | {"queue", "/queue", "queue_sort", "status", `<option value="status" selected>`}, |
| 257 | {"library", "/library", "library_sort", "title", `<option value="title" selected>`}, |
| 258 | } |
| 259 | |
| 260 | for _, tc := range cases { |
| 261 | t.Run(tc.name, func(t *testing.T) { |
| 262 | w := getWith(router, tc.path+"?sort="+tc.sort, nil) |
| 263 | if w.Code != http.StatusOK { |
| 264 | t.Fatalf("GET %s: %d", tc.path, w.Code) |
| 265 | } |
| 266 | got, ok := findCookie(w, tc.cookie) |
| 267 | if !ok || got != tc.sort { |
| 268 | t.Fatalf("cookie %s = %q (set=%v), want %q", tc.cookie, got, ok, tc.sort) |
| 269 | } |
| 270 | |
| 271 | // No ?sort= this time: the remembered order must still apply. |
| 272 | page := getWith(router, tc.path, w.Result().Cookies()) |
| 273 | if !strings.Contains(page.Body.String(), tc.selected) { |
| 274 | t.Errorf("remembered sort not applied: %s not found in the rendered page", tc.selected) |
| 275 | } |
| 276 | // Nothing to remember, so the page must not rewrite the cookie. |
| 277 | if _, rewritten := findCookie(page, tc.cookie); rewritten { |
| 278 | t.Errorf("cookie %s was rewritten on a request without ?sort=", tc.cookie) |
| 279 | } |
| 280 | }) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // The cancelled status has its own queue filter, so a stopped download stays |
| 285 | // reachable instead of disappearing from every view. |
| 286 | func TestQueueFilterOffersCancelled(t *testing.T) { |
| 287 | srv, _, cleanup := setupTestServer(t) |
| 288 | defer cleanup() |
| 289 | |
| 290 | body := getWith(srv.Router(), "/queue", nil).Body.String() |
| 291 | if !strings.Contains(body, `<option value="cancelled"`) { |
| 292 | t.Error("queue filter has no cancelled option") |
| 293 | } |
| 294 | |
| 295 | w := getWith(srv.Router(), "/queue?status=cancelled", nil) |
| 296 | if w.Code != http.StatusOK { |
| 297 | t.Errorf("filtering by cancelled: %d, want 200", w.Code) |
| 298 | } |
| 299 | if !strings.Contains(w.Body.String(), `<option value="cancelled" selected>`) { |
| 300 | t.Error("cancelled filter not marked selected after being applied") |
| 301 | } |
| 302 | } |
| 303 |