362 lines
9.4 KiB
Go
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")
|
|
}
|
|
}
|