go-scm/jobrunner/poller_test.go
Claude 3e883f6976
feat: extract SCM/forge integration packages from core/go
Forgejo and Gitea SDK wrappers, multi-repo git utilities, AgentCI
dispatch, distributed job orchestrator, and data collection pipelines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:25:58 +00:00

307 lines
6.4 KiB
Go

package jobrunner
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Mock source ---
type mockSource struct {
name string
signals []*PipelineSignal
reports []*ActionResult
mu sync.Mutex
}
func (m *mockSource) Name() string { return m.name }
func (m *mockSource) Poll(_ context.Context) ([]*PipelineSignal, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.signals, nil
}
func (m *mockSource) Report(_ context.Context, result *ActionResult) error {
m.mu.Lock()
defer m.mu.Unlock()
m.reports = append(m.reports, result)
return nil
}
// --- Mock handler ---
type mockHandler struct {
name string
matchFn func(*PipelineSignal) bool
executed []*PipelineSignal
mu sync.Mutex
}
func (m *mockHandler) Name() string { return m.name }
func (m *mockHandler) Match(sig *PipelineSignal) bool {
if m.matchFn != nil {
return m.matchFn(sig)
}
return true
}
func (m *mockHandler) Execute(_ context.Context, sig *PipelineSignal) (*ActionResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.executed = append(m.executed, sig)
return &ActionResult{
Action: m.name,
RepoOwner: sig.RepoOwner,
RepoName: sig.RepoName,
PRNumber: sig.PRNumber,
Success: true,
Timestamp: time.Now(),
}, nil
}
func TestPoller_RunOnce_Good(t *testing.T) {
sig := &PipelineSignal{
EpicNumber: 1,
ChildNumber: 2,
PRNumber: 10,
RepoOwner: "host-uk",
RepoName: "core-php",
PRState: "OPEN",
CheckStatus: "SUCCESS",
Mergeable: "MERGEABLE",
}
src := &mockSource{
name: "test-source",
signals: []*PipelineSignal{sig},
}
handler := &mockHandler{
name: "test-handler",
matchFn: func(s *PipelineSignal) bool {
return s.PRNumber == 10
},
}
p := NewPoller(PollerConfig{
Sources: []JobSource{src},
Handlers: []JobHandler{handler},
})
err := p.RunOnce(context.Background())
require.NoError(t, err)
// Handler should have been called with our signal.
handler.mu.Lock()
defer handler.mu.Unlock()
require.Len(t, handler.executed, 1)
assert.Equal(t, 10, handler.executed[0].PRNumber)
// Source should have received a report.
src.mu.Lock()
defer src.mu.Unlock()
require.Len(t, src.reports, 1)
assert.Equal(t, "test-handler", src.reports[0].Action)
assert.True(t, src.reports[0].Success)
assert.Equal(t, 1, src.reports[0].Cycle)
assert.Equal(t, 1, src.reports[0].EpicNumber)
assert.Equal(t, 2, src.reports[0].ChildNumber)
// Cycle counter should have incremented.
assert.Equal(t, 1, p.Cycle())
}
func TestPoller_RunOnce_Good_NoSignals(t *testing.T) {
src := &mockSource{
name: "empty-source",
signals: nil,
}
handler := &mockHandler{
name: "unused-handler",
}
p := NewPoller(PollerConfig{
Sources: []JobSource{src},
Handlers: []JobHandler{handler},
})
err := p.RunOnce(context.Background())
require.NoError(t, err)
// Handler should not have been called.
handler.mu.Lock()
defer handler.mu.Unlock()
assert.Empty(t, handler.executed)
// Source should not have received reports.
src.mu.Lock()
defer src.mu.Unlock()
assert.Empty(t, src.reports)
assert.Equal(t, 1, p.Cycle())
}
func TestPoller_RunOnce_Good_NoMatchingHandler(t *testing.T) {
sig := &PipelineSignal{
EpicNumber: 5,
ChildNumber: 8,
PRNumber: 42,
RepoOwner: "host-uk",
RepoName: "core-tenant",
PRState: "OPEN",
}
src := &mockSource{
name: "test-source",
signals: []*PipelineSignal{sig},
}
handler := &mockHandler{
name: "picky-handler",
matchFn: func(s *PipelineSignal) bool {
return false // never matches
},
}
p := NewPoller(PollerConfig{
Sources: []JobSource{src},
Handlers: []JobHandler{handler},
})
err := p.RunOnce(context.Background())
require.NoError(t, err)
// Handler should not have been called.
handler.mu.Lock()
defer handler.mu.Unlock()
assert.Empty(t, handler.executed)
// Source should not have received reports (no action taken).
src.mu.Lock()
defer src.mu.Unlock()
assert.Empty(t, src.reports)
}
func TestPoller_RunOnce_Good_DryRun(t *testing.T) {
sig := &PipelineSignal{
EpicNumber: 1,
ChildNumber: 3,
PRNumber: 20,
RepoOwner: "host-uk",
RepoName: "core-admin",
PRState: "OPEN",
CheckStatus: "SUCCESS",
Mergeable: "MERGEABLE",
}
src := &mockSource{
name: "test-source",
signals: []*PipelineSignal{sig},
}
handler := &mockHandler{
name: "merge-handler",
matchFn: func(s *PipelineSignal) bool {
return true
},
}
p := NewPoller(PollerConfig{
Sources: []JobSource{src},
Handlers: []JobHandler{handler},
DryRun: true,
})
assert.True(t, p.DryRun())
err := p.RunOnce(context.Background())
require.NoError(t, err)
// Handler should NOT have been called in dry-run mode.
handler.mu.Lock()
defer handler.mu.Unlock()
assert.Empty(t, handler.executed)
// Source should not have received reports.
src.mu.Lock()
defer src.mu.Unlock()
assert.Empty(t, src.reports)
}
func TestPoller_SetDryRun_Good(t *testing.T) {
p := NewPoller(PollerConfig{})
assert.False(t, p.DryRun())
p.SetDryRun(true)
assert.True(t, p.DryRun())
p.SetDryRun(false)
assert.False(t, p.DryRun())
}
func TestPoller_AddSourceAndHandler_Good(t *testing.T) {
p := NewPoller(PollerConfig{})
sig := &PipelineSignal{
EpicNumber: 1,
ChildNumber: 1,
PRNumber: 5,
RepoOwner: "host-uk",
RepoName: "core-php",
PRState: "OPEN",
}
src := &mockSource{
name: "added-source",
signals: []*PipelineSignal{sig},
}
handler := &mockHandler{
name: "added-handler",
matchFn: func(s *PipelineSignal) bool { return true },
}
p.AddSource(src)
p.AddHandler(handler)
err := p.RunOnce(context.Background())
require.NoError(t, err)
handler.mu.Lock()
defer handler.mu.Unlock()
require.Len(t, handler.executed, 1)
assert.Equal(t, 5, handler.executed[0].PRNumber)
}
func TestPoller_Run_Good(t *testing.T) {
src := &mockSource{
name: "tick-source",
signals: nil,
}
p := NewPoller(PollerConfig{
Sources: []JobSource{src},
PollInterval: 50 * time.Millisecond,
})
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Millisecond)
defer cancel()
err := p.Run(ctx)
assert.ErrorIs(t, err, context.DeadlineExceeded)
// Should have completed at least 2 cycles (one immediate + at least one tick).
assert.GreaterOrEqual(t, p.Cycle(), 2)
}
func TestPoller_DefaultInterval_Good(t *testing.T) {
p := NewPoller(PollerConfig{})
assert.Equal(t, 60*time.Second, p.interval)
}