package main import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/runner" ) func quietLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // fakeLister returns a fixed user set (or an error) for the scheduler under test. type fakeLister struct { users []store.UserIdentity err error noConn map[string]bool // users that have NOT connected a video source } func (f fakeLister) ListAllUsers(context.Context) ([]store.UserIdentity, error) { return f.users, f.err } // ConnectionsForUser reports a single youtube connection for every user except // those in noConn, which return zero — the connection-less case the scheduler // must skip instead of running (and failing to resolve a token for). func (f fakeLister) ConnectionsForUser(_ context.Context, userID string) ([]store.Connection, error) { if f.noConn[userID] { return nil, nil } return []store.Connection{{Provider: "youtube"}}, nil } // countingRunUser records how many passes each user got, optionally failing for // specific users, under a mutex so it is safe across the scheduler goroutine. type countingRunUser struct { mu sync.Mutex calls map[string]int order []string // userIDs in the order they were run, across all passes failFor map[string]bool } func newCountingRunUser(failFor ...string) *countingRunUser { c := &countingRunUser{calls: map[string]int{}, failFor: map[string]bool{}} for _, u := range failFor { c.failFor[u] = true } return c } func (c *countingRunUser) run(_ context.Context, userID string) (runner.Stats, error) { c.mu.Lock() defer c.mu.Unlock() c.calls[userID]++ c.order = append(c.order, userID) if c.failFor[userID] { return runner.Stats{Errors: 1}, errors.New("boom") } return runner.Stats{Summarized: 1}, nil } func (c *countingRunUser) runOrder() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.order...) } func (c *countingRunUser) count(userID string) int { c.mu.Lock() defer c.mu.Unlock() return c.calls[userID] } func (c *countingRunUser) total() int { c.mu.Lock() defer c.mu.Unlock() n := 0 for _, v := range c.calls { n += v } return n } func usersN(ids ...string) []store.UserIdentity { out := make([]store.UserIdentity, len(ids)) for i, id := range ids { out[i] = store.UserIdentity{UserID: id, DexSubject: "dex|" + id} } return out } func TestDiscoveryPassRunsEveryUserOnce(t *testing.T) { lister := fakeLister{users: usersN("a", "b", "c")} rc := newCountingRunUser() stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog()) require.Equal(t, 1, rc.count("a")) require.Equal(t, 1, rc.count("b")) require.Equal(t, 1, rc.count("c")) require.Equal(t, 3, stats.Summarized, "stats are summed across users") } // Caption fetches share one per-IP budget; a fixed user order starves whoever is // last. Each pass must rotate which user leads so the lead slot is shared. func TestDiscoveryPassRotatesLeadUser(t *testing.T) { lister := fakeLister{users: usersN("a", "b", "c")} rc := newCountingRunUser() runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog()) runDiscoveryPass(context.Background(), 1, lister, rc.run, quietLog()) runDiscoveryPass(context.Background(), 2, lister, rc.run, quietLog()) require.Equal(t, []string{"a", "b", "c", "b", "c", "a", "c", "a", "b"}, rc.runOrder(), "each pass left-rotates the user order so every user leads in turn") // Fairness: over a full rotation cycle every user ran the same number of times. require.Equal(t, 3, rc.count("a")) require.Equal(t, 3, rc.count("b")) require.Equal(t, 3, rc.count("c")) } // A connectionless orphan must not consume a rotation slot: rotation is over the // connected users only, so two real users alternate the lead 50/50 even with a // dead identity listed between them. func TestDiscoveryPassRotationIgnoresConnectionlessUsers(t *testing.T) { lister := fakeLister{users: usersN("a", "orphan", "c"), noConn: map[string]bool{"orphan": true}} rc := newCountingRunUser() runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog()) runDiscoveryPass(context.Background(), 1, lister, rc.run, quietLog()) require.Equal(t, []string{"a", "c", "c", "a"}, rc.runOrder(), "only connected users rotate; the orphan never runs and never holds a slot") require.Equal(t, 0, rc.count("orphan")) } func TestDiscoveryPassSkipsUsersWithoutConnections(t *testing.T) { // b never connected a video source (e.g. a stale Dex-era orphan identity). // It must be skipped silently — not run and logged as a token error every pass. lister := fakeLister{users: usersN("a", "b", "c"), noConn: map[string]bool{"b": true}} rc := newCountingRunUser() stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog()) require.Equal(t, 1, rc.count("a")) require.Equal(t, 0, rc.count("b"), "a user with no connection must be skipped, not run") require.Equal(t, 1, rc.count("c")) require.Equal(t, 2, stats.Summarized, "only connected users contribute") require.Equal(t, 0, stats.Errors, "skipping is silent — no spurious error stat") } func TestDiscoveryPassOneUserFailureDoesNotStopOthers(t *testing.T) { lister := fakeLister{users: usersN("a", "b", "c")} rc := newCountingRunUser("b") // user b's pass errors stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog()) require.Equal(t, 1, rc.count("a")) require.Equal(t, 1, rc.count("b")) require.Equal(t, 1, rc.count("c"), "a failing user must not abort the rest") require.Equal(t, 2, stats.Summarized) // a + c require.Equal(t, 1, stats.Errors) // b } func TestDiscoveryPassListerErrorIsContained(t *testing.T) { lister := fakeLister{err: errors.New("db down")} rc := newCountingRunUser() stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog()) require.Equal(t, 0, rc.total(), "no users enumerated → no passes") require.Equal(t, runner.Stats{}, stats) } func TestSchedulerIntervalZeroDisablesEntirely(t *testing.T) { lister := fakeLister{users: usersN("a", "b")} rc := newCountingRunUser() runScheduler(context.Background(), 0, lister, rc.run, quietLog()) require.Equal(t, 0, rc.total(), "interval 0 must not run even a startup pass") } func TestSchedulerRunsStartupPassThenStopsOnCancel(t *testing.T) { lister := fakeLister{users: usersN("a", "b", "c")} rc := newCountingRunUser() ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { // A long interval so only the startup pass runs before we cancel. runScheduler(ctx, time.Hour, lister, rc.run, quietLog()) close(done) }() // The startup pass is synchronous at the top of runScheduler; once all three // users have a pass it has completed and the loop is parked on the ticker. require.Eventually(t, func() bool { return rc.total() == 3 }, time.Second, 5*time.Millisecond) cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("scheduler did not exit after ctx cancel") } require.Equal(t, 3, rc.total(), "no extra passes fired between startup and cancel") }