queue_test.go
| 1 | package server |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // insertDownload writes a queue row in a chosen status, which is the only way to |
| 14 | // reach the finished states an HTTP test can't produce on its own. |
| 15 | func insertDownload(t *testing.T, db *sql.DB, status, errMsg string) int64 { |
| 16 | t.Helper() |
| 17 | res, err := db.Exec( |
| 18 | `INSERT INTO downloads (url, status, error_message, format_override, custom_flags) |
| 19 | VALUES (?, ?, ?, '', '')`, |
| 20 | "https://example.com/"+status, status, sql.NullString{String: errMsg, Valid: errMsg != ""}, |
| 21 | ) |
| 22 | if err != nil { |
| 23 | t.Fatalf("insert %s download: %v", status, err) |
| 24 | } |
| 25 | id, err := res.LastInsertId() |
| 26 | if err != nil { |
| 27 | t.Fatalf("insert id: %v", err) |
| 28 | } |
| 29 | return id |
| 30 | } |
| 31 | |
| 32 | func downloadStatus(t *testing.T, db *sql.DB, id int64) (string, sql.NullString) { |
| 33 | t.Helper() |
| 34 | var status string |
| 35 | var errMsg sql.NullString |
| 36 | err := db.QueryRow(`SELECT status, error_message FROM downloads WHERE id = ?`, id).Scan(&status, &errMsg) |
| 37 | if err != nil { |
| 38 | t.Fatalf("read download %d: %v", id, err) |
| 39 | } |
| 40 | return status, errMsg |
| 41 | } |
| 42 | |
| 43 | func TestRetryRequeuesFailedDownload(t *testing.T) { |
| 44 | srv, _, db, cleanup := setupTestServerDB(t) |
| 45 | defer cleanup() |
| 46 | router := srv.Router() |
| 47 | |
| 48 | failed := insertDownload(t, db, "error", "boom") |
| 49 | w := postForm(router, "/queue/"+strconv.FormatInt(failed, 10)+"/retry", nil) |
| 50 | if w.Code != http.StatusSeeOther { |
| 51 | t.Fatalf("retry = %d, want 303", w.Code) |
| 52 | } |
| 53 | if k, _, ok := flash(w); !ok || k != "success" { |
| 54 | t.Error("retry did not flash success") |
| 55 | } |
| 56 | |
| 57 | // A worker may claim the re-queued row on its next tick, so accept either |
| 58 | // state; what matters is that it left the terminal error state. |
| 59 | status, errMsg := downloadStatus(t, db, failed) |
| 60 | if status != "queued" && status != "downloading" { |
| 61 | t.Errorf("status after retry = %q, want queued", status) |
| 62 | } |
| 63 | if errMsg.Valid { |
| 64 | t.Errorf("error message %q survived the retry", errMsg.String) |
| 65 | } |
| 66 | |
| 67 | // A completed download is not retryable: re-running it would duplicate work. |
| 68 | done := insertDownload(t, db, "completed", "") |
| 69 | w = postForm(router, "/queue/"+strconv.FormatInt(done, 10)+"/retry", nil) |
| 70 | if k, _, ok := flash(w); !ok || k != "error" { |
| 71 | t.Error("retrying a completed download did not flash an error") |
| 72 | } |
| 73 | if status, _ := downloadStatus(t, db, done); status != "completed" { |
| 74 | t.Errorf("completed download moved to %q", status) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestCancelKeepsTheRow(t *testing.T) { |
| 79 | srv, _, db, cleanup := setupTestServerDB(t) |
| 80 | defer cleanup() |
| 81 | |
| 82 | id := insertDownload(t, db, "queued", "") |
| 83 | path := "/queue/" + strconv.FormatInt(id, 10) |
| 84 | |
| 85 | detail := getWith(srv.Router(), path, nil).Body.String() |
| 86 | if !strings.Contains(detail, path+"/cancel") { |
| 87 | t.Error("detail page of a queued download offers no cancel button") |
| 88 | } |
| 89 | |
| 90 | w := postForm(srv.Router(), path+"/cancel", nil) |
| 91 | if w.Code != http.StatusSeeOther { |
| 92 | t.Fatalf("cancel = %d, want 303", w.Code) |
| 93 | } |
| 94 | if status, _ := downloadStatus(t, db, id); status != "cancelled" { |
| 95 | t.Errorf("status after cancel = %q, want cancelled", status) |
| 96 | } |
| 97 | |
| 98 | // The row survives the cancel, and now offers a retry instead. |
| 99 | detail = getWith(srv.Router(), path, nil).Body.String() |
| 100 | if !strings.Contains(detail, path+"/retry") { |
| 101 | t.Error("cancelled download offers no retry button") |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestClearByStatus(t *testing.T) { |
| 106 | srv, _, db, cleanup := setupTestServerDB(t) |
| 107 | defer cleanup() |
| 108 | router := srv.Router() |
| 109 | |
| 110 | done := insertDownload(t, db, "completed", "") |
| 111 | failed := insertDownload(t, db, "error", "boom") |
| 112 | |
| 113 | postForm(router, "/queue/clear", url.Values{"status": {"completed"}}) |
| 114 | if err := db.QueryRow(`SELECT status FROM downloads WHERE id = ?`, done).Scan(new(string)); err != sql.ErrNoRows { |
| 115 | t.Errorf("completed download survived the clear: %v", err) |
| 116 | } |
| 117 | if status, _ := downloadStatus(t, db, failed); status != "error" { |
| 118 | t.Errorf("failed download was cleared too, status = %q", status) |
| 119 | } |
| 120 | |
| 121 | postForm(router, "/queue/clear", url.Values{"status": {"error"}}) |
| 122 | if err := db.QueryRow(`SELECT status FROM downloads WHERE id = ?`, failed).Scan(new(string)); err != sql.ErrNoRows { |
| 123 | t.Errorf("failed download survived the clear: %v", err) |
| 124 | } |
| 125 | |
| 126 | // Running downloads are not clearable, so a hand-made form can't kill them. |
| 127 | running := insertDownload(t, db, "downloading", "") |
| 128 | w := postForm(router, "/queue/clear", url.Values{"status": {"downloading"}}) |
| 129 | if w.Code != http.StatusBadRequest { |
| 130 | t.Errorf("clearing downloading = %d, want 400", w.Code) |
| 131 | } |
| 132 | if status, _ := downloadStatus(t, db, running); status != "downloading" { |
| 133 | t.Errorf("running download was cleared, status = %q", status) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestQueuePagination(t *testing.T) { |
| 138 | srv, _, db, cleanup := setupTestServerDB(t) |
| 139 | defer cleanup() |
| 140 | router := srv.Router() |
| 141 | |
| 142 | // Completed rows, so no worker picks them up mid-test. |
| 143 | for i := 0; i < 55; i++ { |
| 144 | insertDownload(t, db, "completed", "") |
| 145 | } |
| 146 | |
| 147 | first := getWith(router, "/queue?status=completed", nil).Body.String() |
| 148 | if got := strings.Count(first, `class="queue-item`); got != 50 { |
| 149 | t.Errorf("page 1 rendered %d items, want 50", got) |
| 150 | } |
| 151 | if !strings.Contains(first, "page=2") { |
| 152 | t.Error("page 1 has no next link") |
| 153 | } |
| 154 | |
| 155 | second := getWith(router, "/queue?status=completed&page=2", nil).Body.String() |
| 156 | if got := strings.Count(second, `class="queue-item`); got != 5 { |
| 157 | t.Errorf("page 2 rendered %d items, want 5", got) |
| 158 | } |
| 159 | if !strings.Contains(second, "page=1") { |
| 160 | t.Error("page 2 has no previous link") |
| 161 | } |
| 162 | // The link has to carry the filter, otherwise paging resets it. |
| 163 | if !strings.Contains(second, "status=completed&page=1") { |
| 164 | t.Error("previous link dropped the status filter") |
| 165 | } |
| 166 | if strings.Contains(second, "page=3") { |
| 167 | t.Error("page 2 offers a next page that does not exist") |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestLibraryPagination(t *testing.T) { |
| 172 | srv, cfg, cleanup := setupTestServer(t) |
| 173 | defer cleanup() |
| 174 | |
| 175 | for i := 0; i < 52; i++ { |
| 176 | name := "item-" + strconv.Itoa(i) |
| 177 | createItem(t, cfg.LibraryDir, name, name, map[string]string{"video.mp4": "dummy"}) |
| 178 | } |
| 179 | |
| 180 | first := getWith(srv.Router(), "/library", nil).Body.String() |
| 181 | if got := strings.Count(first, `class="media-item`); got != 50 { |
| 182 | t.Errorf("page 1 rendered %d items, want 50", got) |
| 183 | } |
| 184 | |
| 185 | second := getWith(srv.Router(), "/library?page=2", nil).Body.String() |
| 186 | if got := strings.Count(second, `class="media-item`); got != 2 { |
| 187 | t.Errorf("page 2 rendered %d items, want 2", got) |
| 188 | } |
| 189 | |
| 190 | // A page past the end is empty, not a crash or a wrapped-around first page. |
| 191 | last := getWith(srv.Router(), "/library?page=9", nil) |
| 192 | if last.Code != http.StatusOK { |
| 193 | t.Fatalf("page 9 = %d, want 200", last.Code) |
| 194 | } |
| 195 | if got := strings.Count(last.Body.String(), `class="media-item`); got != 0 { |
| 196 | t.Errorf("page 9 rendered %d items, want 0", got) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // postFrom is postForm with a Referer, which is how the queue actions know |
| 201 | // which page to send the user back to. |
| 202 | func postFrom(router http.Handler, path, referer string, form url.Values) *httptest.ResponseRecorder { |
| 203 | req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) |
| 204 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 205 | req.Header.Set("Referer", "http://example.test"+referer) |
| 206 | w := httptest.NewRecorder() |
| 207 | router.ServeHTTP(w, req) |
| 208 | return w |
| 209 | } |
| 210 | |
| 211 | func TestQueueActionsKeepFilterAndPage(t *testing.T) { |
| 212 | srv, _, db, cleanup := setupTestServerDB(t) |
| 213 | defer cleanup() |
| 214 | router := srv.Router() |
| 215 | |
| 216 | from := "/queue?status=error&page=2" |
| 217 | id := insertDownload(t, db, "error", "boom") |
| 218 | w := postFrom(router, "/queue/"+strconv.FormatInt(id, 10)+"/retry", from, nil) |
| 219 | if got := w.Header().Get("Location"); got != from { |
| 220 | t.Errorf("retry redirected to %q, wanted back to %q", got, from) |
| 221 | } |
| 222 | |
| 223 | // An off-site Referer must not be honoured as a redirect target. |
| 224 | req := httptest.NewRequest("POST", "/queue/clear", nil) |
| 225 | req.Header.Set("Referer", "https://evil.example/queue?x=1") |
| 226 | rec := httptest.NewRecorder() |
| 227 | router.ServeHTTP(rec, req) |
| 228 | if got := rec.Header().Get("Location"); got != "/queue?x=1" { |
| 229 | t.Errorf("clear redirected to %q, want the local path only", got) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestPaginationRejectsOverflowingPage(t *testing.T) { |
| 234 | srv, cfg, db, cleanup := setupTestServerDB(t) |
| 235 | defer cleanup() |
| 236 | router := srv.Router() |
| 237 | |
| 238 | createItem(t, cfg.LibraryDir, "item", "Item", map[string]string{"video.mp4": "dummy"}) |
| 239 | insertDownload(t, db, "completed", "") |
| 240 | |
| 241 | // A page number large enough to overflow page*pageSize would slice the library |
| 242 | // listing with a negative index and panic into a 500. |
| 243 | for _, page := range []string{"9223372036854775807", "184467440737095518", "99999999999999999999", "-1", "abc"} { |
| 244 | for _, path := range []string{"/library?page=", "/queue?page="} { |
| 245 | if got := getWith(router, path+page, nil); got.Code != http.StatusOK { |
| 246 | t.Errorf("GET %s%s = %d, want 200", path, page, got.Code) |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 |