Files
kandev/apps/backend/internal/github/poller_test.go
T

362 lines
9.4 KiB
Go

package github
import (
"context"
"database/sql"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
)
// setupPollerTest creates a Poller backed by an in-memory SQLite store and MockClient.
func setupPollerTest(t *testing.T) (*Poller, *Service, *MockClient, *Store) {
t.Helper()
rawDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
rawDB.SetMaxOpenConns(1)
rawDB.SetMaxIdleConns(1)
sqlxDB := sqlx.NewDb(rawDB, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
store, err := NewStore(sqlxDB, sqlxDB)
if err != nil {
t.Fatalf("new store: %v", err)
}
mockClient := NewMockClient()
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
eventBus := bus.NewMemoryEventBus(log)
svc := NewService(mockClient, "pat", nil, store, eventBus, log)
poller := NewPoller(svc, eventBus, log)
return poller, svc, mockClient, store
}
func TestCheckSinglePRWatch_MergedPR_SyncsThenDeletes(t *testing.T) {
poller, _, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Set up a merged PR in the mock client.
now := time.Now().UTC()
mergedAt := now.Add(-1 * time.Hour)
mockClient.AddPR(&PR{
Number: 42,
Title: "Feature PR",
State: prStateMerged,
HeadSHA: "abc123",
HeadBranch: "feature-branch",
RepoOwner: "owner",
RepoName: "repo",
MergedAt: &mergedAt,
})
// Create a PRWatch in the DB.
watch := &PRWatch{
SessionID: "sess-1",
TaskID: "task-1",
Owner: "owner",
Repo: "repo",
PRNumber: 42,
Branch: "feature-branch",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Create a TaskPR record so SyncTaskPR has something to update.
taskPR := &TaskPR{
TaskID: "task-1",
Owner: "owner",
Repo: "repo",
PRNumber: 42,
PRURL: "https://github.com/owner/repo/pull/42",
PRTitle: "Feature PR",
HeadBranch: "feature-branch",
BaseBranch: "main",
State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Act
poller.checkSinglePRWatch(ctx, watch)
// Assert: TaskPR record should be updated with state="merged".
updatedTP, err := store.GetTaskPR(ctx, "task-1")
if err != nil {
t.Fatalf("get task PR: %v", err)
}
if updatedTP == nil {
t.Fatal("expected task PR to exist after sync")
}
if updatedTP.State != prStateMerged {
t.Errorf("expected task PR state=%q, got %q", prStateMerged, updatedTP.State)
}
if updatedTP.MergedAt == nil {
t.Error("expected task PR MergedAt to be set")
}
// Assert: PRWatch should be deleted.
remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-1")
if err != nil {
t.Fatalf("get PR watch: %v", err)
}
if remainingWatch != nil {
t.Error("expected PR watch to be deleted after merged PR")
}
}
func TestCheckSinglePRWatch_OpenPR_SyncsOnChange(t *testing.T) {
poller, _, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Set up an open PR with a passing check.
mockClient.AddPR(&PR{
Number: 10,
Title: "Open PR",
State: "open",
HeadSHA: "def456",
HeadBranch: "open-branch",
RepoOwner: "owner",
RepoName: "repo",
Additions: 5,
Deletions: 3,
})
mockClient.AddCheckRuns("owner", "repo", "def456", []CheckRun{
{Name: "ci", Status: "completed", Conclusion: "success"},
})
// Create a PRWatch with a different last_check_status to trigger hasNew.
watch := &PRWatch{
SessionID: "sess-2",
TaskID: "task-2",
Owner: "owner",
Repo: "repo",
PRNumber: 10,
Branch: "open-branch",
LastCheckStatus: "pending", // different from "success" -> hasNew=true
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Create a TaskPR record.
taskPR := &TaskPR{
TaskID: "task-2",
Owner: "owner",
Repo: "repo",
PRNumber: 10,
PRURL: "https://github.com/owner/repo/pull/10",
PRTitle: "Open PR",
HeadBranch: "open-branch",
BaseBranch: "main",
State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Act
poller.checkSinglePRWatch(ctx, watch)
// Assert: TaskPR should be synced with latest data.
updatedTP, err := store.GetTaskPR(ctx, "task-2")
if err != nil {
t.Fatalf("get task PR: %v", err)
}
if updatedTP == nil {
t.Fatal("expected task PR to exist")
}
if updatedTP.State != "open" {
t.Errorf("expected state=open, got %q", updatedTP.State)
}
if updatedTP.ChecksState != "success" {
t.Errorf("expected checks_state=success, got %q", updatedTP.ChecksState)
}
if updatedTP.Additions != 5 {
t.Errorf("expected additions=5, got %d", updatedTP.Additions)
}
// Assert: PRWatch should NOT be deleted (PR is still open).
remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-2")
if err != nil {
t.Fatalf("get PR watch: %v", err)
}
if remainingWatch == nil {
t.Error("expected PR watch to still exist for open PR")
}
}
// mockTaskBranchProvider implements TaskBranchProvider for testing.
type mockTaskBranchProvider struct {
tasks []TaskBranchInfo
err error
}
func (m *mockTaskBranchProvider) ListTasksNeedingPRWatch(_ context.Context) ([]TaskBranchInfo, error) {
return m.tasks, m.err
}
func TestReconcileWatches_CreatesWatchesForTasks(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
prov := &mockTaskBranchProvider{
tasks: []TaskBranchInfo{
{TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"},
{TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"},
},
}
poller.SetTaskBranchProvider(prov)
poller.reconcileWatches(ctx)
// Verify watches were created for both sessions.
w1, err := store.GetPRWatchBySession(ctx, "s1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w1 == nil {
t.Fatal("expected PR watch for session s1")
}
if w1.Branch != "feature-a" {
t.Errorf("expected branch %q, got %q", "feature-a", w1.Branch)
}
if w1.TaskID != "t1" {
t.Errorf("expected task_id %q, got %q", "t1", w1.TaskID)
}
w2, err := store.GetPRWatchBySession(ctx, "s2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w2 == nil {
t.Fatal("expected PR watch for session s2")
}
if w2.Branch != "feature-b" {
t.Errorf("expected branch %q, got %q", "feature-b", w2.Branch)
}
}
func TestReconcileWatches_NilProvider(t *testing.T) {
poller, _, _, _ := setupPollerTest(t)
ctx := context.Background()
// Should not panic when provider is nil.
poller.reconcileWatches(ctx)
}
func TestReconcileWatches_SkipsExistingWatches(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
// Pre-create a watch for s1.
existing := &PRWatch{
SessionID: "s1",
TaskID: "t1",
Owner: "myorg",
Repo: "myrepo",
PRNumber: 0,
Branch: "feature-a",
}
if err := store.CreatePRWatch(ctx, existing); err != nil {
t.Fatalf("create PR watch: %v", err)
}
prov := &mockTaskBranchProvider{
tasks: []TaskBranchInfo{
{TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"},
{TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"},
},
}
poller.SetTaskBranchProvider(prov)
poller.reconcileWatches(ctx)
// s1 should still have its original watch (EnsurePRWatch is idempotent).
w1, _ := store.GetPRWatchBySession(ctx, "s1")
if w1 == nil {
t.Fatal("expected PR watch for session s1")
}
if w1.ID != existing.ID {
t.Errorf("expected original watch ID %q, got %q", existing.ID, w1.ID)
}
// s2 should have a new watch.
w2, _ := store.GetPRWatchBySession(ctx, "s2")
if w2 == nil {
t.Fatal("expected PR watch for session s2")
}
}
func TestCheckSinglePRWatch_OpenPR_NoChange_NoSync(t *testing.T) {
poller, _, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Set up an open PR with a passing check.
mockClient.AddPR(&PR{
Number: 20,
Title: "Stable PR",
State: "open",
HeadSHA: "ghi789",
HeadBranch: "stable-branch",
RepoOwner: "owner",
RepoName: "repo",
})
mockClient.AddCheckRuns("owner", "repo", "ghi789", []CheckRun{
{Name: "ci", Status: "completed", Conclusion: "success"},
})
// Create a PRWatch with matching last_check_status -> no change.
watch := &PRWatch{
SessionID: "sess-3",
TaskID: "task-3",
Owner: "owner",
Repo: "repo",
PRNumber: 20,
Branch: "stable-branch",
LastCheckStatus: "success", // same -> hasNew=false
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Create a TaskPR record.
taskPR := &TaskPR{
TaskID: "task-3",
Owner: "owner",
Repo: "repo",
PRNumber: 20,
PRURL: "https://github.com/owner/repo/pull/20",
PRTitle: "Stable PR",
HeadBranch: "stable-branch",
BaseBranch: "main",
State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Act
poller.checkSinglePRWatch(ctx, watch)
// Assert: PRWatch should NOT be deleted.
remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-3")
if err != nil {
t.Fatalf("get PR watch: %v", err)
}
if remainingWatch == nil {
t.Error("expected PR watch to still exist")
}
}