Files
NaviWatcher/internal/navidrome/sync_test.go
Vladimir Zagainov 0065057514 feat: add Navidrome client with Subsonic API sync
Add the Navidrome client module that connects to a Navidrome server via
the Subsonic API, fetches artist and album data, and syncs it into the
local SQLite database.

- Add go-subsonic dependency for Subsonic API communication
- Create internal/navidrome/client.go with NavidromeClient wrapper
  - NewClient constructor with token-based auth
  - Ping health check with HTTP status validation
  - GetArtists fetches all artists via getArtists endpoint
  - GetArtistAlbums fetches albums per artist via getArtist endpoint
- Create internal/navidrome/sync.go with sync orchestration
  - SyncArtists upserts artists into artist_settings table
  - SyncAlbums fetches and stores albums for monitored artists
- Add local_albums table (migration 003) with FK to artist_settings
- Add LocalAlbum CRUD operations in internal/database/local_albums.go
- Full test coverage: 19 tests across client and sync packages
- All tests pass, go vet and go fmt clean
2026-05-21 09:45:27 +03:00

446 lines
13 KiB
Go

package navidrome
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
)
// newTestServerAndClient creates a mock Subsonic server and a NavidromeClient
// pointing at it. The handler receives the raw HTTP requests so tests can
// inspect them if needed.
func newTestServerAndClient(handler http.HandlerFunc) (*httptest.Server, *NavidromeClient) {
server := httptest.NewServer(handler)
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
server.Close()
panic(fmt.Sprintf("NewClient() in test setup failed: %v", err))
}
return server, nc
}
// subsonicOKResponse returns a minimal valid Subsonic XML response.
func subsonicOKResponse(body string) string {
return `<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">` +
body + `</subsonic-response>`
}
func TestSyncArtists_EmptyLibrary(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
ctx := context.Background()
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("SyncArtists() error: %v", err)
}
// Verify no artists in DB.
artists, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() error: %v", err)
}
if len(artists) != 0 {
t.Errorf("expected 0 artists in DB, got %d", len(artists))
}
}
func TestSyncArtists_MultipleArtists(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`
<artists ignoredArticles="The">
<index name="A">
<artist id="1" name="Artist One" albumCount="3"/>
<artist id="2" name="Artist Two" albumCount="1"/>
</index>
<index name="B">
<artist id="3" name="Band Three" albumCount="5"/>
</index>
</artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
ctx := context.Background()
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("SyncArtists() error: %v", err)
}
artists, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() error: %v", err)
}
if len(artists) != 3 {
t.Fatalf("expected 3 artists in DB, got %d", len(artists))
}
// Build a map for order-independent comparison.
byID := make(map[string]database.ArtistSettings)
for _, a := range artists {
byID[a.ID] = a
}
expected := map[string]database.ArtistSettings{
"1": {ID: "1", Name: "Artist One", Monitored: true},
"2": {ID: "2", Name: "Artist Two", Monitored: true},
"3": {ID: "3", Name: "Band Three", Monitored: true},
}
for id, exp := range expected {
got, ok := byID[id]
if !ok {
t.Errorf("expected artist %s in DB", id)
continue
}
if got.Name != exp.Name {
t.Errorf("artist %s: expected Name %q, got %q", id, exp.Name, got.Name)
}
if !got.Monitored {
t.Errorf("artist %s: expected Monitored=true, got false", id)
}
}
}
func TestSyncArtists_Idempotency(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`
<artists ignoredArticles="The">
<index name="A">
<artist id="1" name="Artist One" albumCount="2"/>
</index>
</artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
ctx := context.Background()
// First sync.
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("first SyncArtists() error: %v", err)
}
artists1, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() after first sync error: %v", err)
}
if len(artists1) != 1 {
t.Fatalf("expected 1 artist after first sync, got %d", len(artists1))
}
// Manually change monitored to false to verify it is preserved across syncs.
if err := database.UpdateArtistSettings(db, "1", map[string]interface{}{"monitored": false}); err != nil {
t.Fatalf("UpdateArtistSettings() error: %v", err)
}
// Second sync — should not duplicate, and should preserve monitored=false.
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("second SyncArtists() error: %v", err)
}
artists2, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() after second sync error: %v", err)
}
if len(artists2) != 1 {
t.Fatalf("expected 1 artist after second sync (no duplicates), got %d", len(artists2))
}
// Verify the artist was updated (monitored setting was preserved (not reset to true)).
got, err := database.GetArtistSettings(db, "1")
if err != nil {
t.Fatalf("GetArtistSettings() error: %v", err)
}
if got.Monitored {
t.Error("expected Monitored=false to be preserved after re-sync, got true")
}
if got.Name != "Artist One" {
t.Errorf("expected Name 'Artist One', got %q", got.Name)
}
}
func TestSyncAlbums_SingleArtist(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
// No artists returned — we pre-seed the DB below.
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
case "/rest/getArtist":
w.Write([]byte(subsonicOKResponse(`
<artist id="1" name="Artist One" albumCount="2">
<album id="101" name="First Album" artist="Artist One" artistId="1" songCount="10" duration="3600" created="2023-01-15T10:30:00Z"/>
<album id="102" name="Second Album" artist="Artist One" artistId="1" songCount="8" duration="2800" created="2024-03-20T14:00:00Z"/>
</artist>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed a monitored artist.
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "1",
Name: "Artist One",
Monitored: true,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
ctx := context.Background()
if err := SyncAlbums(ctx, nc, db); err != nil {
t.Fatalf("SyncAlbums() error: %v", err)
}
albums, err := database.GetLocalAlbumsByArtist(db, "1")
if err != nil {
t.Fatalf("GetLocalAlbumsByArtist() error: %v", err)
}
if len(albums) != 2 {
t.Fatalf("expected 2 albums, got %d", len(albums))
}
byID := make(map[string]database.LocalAlbum)
for _, a := range albums {
byID[a.ID] = a
}
exp1 := database.LocalAlbum{ID: "101", ArtistID: "1", Title: "First Album"}
if got, ok := byID["101"]; !ok {
t.Error("expected album 101 in DB")
} else if got != exp1 {
t.Errorf("album 101 = %+v, want %+v", got, exp1)
}
exp2 := database.LocalAlbum{ID: "102", ArtistID: "1", Title: "Second Album"}
if got, ok := byID["102"]; !ok {
t.Error("expected album 102 in DB")
} else if got != exp2 {
t.Errorf("album 102 = %+v, want %+v", got, exp2)
}
}
func TestSyncAlbums_SkipsUnmonitored(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
case "/rest/getArtist":
// This should NOT be called for unmonitored artist.
t.Error("GetArtist should not be called for unmonitored artist")
w.Write([]byte(subsonicOKResponse(`<artist id="2" name="Unmonitored" albumCount="0"></artist>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed an unmonitored artist.
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "2",
Name: "Unmonitored",
Monitored: false,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
ctx := context.Background()
if err := SyncAlbums(ctx, nc, db); err != nil {
t.Fatalf("SyncAlbums() error: %v", err)
}
// Verify no albums were stored.
allAlbums, err := database.GetAllLocalAlbums(db)
if err != nil {
t.Fatalf("GetAllLocalAlbums() error: %v", err)
}
if len(allAlbums) != 0 {
t.Errorf("expected 0 albums for unmonitored artist, got %d", len(allAlbums))
}
}
func TestSyncAlbums_APIErrorMidSync(t *testing.T) {
callCount := 0
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
case "/rest/getArtist":
callCount++
if callCount == 2 {
// Fail on the second artist.
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
<error code="70" message="Requested resource not found"/>
</subsonic-response>`))
return
}
w.Write([]byte(subsonicOKResponse(`
<artist id="` + r.URL.Query().Get("id") + `" name="Artist" albumCount="1">
<album id="101" name="Album One" artist="Artist" artistId="1" songCount="5" duration="1800" created="2023-01-01T00:00:00Z"/>
</artist>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed two monitored artists.
for _, id := range []string{"1", "2"} {
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: id,
Name: "Artist " + id,
Monitored: true,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
}
ctx := context.Background()
err = SyncAlbums(ctx, nc, db)
if err == nil {
t.Fatal("SyncAlbums() expected error for API failure mid-sync, got nil")
}
// The first artist's albums should have been stored before the error.
albums1, err := database.GetLocalAlbumsByArtist(db, "1")
if err != nil {
t.Fatalf("GetLocalAlbumsByArtist(1) error: %v", err)
}
if len(albums1) != 1 {
t.Errorf("expected 1 album for artist 1 (synced before error), got %d", len(albums1))
}
}
func TestSyncArtists_ContextCancellation(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`
<artists ignoredArticles="The">
<index name="A">
<artist id="1" name="Artist One" albumCount="1"/>
</index>
</artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Create a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = SyncArtists(ctx, nc, db)
if err == nil {
t.Fatal("SyncArtists() expected error for cancelled context, got nil")
}
}
func TestSyncAlbums_ContextCancellation(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed a monitored artist so SyncAlbums has work to do.
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "1",
Name: "Artist One",
Monitored: true,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
// Create a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = SyncAlbums(ctx, nc, db)
if err == nil {
t.Fatal("SyncAlbums() expected error for cancelled context, got nil")
}
}