package subsonic import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbq.Artist) { t.Helper() if testing.Short() { t.Skip("skipping in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) dbtest.ResetDB(t, pool) q := dbq.New(pool) u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, }) a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/star.flac", DurationMs: 100_000, }) return pool, u, tr, al, a } func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) return newMediaHandlers(pool, w, logger) } func TestHandleStar_TrackIDInsertsRow(t *testing.T) { pool, user, track, _, _ := testStarPool(t) m := newStarHandlers(pool) q := url.Values{} q.Set("id", uuidToID(track.ID)) req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) ctx := context.WithValue(req.Context(), userCtxKey, user) w := httptest.NewRecorder() m.handleStar(w, req.WithContext(ctx)) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var count int _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) if count != 1 { t.Errorf("count = %d", count) } } func TestHandleStar_AllThreeIDsAtOnce(t *testing.T) { pool, user, track, album, artist := testStarPool(t) m := newStarHandlers(pool) q := url.Values{} q.Set("id", uuidToID(track.ID)) q.Set("albumId", uuidToID(album.ID)) q.Set("artistId", uuidToID(artist.ID)) req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) ctx := context.WithValue(req.Context(), userCtxKey, user) w := httptest.NewRecorder() m.handleStar(w, req.WithContext(ctx)) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var tCount, alCount, arCount int _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&tCount) _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_albums WHERE user_id=$1", user.ID).Scan(&alCount) _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_artists WHERE user_id=$1", user.ID).Scan(&arCount) if tCount != 1 || alCount != 1 || arCount != 1 { t.Errorf("counts: tracks=%d albums=%d artists=%d", tCount, alCount, arCount) } } func TestHandleStar_UnknownTrackReturnsError70(t *testing.T) { pool, user, _, _, _ := testStarPool(t) m := newStarHandlers(pool) q := url.Values{} q.Set("id", "00000000-0000-0000-0000-000000000000") req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) ctx := context.WithValue(req.Context(), userCtxKey, user) w := httptest.NewRecorder() m.handleStar(w, req.WithContext(ctx)) body := w.Body.String() // Subsonic error envelope returns 200 OK with status="failed" inside the body. // The exact code is 70 (data not found). if w.Code != http.StatusOK || !contains(body, `"code":70`) && !contains(body, `code="70"`) { t.Errorf("expected error 70 envelope, got status=%d body=%s", w.Code, body) } } func TestHandleStar_PartialMissingAtomic(t *testing.T) { pool, user, track, _, _ := testStarPool(t) m := newStarHandlers(pool) q := url.Values{} q.Set("id", uuidToID(track.ID)) q.Set("albumId", "00000000-0000-0000-0000-000000000000") req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) ctx := context.WithValue(req.Context(), userCtxKey, user) w := httptest.NewRecorder() m.handleStar(w, req.WithContext(ctx)) // Album missing → atomic refusal: track NOT starred. var count int _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) if count != 0 { t.Errorf("track was starred despite album miss; count = %d, want 0", count) } } func TestHandleUnstar_RemovesAndIsIdempotent(t *testing.T) { pool, user, track, _, _ := testStarPool(t) m := newStarHandlers(pool) // First star. q := url.Values{} q.Set("id", uuidToID(track.ID)) star := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil) starCtx := context.WithValue(star.Context(), userCtxKey, user) starResp := httptest.NewRecorder() m.handleStar(starResp, star.WithContext(starCtx)) // Unstar. un := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil) unCtx := context.WithValue(un.Context(), userCtxKey, user) unResp := httptest.NewRecorder() m.handleUnstar(unResp, un.WithContext(unCtx)) if unResp.Code != http.StatusOK { t.Fatalf("unstar status = %d", unResp.Code) } var count int _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count) if count != 0 { t.Errorf("count after unstar = %d, want 0", count) } // Idempotent — unstar again. un2 := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil) un2 = un2.WithContext(context.WithValue(un2.Context(), userCtxKey, user)) un2Resp := httptest.NewRecorder() m.handleUnstar(un2Resp, un2) if un2Resp.Code != http.StatusOK { t.Errorf("second unstar status = %d", un2Resp.Code) } } func contains(s, substr string) bool { for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return true } } return false } func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) { pool, user, track, album, artist := testStarPool(t) m := newStarHandlers(pool) q := dbq.New(pool) // Star one of each. _, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID}) _ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID}) _ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID}) req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) ctx := context.WithValue(req.Context(), userCtxKey, user) w := httptest.NewRecorder() m.handleGetStarred2(w, req.WithContext(ctx)) if w.Code != http.StatusOK { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } // Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}} var raw map[string]any _ = json.Unmarshal(w.Body.Bytes(), &raw) resp, _ := raw["subsonic-response"].(map[string]any) starred, _ := resp["starred2"].(map[string]any) if starred == nil { t.Fatalf("missing starred2 envelope: %v", raw) } songs, _ := starred["song"].([]any) albums, _ := starred["album"].([]any) artists, _ := starred["artist"].([]any) if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 { t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists)) } } func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { pool, alice, track, _, _ := testStarPool(t) q := dbq.New(pool) bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, }) _, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) m := newStarHandlers(pool) req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) ctx := context.WithValue(req.Context(), userCtxKey, bob) w := httptest.NewRecorder() m.handleGetStarred2(w, req.WithContext(ctx)) var raw map[string]any _ = json.Unmarshal(w.Body.Bytes(), &raw) resp, _ := raw["subsonic-response"].(map[string]any) starred, _ := resp["starred2"].(map[string]any) songs, _ := starred["song"].([]any) if len(songs) != 0 { t.Errorf("bob's starred songs should be empty, got %d", len(songs)) } } func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) { pool, user, track1, _, _ := testStarPool(t) // Add a second track so star refers to a different track than the one playing. q := dbq.New(pool) a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"}) al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID}) track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000, }) m := newStarHandlers(pool) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) m.logger = logger // Start now-playing for track1 (creates open play_event with vector). q1 := url.Values{} q1.Set("id", uuidToID(track1.ID)) q1.Set("submission", "false") scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil) scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user) scrobResp := httptest.NewRecorder() m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx)) if scrobResp.Code != http.StatusOK { t.Fatalf("scrobble: %d", scrobResp.Code) } // Star track2. q2 := url.Values{} q2.Set("id", uuidToID(track2.ID)) starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil) starCtx := context.WithValue(starReq.Context(), userCtxKey, user) starResp := httptest.NewRecorder() m.handleStar(starResp, starReq.WithContext(starCtx)) if starResp.Code != http.StatusOK { t.Fatalf("star: %d", starResp.Code) } // Verify contextual_likes row. var count int _ = pool.QueryRow(context.Background(), "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count) if count != 1 { t.Errorf("contextual_likes count = %d, want 1", count) } }