feat(discovery): trigger a discovery pass on YouTube connect
A newly connected account showed no videos until the next 2h scheduled pass — the gap that made onboarding look broken (a second user connected, saw nothing, read as failure). The connect callback now fires an out-of-band discovery pass for the connecting user, so videos appear promptly. Concurrency: scheduled and connect-triggered passes share one lock (serialize), preserving the single-fetcher invariant (ADR-018). A trigger interleaves between the scheduler's per-user passes rather than fetching concurrently or waiting for a whole pass. The trigger runs on the server ctx (survives the redirect) and is non-blocking for the request goroutine. Scope: connect-trigger only. The optional login-refresh / "Discover now" button from #6 are intentionally not built — an unconditional login hook risks 429 storms (per the ticket's own recommendation); defer until wanted. TDD: TestCallbackTriggersDiscovery, TestSerializeRunsOneAtATime, TestDiscoveryTriggerEnqueueRunsUser; new BDD scenario mapped. Refs #6 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,15 @@ type Connections interface {
|
||||
UpsertConnection(ctx context.Context, userID string, c store.Connection) error
|
||||
}
|
||||
|
||||
// DiscoveryTrigger requests an out-of-band discovery pass for a user. The connect
|
||||
// flow fires it the moment a YouTube account is linked so videos appear promptly
|
||||
// instead of waiting for the next scheduled pass (#6). Enqueue must be
|
||||
// non-blocking and safe to call from the request goroutine; the implementation
|
||||
// owns serialization with the scheduler (one pass at a time). nil = no trigger.
|
||||
type DiscoveryTrigger interface {
|
||||
Enqueue(userID string)
|
||||
}
|
||||
|
||||
// connectStateTTL bounds how long a generated CSRF state is valid between the
|
||||
// connect redirect and the provider callback.
|
||||
const connectStateTTL = 10 * time.Minute
|
||||
@@ -43,6 +52,10 @@ type ConnectHandler struct {
|
||||
Conns Connections
|
||||
Log *slog.Logger
|
||||
|
||||
// Discovery, when set, is fired after a successful connect so the new
|
||||
// connection's videos are discovered immediately (#6). Optional.
|
||||
Discovery DiscoveryTrigger
|
||||
|
||||
states *connectStateStore
|
||||
now func() time.Time
|
||||
}
|
||||
@@ -134,6 +147,12 @@ func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Discover this user's videos now rather than waiting for the next scheduled
|
||||
// pass (#6). Non-blocking; the trigger serializes with the scheduler.
|
||||
if h.Discovery != nil {
|
||||
h.Discovery.Enqueue(userID)
|
||||
}
|
||||
|
||||
setFlash(w, flashConnected)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,24 @@ func (c *fakeConns) UpsertConnection(_ context.Context, userID string, conn stor
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeTrigger records Enqueue calls so a test can assert connect fired discovery.
|
||||
type fakeTrigger struct {
|
||||
mu sync.Mutex
|
||||
users []string
|
||||
}
|
||||
|
||||
func (f *fakeTrigger) Enqueue(userID string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.users = append(f.users, userID)
|
||||
}
|
||||
|
||||
func (f *fakeTrigger) seen() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.users...)
|
||||
}
|
||||
|
||||
// tokenServer fakes Google's token endpoint, returning body for any POST.
|
||||
func tokenServer(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
@@ -126,6 +144,36 @@ func TestCallbackExchangesAndRecordsConnection(t *testing.T) {
|
||||
require.Equal(t, wantRef, conns.conn.TokenRef)
|
||||
}
|
||||
|
||||
func TestCallbackTriggersDiscovery(t *testing.T) {
|
||||
srv := tokenServer(t,
|
||||
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
||||
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
|
||||
trig := &fakeTrigger{}
|
||||
app.Connect.Discovery = trig
|
||||
|
||||
state := connectState(t, app)
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
||||
"/oauth/youtube/callback?state="+state+"&code=the-code", nil))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
require.Equal(t, []string{userID}, trig.seen(),
|
||||
"a successful connect must trigger discovery for the connecting user")
|
||||
}
|
||||
|
||||
func TestCallbackNoDiscoveryOnFailedConnect(t *testing.T) {
|
||||
srv := tokenServer(t,
|
||||
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
|
||||
trig := &fakeTrigger{}
|
||||
app.Connect.Discovery = trig
|
||||
|
||||
// No state → CSRF reject → nothing connected, so no discovery.
|
||||
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
||||
"/oauth/youtube/callback?code=the-code", nil))
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Empty(t, trig.seen(), "a failed connect must not trigger discovery")
|
||||
}
|
||||
|
||||
func TestCallbackRejectsMissingState(t *testing.T) {
|
||||
srv := tokenServer(t,
|
||||
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
|
||||
Reference in New Issue
Block a user