Files
kandev/apps/backend/internal/workflowsync/circuit_test.go
T
2026-09-22 10:31:53 +01:00

192 lines
7.9 KiB
Go

package workflowsync
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/common/authcircuit"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/github"
)
// fakeFingerprintGitHubClients wraps fakeGitHubClients with a
// CredentialFingerprintProvider implementation, so tests can drive the
// "credential rotated" reset path without a real github.Service.
type fakeFingerprintGitHubClients struct {
fakeGitHubClients
fingerprint string
err error
}
func (f fakeFingerprintGitHubClients) WorkspaceConnectionFingerprint(
_ context.Context, _ string,
) (string, error) {
return f.fingerprint, f.err
}
var _ CredentialFingerprintProvider = fakeFingerprintGitHubClients{}
func setupCircuitTestService(t *testing.T, clients GitHubClientProvider) (*Service, *fakeApplier) {
t.Helper()
store := setupTestStore(t)
applier := &fakeApplier{}
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
require.NoError(t, err)
return NewService(store, clients, nil, applier, log), applier
}
// TestSyncDueConfigs_OpenCircuitSkipsWithoutCallingProvider is the core
// AC-9 behavior: once a config's circuit is open, SyncDueConfigs must not
// call the provider at all — not even to fail again — until the backoff
// window elapses or the credential/config changes.
func TestSyncDueConfigs_OpenCircuitSkipsWithoutCallingProvider(t *testing.T) {
svc, applier := setupCircuitTestService(t, fakeGitHubClients{client: seededMockClient()})
configureWorkspace(t, svc, "ws-1")
future := time.Now().UTC().Add(time.Hour)
require.NoError(t, svc.store.RecordSyncStatus(
context.Background(), "ws-1", false, "boom", nil, "", time.Now().UTC(),
authcircuit.State{FailureClass: authcircuit.FailureClassAuth, ConsecutiveFailures: 3, NextRetryAt: &future},
))
svc.SyncDueConfigs(context.Background())
assert.Empty(t, applier.calls, "an open circuit must skip the sync attempt entirely")
}
// TestSyncDueConfigs_RepeatedFailuresOpenCircuit confirms a config that
// keeps failing (here: a 404 "directory not found" from the mock client,
// classified as config) eventually stops retrying every tick, rather than
// costing one GitHub call per tick forever.
func TestSyncDueConfigs_RepeatedFailuresOpenCircuit(t *testing.T) {
svc, applier := setupCircuitTestService(t, fakeGitHubClients{client: github.NewMockClient()}) // nothing seeded → 404 every time
configureWorkspace(t, svc, "ws-1")
// First attempt: due (never synced), fails, opens the circuit.
svc.SyncDueConfigs(context.Background())
cfg, err := svc.GetConfigForWorkspace(context.Background(), "ws-1")
require.NoError(t, err)
assert.Equal(t, authcircuit.FailureClassConfig, cfg.FailureClass)
require.NotNil(t, cfg.NextRetryAt)
assert.True(t, cfg.NextRetryAt.After(time.Now().UTC()), "backoff should schedule a future retry")
// A config-class failure uses the long "permanent" backoff schedule
// (minutes, not the config's own 5-minute poll interval alone) — so a
// tick that would otherwise be "due" per the plain interval must still
// be skipped while the circuit is open.
svc.SyncDueConfigs(context.Background())
assert.Empty(t, applier.calls, "no successful apply ever happened, but no second GitHub call either")
}
// TestSyncDueConfigs_CredentialFingerprintChangeResetsCircuitAndForcesSync
// confirms Task 04's headline behavior: rotating/reconnecting a credential
// (a fingerprint change) resumes syncing on the very next tick rather than
// waiting out the remaining permanent backoff.
func TestSyncDueConfigs_CredentialFingerprintChangeResetsCircuitAndForcesSync(t *testing.T) {
clients := fakeFingerprintGitHubClients{
fakeGitHubClients: fakeGitHubClients{client: seededMockClient()},
fingerprint: "active:1",
}
svc, applier := setupCircuitTestService(t, clients)
configureWorkspace(t, svc, "ws-1")
future := time.Now().UTC().Add(time.Hour)
require.NoError(t, svc.store.RecordCircuitState(
context.Background(), "ws-1",
authcircuit.State{
FailureClass: authcircuit.FailureClassAuth, ConsecutiveFailures: 3, NextRetryAt: &future,
Fingerprint: "active:1",
},
))
// Same fingerprint: circuit stays open, still no sync.
svc.SyncDueConfigs(context.Background())
assert.Empty(t, applier.calls, "an unchanged fingerprint must not reset an open circuit")
// Credential rotates (generation bumps).
clients.fingerprint = "active:2"
svc.githubClients = clients
svc.SyncDueConfigs(context.Background())
assert.Len(t, applier.calls, 1, "a changed fingerprint must reset the circuit and sync immediately")
cfg, err := svc.GetConfigForWorkspace(context.Background(), "ws-1")
require.NoError(t, err)
assert.Empty(t, cfg.FailureClass, "a successful sync after reset clears the failure class")
}
// TestSyncDueConfigs_EmptyFingerprintNeverResets confirms a provider that
// cannot currently determine the fingerprint (transient lookup error, or a
// workspace with no connection at all) never accidentally clears an open
// circuit — see authcircuit.State.ResetIfFingerprintChanged's contract.
func TestSyncDueConfigs_EmptyFingerprintNeverResets(t *testing.T) {
clients := fakeFingerprintGitHubClients{
fakeGitHubClients: fakeGitHubClients{client: seededMockClient()},
fingerprint: "",
}
svc, applier := setupCircuitTestService(t, clients)
configureWorkspace(t, svc, "ws-1")
future := time.Now().UTC().Add(time.Hour)
require.NoError(t, svc.store.RecordSyncStatus(
context.Background(), "ws-1", false, "boom", nil, "", time.Now().UTC(),
authcircuit.State{FailureClass: authcircuit.FailureClassAuth, ConsecutiveFailures: 3, NextRetryAt: &future},
))
svc.SyncDueConfigs(context.Background())
assert.Empty(t, applier.calls, "an empty fingerprint must never reset an open circuit")
}
func TestSyncDueConfigs_CredentialChangeRespectsPollingDisabled(t *testing.T) {
clients := fakeFingerprintGitHubClients{
fakeGitHubClients: fakeGitHubClients{client: seededMockClient()},
fingerprint: "active:1",
}
svc, applier := setupCircuitTestService(t, clients)
disabled := false
_, err := svc.SetConfigForWorkspace(context.Background(), "ws-1", &SetConfigRequest{
RepoOwner: "acme",
RepoName: "flows",
PollEnabled: &disabled,
})
require.NoError(t, err)
// The first tick records the fingerprint baseline, but polling is disabled.
svc.SyncDueConfigs(context.Background())
assert.Empty(t, applier.calls)
// A rotated credential must not turn a manual-only config into an automatic
// sync. The explicit SyncWorkspace path remains available to the user.
clients.fingerprint = "active:2"
svc.githubClients = clients
svc.SyncDueConfigs(context.Background())
assert.Empty(t, applier.calls, "credential changes must not bypass PollEnabled")
}
// TestWorkflowSyncCircuitSummary_AggregatesByClass confirms the health
// checker's data source counts only open circuits, grouped by class, with
// no per-workspace identifiers leaking into the aggregate.
func TestWorkflowSyncCircuitSummary_AggregatesByClass(t *testing.T) {
svc, _ := setupCircuitTestService(t, fakeGitHubClients{client: seededMockClient()})
configureWorkspace(t, svc, "ws-auth")
configureWorkspace(t, svc, "ws-config")
configureWorkspace(t, svc, "ws-ok")
future := time.Now().UTC().Add(time.Hour)
ctx := context.Background()
require.NoError(t, svc.store.RecordSyncStatus(ctx, "ws-auth", false, "boom", nil, "", time.Now().UTC(),
authcircuit.State{FailureClass: authcircuit.FailureClassAuth, ConsecutiveFailures: 1, NextRetryAt: &future}))
require.NoError(t, svc.store.RecordSyncStatus(ctx, "ws-config", false, "boom", nil, "", time.Now().UTC(),
authcircuit.State{FailureClass: authcircuit.FailureClassConfig, ConsecutiveFailures: 1, NextRetryAt: &future}))
summary, err := svc.WorkflowSyncCircuitSummary(ctx)
require.NoError(t, err)
assert.Equal(t, 3, summary.Total)
assert.Equal(t, 1, summary.OpenAuth)
assert.Equal(t, 1, summary.OpenConfig)
assert.Equal(t, 0, summary.OpenTransient)
}