Files
NaviWatcher/internal/notifier/scheduler_test.go

239 lines
6.1 KiB
Go

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")
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_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")
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")
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"); 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")
}
}