···439439 s.logger.Printf("failed to get last tracks for user %d: %v", user.ID, err)
440440 }
441441442442- // Check if this is a new track (by PlayParams.id)
442442+ // Pre-compute the hash for uploaded tracks so comparisons against stored
443443+ // latest tracks will work
444444+ currentURL := currentAppleTrack.Attributes.URL
445445+ if currentURL == "" {
446446+ currentURL = generateUploadHash(currentAppleTrack)
447447+ }
448448+449449+ // Check if this is a new track (by URL / upload hash)
443450 if len(lastTracks) > 0 {
444451 lastTrack := lastTracks[0]
445445- // If the URL matches, it's the same track
446446- if lastTrack.URL == currentAppleTrack.Attributes.URL {
452452+ if lastTrack.URL == currentURL {
447453 s.logger.Printf("track unchanged for user %d: %s by %s", user.ID, currentAppleTrack.Attributes.Name, currentAppleTrack.Attributes.ArtistName)
448454 return nil
449455 }
+168
service/applemusic/applemusic_test.go
···11package applemusic
2233import (
44+ "context"
55+ "encoding/base64"
66+ "encoding/json"
77+ "io"
88+ "log"
99+ "net/http"
410 "strings"
511 "testing"
1212+ "time"
1313+1414+ "github.com/teal-fm/piper/db"
1515+ "github.com/teal-fm/piper/models"
616)
7171818+// createTestJWT creates a minimal JWT for testing that will pass structural validation
1919+func createTestJWT(teamID string, expiry time.Time) string {
2020+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256","typ":"JWT"}`))
2121+2222+ claims := map[string]any{
2323+ "iss": teamID,
2424+ "iat": time.Now().Unix(),
2525+ "exp": expiry.Unix(),
2626+ }
2727+ claimsJSON, _ := json.Marshal(claims)
2828+ payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
2929+3030+ // Signature doesn't need to be valid for structural validation
3131+ signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
3232+3333+ return header + "." + payload + "." + signature
3434+}
3535+836// Helper to create AppleRecentTrack for testing
937func makeTestTrack(name, album, artist string) *AppleRecentTrack {
1038 track := &AppleRecentTrack{}
···1240 track.Attributes.AlbumName = album
1341 track.Attributes.ArtistName = artist
1442 return track
4343+}
4444+4545+// trackResponseTransport returns a fixed JSON response simulating the Apple Music API.
4646+type trackResponseTransport struct {
4747+ response string
4848+}
4949+5050+func (t *trackResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
5151+ return &http.Response{
5252+ StatusCode: http.StatusOK,
5353+ Body: io.NopCloser(strings.NewReader(t.response)),
5454+ Header: make(http.Header),
5555+ }, nil
5656+}
5757+5858+// newTestDB creates an in-memory SQLite database for testing.
5959+func newTestDB(t *testing.T) *db.DB {
6060+ t.Helper()
6161+ testDB, err := db.New(":memory:")
6262+ if err != nil {
6363+ t.Fatalf("failed to create test db: %v", err)
6464+ }
6565+ if err := testDB.Initialize(); err != nil {
6666+ t.Fatalf("failed to initialize test db: %v", err)
6767+ }
6868+ t.Cleanup(func() { testDB.Close() })
6969+ return testDB
7070+}
7171+7272+// createTestUser creates a user in the DB with an Apple Music token set.
7373+func createTestUser(t *testing.T, testDB *db.DB) *models.User {
7474+ t.Helper()
7575+ userID, err := testDB.CreateUser(&models.User{})
7676+ if err != nil {
7777+ t.Fatalf("failed to create user: %v", err)
7878+ }
7979+ if err := testDB.UpdateAppleMusicUserToken(userID, "fake-token"); err != nil {
8080+ t.Fatalf("failed to set apple music token: %v", err)
8181+ }
8282+ user, err := testDB.GetUserByID(userID)
8383+ if err != nil {
8484+ t.Fatalf("failed to get user: %v", err)
8585+ }
8686+ return user
8787+}
8888+8989+// newTestService creates a Service backed by an in-memory DB and the given transport.
9090+func newTestService(t *testing.T, testDB *db.DB, transport http.RoundTripper) *Service {
9191+ t.Helper()
9292+ tokenExpiry := time.Now().Add(1 * time.Hour)
9393+ return &Service{
9494+ DB: testDB,
9595+ httpClient: &http.Client{Transport: transport},
9696+ logger: log.New(io.Discard, "", 0),
9797+ teamID: "test-team",
9898+ keyID: "test-key",
9999+ cachedToken: createTestJWT("test-team", tokenExpiry),
100100+ cachedExpiry: tokenExpiry,
101101+ }
102102+}
103103+104104+// uploadedTrackJSON builds an Apple Music API response for an uploaded track (no URL).
105105+func uploadedTrackJSON(name, artist, album string) string {
106106+ track := map[string]any{
107107+ "id": "1",
108108+ "attributes": map[string]string{
109109+ "name": name,
110110+ "artistName": artist,
111111+ "albumName": album,
112112+ },
113113+ }
114114+ data, _ := json.Marshal(map[string]any{"data": []any{track}})
115115+ return string(data)
116116+}
117117+118118+// processUserTestEnv sets up a DB, user, and service wired to the given API response.
119119+type processUserTestEnv struct {
120120+ testDB *db.DB
121121+ user *models.User
122122+ svc *Service
123123+}
124124+125125+func newProcessUserTestEnv(t *testing.T, apiResponse string) *processUserTestEnv {
126126+ t.Helper()
127127+ testDB := newTestDB(t)
128128+ user := createTestUser(t, testDB)
129129+ transport := &trackResponseTransport{response: apiResponse}
130130+ svc := newTestService(t, testDB, transport)
131131+ return &processUserTestEnv{testDB: testDB, user: user, svc: svc}
132132+}
133133+134134+// seedUploadedTrack saves an uploaded track to the DB, using its upload hash as the URL.
135135+func (env *processUserTestEnv) seedUploadedTrack(t *testing.T, name, artist, album string) {
136136+ t.Helper()
137137+ hash := generateUploadHash(makeTestTrack(name, album, artist))
138138+ _, err := env.testDB.SaveTrack(env.user.ID, &models.Track{
139139+ Name: name,
140140+ Artist: []models.Artist{{Name: artist}},
141141+ Album: album,
142142+ URL: hash,
143143+ })
144144+ if err != nil {
145145+ t.Fatalf("failed to seed track: %v", err)
146146+ }
147147+}
148148+149149+// trackCount returns the number of tracks stored for the test user.
150150+func (env *processUserTestEnv) trackCount(t *testing.T) int {
151151+ t.Helper()
152152+ tracks, err := env.testDB.GetRecentTracks(env.user.ID, 100)
153153+ if err != nil {
154154+ t.Fatalf("failed to get recent tracks: %v", err)
155155+ }
156156+ return len(tracks)
157157+}
158158+159159+func TestProcessUserSkipsDuplicateUploadedTrack(t *testing.T) {
160160+ env := newProcessUserTestEnv(t, uploadedTrackJSON("My Upload", "Local Artist", "Local Album"))
161161+ env.seedUploadedTrack(t, "My Upload", "Local Artist", "Local Album")
162162+163163+ if err := env.svc.ProcessUser(context.Background(), env.user); err != nil {
164164+ t.Fatalf("ProcessUser returned error: %v", err)
165165+ }
166166+167167+ if got := env.trackCount(t); got != 1 {
168168+ t.Errorf("expected 1 track (no duplicate save), got %d", got)
169169+ }
170170+}
171171+172172+func TestProcessUserSavesDifferentUploadedTrack(t *testing.T) {
173173+ env := newProcessUserTestEnv(t, uploadedTrackJSON("New Upload", "New Artist", "New Album"))
174174+ env.seedUploadedTrack(t, "Old Upload", "Old Artist", "Old Album")
175175+176176+ if err := env.svc.ProcessUser(context.Background(), env.user); err != nil {
177177+ t.Fatalf("ProcessUser returned error: %v", err)
178178+ }
179179+180180+ if got := env.trackCount(t); got != 2 {
181181+ t.Errorf("expected 2 tracks (new upload saved), got %d", got)
182182+ }
15183}
1618417185func TestGenerateUploadHash(t *testing.T) {