package server import ( "database/sql" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" ) // insertDownload writes a queue row in a chosen status, which is the only way to // reach the finished states an HTTP test can't produce on its own. func insertDownload(t *testing.T, db *sql.DB, status, errMsg string) int64 { t.Helper() res, err := db.Exec( `INSERT INTO downloads (url, status, error_message, format_override, custom_flags) VALUES (?, ?, ?, '', '')`, "https://example.com/"+status, status, sql.NullString{String: errMsg, Valid: errMsg != ""}, ) if err != nil { t.Fatalf("insert %s download: %v", status, err) } id, err := res.LastInsertId() if err != nil { t.Fatalf("insert id: %v", err) } return id } func downloadStatus(t *testing.T, db *sql.DB, id int64) (string, sql.NullString) { t.Helper() var status string var errMsg sql.NullString err := db.QueryRow(`SELECT status, error_message FROM downloads WHERE id = ?`, id).Scan(&status, &errMsg) if err != nil { t.Fatalf("read download %d: %v", id, err) } return status, errMsg } func TestRetryRequeuesFailedDownload(t *testing.T) { srv, _, db, cleanup := setupTestServerDB(t) defer cleanup() router := srv.Router() failed := insertDownload(t, db, "error", "boom") w := postForm(router, "/queue/"+strconv.FormatInt(failed, 10)+"/retry", nil) if w.Code != http.StatusSeeOther { t.Fatalf("retry = %d, want 303", w.Code) } if k, _, ok := flash(w); !ok || k != "success" { t.Error("retry did not flash success") } // A worker may claim the re-queued row on its next tick, so accept either // state; what matters is that it left the terminal error state. status, errMsg := downloadStatus(t, db, failed) if status != "queued" && status != "downloading" { t.Errorf("status after retry = %q, want queued", status) } if errMsg.Valid { t.Errorf("error message %q survived the retry", errMsg.String) } // A completed download is not retryable: re-running it would duplicate work. done := insertDownload(t, db, "completed", "") w = postForm(router, "/queue/"+strconv.FormatInt(done, 10)+"/retry", nil) if k, _, ok := flash(w); !ok || k != "error" { t.Error("retrying a completed download did not flash an error") } if status, _ := downloadStatus(t, db, done); status != "completed" { t.Errorf("completed download moved to %q", status) } } func TestCancelKeepsTheRow(t *testing.T) { srv, _, db, cleanup := setupTestServerDB(t) defer cleanup() id := insertDownload(t, db, "queued", "") path := "/queue/" + strconv.FormatInt(id, 10) detail := getWith(srv.Router(), path, nil).Body.String() if !strings.Contains(detail, path+"/cancel") { t.Error("detail page of a queued download offers no cancel button") } w := postForm(srv.Router(), path+"/cancel", nil) if w.Code != http.StatusSeeOther { t.Fatalf("cancel = %d, want 303", w.Code) } if status, _ := downloadStatus(t, db, id); status != "cancelled" { t.Errorf("status after cancel = %q, want cancelled", status) } // The row survives the cancel, and now offers a retry instead. detail = getWith(srv.Router(), path, nil).Body.String() if !strings.Contains(detail, path+"/retry") { t.Error("cancelled download offers no retry button") } } func TestClearByStatus(t *testing.T) { srv, _, db, cleanup := setupTestServerDB(t) defer cleanup() router := srv.Router() done := insertDownload(t, db, "completed", "") failed := insertDownload(t, db, "error", "boom") postForm(router, "/queue/clear", url.Values{"status": {"completed"}}) if err := db.QueryRow(`SELECT status FROM downloads WHERE id = ?`, done).Scan(new(string)); err != sql.ErrNoRows { t.Errorf("completed download survived the clear: %v", err) } if status, _ := downloadStatus(t, db, failed); status != "error" { t.Errorf("failed download was cleared too, status = %q", status) } postForm(router, "/queue/clear", url.Values{"status": {"error"}}) if err := db.QueryRow(`SELECT status FROM downloads WHERE id = ?`, failed).Scan(new(string)); err != sql.ErrNoRows { t.Errorf("failed download survived the clear: %v", err) } // Running downloads are not clearable, so a hand-made form can't kill them. running := insertDownload(t, db, "downloading", "") w := postForm(router, "/queue/clear", url.Values{"status": {"downloading"}}) if w.Code != http.StatusBadRequest { t.Errorf("clearing downloading = %d, want 400", w.Code) } if status, _ := downloadStatus(t, db, running); status != "downloading" { t.Errorf("running download was cleared, status = %q", status) } } func TestQueuePagination(t *testing.T) { srv, _, db, cleanup := setupTestServerDB(t) defer cleanup() router := srv.Router() // Completed rows, so no worker picks them up mid-test. for i := 0; i < 55; i++ { insertDownload(t, db, "completed", "") } first := getWith(router, "/queue?status=completed", nil).Body.String() if got := strings.Count(first, `class="queue-item`); got != 50 { t.Errorf("page 1 rendered %d items, want 50", got) } if !strings.Contains(first, "page=2") { t.Error("page 1 has no next link") } second := getWith(router, "/queue?status=completed&page=2", nil).Body.String() if got := strings.Count(second, `class="queue-item`); got != 5 { t.Errorf("page 2 rendered %d items, want 5", got) } if !strings.Contains(second, "page=1") { t.Error("page 2 has no previous link") } // The link has to carry the filter, otherwise paging resets it. if !strings.Contains(second, "status=completed&page=1") { t.Error("previous link dropped the status filter") } if strings.Contains(second, "page=3") { t.Error("page 2 offers a next page that does not exist") } } func TestLibraryPagination(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() for i := 0; i < 52; i++ { name := "item-" + strconv.Itoa(i) createItem(t, cfg.LibraryDir, name, name, map[string]string{"video.mp4": "dummy"}) } first := getWith(srv.Router(), "/library", nil).Body.String() if got := strings.Count(first, `class="media-item`); got != 50 { t.Errorf("page 1 rendered %d items, want 50", got) } second := getWith(srv.Router(), "/library?page=2", nil).Body.String() if got := strings.Count(second, `class="media-item`); got != 2 { t.Errorf("page 2 rendered %d items, want 2", got) } // A page past the end is empty, not a crash or a wrapped-around first page. last := getWith(srv.Router(), "/library?page=9", nil) if last.Code != http.StatusOK { t.Fatalf("page 9 = %d, want 200", last.Code) } if got := strings.Count(last.Body.String(), `class="media-item`); got != 0 { t.Errorf("page 9 rendered %d items, want 0", got) } } // postFrom is postForm with a Referer, which is how the queue actions know // which page to send the user back to. func postFrom(router http.Handler, path, referer string, form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Referer", "http://example.test"+referer) w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } func TestQueueActionsKeepFilterAndPage(t *testing.T) { srv, _, db, cleanup := setupTestServerDB(t) defer cleanup() router := srv.Router() from := "/queue?status=error&page=2" id := insertDownload(t, db, "error", "boom") w := postFrom(router, "/queue/"+strconv.FormatInt(id, 10)+"/retry", from, nil) if got := w.Header().Get("Location"); got != from { t.Errorf("retry redirected to %q, wanted back to %q", got, from) } // An off-site Referer must not be honoured as a redirect target. req := httptest.NewRequest("POST", "/queue/clear", nil) req.Header.Set("Referer", "https://evil.example/queue?x=1") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if got := rec.Header().Get("Location"); got != "/queue?x=1" { t.Errorf("clear redirected to %q, want the local path only", got) } } func TestPaginationRejectsOverflowingPage(t *testing.T) { srv, cfg, db, cleanup := setupTestServerDB(t) defer cleanup() router := srv.Router() createItem(t, cfg.LibraryDir, "item", "Item", map[string]string{"video.mp4": "dummy"}) insertDownload(t, db, "completed", "") // A page number large enough to overflow page*pageSize used to slice the // library listing with a negative index, which panicked into a 500. for _, page := range []string{"9223372036854775807", "184467440737095518", "99999999999999999999", "-1", "abc"} { for _, path := range []string{"/library?page=", "/queue?page="} { if got := getWith(router, path+page, nil); got.Code != http.StatusOK { t.Errorf("GET %s%s = %d, want 200", path, page, got.Code) } } } }