Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and all .go import paths. Build and tests pass unchanged. Closes #20 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
217 lines
7.1 KiB
Go
217 lines
7.1 KiB
Go
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")
|
|
}
|