Something went wrong. Try again.
Self-hosted web interface and downloader for Qobuz.
downloader self-hosted music qobuz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433package queue_test
import ( "context" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time"
"gobuz/internal/auth" "gobuz/internal/models" "gobuz/internal/testutil" "gobuz/internal/web/pages" "gobuz/internal/web/queue")
func newTestQueueController(mgr *queue.Manager, broker *queue.SSEBroker, p *pages.Pages, cat queue.Catalog) *queue.Controller { return queue.NewController(mgr, broker, p, cat, slog.New(slog.DiscardHandler))}
func newTestQueueContext() (*queue.Controller, *queue.Manager, *models.UserSession) { broker := queue.NewSSEBroker() p := testutil.NewTestPages() mgr := newTestManager(broker, p) controller := newTestQueueController(mgr, broker, p, &testutil.FakeCatalog{}) sess := &models.UserSession{UserID: 1, Username: "alice"} return controller, mgr, sess}
func TestQueue_AddHandler_AttributesUserAndReturnsStatus(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test_queue_user.db")
authDB, err := auth.OpenStore(dbPath) if err != nil { t.Fatalf("OpenStore failed: %v", err) } defer authDB.Close()
user, err := authDB.CreateUser(context.Background(), "alice", "password1234") if err != nil { t.Fatalf("CreateUser failed: %v", err) }
sess, err := authDB.CreateSession(context.Background(), user.ID, user.Username, 1*time.Hour) if err != nil { t.Fatalf("CreateSession failed: %v", err) }
broker := queue.NewSSEBroker() p := testutil.NewTestPages() mgr := newTestManager(broker, p) controller := newTestQueueController(mgr, broker, p, &testutil.FakeCatalog{})
form := url.Values{ "id": {"alb_test_123"}, "title": {"Test Album"}, "artist": {"Test Artist"}, "type": {"album"}, }
req := httptest.NewRequest("POST", "/queue/add", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req = req.WithContext(models.WithUser(req.Context(), sess))
rec := httptest.NewRecorder() controller.AddHandler(rec, req)
if rec.Code != http.StatusOK { t.Fatalf("expected 200 OK for /queue/add, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), `id="queue-content"`) { t.Errorf("expected response to contain queue-content OOB swap, got %s", rec.Body.String()) }
vm := mgr.GetViewModel(user.ID) if len(vm.Items) != 1 { t.Fatalf("expected 1 item in queue, got %d", len(vm.Items)) }
if vm.Items[0].RequestedBy != "alice" { t.Errorf("expected QueueItem.RequestedBy = 'alice', got '%s'", vm.Items[0].RequestedBy) }}
func TestQueue_AddHandler_CardButton_ReturnsDequeuedButton(t *testing.T) { controller, _, sess := newTestQueueContext()
formAddCard := url.Values{ "id": {"alb_test_123"}, "title": {"Test Album"}, "artist": {"Test Artist"}, "type": {"album"}, } reqAddCard := httptest.NewRequest("POST", "/queue/add", strings.NewReader(formAddCard.Encode())) reqAddCard.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqAddCard = reqAddCard.WithContext(models.WithUser(reqAddCard.Context(), sess)) recAddCard := httptest.NewRecorder() controller.AddHandler(recAddCard, reqAddCard)
if recAddCard.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d", recAddCard.Code) }
body := recAddCard.Body.String() // Must contain card button with dequeue action and minus/checkmark indicating queued state if !strings.Contains(body, `hx-post="/queue/remove?`) { t.Errorf("expected response to contain /queue/remove action, got: %s", body) } if !strings.Contains(body, "btn-queued") { t.Errorf("expected card button to have btn-queued class, got: %s", body) }}
func TestQueue_RemoveHandler_CardButton_ReturnsAddButton(t *testing.T) { controller, mgr, sess := newTestQueueContext()
mgr.StageRelease(sess.UserID, models.QueueItem{ Release: models.Release{ ID: "alb_test_123", Title: "Test Album", ArtistName: "Test Artist", }, })
formRemoveCard := url.Values{ "id": {"alb_test_123"}, "title": {"Test Album"}, "artist": {"Test Artist"}, "type": {"album"}, } reqRemoveCard := httptest.NewRequest("POST", "/queue/remove", strings.NewReader(formRemoveCard.Encode())) reqRemoveCard.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqRemoveCard = reqRemoveCard.WithContext(models.WithUser(reqRemoveCard.Context(), sess)) recRemoveCard := httptest.NewRecorder() controller.RemoveHandler(recRemoveCard, reqRemoveCard)
if recRemoveCard.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d", recRemoveCard.Code) }
body := recRemoveCard.Body.String() // Must contain reset card button with add action if !strings.Contains(body, `hx-post="/queue/add?`) { t.Errorf("expected response to contain /queue/add action, got: %s", body) } if strings.Contains(body, "btn-queued") { t.Errorf("expected card button to not have btn-queued class, got: %s", body) }}
func TestQueue_AddHandler_DetailButton_ReturnsDequeuedButton(t *testing.T) { controller, _, sess := newTestQueueContext()
formAddDetail := url.Values{ "id": {"alb_detail_456"}, "title": {"Detail Album"}, "artist": {"Detail Artist"}, "type": {"album"}, "btn": {"detail"}, } reqAddDetail := httptest.NewRequest("POST", "/queue/add", strings.NewReader(formAddDetail.Encode())) reqAddDetail.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqAddDetail = reqAddDetail.WithContext(models.WithUser(reqAddDetail.Context(), sess)) recAddDetail := httptest.NewRecorder() controller.AddHandler(recAddDetail, reqAddDetail)
if recAddDetail.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d", recAddDetail.Code) }
body := recAddDetail.Body.String() // Must contain detail button styled as queued ("dequeue" / "Remove from Queue") if !strings.Contains(body, "dequeue") && !strings.Contains(body, "Remove from Queue") { t.Errorf("expected detail button to show dequeue text, got: %s", body) }}
func TestQueue_AddHandler_Discography_EnqueuesAllArtistReleases(t *testing.T) { controller, _, sess := newTestQueueContext()
formAddDiscog := url.Values{ "id": {"art_789"}, "type": {"artist"}, } reqAddDiscog := httptest.NewRequest("POST", "/queue/add", strings.NewReader(formAddDiscog.Encode())) reqAddDiscog.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqAddDiscog = reqAddDiscog.WithContext(models.WithUser(reqAddDiscog.Context(), sess)) recAddDiscog := httptest.NewRecorder() controller.AddHandler(recAddDiscog, reqAddDiscog)
if recAddDiscog.Code != http.StatusOK { t.Fatalf("expected 200 OK, got %d", recAddDiscog.Code) }
body := recAddDiscog.Body.String() // Must contain discography button styled as queued if !strings.Contains(body, "btn-queued") { t.Errorf("expected discography button to have btn-queued class, got: %s", body) }}
func TestQueue_ClearHandler_DeletesAllUserItems(t *testing.T) { broker := queue.NewSSEBroker() p := testutil.NewTestPages() mgr := newTestManager(broker, p) controller := newTestQueueController(mgr, broker, p, &testutil.FakeCatalog{})
sess := &models.UserSession{UserID: 99, Username: "carol"}
mgr.StageRelease(99, models.QueueItem{Release: models.Release{ID: "c1", Title: "Carol 1"}}) mgr.StageRelease(99, models.QueueItem{Release: models.Release{ID: "c2", Title: "Carol 2"}})
if len(mgr.GetViewModel(99).Items) != 2 { t.Fatalf("expected 2 items staged for carol") }
reqClear := httptest.NewRequest("POST", "/queue/clear", nil) reqClear = reqClear.WithContext(models.WithUser(reqClear.Context(), sess)) recClear := httptest.NewRecorder() controller.ClearHandler(recClear, reqClear)
if recClear.Code != http.StatusOK { t.Fatalf("expected 200 OK on clear, got %d", recClear.Code) }
vm := mgr.GetViewModel(99) if len(vm.Items) != 0 { t.Fatalf("expected 0 items in queue after clear, got %d", len(vm.Items)) }
body := recClear.Body.String() // Should contain OOB swaps resetting buttons for c1 and c2 if !strings.Contains(body, `id="queue-btn-c1"`) || !strings.Contains(body, `id="queue-btn-c2"`) { t.Errorf("expected clear response to reset buttons for c1 and c2, got: %s", body) }}
func TestQueue_RemoveHandler_ViaSidebar_SyncsMatchingButtons(t *testing.T) { broker := queue.NewSSEBroker() p := testutil.NewTestPages() mgr := newTestManager(broker, p) controller := newTestQueueController(mgr, broker, p, &testutil.FakeCatalog{})
sess := &models.UserSession{UserID: 55, Username: "dave"}
mgr.StageRelease(55, models.QueueItem{ Release: models.Release{ ID: "alb_sidebar_999", Title: "Sidebar Target", ArtistName: "Sidebar Artist", }, })
// Client clicks remove from the queue drawer/sidebar with only id formRemove := url.Values{"id": {"alb_sidebar_999"}} reqRemove := httptest.NewRequest("POST", "/queue/remove", strings.NewReader(formRemove.Encode())) reqRemove.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqRemove = reqRemove.WithContext(models.WithUser(reqRemove.Context(), sess)) recRemove := httptest.NewRecorder() controller.RemoveHandler(recRemove, reqRemove)
if recRemove.Code != http.StatusOK { t.Fatalf("expected 200 OK on sidebar remove, got %d", recRemove.Code) }
body := recRemove.Body.String()
// 1. Must contain OOB swap for the album card button if !strings.Contains(body, `id="queue-btn-alb_sidebar_999"`) { t.Errorf("expected card button OOB swap for alb_sidebar_999, got: %s", body) } // Card button should be reset to add if !strings.Contains(body, `hx-post="/queue/add?`) { t.Errorf("expected card button to have hx-post=/queue/add, got: %s", body) } if strings.Contains(body, "btn-queued") { t.Errorf("expected card button to NOT have btn-queued class, got: %s", body) }
// 2. Must contain OOB swap for the detail button if !strings.Contains(body, `id="queue-detail-btn-alb_sidebar_999"`) { t.Errorf("expected detail button OOB swap for alb_sidebar_999, got: %s", body) } // Detail button should have unqueued text if !strings.Contains(body, "+ add to queue") { t.Errorf("expected detail button to have '+ add to queue', got: %s", body) }
// 3. Queue content should be updated and empty vm := mgr.GetViewModel(55) if len(vm.Items) != 0 { t.Errorf("expected queue to be empty, got %d items", len(vm.Items)) }}
func TestQueue_RemoveHandler_Discography_PreservesCardMetadata(t *testing.T) { broker := queue.NewSSEBroker() p := testutil.NewTestPages() mockCat := &testutil.FakeCatalog{ Releases: map[string][]models.Release{ "36819": { { ID: "alb_ram_123", Title: "Random Access Memories", ArtistName: "Daft Punk", ArtistID: "36819", CoverURL: "https://static.qobuz.com/images/covers/ram.jpg", Duration: 4460, Year: "2013", UPC: "0886443927087", }, }, }, }
mgr := newTestManager(broker, p) controller := newTestQueueController(mgr, broker, p, mockCat)
sess := &models.UserSession{UserID: 77, Username: "dave"}
// Enqueue artist discography formAdd := url.Values{ "id": {"36819"}, "type": {"artist"}, } reqAdd := httptest.NewRequest("POST", "/queue/add", strings.NewReader(formAdd.Encode())) reqAdd.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqAdd = reqAdd.WithContext(models.WithUser(reqAdd.Context(), sess)) recAdd := httptest.NewRecorder() controller.AddHandler(recAdd, reqAdd)
if recAdd.Code != http.StatusOK { t.Fatalf("expected 200 OK on add, got %d", recAdd.Code) }
if !mgr.IsQueued(77, "alb_ram_123") { t.Fatalf("expected alb_ram_123 to be queued") }
// Remove artist discography and verify album card resets cleanly formRem := url.Values{ "id": {"36819"}, "type": {"artist"}, } reqRem := httptest.NewRequest("POST", "/queue/remove", strings.NewReader(formRem.Encode())) reqRem.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqRem = reqRem.WithContext(models.WithUser(reqRem.Context(), sess)) recRem := httptest.NewRecorder() controller.RemoveHandler(recRem, reqRem)
if recRem.Code != http.StatusOK { t.Fatalf("expected 200 OK on remove, got %d", recRem.Code) }
body := recRem.Body.String() // Must contain card OOB reset with full metadata preserved in the URL if !strings.Contains(body, `id="queue-btn-alb_ram_123"`) { t.Fatalf("expected OOB for queue-btn-alb_ram_123, got: %s", body) } if !strings.Contains(body, `title=Random`) { t.Errorf("expected button URL to preserve title=Random, got: %s", body) } if !strings.Contains(body, `artist=Daft`) { t.Errorf("expected button URL to preserve artist=Daft, got: %s", body) } if !strings.Contains(body, `cover=`) { t.Errorf("expected button URL to preserve cover, got: %s", body) } if !strings.Contains(body, `duration=4460`) { t.Errorf("expected button URL to preserve duration=4460, got: %s", body) } if !strings.Contains(body, `year=2013`) { t.Errorf("expected button URL to preserve year=2013, got: %s", body) } if !strings.Contains(body, `upc=0886443927087`) { t.Errorf("expected button URL to preserve upc, got: %s", body) }}
func TestQueue_QueueHandler_ValidUser_RendersQueuePage(t *testing.T) { controller, mgr, sess := newTestQueueContext()
mgr.StageRelease(sess.UserID, models.QueueItem{ Release: models.Release{ ID: "alb_render_test", Title: "Staged Album", ArtistName: "Staged Artist", }, RequestedBy: sess.Username, })
req := httptest.NewRequest("GET", "/queue", nil) req = req.WithContext(models.WithUser(req.Context(), sess)) rec := httptest.NewRecorder()
controller.QueueHandler(rec, req)
if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "Staged Album") { t.Errorf("expected queue page to render staged album, got: %s", body) }}
func TestQueue_StartHandler_ValidUser_StartsDownloadsAndReturns204(t *testing.T) { controller, mgr, sess := newTestQueueContext()
mgr.StageRelease(sess.UserID, models.QueueItem{ Release: models.Release{ ID: "alb_start_test", Title: "Download Target", }, RequestedBy: sess.Username, })
req := httptest.NewRequest("POST", "/queue/start", nil) req = req.WithContext(models.WithUser(req.Context(), sess)) rec := httptest.NewRecorder()
controller.StartHandler(rec, req)
if rec.Code != http.StatusNoContent { t.Errorf("expected status 204 No Content, got %d", rec.Code) }}