Replace compute-only run() with an immediate sync+scan followed by a ticker-driven periodic loop, add the sync.interval config field (default 6h) with defaults and validation, and add tests for the scheduling logic.
383 lines
9.6 KiB
Go
383 lines
9.6 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 would never fire on its own; cancel should
|
|
// return promptly.
|
|
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 != 0 {
|
|
t.Errorf("expected no sync calls 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
|
|
}
|
|
}
|