package scheduler import ( "path/filepath" "testing" "pete/internal/config" "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, FeedHint: "tech", SeenAt: seenAt, }); err != nil { t.Fatalf("InsertStory(%s): %v", guid, err) } storage.MarkClassified(guid, channel, "[]") } func sources(names ...string) []config.SourceConfig { out := make([]config.SourceConfig, len(names)) for i, n := range names { out[i] = config.SourceConfig{ Name: n, FeedURL: "https://example.com/" + n, Tier: 1, PollIntervalMinutes: 20, Enabled: true, } } return out } func TestNew_FiltersDisabledSources(t *testing.T) { srcs := []config.SourceConfig{ {Name: "A", Enabled: true}, {Name: "B", Enabled: false}, {Name: "C", Enabled: true}, } rr := New(srcs, 4, &fakeQueue{}) if rr == nil { t.Fatal("expected non-nil RoundRobin") } if len(rr.sources) != 2 || rr.sources[0].Name != "A" || rr.sources[1].Name != "C" { t.Errorf("expected [A,C], got %v", rr.sources) } } func TestNew_NoEnabledSources_ReturnsNil(t *testing.T) { srcs := []config.SourceConfig{{Name: "A", Enabled: false}} if New(srcs, 4, &fakeQueue{}) != nil { t.Error("expected nil when no sources are enabled") } } func TestTick_PicksNewestPostableFromFirstSource(t *testing.T) { setupTestDB(t) insertPostable(t, "a-old", "A", "tech", 100) insertPostable(t, "a-new", "A", "tech", 200) insertPostable(t, "b-1", "B", "politics", 150) fq := &fakeQueue{} rr := New(sources("A", "B"), 4, fq) rr.tick() if len(fq.items) != 1 { t.Fatalf("expected 1 enqueued, got %d", len(fq.items)) } if fq.items[0].GUID != "a-new" { t.Errorf("expected newest from A (a-new), got %s", fq.items[0].GUID) } lastSource, _, _ := storage.GetRoundRobinState() if lastSource != "A" { t.Errorf("expected last_source=A, got %s", lastSource) } } func TestTick_SkipsEmptyAndAdvancesPointer(t *testing.T) { setupTestDB(t) // Pretend we just posted from A; next tick should start at B, which is // empty, so it should skip to C. storage.SetRoundRobinState("A", 0) insertPostable(t, "c-1", "C", "tech", 100) fq := &fakeQueue{} rr := New(sources("A", "B", "C"), 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "c-1" { t.Fatalf("expected c-1 to be enqueued, got %+v", fq.items) } lastSource, _, _ := storage.GetRoundRobinState() if lastSource != "C" { t.Errorf("expected last_source=C after skipping B, got %s", lastSource) } } func TestTick_NothingPostable_LeavesPointerUnchanged(t *testing.T) { setupTestDB(t) storage.SetRoundRobinState("A", 1000) fq := &fakeQueue{} rr := New(sources("A", "B"), 4, fq) rr.tick() if len(fq.items) != 0 { t.Fatalf("expected 0 enqueued, got %d", len(fq.items)) } lastSource, lastTickAt, _ := storage.GetRoundRobinState() if lastSource != "A" { t.Errorf("expected last_source unchanged=A, got %s", lastSource) } if lastTickAt <= 1000 { t.Errorf("expected last_tick_at to advance past 1000, got %d", lastTickAt) } } func TestTick_WrapsAroundFromLastSource(t *testing.T) { setupTestDB(t) // last_source = C means next tick starts at A (wraparound). storage.SetRoundRobinState("C", 0) insertPostable(t, "a-1", "A", "tech", 100) insertPostable(t, "c-1", "C", "tech", 200) // C is newer, but rotation must pick A fq := &fakeQueue{} rr := New(sources("A", "B", "C"), 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "a-1" { t.Fatalf("expected a-1 (wraparound), got %+v", fq.items) } } func TestTick_DiscardedAndDuplicateExcluded(t *testing.T) { setupTestDB(t) insertPostable(t, "a-discard", "A", "_discarded", 200) // sentinel insertPostable(t, "a-dup", "A", "_duplicate", 150) // sentinel insertPostable(t, "a-real", "A", "tech", 100) // older but only real one fq := &fakeQueue{} rr := New(sources("A"), 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "a-real" { t.Fatalf("expected a-real (sentinels excluded), got %+v", fq.items) } } func TestTick_AlreadyPostedExcluded(t *testing.T) { setupTestDB(t) insertPostable(t, "a-posted", "A", "tech", 200) insertPostable(t, "a-fresh", "A", "tech", 100) storage.InsertPostLog("a-posted", "tech", "$evt1", "https://example.com/a-posted") fq := &fakeQueue{} rr := New(sources("A"), 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "a-fresh" { t.Fatalf("expected a-fresh (a-posted already in post_log), got %+v", fq.items) } } func TestTick_UnknownLastSource_StartsAtIndexZero(t *testing.T) { setupTestDB(t) // Stale state pointing at a source no longer in config. storage.SetRoundRobinState("RetiredSource", 0) insertPostable(t, "a-1", "A", "tech", 100) insertPostable(t, "b-1", "B", "tech", 200) fq := &fakeQueue{} rr := New(sources("A", "B"), 4, fq) rr.tick() if len(fq.items) != 1 || fq.items[0].GUID != "a-1" { t.Fatalf("expected a-1 (start at idx 0 when last_source unknown), got %+v", fq.items) } }