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
This commit is contained in:
372
internal/navidrome/client_test.go
Normal file
372
internal/navidrome/client_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package navidrome
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/delucks/go-subsonic"
|
||||
"naviwatcher/internal/config"
|
||||
)
|
||||
|
||||
func TestNewClient_ValidConfig(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
client, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("NewClient() returned nil client")
|
||||
}
|
||||
if client.client == nil {
|
||||
t.Fatal("NewClient() returned client with nil subsonic client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_InvalidCredentials(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
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="40" message="Wrong username or password."/>
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "baduser",
|
||||
Password: "badpass",
|
||||
}
|
||||
|
||||
_, err := NewClient(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("NewClient() expected error for invalid credentials, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
client, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
if err := client.Ping(); err != nil {
|
||||
t.Errorf("Ping() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_ServerUnreachable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
client := &NavidromeClient{
|
||||
client: &subsonic.Client{
|
||||
Client: &http.Client{},
|
||||
BaseUrl: serverURL,
|
||||
User: "testuser",
|
||||
ClientName: "naviwatcher",
|
||||
},
|
||||
}
|
||||
|
||||
err := client.Ping()
|
||||
if err == nil {
|
||||
t.Error("Ping() expected error for unreachable server, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_Non200Status(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &NavidromeClient{
|
||||
client: &subsonic.Client{
|
||||
Client: &http.Client{},
|
||||
BaseUrl: server.URL,
|
||||
User: "testuser",
|
||||
ClientName: "naviwatcher",
|
||||
},
|
||||
}
|
||||
|
||||
err := client.Ping()
|
||||
if err == nil {
|
||||
t.Error("Ping() expected error for non-200 status, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtists_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artists ignoredArticles="The El La Los Las Le Les">
|
||||
<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>
|
||||
</subsonic-response>`))
|
||||
} else {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
artists, err := nc.GetArtists()
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtists() error = %v", err)
|
||||
}
|
||||
|
||||
if len(artists) != 3 {
|
||||
t.Fatalf("GetArtists() returned %d artists, want 3", len(artists))
|
||||
}
|
||||
|
||||
expected := []ArtistInfo{
|
||||
{ID: "1", Name: "Artist One"},
|
||||
{ID: "2", Name: "Artist Two"},
|
||||
{ID: "3", Name: "Band Three"},
|
||||
}
|
||||
|
||||
for i, a := range artists {
|
||||
if a.ID != expected[i].ID || a.Name != expected[i].Name {
|
||||
t.Errorf("GetArtists()[%d] = {ID: %q, Name: %q}, want {ID: %q, Name: %q}",
|
||||
i, a.ID, a.Name, expected[i].ID, expected[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtists_EmptyLibrary(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artists ignoredArticles="The El La Los Las Le Les">
|
||||
</artists>
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
artists, err := nc.GetArtists()
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtists() error = %v", err)
|
||||
}
|
||||
|
||||
if len(artists) != 0 {
|
||||
t.Errorf("GetArtists() returned %d artists, want 0", len(artists))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtists_APIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = nc.GetArtists()
|
||||
if err == nil {
|
||||
t.Fatal("GetArtists() expected error for API failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAlbums_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtist" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<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>
|
||||
</subsonic-response>`))
|
||||
} else {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
albums, err := nc.GetArtistAlbums("1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistAlbums() error = %v", err)
|
||||
}
|
||||
|
||||
if len(albums) != 2 {
|
||||
t.Fatalf("GetArtistAlbums() returned %d albums, want 2", len(albums))
|
||||
}
|
||||
|
||||
expected := []AlbumInfo{
|
||||
{ID: "101", Name: "First Album", ArtistID: "1"},
|
||||
{ID: "102", Name: "Second Album", ArtistID: "1"},
|
||||
}
|
||||
|
||||
for i, a := range albums {
|
||||
if a.ID != expected[i].ID || a.Name != expected[i].Name || a.ArtistID != expected[i].ArtistID {
|
||||
t.Errorf("GetArtistAlbums()[%d] = {ID: %q, Name: %q, ArtistID: %q}, want {ID: %q, Name: %q, ArtistID: %q}",
|
||||
i, a.ID, a.Name, a.ArtistID, expected[i].ID, expected[i].Name, expected[i].ArtistID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAlbums_NoAlbums(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtist" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artist id="5" name="Lonely Artist" albumCount="0">
|
||||
</artist>
|
||||
</subsonic-response>`))
|
||||
} else {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
albums, err := nc.GetArtistAlbums("5")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistAlbums() error = %v", err)
|
||||
}
|
||||
|
||||
if len(albums) != 0 {
|
||||
t.Errorf("GetArtistAlbums() returned %d albums, want 0", len(albums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAlbums_APIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtist" {
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = nc.GetArtistAlbums("999")
|
||||
if err == nil {
|
||||
t.Fatal("GetArtistAlbums() expected error for API failure, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user