- Fix artist-ID namespace mismatch in MusicBrainz provider: SyncArtistDiscography now stores the canonical Navidrome artist ID (artist_settings.id) as external_releases.artist_id instead of the MusicBrainz MBID. Previously the MBID was stored, which violated the FK to artist_settings and broke the scanner join (local_albums.artist_id is the Navidrome ID), causing every external release to be falsely reported as missing and the sync insert to fail at runtime. getArtistFilterOptions now also resolves by the Navidrome ID. - Resolve threshold in FindMissingReleases so the exported primitive honors the same zero-means-default contract as ScanArtist/ScanAll. - Remove dead maxLen==0 guard in scanner.Similarity. - Inline trivial buildPath helper; drop unused url import in client.go. - Replace hand-rolled itoa with strconv.Itoa in tests. - Rewrite SyncArtistDiscography tests to seed artist_settings with the Navidrome ID (tests previously seeded the MBID to mask the FK mismatch). - Fix TestFuzzySmoke to exercise the real dependency (fuzzy.LevenshteinDistance / scanner.Similarity) instead of an unused API. - Fix TestAppRun_ScanLogsMissingReleases to run the scan against a live context and assert the missing release is found. - Document cached_at column in Specification.md and note startup scan / required musicbrainz.user_agent in README. - Stop tracking .serena/ tooling config; add it to .gitignore.
236 lines
5.9 KiB
Go
236 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"naviwatcher/internal/config"
|
|
"naviwatcher/internal/database"
|
|
"naviwatcher/internal/scanner"
|
|
)
|
|
|
|
func TestAppRun_ScanLogsMissingReleases(t *testing.T) {
|
|
// Verify the compute-only run() hook scans monitored artists and returns
|
|
// nil without starting notifier/web. Uses an in-memory DB with one
|
|
// monitored artist that has one missing release (Animals) vs a local album
|
|
// (The Wall). The context is left live so the scan actually executes; we
|
|
// cancel shortly after to let run() return cleanly.
|
|
db, err := database.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("database.New() error: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
|
ID: "artist-1",
|
|
Name: "Pink Floyd",
|
|
Monitored: true,
|
|
}); err != nil {
|
|
t.Fatalf("seed artist: %v", err)
|
|
}
|
|
if err := database.SaveLocalAlbum(db, &database.LocalAlbum{
|
|
ID: "l1",
|
|
ArtistID: "artist-1",
|
|
Title: "The Wall",
|
|
}); err != nil {
|
|
t.Fatalf("seed local album: %v", err)
|
|
}
|
|
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
|
|
RGID: "rg2",
|
|
ArtistID: "artist-1",
|
|
Title: "Animals",
|
|
}); err != nil {
|
|
t.Fatalf("seed external release: %v", err)
|
|
}
|
|
|
|
app := &App{
|
|
cfg: &config.Config{Scanner: config.ScannerConfig{FuzzyThreshold: 0.85}},
|
|
db: db,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Run the (blocking) hook in a goroutine; cancel after it has had time to
|
|
// perform the scan so run() returns nil via the ctx.Done() path.
|
|
done := make(chan error, 1)
|
|
go func() { done <- app.run(ctx) }()
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("app.run() returned error: %v", err)
|
|
}
|
|
|
|
// The scan should have found the missing release (Animals) for artist-1.
|
|
// Use a fresh context for the verification scan since the run context was
|
|
// cancelled above.
|
|
missing, err := scanner.ScanAll(context.Background(), db, 0.85)
|
|
if err != nil {
|
|
t.Fatalf("ScanAll() error: %v", err)
|
|
}
|
|
if len(missing) != 1 || missing[0].RGID != "rg2" {
|
|
t.Fatalf("expected 1 missing release (rg2/Animals), got %+v", missing)
|
|
}
|
|
}
|
|
|
|
func TestConfigIntegration(t *testing.T) {
|
|
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
|
// verifying the full path that main() uses.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
|
|
yaml := `server:
|
|
host: "127.0.0.1"
|
|
port: 9090
|
|
navidrome:
|
|
url: "http://localhost:4533"
|
|
user: "test"
|
|
password: "test"
|
|
musicbrainz:
|
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
|
`
|
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
|
t.Fatalf("failed to write config: %v", err)
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Server.Host != "127.0.0.1" {
|
|
t.Errorf("expected host 127.0.0.1, got %q", cfg.Server.Host)
|
|
}
|
|
if cfg.Server.Port != 9090 {
|
|
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
|
|
}
|
|
if cfg.Navidrome.URL != "http://localhost:4533" {
|
|
t.Errorf("expected navidrome url http://localhost:4533, got %q", cfg.Navidrome.URL)
|
|
}
|
|
}
|
|
|
|
func TestNewApp_CreatesMusicBrainzClient(t *testing.T) {
|
|
// Verify that NewApp initializes the MusicBrainz client from config.
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{
|
|
Host: "127.0.0.1",
|
|
Port: 9090,
|
|
},
|
|
Navidrome: config.NavidromeConfig{
|
|
URL: "http://localhost:4533",
|
|
User: "test",
|
|
Password: "test",
|
|
},
|
|
MusicBrainz: config.MusicBrainzConfig{
|
|
UserAgent: "NaviWatcher/1.0 ( test@example.com )",
|
|
},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
app, err := NewApp(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewApp returned error: %v", err)
|
|
}
|
|
defer app.Close()
|
|
|
|
if app.mbClient == nil {
|
|
t.Fatal("expected MusicBrainz client to be initialized, got nil")
|
|
}
|
|
if app.db == nil {
|
|
t.Fatal("expected database to be initialized, got nil")
|
|
}
|
|
if app.cfg != cfg {
|
|
t.Fatal("expected app.cfg to be the config passed to NewApp")
|
|
}
|
|
}
|
|
|
|
func TestNewApp_GracefulShutdown(t *testing.T) {
|
|
// Verify that App.Close() cleans up resources without error.
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{
|
|
Host: "127.0.0.1",
|
|
Port: 9090,
|
|
},
|
|
Navidrome: config.NavidromeConfig{
|
|
URL: "http://localhost:4533",
|
|
User: "test",
|
|
Password: "test",
|
|
},
|
|
MusicBrainz: config.MusicBrainzConfig{
|
|
UserAgent: "NaviWatcher/1.0 ( test@example.com )",
|
|
},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
app, err := NewApp(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewApp returned error: %v", err)
|
|
}
|
|
|
|
// Close should not panic or return error.
|
|
app.Close()
|
|
}
|
|
|
|
func TestAppRun_GracefulShutdown(t *testing.T) {
|
|
// Verify that app.run() returns nil when context is cancelled.
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{
|
|
Host: "127.0.0.1",
|
|
Port: 9090,
|
|
},
|
|
Navidrome: config.NavidromeConfig{
|
|
URL: "http://localhost:4533",
|
|
User: "test",
|
|
Password: "test",
|
|
},
|
|
MusicBrainz: config.MusicBrainzConfig{
|
|
UserAgent: "NaviWatcher/1.0 ( test@example.com )",
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
app, err := NewApp(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewApp returned error: %v", err)
|
|
}
|
|
defer app.Close()
|
|
|
|
// Cancel the context to trigger shutdown.
|
|
cancel()
|
|
|
|
if err := app.run(ctx); err != nil {
|
|
t.Fatalf("app.run returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMusicBrainzUserAgentValidation(t *testing.T) {
|
|
// Verify that config validation requires MusicBrainz.UserAgent to be set.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
|
|
yaml := `server:
|
|
host: "127.0.0.1"
|
|
port: 9090
|
|
navidrome:
|
|
url: "http://localhost:4533"
|
|
user: "test"
|
|
password: "test"
|
|
musicbrainz:
|
|
user_agent: ""
|
|
`
|
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
|
t.Fatalf("failed to write config: %v", err)
|
|
}
|
|
|
|
_, err := config.LoadConfig(path)
|
|
if err == nil {
|
|
t.Fatal("expected config validation error for empty musicbrainz.user_agent, got nil")
|
|
}
|
|
}
|