package 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) } }