- Start Web UI before the blocking initial sync so the dashboard is reachable during the (rate-limited, potentially multi-minute) first sync; fold the immediate sync into startPeriodicSync's overlap guard so it can never race a concurrent tick over the shared DB / MB client. - Make MarkNotificationSent idempotent: INSERT OR IGNORE for same-second PK collisions, and explicitly swallow FK violations when a release was pruned by a concurrent re-sync. Prevents a single vanished/duplicate release from aborting the digest mark-sent loop and re-sending. - Do not abort NotifyOnce's mark-sent loop on a single failure; log and continue so every release in the batch is marked. - NULL-safe reads: COALESCE(type,''), COALESCE(release_date,'') in the external_releases and unnotified readers to match the cache reader. - Update/extend tests for the new idempotency and startup contracts.
385 lines
9.8 KiB
Go
385 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"naviwatcher/internal/config"
|
|
"naviwatcher/internal/database"
|
|
"naviwatcher/internal/navidrome"
|
|
"naviwatcher/internal/scanner"
|
|
)
|
|
|
|
func TestAppRun_ScanLogsMissingReleases(t *testing.T) {
|
|
// Verify run() performs the sync+scan pipeline once (via the injected
|
|
// syncFn) and then blocks until ctx cancellation, returning nil. The
|
|
// injected syncFn performs the scan and logs the missing release, mirroring
|
|
// what syncAndScan does against live clients.
|
|
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)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
log.SetOutput(&buf)
|
|
defer log.SetOutput(os.Stderr)
|
|
|
|
app := &App{
|
|
cfg: &config.Config{
|
|
Scanner: config.ScannerConfig{FuzzyThreshold: 0.85},
|
|
Sync: config.SyncConfig{Interval: time.Hour},
|
|
},
|
|
db: db,
|
|
syncFn: func(ctx context.Context) error {
|
|
missing, err := scanner.ScanAll(ctx, db, 0.85)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, m := range missing {
|
|
log.Printf(" missing: artist=%s rgid=%s title=%q", m.ArtistID, m.RGID, m.Title)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// run() must have logged the missing release (Animals) for artist-1.
|
|
out := buf.String()
|
|
if !strings.Contains(out, "missing: artist=artist-1") || !strings.Contains(out, "Animals") {
|
|
t.Fatalf("app.run() did not log the expected missing release; log output:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestStartPeriodicSync_FiresOnTick(t *testing.T) {
|
|
var calls int64
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
app := &App{
|
|
cfg: &config.Config{Sync: config.SyncConfig{Interval: 20 * time.Millisecond}},
|
|
syncFn: func(ctx context.Context) error {
|
|
atomic.AddInt64(&calls, 1)
|
|
wg.Done()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
go app.startPeriodicSync(ctx)
|
|
|
|
if !waitWG(&wg, 2*time.Second) {
|
|
t.Fatal("expected syncFn to be called at least twice within timeout")
|
|
}
|
|
|
|
if got := atomic.LoadInt64(&calls); got < 2 {
|
|
t.Errorf("expected at least 2 sync calls, got %d", got)
|
|
}
|
|
|
|
cancel()
|
|
}
|
|
|
|
func TestStartPeriodicSync_CancelsCleanly(t *testing.T) {
|
|
var calls int64
|
|
app := &App{
|
|
cfg: &config.Config{Sync: config.SyncConfig{Interval: time.Hour}},
|
|
syncFn: func(ctx context.Context) error {
|
|
atomic.AddInt64(&calls, 1)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
app.startPeriodicSync(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
// With a 1h interval the ticker never fires on its own; cancel should
|
|
// return promptly. The loop does run one immediate (guarded) sync at
|
|
// startup, so depending on scheduling calls may be 0 (cancel won the race)
|
|
// or 1 (immediate sync ran) — but never more, since no tick can fire in 1h.
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
// clean exit
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("startPeriodicSync did not exit after ctx cancellation")
|
|
}
|
|
|
|
if got := atomic.LoadInt64(&calls); got > 1 {
|
|
t.Errorf("expected at most 1 (immediate) sync call with 1h interval, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestDoSync_UsesInjectedSyncFn(t *testing.T) {
|
|
// Verify doSync prefers an injected syncFn when present (so the periodic
|
|
// loop and immediate run can be driven by tests without live clients),
|
|
// and falls back to the real syncAndScan otherwise.
|
|
db, err := database.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("database.New() error: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
var called int32
|
|
app := &App{
|
|
cfg: &config.Config{Sync: config.SyncConfig{Interval: time.Hour}},
|
|
db: db,
|
|
syncFn: func(ctx context.Context) error {
|
|
atomic.StoreInt32(&called, 1)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
if err := app.doSync(context.Background()); err != nil {
|
|
t.Fatalf("doSync returned error: %v", err)
|
|
}
|
|
if atomic.LoadInt32(&called) != 1 {
|
|
t.Fatal("expected injected syncFn to be called")
|
|
}
|
|
}
|
|
|
|
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 )",
|
|
},
|
|
}
|
|
|
|
// Inject an unauthenticated Navidrome client so the test needs no live server.
|
|
prevFactory := navidromeClientFactory
|
|
navidromeClientFactory = func(c config.NavidromeConfig) (*navidrome.NavidromeClient, error) {
|
|
return navidrome.NewClientUnauthenticated(c), nil
|
|
}
|
|
defer func() { navidromeClientFactory = prevFactory }()
|
|
|
|
ctx := context.Background()
|
|
app, err := NewApp(ctx, cfg, ":memory:")
|
|
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.ndClient == nil {
|
|
t.Fatal("expected Navidrome 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 )",
|
|
},
|
|
}
|
|
|
|
prevFactory := navidromeClientFactory
|
|
navidromeClientFactory = func(c config.NavidromeConfig) (*navidrome.NavidromeClient, error) {
|
|
return navidrome.NewClientUnauthenticated(c), nil
|
|
}
|
|
defer func() { navidromeClientFactory = prevFactory }()
|
|
|
|
ctx := context.Background()
|
|
app, err := NewApp(ctx, cfg, ":memory:")
|
|
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 )",
|
|
},
|
|
Sync: config.SyncConfig{Interval: time.Hour},
|
|
}
|
|
|
|
prevFactory := navidromeClientFactory
|
|
navidromeClientFactory = func(c config.NavidromeConfig) (*navidrome.NavidromeClient, error) {
|
|
return navidrome.NewClientUnauthenticated(c), nil
|
|
}
|
|
defer func() { navidromeClientFactory = prevFactory }()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
app, err := NewApp(ctx, cfg, ":memory:")
|
|
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")
|
|
}
|
|
}
|
|
|
|
// waitWG waits for wg with a timeout; returns true if it completed in time.
|
|
func waitWG(wg *sync.WaitGroup, timeout time.Duration) bool {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
return true
|
|
case <-time.After(timeout):
|
|
return false
|
|
}
|
|
}
|