Files
tapir/cmd/tapir/scheduler_test.go
T
mathiasandClaude Opus 4.8 f6623afd41 feat(serve): in-process scheduled discovery for all users (ADR-018)
Stage 0's "returns and reads in >=2 weeks" gate can't be met while discovery
is host-side manual (`tapir run`): a newly onboarded user sees an empty list
and never comes back. Make Tapir watch on its own.

cmdServe launches a background goroutine (when TAPIR_DISCOVERY_INTERVAL > 0)
that runs a discovery pass for ALL users on that cadence: enumerate via the
un-RLS'd ListAllUsers, then run each user's pass through the EXISTING
runner.Runner — the only new code is the per-user loop, not a new scheduler.
Run-once-on-startup then ticked; ctx-cancelled on SIGTERM; per-user failures
(including buildUserRunner errors) are logged and skipped so one bad user
never aborts the rest. interval <= 0 disables it entirely (dev/tests).

buildUserRunner binds each runner to that user's own YouTube refresh token
(web.YouTubeTokenRef) — the Stage-1 per-tenant ref — reusing buildProcessor's
engine wiring. SetFetchRate is also wired in cmdServe so the click-path shares
the gate.

SINGLE-REPLICA is now load-bearing: the loop lives in the web process, so >1
replica double-runs discovery (429s + duplicate work). Documented in cmdServe
and warned at startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:39:39 +02:00

150 lines
4.1 KiB
Go

package main
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.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
}
func (f fakeLister) ListAllUsers(context.Context) ([]store.UserIdentity, error) {
return f.users, f.err
}
// 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
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]++
if c.failFor[userID] {
return runner.Stats{Errors: 1}, errors.New("boom")
}
return runner.Stats{Summarized: 1}, nil
}
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(), 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")
}
func TestDiscoveryPassOneUserFailureDoesNotStopOthers(t *testing.T) {
lister := fakeLister{users: usersN("a", "b", "c")}
rc := newCountingRunUser("b") // user b's pass errors
stats := runDiscoveryPass(context.Background(), 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(), 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")
}