package scheduler import ( "path/filepath" "testing" "pete/internal/poster" "pete/internal/storage" ) type fakeQueue struct { items []poster.QueueItem } func (f *fakeQueue) Enqueue(item poster.QueueItem) { f.items = append(f.items, item) } func setupTestDB(t *testing.T) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") if err := storage.Init(dbPath); err != nil { t.Fatal(err) } t.Cleanup(func() { storage.Close() }) } func insertPostable(t *testing.T, guid, source, channel string, seenAt int64) { t.Helper() if err := storage.InsertStory(&storage.Story{ GUID: guid, Headline: "Headline for " + guid, Lede: "Lede for " + guid, ArticleURL: "https://example.com/" + guid, Source: source, SeenAt: seenAt, }); err != nil { t.Fatalf("InsertStory(%s): %v", guid, err) } storage.MarkClassified(guid, channel, "[]") } func TestNew_EmptyChannels_ReturnsNil(t *testing.T) { if New(nil, 4, &fakeQueue{}) != nil { t.Error("expected nil when no channels are given") } } func TestNew_SortsChannels(t *testing.T) { rr := New([]string{"tech", "gaming", "politics"}, 4, &fakeQueue{}) if rr == nil { t.Fatal("expected non-nil RoundRobin") } want := []string{"gaming", "politics", "tech"} for i, ch := range rr.channels { if ch != want[i] { t.Errorf("channel[%d]=%s, want %s", i, ch, want[i]) } } } func TestTick_PicksNewestPostableFromFirstChannel(t *testing.T) { setupTestDB(t) insertPostable(t, "g-old", "TimeExt", "gaming", 100) insertPostable(t, "g-new", "TimeExt", "gaming", 200) insertPostable(t, "p-1", "GuardianPolitics", "politics", 150) fq := &fakeQueue{} rr := New([]string{"gaming", "politics"}, 4, fq) rr.tick() if len(fq.items) != 1 { t.Fatalf("expected 1 enqueued, got %d", len(fq.items)) } if fq.items[0].GUID != "g-new" { t.Errorf("expected newest from gaming (g-new), got %s", fq.items[0].GUID) } lastChannel, _, _ := storage.GetRoundRobinState() if lastChannel != "gaming" { t.Errorf("expected last_channel=gaming, got %s", lastChannel) } } func TestTick_SkipsEmptyAndAdvancesPointer(t *testing.T) { setupTestDB(t) // Pretend we just posted in gaming; next tick should start at politics, which // is empty, so it should skip to tech. storage.SetRoundRobinState("gaming", 0) insertPostable(t, "t-1", "GuardianTech", "tech", 100) fq := &fakeQueue{} rr := New([]string{"gaming", "politics", "tech"}, 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "t-1" { t.Fatalf("expected t-1 to be enqueued, got %+v", fq.items) } lastChannel, _, _ := storage.GetRoundRobinState() if lastChannel != "tech" { t.Errorf("expected last_channel=tech after skipping politics, got %s", lastChannel) } } func TestTick_NothingPostable_LeavesPointerUnchanged(t *testing.T) { setupTestDB(t) storage.SetRoundRobinState("gaming", 1000) fq := &fakeQueue{} rr := New([]string{"gaming", "tech"}, 4, fq) rr.tick() if len(fq.items) != 0 { t.Fatalf("expected 0 enqueued, got %d", len(fq.items)) } lastChannel, lastTickAt, _ := storage.GetRoundRobinState() if lastChannel != "gaming" { t.Errorf("expected last_channel unchanged=gaming, got %s", lastChannel) } if lastTickAt <= 1000 { t.Errorf("expected last_tick_at to advance past 1000, got %d", lastTickAt) } } func TestTick_WrapsAroundFromLastChannel(t *testing.T) { setupTestDB(t) // Sorted order is [gaming, politics, tech]. last_channel=tech wraps to gaming. storage.SetRoundRobinState("tech", 0) insertPostable(t, "g-1", "TimeExt", "gaming", 100) insertPostable(t, "t-1", "GuardianTech", "tech", 200) // tech is newer, but rotation must pick gaming fq := &fakeQueue{} rr := New([]string{"gaming", "politics", "tech"}, 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "g-1" { t.Fatalf("expected g-1 (wraparound), got %+v", fq.items) } } func TestTick_DiscardedAndDuplicateExcluded(t *testing.T) { setupTestDB(t) insertPostable(t, "t-discard", "GuardianTech", "_discarded", 200) insertPostable(t, "t-dup", "GuardianTech", "_duplicate", 150) insertPostable(t, "t-real", "GuardianTech", "tech", 100) fq := &fakeQueue{} rr := New([]string{"tech"}, 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "t-real" { t.Fatalf("expected t-real (sentinels excluded), got %+v", fq.items) } } func TestTick_AlreadyPostedExcluded(t *testing.T) { setupTestDB(t) insertPostable(t, "t-posted", "GuardianTech", "tech", 200) insertPostable(t, "t-fresh", "GuardianTech", "tech", 100) storage.InsertPostLog("t-posted", "tech", "$evt1", "https://example.com/t-posted") fq := &fakeQueue{} rr := New([]string{"tech"}, 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "t-fresh" { t.Fatalf("expected t-fresh (t-posted already in post_log), got %+v", fq.items) } } func TestTick_UnknownLastChannel_StartsAtIndexZero(t *testing.T) { setupTestDB(t) // Stale state pointing at a channel no longer in config. storage.SetRoundRobinState("retired", 0) insertPostable(t, "g-1", "TimeExt", "gaming", 100) insertPostable(t, "t-1", "GuardianTech", "tech", 200) fq := &fakeQueue{} rr := New([]string{"gaming", "tech"}, 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "g-1" { t.Fatalf("expected g-1 (start at idx 0 when last_channel unknown), got %+v", fq.items) } }