···439439 s.logger.Printf("failed to get last tracks for user %d: %v", user.ID, err)
440440 }
441441442442- // 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)
442442+ // Check if this is a new track (by PlayParams.id)
450443 if len(lastTracks) > 0 {
451444 lastTrack := lastTracks[0]
452452- if lastTrack.URL == currentURL {
445445+ // If the URL matches, it's the same track
446446+ if lastTrack.URL == currentAppleTrack.Attributes.URL {
453447 s.logger.Printf("track unchanged for user %d: %s by %s", user.ID, currentAppleTrack.Attributes.Name, currentAppleTrack.Attributes.ArtistName)
454448 return nil
455449 }
-168
service/applemusic/applemusic_test.go
···11package applemusic
2233import (
44- "context"
55- "encoding/base64"
66- "encoding/json"
77- "io"
88- "log"
99- "net/http"
104 "strings"
115 "testing"
1212- "time"
1313-1414- "github.com/teal-fm/piper/db"
1515- "github.com/teal-fm/piper/models"
166)
1771818-// 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-368// Helper to create AppleRecentTrack for testing
379func makeTestTrack(name, album, artist string) *AppleRecentTrack {
3810 track := &AppleRecentTrack{}
···4012 track.Attributes.AlbumName = album
4113 track.Attributes.ArtistName = artist
4214 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- }
18315}
1841618517func TestGenerateUploadHash(t *testing.T) {