package notifier import ( "context" "errors" "sync" "testing" "time" "naviwatcher/internal/config" "naviwatcher/internal/database" ) // fixedSchedule is a test Schedule that fires a fixed duration after every call // to Next, so a scheduler test can run deterministically without a real cron. type fixedSchedule struct { interval time.Duration } func (f fixedSchedule) Next(t time.Time) time.Time { return t.Add(f.interval) } // collectSender records messages and can be told to fail. type collectSender struct { mu sync.Mutex messages []string failErr error } func (s *collectSender) Send(ctx context.Context, message string) error { if s.failErr != nil { return s.failErr } s.mu.Lock() s.messages = append(s.messages, message) s.mu.Unlock() return nil } func (s *collectSender) count() int { s.mu.Lock() defer s.mu.Unlock() return len(s.messages) } // seedRelease inserts an external_release row (with an artist) and optionally // marks it as already notified. Returns the rgid. func seedRelease(t *testing.T, db *database.DB, rgid, artistID string, notified bool) { t.Helper() if _, err := db.Conn().Exec( "INSERT OR IGNORE INTO artist_settings (id, name) VALUES (?, ?)", artistID, "Test Artist "+artistID, ); err != nil { t.Fatalf("seed artist: %v", err) } if _, err := db.Conn().Exec( "INSERT INTO external_releases (rgid, artist_id, title, type, release_date) VALUES (?, ?, ?, ?, ?)", rgid, artistID, "Release "+rgid, "album", "", ); err != nil { t.Fatalf("seed release: %v", err) } if notified { if err := database.MarkNotificationSent(db, rgid); err != nil { t.Fatalf("mark sent: %v", err) } } } func TestNotifyOnce_SendsAndMarksSent(t *testing.T) { db, err := database.New(":memory:") if err != nil { t.Fatalf("New(): %v", err) } defer db.Close() seedRelease(t, db, "rgid-1", "artist-1", false) seedRelease(t, db, "rgid-2", "artist-1", false) sender := &collectSender{} cfg := config.TelegramConfig{Enabled: true} n, err := NotifyOnce(context.Background(), db, sender, cfg, "http://ui:8080", 0.85) if err != nil { t.Fatalf("NotifyOnce: %v", err) } if n != 2 { t.Fatalf("expected 2 releases notified, got %d", n) } if sender.count() != 1 { t.Fatalf("expected a single digest message, got %d", sender.count()) } // After notifying, both should now be considered sent. remaining, err := database.GetUnnotifiedReleases(db) if err != nil { t.Fatalf("GetUnnotifiedReleases: %v", err) } if len(remaining) != 0 { t.Fatalf("expected 0 unnotified after NotifyOnce, got %d", len(remaining)) } } func TestNotifyOnce_OwnedReleaseNotNotified(t *testing.T) { // Regression: a cached external release the user already owns locally must // not be reported as "missing". Before the scanner intersection was added, // NotifyOnce treated every unnotified external release as missing and would // spam the operator with releases they already have in Navidrome. db, err := database.New(":memory:") if err != nil { t.Fatalf("New(): %v", err) } defer db.Close() seedRelease(t, db, "rgid-owned", "artist-1", false) // The user already has this album locally, with a matching normalized title. if _, err := db.Conn().Exec( "INSERT INTO local_albums (id, artist_id, title) VALUES (?, ?, ?)", "local-1", "artist-1", "Release rgid-owned", ); err != nil { t.Fatalf("seed local album: %v", err) } sender := &collectSender{} n, err := NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui", 0.85) if err != nil { t.Fatalf("NotifyOnce: %v", err) } if n != 0 { t.Fatalf("expected 0 notified (release already owned), got %d", n) } if sender.count() != 0 { t.Fatalf("expected no digest for an owned release, got %d message(s)", sender.count()) } // The owned release is genuinely missing per the scanner, so it must remain // un-marked-sent to avoid corrupting notifications_sent state. sent, err := database.IsNotificationSent(db, "rgid-owned") if err != nil { t.Fatalf("IsNotificationSent: %v", err) } if sent { t.Error("owned release should NOT be marked sent") } } func TestNotifyOnce_EmptyDoesNotSend(t *testing.T) { db, err := database.New(":memory:") if err != nil { t.Fatalf("New(): %v", err) } defer db.Close() sender := &collectSender{} n, err := NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui", 0.85) if err != nil { t.Fatalf("NotifyOnce: %v", err) } if n != 0 { t.Fatalf("expected 0 releases notified, got %d", n) } if sender.count() != 0 { t.Fatalf("expected no message sent for empty digest, got %d", sender.count()) } } func TestNotifyOnce_SkipsAlreadySent(t *testing.T) { db, err := database.New(":memory:") if err != nil { t.Fatalf("New(): %v", err) } defer db.Close() // One already-notified, one new. seedRelease(t, db, "rgid-done", "artist-1", true) seedRelease(t, db, "rgid-new", "artist-1", false) sender := &collectSender{} n, err := NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui", 0.85) if err != nil { t.Fatalf("NotifyOnce: %v", err) } if n != 1 { t.Fatalf("expected 1 newly notified release, got %d", n) } // The already-sent one stays marked sent; the new one is now marked. done, err := database.IsNotificationSent(db, "rgid-done") if err != nil { t.Fatalf("IsNotificationSent done: %v", err) } if !done { t.Error("expected rgid-done to remain sent") } newsent, err := database.IsNotificationSent(db, "rgid-new") if err != nil { t.Fatalf("IsNotificationSent new: %v", err) } if !newsent { t.Error("expected rgid-new to be marked sent") } } func TestNotifyOnce_SendErrorNotMarked(t *testing.T) { db, err := database.New(":memory:") if err != nil { t.Fatalf("New(): %v", err) } defer db.Close() seedRelease(t, db, "rgid-1", "artist-1", false) want := errors.New("send boom") sender := &collectSender{failErr: want} _, err = NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui", 0.85) if err == nil || !errors.Is(err, want) { t.Fatalf("expected error %v, got %v", want, err) } // On send failure nothing should be marked sent. sent, err := database.IsNotificationSent(db, "rgid-1") if err != nil { t.Fatalf("IsNotificationSent: %v", err) } if sent { t.Error("release should NOT be marked sent when send fails") } } func TestNotifyOnce_NilSender(t *testing.T) { db, err := database.New(":memory:") if err != nil { t.Fatalf("New(): %v", err) } defer db.Close() if _, err := NotifyOnce(context.Background(), db, nil, config.TelegramConfig{}, "http://ui", 0.85); err == nil { t.Fatal("expected error for nil sender") } } func TestStartScheduler_FiresOnSchedule(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() var mu sync.Mutex var calls int notify := func(ctx context.Context) error { mu.Lock() calls++ mu.Unlock() return nil } // Fixed 10ms interval schedule; injected now func is unused by fixedSchedule. StartScheduler(ctx, true, fixedSchedule{interval: 10 * time.Millisecond}, notify, time.Now) // Allow a few ticks (immediate fire + scheduled ones). time.Sleep(60 * time.Millisecond) cancel() mu.Lock() got := calls mu.Unlock() if got < 2 { t.Fatalf("expected scheduler to fire at least twice, got %d", got) } } func TestStartScheduler_DisabledNoOp(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() fired := false notify := func(ctx context.Context) error { fired = true return nil } StartScheduler(ctx, false, fixedSchedule{interval: time.Millisecond}, notify, time.Now) time.Sleep(20 * time.Millisecond) if fired { t.Fatal("scheduler should not fire when disabled") } } func TestCronSchedule_ParsesAndNext(t *testing.T) { s, err := NewCronSchedule("0 9 * * *") if err != nil { t.Fatalf("NewCronSchedule: %v", err) } base := time.Date(2026, 7, 19, 10, 0, 0, 0, time.Local) next := s.Next(base) // After 10:00, the next 09:00 daily fire is the next day. if next.Day() != 20 || next.Hour() != 9 { t.Fatalf("expected next fire at 09:00 next day, got %v", next) } } func TestCronSchedule_InvalidSpec(t *testing.T) { if _, err := NewCronSchedule("not a cron"); err == nil { t.Fatal("expected error for invalid cron spec") } }