diff --git a/COVERAGE_REPORT.md b/COVERAGE_REPORT.md new file mode 100644 index 0000000..a56db8b --- /dev/null +++ b/COVERAGE_REPORT.md @@ -0,0 +1,176 @@ +# Differential Coverage Report — git-k8s + +Generated using the [diffcover technique](https://research.swtch.com/diffcover): +collect per-test coverage profiles, then compare covered vs. uncovered lines +to map code snippets to features and identify under-tested components. + +## Methodology + +1. Ran `go test -coverprofile=coverage.out -covermode=count ./...` +2. Analyzed per-function coverage with `go tool cover -func=coverage.out` +3. Mapped covered lines to the tests that exercise them +4. Identified uncovered business-logic code that represents untested features + +## Baseline Coverage (Before) + +| Package | Coverage | Verdict | +|---------|----------|---------| +| `pkg/apis/git/v1alpha1` | 27.6% | Only covers helpers (DeepCopy, scheme, constants) | +| `pkg/client` | 5.6% | Only covers `toUnstructured`/`fromUnstructured`/context injection | +| `pkg/reconciler/internal` | 26.7% | Only covers `splitKey`; `Reconcile`, `NewReconciler`, `Promote`, `Demote` untested | +| `pkg/reconciler/push` | 0.0% | Only tests `base64.StdEncoding` (stdlib), no reconciler logic tested | +| `pkg/reconciler/sync` | 0.0% | Empty test file — no tests at all | +| `pkg/reconciler/resolver` | 2.1% | Only tests `changeName` helper | +| `pkg/reconciler/repowatcher` | 10.1% | Tests `branchCRDName`, `pollInterval`, `isOwnedBy`, `minLen` — no ReconcileKind | +| **Total** | **10.3%** | | + +## Code-to-Feature Mapping + +### Feature 1: Push Transaction Lifecycle (pkg/reconciler/push) + +| Code Snippet | Lines | Feature | Tested? | +|---|---|---|---| +| `ReconcileKind` | push.go:31-86 | Phase machine: Pending→InProgress→Succeeded/Failed, status updates | **NO** | +| `executePush` | push.go:89-136 | In-memory clone + push via go-git, refspec building, atomic flag | **NO** | +| `resolveAuth` | push.go:139-172 | Secret lookup via dynamic client, base64 decode | **NO** | +| `failTransaction` | push.go:175-187 | Mark transaction as Failed with message | **NO** | +| `updateBranches` | push.go:190-212 | Update GitBranch CRDs after successful push | **NO** | + +### Feature 2: Two-Way Repo Sync (pkg/reconciler/sync) + +| Code Snippet | Lines | Feature | Tested? | +|---|---|---|---| +| `ReconcileKind` | sync.go:26-93 | Compare HEAD commits, decide A-ahead/B-ahead/diverged | **NO** | +| `findBranch` | sync.go:96-108 | Find GitBranch CRD by repo + branch name | **NO** | +| `calculateMergeBase` | sync.go:113-147 | In-memory clone + go-git merge base calculation | **NO** | +| `createPushTransaction` | sync.go:150-183 | Create GitPushTransaction with owner refs and labels | **NO** | +| `updateSyncStatus` | sync.go:186-200 | Update GitRepoSync status with phase/commits/message | **NO** | + +### Feature 3: Conflict Resolution (pkg/reconciler/resolver) + +| Code Snippet | Lines | Feature | Tested? | +|---|---|---|---| +| `ReconcileKind` | resolver.go:28-75 | Orchestrate 3-way merge for Conflicted syncs | **NO** | +| `attemptMerge` | resolver.go:81-193 | In-memory 3-way merge: tree diffing, conflict detection, merge commit | **NO** | +| `buildMergedTree` | resolver.go:198-243 | Construct merged tree with additions/deletions/renames from both branches | **NO** | +| `changeName` | resolver.go:246-251 | Extract file name from tree change | YES | +| `createMergePushTransactions` | resolver.go:254-328 | Create push transactions to both repos | **NO** | +| `markManualIntervention` | resolver.go:331-336 | Set RequiresManualIntervention phase | **NO** | + +### Feature 4: Remote Polling & Branch Lifecycle (pkg/reconciler/repowatcher) + +| Code Snippet | Lines | Feature | Tested? | +|---|---|---|---| +| `ReconcileKind` | reconciler.go:50-183 | Poll remote, create/update/delete GitBranch CRDs | **NO** | +| `pollInterval` | reconciler.go:186-191 | Per-repo or default poll interval | YES | +| `enqueueAfter` | reconciler.go:194-201 | Re-enqueue with delay for next poll | **NO** | +| `resolveAuth` | reconciler.go:204-233 | Secret lookup + base64 decode | **NO** | +| `defaultLsRemote` | reconciler.go:236-247 | Real git ls-remote via go-git | **NO** | +| `branchCRDName` | reconciler.go:250-254 | Deterministic CRD name from repo+branch | YES | +| `isOwnedBy` | reconciler.go:257-264 | Check owner reference chain | YES | +| `minLen` | reconciler.go:266-271 | Integer min helper | YES | + +### Feature 5: Generic Reconciler Adapter (pkg/reconciler/internal) + +| Code Snippet | Lines | Feature | Tested? | +|---|---|---|---| +| `NewReconciler` | reconcile.go:35-40 | Construct adapter wrapping KindReconciler | **NO** | +| `Reconcile` | reconcile.go:43-58 | Key split → get → not-found handling → ReconcileKind dispatch | **NO** | +| `Promote`/`Demote` | reconcile.go:61-67 | LeaderAware no-ops | **NO** | +| `splitKey` | key.go:7-16 | Parse "namespace/name" keys | YES | + +### Feature 6: Typed Client CRUD (pkg/client) + +| Code Snippet | Lines | Feature | Tested? | +|---|---|---|---| +| `NewForConfig`/`NewFromDynamic` | clientset.go:29-40 | Client construction | **NO** | +| `WithClient`/`Get` | clientset.go:45-57 | Context injection/retrieval | YES | +| `toUnstructured`/`fromUnstructured` | clientset.go:83-102 | JSON round-trip conversion | YES | +| `GitRepositories().Create/Get/Update/UpdateStatus/List/Delete` | clientset.go:105-200 | CRUD for GitRepository | **NO** | +| `GitBranches().Create/Get/Update/UpdateStatus/List/Delete` | clientset.go:203-298 | CRUD for GitBranch | **NO** | +| `GitPushTransactions().Create/Get/Update/UpdateStatus/List/Delete` | clientset.go:301-396 | CRUD for GitPushTransaction | **NO** | +| `GitRepoSyncs().Create/Get/Update/UpdateStatus/List/Delete` | clientset.go:399-494 | CRUD for GitRepoSync | **NO** | + +## Gap Analysis + +The existing tests cover only **pure helper functions** — no business logic is exercised: + +- `push_test.go` — Tests stdlib `base64.StdEncoding.DecodeString` (not even project code) +- `sync_test.go` — Empty file (just `package sync`) +- `resolver_test.go` — Tests `changeName` (5-line helper) +- `reconciler_test.go` (repowatcher) — Tests `branchCRDName`, `pollInterval`, `isOwnedBy`, `minLen` +- `key_test.go` — Tests `splitKey` +- `clientset_test.go` — Tests `toUnstructured`/`fromUnstructured` round-trips +- `types_test.go` — Tests scheme registration, constants, DeepCopy +- `defaults_test.go` — Tests `SetDefaults_*` functions + +**Zero lines of reconciler business logic are covered by tests.** + +## Coverage After Improvements + +| Package | Before | After | Change | +|---------|--------|-------|--------| +| `pkg/apis/git/v1alpha1` | 27.6% | 29.7% | +2.1pp | +| `pkg/client` | 5.6% | 71.8% | +66.2pp | +| `pkg/reconciler/internal` | 26.7% | 100.0% | +73.3pp | +| `pkg/reconciler/push` | 0.0% | 33.3% | +33.3pp | +| `pkg/reconciler/sync` | 0.0% | 33.0% | +33.0pp | +| `pkg/reconciler/resolver` | 2.1% | 17.0% | +14.9pp | +| `pkg/reconciler/repowatcher` | 10.1% | 56.3% | +46.2pp | +| **Total** | **10.3%** | **42.7%** | **+32.4pp** | + +Key per-function highlights: +- `ReconcileKind` (repowatcher): 0% → 81.2% +- `ReconcileKind` (resolver): 0% → 48.0% +- `ReconcileKind` (sync): 0% → 40.6% +- `createPushTransaction` (sync): 0% → 100% +- `updateSyncStatus` (sync): 0% → 100% +- `createMergePushTransactions` (resolver): 0% → 71.4% +- `markManualIntervention` (resolver): 0% → 100% +- `failTransaction` (push): 0% → 88.9% +- `updateBranches` (push): 0% → 85.7% +- `resolveAuth` (push): 0% → 75.0% +- All client CRUD operations: 0% → 71.8% +- `internal/reconcile.go`: 26.7% → 100% + +## Tests Added + +### 1. `pkg/reconciler/internal/reconcile_test.go` +- Tests `NewReconciler` + `Reconcile` with a mock `KindReconciler` +- Tests not-found handling (deleted resources) +- Tests error propagation from the get function +- Tests `Promote`/`Demote` no-ops +- **Covers**: `reconcile.go` lines 35-67 (NewReconciler, Reconcile, Promote, Demote) + +### 2. `pkg/reconciler/push/push_test.go` (rewritten) +- Tests `failTransaction`: verifies phase set to Failed, message set, status updated +- Tests `updateBranches`: verifies matching branches get updated head commit +- Tests `ReconcileKind` terminal-state skip (Succeeded/Failed transactions) +- **Covers**: `push.go` lines 36-38 (terminal skip), 175-212 (failTransaction, updateBranches) + +### 3. `pkg/reconciler/sync/sync_test.go` (rewritten) +- Tests `findBranch`: found, not found, list error cases +- Tests `updateSyncStatus`: all phases including InSync (sets LastSyncTime) +- Tests `createPushTransaction`: labels, owner refs, refspecs +- **Covers**: `sync.go` lines 96-200 (findBranch, createPushTransaction, updateSyncStatus) + +### 4. `pkg/reconciler/resolver/resolver_test.go` (extended) +- Tests `markManualIntervention`: phase and message set +- Tests `createMergePushTransactions`: both txn A and B created with correct labels/refs +- Tests `ReconcileKind` skip for non-Conflicted phase +- **Covers**: `resolver.go` lines 33-35 (non-Conflicted skip), 254-336 (createMergePushTransactions, markManualIntervention) + +### 5. `pkg/reconciler/repowatcher/reconciler_test.go` (extended) +- Tests `ReconcileKind` full lifecycle: creates new branches, updates changed branches, deletes stale branches +- Tests poll interval throttling (skips when last fetch was recent) +- Tests `enqueueAfter` with nil impl (no panic) +- **Covers**: `reconciler.go` lines 50-183 (ReconcileKind), 194-201 (enqueueAfter) + +### 6. `pkg/client/clientset_test.go` (extended) +- Tests `NewFromDynamic` constructor +- Tests full CRUD operations (Create/Get/Update/UpdateStatus/List/Delete) for all 4 resource types using `k8s.io/client-go/dynamic/fake` +- **Covers**: `clientset.go` lines 38-494 (NewFromDynamic + all CRUD methods) + +### 7. `pkg/apis/git/v1alpha1/defaults_test.go` (extended) +- Tests `RegisterDefaults` with a real scheme +- **Covers**: `defaults.go` lines 20-28 (RegisterDefaults) diff --git a/pkg/apis/git/v1alpha1/defaults_test.go b/pkg/apis/git/v1alpha1/defaults_test.go index 75e643a..b3d8297 100644 --- a/pkg/apis/git/v1alpha1/defaults_test.go +++ b/pkg/apis/git/v1alpha1/defaults_test.go @@ -2,6 +2,8 @@ package v1alpha1 import ( "testing" + + "k8s.io/apimachinery/pkg/runtime" ) func TestSetDefaults_GitRepository(t *testing.T) { @@ -67,3 +69,31 @@ func TestSetDefaults_GitPushTransaction(t *testing.T) { }) } } + +func TestRegisterDefaults(t *testing.T) { + scheme := runtime.NewScheme() + + // Register the types so the scheme knows about them. + if err := AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme() error = %v", err) + } + + // Register defaults. + if err := RegisterDefaults(scheme); err != nil { + t.Fatalf("RegisterDefaults() error = %v", err) + } + + // Verify GitRepository defaults are applied via scheme. + repo := &GitRepository{} + scheme.Default(repo) + if repo.Spec.DefaultBranch != "main" { + t.Errorf("DefaultBranch after scheme.Default = %q, want %q", repo.Spec.DefaultBranch, "main") + } + + // Verify GitPushTransaction defaults are applied via scheme. + txn := &GitPushTransaction{} + scheme.Default(txn) + if txn.Status.Phase != TransactionPhasePending { + t.Errorf("Phase after scheme.Default = %q, want %q", txn.Status.Phase, TransactionPhasePending) + } +} diff --git a/pkg/client/clientset_test.go b/pkg/client/clientset_test.go index 0cd5021..c3030e5 100644 --- a/pkg/client/clientset_test.go +++ b/pkg/client/clientset_test.go @@ -7,10 +7,42 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakedynamic "k8s.io/client-go/dynamic/fake" gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" ) +func newFakeScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + for _, kind := range []string{ + "GitRepository", "GitBranch", "GitPushTransaction", "GitRepoSync", + } { + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind + "List"}, + &unstructured.UnstructuredList{}, + ) + } + return scheme +} + +var customListKinds = map[schema.GroupVersionResource]string{ + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}: "GitRepositoryList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}: "GitBranchList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}: "GitPushTransactionList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}: "GitRepoSyncList", +} + +func newFakeClient(objects ...runtime.Object) *GitV1alpha1Client { + dynClient := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(newFakeScheme(), customListKinds, objects...) + return NewFromDynamic(dynClient) +} + func TestToUnstructured(t *testing.T) { repo := &gitv1alpha1.GitRepository{ TypeMeta: metav1.TypeMeta{ @@ -296,3 +328,252 @@ func TestToUnstructured_PreservesJSON(t *testing.T) { t.Errorf("branchName = %v, want %q", got, "feature/awesome") } } + +func TestNewFromDynamic(t *testing.T) { + dynClient := fakedynamic.NewSimpleDynamicClient(newFakeScheme()) + gc := NewFromDynamic(dynClient) + if gc == nil { + t.Fatal("NewFromDynamic returned nil") + } + if gc.client != dynClient { + t.Error("NewFromDynamic did not store the dynamic client") + } +} + +func TestGitRepositories_CRUD(t *testing.T) { + c := newFakeClient() + ctx := context.Background() + + // Create. + repo := &gitv1alpha1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://example.com/repo.git", + DefaultBranch: "main", + }, + } + created, err := c.GitRepositories("default").Create(ctx, repo, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if created.Name != "test-repo" { + t.Errorf("Name = %q, want %q", created.Name, "test-repo") + } + + // Get. + got, err := c.GitRepositories("default").Get(ctx, "test-repo", metav1.GetOptions{}) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.Spec.URL != "https://example.com/repo.git" { + t.Errorf("URL = %q, want %q", got.Spec.URL, "https://example.com/repo.git") + } + + // Update. + got.Spec.DefaultBranch = "develop" + updated, err := c.GitRepositories("default").Update(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if updated.Spec.DefaultBranch != "develop" { + t.Errorf("DefaultBranch = %q, want %q", updated.Spec.DefaultBranch, "develop") + } + + // UpdateStatus. + now := metav1.Now() + updated.Status.LastFetchTime = &now + statusUpdated, err := c.GitRepositories("default").UpdateStatus(ctx, updated, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + if statusUpdated.Status.LastFetchTime == nil { + t.Error("LastFetchTime should be set") + } + + // List. + list, err := c.GitRepositories("default").List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(list.Items) != 1 { + t.Errorf("List Items = %d, want 1", len(list.Items)) + } + + // Delete. + if err := c.GitRepositories("default").Delete(ctx, "test-repo", metav1.DeleteOptions{}); err != nil { + t.Fatalf("Delete() error = %v", err) + } + list, err = c.GitRepositories("default").List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(list.Items) != 0 { + t.Errorf("List after Delete = %d, want 0", len(list.Items)) + } +} + +func TestGitBranches_CRUD(t *testing.T) { + c := newFakeClient() + ctx := context.Background() + + branch := &gitv1alpha1.GitBranch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-branch", + Namespace: "default", + }, + Spec: gitv1alpha1.GitBranchSpec{ + RepositoryRef: "my-repo", + BranchName: "main", + }, + } + + created, err := c.GitBranches("default").Create(ctx, branch, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if created.Spec.BranchName != "main" { + t.Errorf("BranchName = %q, want %q", created.Spec.BranchName, "main") + } + + got, err := c.GitBranches("default").Get(ctx, "test-branch", metav1.GetOptions{}) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + got.Status.HeadCommit = "abc123" + _, err = c.GitBranches("default").UpdateStatus(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + + got.Spec.BranchName = "develop" + _, err = c.GitBranches("default").Update(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + list, err := c.GitBranches("default").List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(list.Items) != 1 { + t.Errorf("List Items = %d, want 1", len(list.Items)) + } + + if err := c.GitBranches("default").Delete(ctx, "test-branch", metav1.DeleteOptions{}); err != nil { + t.Fatalf("Delete() error = %v", err) + } +} + +func TestGitPushTransactions_CRUD(t *testing.T) { + c := newFakeClient() + ctx := context.Background() + + txn := &gitv1alpha1.GitPushTransaction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "push-1", + Namespace: "default", + }, + Spec: gitv1alpha1.GitPushTransactionSpec{ + RepositoryRef: "my-repo", + Atomic: true, + RefSpecs: []gitv1alpha1.PushRefSpec{ + {Source: "refs/heads/main", Destination: "refs/heads/main"}, + }, + }, + } + + created, err := c.GitPushTransactions("default").Create(ctx, txn, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if !created.Spec.Atomic { + t.Error("Atomic should be true") + } + + got, err := c.GitPushTransactions("default").Get(ctx, "push-1", metav1.GetOptions{}) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + got.Status.Phase = gitv1alpha1.TransactionPhaseSucceeded + _, err = c.GitPushTransactions("default").UpdateStatus(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + + got.Spec.Atomic = false + _, err = c.GitPushTransactions("default").Update(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + list, err := c.GitPushTransactions("default").List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(list.Items) != 1 { + t.Errorf("List Items = %d, want 1", len(list.Items)) + } + + if err := c.GitPushTransactions("default").Delete(ctx, "push-1", metav1.DeleteOptions{}); err != nil { + t.Fatalf("Delete() error = %v", err) + } +} + +func TestGitRepoSyncs_CRUD(t *testing.T) { + c := newFakeClient() + ctx := context.Background() + + sync := &gitv1alpha1.GitRepoSync{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sync-1", + Namespace: "default", + }, + Spec: gitv1alpha1.GitRepoSyncSpec{ + RepoA: gitv1alpha1.SyncRepoRef{Name: "repo-a"}, + RepoB: gitv1alpha1.SyncRepoRef{Name: "repo-b"}, + BranchName: "main", + }, + } + + created, err := c.GitRepoSyncs("default").Create(ctx, sync, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if created.Spec.BranchName != "main" { + t.Errorf("BranchName = %q, want %q", created.Spec.BranchName, "main") + } + + got, err := c.GitRepoSyncs("default").Get(ctx, "sync-1", metav1.GetOptions{}) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + got.Status.Phase = gitv1alpha1.SyncPhaseInSync + _, err = c.GitRepoSyncs("default").UpdateStatus(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + + got.Spec.BranchName = "develop" + _, err = c.GitRepoSyncs("default").Update(ctx, got, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + list, err := c.GitRepoSyncs("default").List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(list.Items) != 1 { + t.Errorf("List Items = %d, want 1", len(list.Items)) + } + + if err := c.GitRepoSyncs("default").Delete(ctx, "sync-1", metav1.DeleteOptions{}); err != nil { + t.Fatalf("Delete() error = %v", err) + } +} diff --git a/pkg/reconciler/internal/reconcile_test.go b/pkg/reconciler/internal/reconcile_test.go new file mode 100644 index 0000000..5a0ecc5 --- /dev/null +++ b/pkg/reconciler/internal/reconcile_test.go @@ -0,0 +1,156 @@ +package internal + +import ( + "context" + "fmt" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "knative.dev/pkg/reconciler" +) + +// testResource is a simple type to use with the generic reconciler. +type testResource struct { + Namespace string + Name string +} + +// testKindReconciler implements KindReconciler[testResource] for testing. +type testKindReconciler struct { + called bool + received *testResource + err error +} + +func (r *testKindReconciler) ReconcileKind(_ context.Context, o *testResource) reconciler.Event { + r.called = true + r.received = o + return r.err +} + +func TestNewReconciler(t *testing.T) { + inner := &testKindReconciler{} + get := func(ctx context.Context, ns, name string) (*testResource, error) { + return &testResource{Namespace: ns, Name: name}, nil + } + r := NewReconciler(get, inner) + if r == nil { + t.Fatal("NewReconciler returned nil") + } + if r.inner != inner { + t.Error("NewReconciler did not store the inner reconciler") + } +} + +func TestReconcile_Success(t *testing.T) { + resource := &testResource{Namespace: "default", Name: "my-obj"} + inner := &testKindReconciler{} + get := func(ctx context.Context, ns, name string) (*testResource, error) { + if ns == "default" && name == "my-obj" { + return resource, nil + } + return nil, fmt.Errorf("unexpected get(%q, %q)", ns, name) + } + + r := NewReconciler(get, inner) + err := r.Reconcile(context.Background(), "default/my-obj") + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if !inner.called { + t.Error("ReconcileKind was not called") + } + if inner.received != resource { + t.Error("ReconcileKind received wrong resource") + } +} + +func TestReconcile_NotFound(t *testing.T) { + inner := &testKindReconciler{} + get := func(ctx context.Context, ns, name string) (*testResource, error) { + return nil, apierrors.NewNotFound(schema.GroupResource{Group: "test", Resource: "resources"}, name) + } + + r := NewReconciler(get, inner) + err := r.Reconcile(context.Background(), "default/deleted-obj") + if err != nil { + t.Fatalf("Reconcile() should return nil for not-found, got %v", err) + } + if inner.called { + t.Error("ReconcileKind should not be called for deleted resources") + } +} + +func TestReconcile_GetError(t *testing.T) { + inner := &testKindReconciler{} + get := func(ctx context.Context, ns, name string) (*testResource, error) { + return nil, fmt.Errorf("connection refused") + } + + r := NewReconciler(get, inner) + err := r.Reconcile(context.Background(), "default/my-obj") + if err == nil { + t.Fatal("Reconcile() should return error on get failure") + } + if inner.called { + t.Error("ReconcileKind should not be called when get fails") + } +} + +func TestReconcile_ReconcileKindError(t *testing.T) { + inner := &testKindReconciler{err: fmt.Errorf("reconcile failed")} + get := func(ctx context.Context, ns, name string) (*testResource, error) { + return &testResource{Namespace: ns, Name: name}, nil + } + + r := NewReconciler(get, inner) + err := r.Reconcile(context.Background(), "default/my-obj") + if err == nil { + t.Fatal("Reconcile() should propagate ReconcileKind error") + } +} + +func TestReconcile_ClusterScopedKey(t *testing.T) { + inner := &testKindReconciler{} + get := func(ctx context.Context, ns, name string) (*testResource, error) { + return &testResource{Namespace: ns, Name: name}, nil + } + + r := NewReconciler(get, inner) + err := r.Reconcile(context.Background(), "cluster-resource") + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if !inner.called { + t.Error("ReconcileKind was not called for cluster-scoped key") + } + if inner.received.Namespace != "" { + t.Errorf("expected empty namespace for cluster-scoped key, got %q", inner.received.Namespace) + } + if inner.received.Name != "cluster-resource" { + t.Errorf("expected name 'cluster-resource', got %q", inner.received.Name) + } +} + +func TestPromote(t *testing.T) { + r := NewReconciler( + func(ctx context.Context, ns, name string) (*testResource, error) { return nil, nil }, + &testKindReconciler{}, + ) + // Promote should be a no-op and return nil. + err := r.Promote(reconciler.UniversalBucket(), func(b reconciler.Bucket, nn types.NamespacedName) {}) + if err != nil { + t.Fatalf("Promote() error = %v", err) + } +} + +func TestDemote(t *testing.T) { + r := NewReconciler( + func(ctx context.Context, ns, name string) (*testResource, error) { return nil, nil }, + &testKindReconciler{}, + ) + // Demote should be a no-op (just ensure no panic). + r.Demote(reconciler.UniversalBucket()) +} diff --git a/pkg/reconciler/push/push_test.go b/pkg/reconciler/push/push_test.go index 1da11b6..d62ae0f 100644 --- a/pkg/reconciler/push/push_test.go +++ b/pkg/reconciler/push/push_test.go @@ -1,10 +1,90 @@ package push import ( + "context" "encoding/base64" "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakedynamic "k8s.io/client-go/dynamic/fake" + + gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" + gitclient "github.com/imjasonh/git-k8s/pkg/client" ) +// newFakeReconciler creates a Reconciler with a fake dynamic client +// pre-loaded with the given objects. +func newFakeScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + for _, kind := range []string{ + "GitRepository", "GitBranch", "GitPushTransaction", "GitRepoSync", + } { + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind + "List"}, + &unstructured.UnstructuredList{}, + ) + } + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "SecretList"}, + &unstructured.UnstructuredList{}, + ) + return scheme +} + +var customListKinds = map[schema.GroupVersionResource]string{ + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}: "GitRepositoryList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}: "GitBranchList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}: "GitPushTransactionList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}: "GitRepoSyncList", + {Group: "", Version: "v1", Resource: "secrets"}: "SecretList", +} + +func newFakeReconciler(t *testing.T, objects ...*unstructured.Unstructured) *Reconciler { + t.Helper() + dynClient := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(newFakeScheme(), customListKinds) + gc := gitclient.NewFromDynamic(dynClient) + + // Seed objects through the API using correct GVRs. + for _, obj := range objects { + gvr, ok := gvrForKind(obj.GetKind()) + if !ok { + t.Fatalf("unknown kind %q", obj.GetKind()) + } + ns := obj.GetNamespace() + if _, err := dynClient.Resource(gvr).Namespace(ns).Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + t.Fatalf("seeding %s/%s: %v", ns, obj.GetName(), err) + } + } + + return &Reconciler{ + dynamicClient: dynClient, + gitClient: gc, + } +} + +func gvrForKind(kind string) (schema.GroupVersionResource, bool) { + m := map[string]schema.GroupVersionResource{ + "GitRepository": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}, + "GitBranch": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}, + "GitPushTransaction": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}, + "GitRepoSync": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}, + "Secret": {Group: "", Version: "v1", Resource: "secrets"}, + } + gvr, ok := m[kind] + return gvr, ok +} + func TestBase64Decoding(t *testing.T) { // Verify that values typically stored in Kubernetes secrets // (base64-encoded via dynamic client) decode correctly. @@ -42,3 +122,247 @@ func TestBase64Decoding(t *testing.T) { }) } } + +func TestReconcileKind_SkipsTerminalPhase(t *testing.T) { + // Transactions already in Succeeded or Failed phase should be skipped. + for _, phase := range []gitv1alpha1.TransactionPhase{ + gitv1alpha1.TransactionPhaseSucceeded, + gitv1alpha1.TransactionPhaseFailed, + } { + t.Run(string(phase), func(t *testing.T) { + r := newFakeReconciler(t) + txn := &gitv1alpha1.GitPushTransaction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-txn", + Namespace: "default", + }, + Status: gitv1alpha1.GitPushTransactionStatus{ + Phase: phase, + }, + } + err := r.ReconcileKind(context.Background(), txn) + if err != nil { + t.Fatalf("ReconcileKind() error = %v, want nil for terminal phase %s", err, phase) + } + }) + } +} + +func TestFailTransaction(t *testing.T) { + // Create the transaction in the fake client so UpdateStatus can find it. + txnObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitPushTransaction", + "metadata": map[string]interface{}{ + "name": "txn-1", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "repositoryRef": "my-repo", + "refSpecs": []interface{}{}, + }, + "status": map[string]interface{}{ + "phase": "InProgress", + }, + }, + } + r := newFakeReconciler(t, txnObj) + + txn := &gitv1alpha1.GitPushTransaction{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitPushTransaction", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "txn-1", + Namespace: "default", + }, + Status: gitv1alpha1.GitPushTransactionStatus{ + Phase: gitv1alpha1.TransactionPhaseInProgress, + }, + } + + err := r.failTransaction(context.Background(), txn, "push rejected") + if err != nil { + t.Fatalf("failTransaction() error = %v", err) + } + + // Verify the transaction status was updated. + if txn.Status.Phase != gitv1alpha1.TransactionPhaseFailed { + t.Errorf("phase = %q, want %q", txn.Status.Phase, gitv1alpha1.TransactionPhaseFailed) + } + if txn.Status.Message != "push rejected" { + t.Errorf("message = %q, want %q", txn.Status.Message, "push rejected") + } + if txn.Status.CompletionTime == nil { + t.Error("CompletionTime should be set") + } +} + +func TestUpdateBranches(t *testing.T) { + // Pre-create a branch CRD in the fake client. + branchObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "my-repo-main", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "repositoryRef": "my-repo", + "branchName": "main", + }, + "status": map[string]interface{}{ + "headCommit": "old-sha", + }, + }, + } + + r := newFakeReconciler(t, branchObj) + + txn := &gitv1alpha1.GitPushTransaction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "txn-1", + Namespace: "default", + }, + Spec: gitv1alpha1.GitPushTransactionSpec{ + RepositoryRef: "my-repo", + RefSpecs: []gitv1alpha1.PushRefSpec{ + { + Source: "abc123", + Destination: "refs/heads/main", + }, + }, + }, + Status: gitv1alpha1.GitPushTransactionStatus{ + ResultCommit: "new-sha", + }, + } + + err := r.updateBranches(context.Background(), "default", txn) + if err != nil { + t.Fatalf("updateBranches() error = %v", err) + } + + // Verify the branch was updated via the fake client. + updatedBranch, err := r.gitClient.GitBranches("default").Get(context.Background(), "my-repo-main", metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting branch after update: %v", err) + } + if updatedBranch.Status.HeadCommit != "new-sha" { + t.Errorf("HeadCommit = %q, want %q", updatedBranch.Status.HeadCommit, "new-sha") + } +} + +func TestUpdateBranches_NoMatchingBranch(t *testing.T) { + // Branch for a different repo should not be updated. + branchObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "other-repo-main", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "repositoryRef": "other-repo", + "branchName": "main", + }, + "status": map[string]interface{}{ + "headCommit": "old-sha", + }, + }, + } + + r := newFakeReconciler(t, branchObj) + + txn := &gitv1alpha1.GitPushTransaction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "txn-1", + Namespace: "default", + }, + Spec: gitv1alpha1.GitPushTransactionSpec{ + RepositoryRef: "my-repo", + RefSpecs: []gitv1alpha1.PushRefSpec{ + {Source: "abc123", Destination: "refs/heads/main"}, + }, + }, + Status: gitv1alpha1.GitPushTransactionStatus{ResultCommit: "new-sha"}, + } + + err := r.updateBranches(context.Background(), "default", txn) + if err != nil { + t.Fatalf("updateBranches() error = %v", err) + } + + // Verify the other branch was NOT updated. + otherBranch, err := r.gitClient.GitBranches("default").Get(context.Background(), "other-repo-main", metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting branch: %v", err) + } + if otherBranch.Status.HeadCommit != "old-sha" { + t.Errorf("HeadCommit = %q, want %q (should be unchanged)", otherBranch.Status.HeadCommit, "old-sha") + } +} + +func TestResolveAuth_NoAuth(t *testing.T) { + r := newFakeReconciler(t) + + repo := &gitv1alpha1.GitRepository{ + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://github.com/example/repo.git", + }, + } + + auth, err := r.resolveAuth(context.Background(), "default", repo) + if err != nil { + t.Fatalf("resolveAuth() error = %v", err) + } + if auth != nil { + t.Errorf("auth = %v, want nil for repo without auth", auth) + } +} + +func TestResolveAuth_WithSecret(t *testing.T) { + secretObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "git-creds", + "namespace": "default", + }, + "data": map[string]interface{}{ + "username": base64.StdEncoding.EncodeToString([]byte("myuser")), + "password": base64.StdEncoding.EncodeToString([]byte("mypass")), + }, + }, + } + + r := newFakeReconciler(t, secretObj) + + repo := &gitv1alpha1.GitRepository{ + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://github.com/example/repo.git", + Auth: &gitv1alpha1.GitAuth{ + SecretRef: &gitv1alpha1.SecretRef{Name: "git-creds"}, + }, + }, + } + + auth, err := r.resolveAuth(context.Background(), "default", repo) + if err != nil { + t.Fatalf("resolveAuth() error = %v", err) + } + if auth == nil { + t.Fatal("auth = nil, want non-nil") + } + if auth.Username != "myuser" { + t.Errorf("username = %q, want %q", auth.Username, "myuser") + } + if auth.Password != "mypass" { + t.Errorf("password = %q, want %q", auth.Password, "mypass") + } +} diff --git a/pkg/reconciler/repowatcher/reconciler_test.go b/pkg/reconciler/repowatcher/reconciler_test.go index e91d193..7466a3c 100644 --- a/pkg/reconciler/repowatcher/reconciler_test.go +++ b/pkg/reconciler/repowatcher/reconciler_test.go @@ -1,16 +1,89 @@ package repowatcher import ( + "context" "testing" "time" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport/http" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakedynamic "k8s.io/client-go/dynamic/fake" gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" + gitclient "github.com/imjasonh/git-k8s/pkg/client" ) +func newFakeScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + for _, kind := range []string{ + "GitRepository", "GitBranch", "GitPushTransaction", "GitRepoSync", + } { + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind + "List"}, + &unstructured.UnstructuredList{}, + ) + } + // Core types for secrets. + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "SecretList"}, + &unstructured.UnstructuredList{}, + ) + return scheme +} + +var customListKinds = map[schema.GroupVersionResource]string{ + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}: "GitRepositoryList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}: "GitBranchList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}: "GitPushTransactionList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}: "GitRepoSyncList", + {Group: "", Version: "v1", Resource: "secrets"}: "SecretList", +} + +func gvrForKind(kind string) (schema.GroupVersionResource, bool) { + m := map[string]schema.GroupVersionResource{ + "GitRepository": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}, + "GitBranch": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}, + "GitPushTransaction": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}, + "GitRepoSync": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}, + "Secret": {Group: "", Version: "v1", Resource: "secrets"}, + } + gvr, ok := m[kind] + return gvr, ok +} + +func newFakeReconciler(t *testing.T, lsRemoteFn func(string, *http.BasicAuth) ([]*plumbing.Reference, error), objects ...*unstructured.Unstructured) *Reconciler { + t.Helper() + dynClient := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(newFakeScheme(), customListKinds) + gc := gitclient.NewFromDynamic(dynClient) + for _, obj := range objects { + gvr, ok := gvrForKind(obj.GetKind()) + if !ok { + t.Fatalf("unknown kind %q", obj.GetKind()) + } + if _, err := dynClient.Resource(gvr).Namespace(obj.GetNamespace()).Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + t.Fatalf("seeding %s/%s: %v", obj.GetNamespace(), obj.GetName(), err) + } + } + return &Reconciler{ + dynamicClient: dynClient, + gitClient: gc, + defaultInterval: 30 * time.Second, + lsRemote: lsRemoteFn, + } +} + func TestBranchCRDName(t *testing.T) { tests := []struct { repo string @@ -165,3 +238,375 @@ func TestRefFiltering(t *testing.T) { t.Error("tag v1.0 should not be in branches") } } + +func TestEnqueueAfter_NilImpl(t *testing.T) { + // enqueueAfter with nil impl should not panic. + r := &Reconciler{} + repo := &gitv1alpha1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + }, + } + r.enqueueAfter(repo, 5*time.Second) // should not panic +} + +func TestReconcileKind_CreatesNewBranches(t *testing.T) { + repoObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "metadata": map[string]interface{}{ + "name": "my-repo", + "namespace": "default", + "uid": "repo-uid", + }, + "spec": map[string]interface{}{ + "url": "https://example.com/repo.git", + "defaultBranch": "main", + }, + "status": map[string]interface{}{}, + }, + } + + mockRefs := []*plumbing.Reference{ + plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("aaa111aaa111aaa111aaa111aaa111aaa111aaa1")), + plumbing.NewHashReference(plumbing.NewBranchReferenceName("develop"), plumbing.NewHash("bbb222bbb222bbb222bbb222bbb222bbb222bbb2")), + } + + lsRemoteFn := func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + return mockRefs, nil + } + + r := newFakeReconciler(t, lsRemoteFn, repoObj) + + repo := &gitv1alpha1.GitRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepository", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + UID: "repo-uid", + }, + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://example.com/repo.git", + DefaultBranch: "main", + }, + } + + err := r.ReconcileKind(context.Background(), repo) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + + // Verify branches were created. + branches, err := r.gitClient.GitBranches("default").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("listing branches: %v", err) + } + if len(branches.Items) != 2 { + t.Fatalf("expected 2 branches, got %d", len(branches.Items)) + } + + // Verify repo status was updated. + if repo.Status.LastFetchTime == nil { + t.Error("LastFetchTime should be set after successful poll") + } +} + +func TestReconcileKind_UpdatesExistingBranch(t *testing.T) { + repoObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "metadata": map[string]interface{}{ + "name": "my-repo", + "namespace": "default", + "uid": "repo-uid", + }, + "spec": map[string]interface{}{ + "url": "https://example.com/repo.git", + "defaultBranch": "main", + }, + "status": map[string]interface{}{}, + }, + } + + existingBranch := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "my-repo-main", + "namespace": "default", + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "name": "my-repo", + "uid": "repo-uid", + }, + }, + }, + "spec": map[string]interface{}{ + "repositoryRef": "my-repo", + "branchName": "main", + }, + "status": map[string]interface{}{ + "headCommit": "ccdd00ccdd00ccdd00ccdd00ccdd00ccdd00ccdd", + }, + }, + } + + newSHA := "aabb00aabb00aabb00aabb00aabb00aabb00aabb" + mockRefs := []*plumbing.Reference{ + plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash(newSHA)), + } + + lsRemoteFn := func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + return mockRefs, nil + } + + r := newFakeReconciler(t, lsRemoteFn, repoObj, existingBranch) + + repo := &gitv1alpha1.GitRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepository", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + UID: "repo-uid", + }, + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://example.com/repo.git", + }, + } + + err := r.ReconcileKind(context.Background(), repo) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + + // Verify branch was updated. + branch, err := r.gitClient.GitBranches("default").Get(context.Background(), "my-repo-main", metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting branch: %v", err) + } + if branch.Status.HeadCommit != newSHA { + t.Errorf("HeadCommit = %q, want %q", branch.Status.HeadCommit, newSHA) + } +} + +func TestReconcileKind_DeletesStaleBranch(t *testing.T) { + repoObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "metadata": map[string]interface{}{ + "name": "my-repo", + "namespace": "default", + "uid": "repo-uid", + }, + "spec": map[string]interface{}{ + "url": "https://example.com/repo.git", + }, + "status": map[string]interface{}{}, + }, + } + + staleBranch := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "my-repo-old-branch", + "namespace": "default", + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "name": "my-repo", + "uid": "repo-uid", + }, + }, + }, + "spec": map[string]interface{}{ + "repositoryRef": "my-repo", + "branchName": "old-branch", + }, + "status": map[string]interface{}{ + "headCommit": "old-sha", + }, + }, + } + + // Remote only has "main", but the CRD has "old-branch" -> should be deleted. + mockRefs := []*plumbing.Reference{ + plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.NewHash("aaa111aaa111aaa111aaa111aaa111aaa111aaa1")), + } + + lsRemoteFn := func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + return mockRefs, nil + } + + r := newFakeReconciler(t, lsRemoteFn, repoObj, staleBranch) + + repo := &gitv1alpha1.GitRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepository", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + UID: "repo-uid", + }, + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://example.com/repo.git", + }, + } + + err := r.ReconcileKind(context.Background(), repo) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + + // Verify stale branch was deleted. + branches, err := r.gitClient.GitBranches("default").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("listing branches: %v", err) + } + + for _, b := range branches.Items { + if b.Spec.BranchName == "old-branch" { + t.Error("stale branch 'old-branch' should have been deleted") + } + } +} + +func TestReconcileKind_SkipsRecentlyFetched(t *testing.T) { + lsRemoteCalled := false + lsRemoteFn := func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + lsRemoteCalled = true + return nil, nil + } + + repoObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "metadata": map[string]interface{}{ + "name": "my-repo", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "url": "https://example.com/repo.git", + }, + "status": map[string]interface{}{}, + }, + } + + r := newFakeReconciler(t, lsRemoteFn, repoObj) + + now := metav1.Now() + repo := &gitv1alpha1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + }, + Spec: gitv1alpha1.GitRepositorySpec{ + URL: "https://example.com/repo.git", + }, + Status: gitv1alpha1.GitRepositoryStatus{ + LastFetchTime: &now, // Just fetched. + }, + } + + err := r.ReconcileKind(context.Background(), repo) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + + if lsRemoteCalled { + t.Error("lsRemote should NOT be called when last fetch was recent") + } +} + +func TestReconcileKind_SkipsManualBranch(t *testing.T) { + // A branch not owned by the repo should NOT be deleted even if missing from remote. + repoObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "metadata": map[string]interface{}{ + "name": "my-repo", + "namespace": "default", + "uid": "repo-uid", + }, + "spec": map[string]interface{}{ + "url": "https://example.com/repo.git", + }, + "status": map[string]interface{}{}, + }, + } + + manualBranch := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "my-repo-manual", + "namespace": "default", + // No ownerReferences — manual branch. + }, + "spec": map[string]interface{}{ + "repositoryRef": "my-repo", + "branchName": "manual", + }, + "status": map[string]interface{}{ + "headCommit": "sha", + }, + }, + } + + // Remote has no branches at all. + lsRemoteFn := func(url string, auth *http.BasicAuth) ([]*plumbing.Reference, error) { + return []*plumbing.Reference{}, nil + } + + r := newFakeReconciler(t, lsRemoteFn, repoObj, manualBranch) + + repo := &gitv1alpha1.GitRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepository", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + UID: "repo-uid", + }, + Spec: gitv1alpha1.GitRepositorySpec{URL: "https://example.com/repo.git"}, + } + + err := r.ReconcileKind(context.Background(), repo) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + + // Manual branch should still exist. + branches, err := r.gitClient.GitBranches("default").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("listing branches: %v", err) + } + if len(branches.Items) != 1 { + t.Fatalf("expected 1 branch (manual), got %d", len(branches.Items)) + } + if branches.Items[0].Spec.BranchName != "manual" { + t.Errorf("expected manual branch to survive, got %q", branches.Items[0].Spec.BranchName) + } +} diff --git a/pkg/reconciler/resolver/resolver_test.go b/pkg/reconciler/resolver/resolver_test.go index 28f06df..df6deaf 100644 --- a/pkg/reconciler/resolver/resolver_test.go +++ b/pkg/reconciler/resolver/resolver_test.go @@ -1,11 +1,76 @@ package resolver import ( + "context" + "fmt" "testing" "github.com/go-git/go-git/v5/plumbing/object" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakedynamic "k8s.io/client-go/dynamic/fake" + ktesting "k8s.io/client-go/testing" + + gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" + gitclient "github.com/imjasonh/git-k8s/pkg/client" ) +func newFakeScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + for _, kind := range []string{ + "GitRepository", "GitBranch", "GitPushTransaction", "GitRepoSync", + } { + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind + "List"}, + &unstructured.UnstructuredList{}, + ) + } + return scheme +} + +var customListKinds = map[schema.GroupVersionResource]string{ + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}: "GitRepositoryList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}: "GitBranchList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}: "GitPushTransactionList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}: "GitRepoSyncList", +} + +func newFakeReconciler(t *testing.T, objects ...*unstructured.Unstructured) *Reconciler { + t.Helper() + dynClient := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(newFakeScheme(), customListKinds) + gc := gitclient.NewFromDynamic(dynClient) + for _, obj := range objects { + gvr, ok := gvrForKind(obj.GetKind()) + if !ok { + t.Fatalf("unknown kind %q", obj.GetKind()) + } + if _, err := dynClient.Resource(gvr).Namespace(obj.GetNamespace()).Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + t.Fatalf("seeding %s/%s: %v", obj.GetNamespace(), obj.GetName(), err) + } + } + return &Reconciler{ + dynamicClient: dynClient, + gitClient: gc, + } +} + +func gvrForKind(kind string) (schema.GroupVersionResource, bool) { + m := map[string]schema.GroupVersionResource{ + "GitRepository": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}, + "GitBranch": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}, + "GitPushTransaction": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}, + "GitRepoSync": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}, + } + gvr, ok := m[kind] + return gvr, ok +} + func TestChangeName(t *testing.T) { tests := []struct { name string @@ -55,3 +120,213 @@ func TestChangeName(t *testing.T) { }) } } + +func TestReconcileKind_SkipsNonConflicted(t *testing.T) { + r := newFakeReconciler(t) + + // A sync that is not in Conflicted phase should be skipped. + for _, phase := range []gitv1alpha1.SyncPhase{ + gitv1alpha1.SyncPhaseInSync, + gitv1alpha1.SyncPhaseSyncing, + gitv1alpha1.SyncPhaseRequiresManualIntervention, + } { + t.Run(string(phase), func(t *testing.T) { + syncObj := &gitv1alpha1.GitRepoSync{ + ObjectMeta: metav1.ObjectMeta{Name: "sync-1", Namespace: "default"}, + Status: gitv1alpha1.GitRepoSyncStatus{ + Phase: phase, + }, + } + err := r.ReconcileKind(context.Background(), syncObj) + if err != nil { + t.Fatalf("ReconcileKind() error = %v, want nil for phase %s", err, phase) + } + }) + } +} + +func TestReconcileKind_MissingCommitHashes(t *testing.T) { + // When commit hashes are missing, should fall back to ManualIntervention. + syncObjU := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepoSync", + "metadata": map[string]interface{}{"name": "sync-1", "namespace": "default"}, + "spec": map[string]interface{}{ + "repoA": map[string]interface{}{"name": "repo-a"}, + "repoB": map[string]interface{}{"name": "repo-b"}, + "branchName": "main", + }, + "status": map[string]interface{}{ + "phase": "Conflicted", + }, + }, + } + + repoA := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepository", + "metadata": map[string]interface{}{"name": "repo-a", "namespace": "default"}, + "spec": map[string]interface{}{"url": "https://example.com/repo-a.git"}, + }, + } + + r := newFakeReconciler(t, syncObjU, repoA) + + syncObj := &gitv1alpha1.GitRepoSync{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepoSync", + }, + ObjectMeta: metav1.ObjectMeta{Name: "sync-1", Namespace: "default"}, + Spec: gitv1alpha1.GitRepoSyncSpec{ + RepoA: gitv1alpha1.SyncRepoRef{Name: "repo-a"}, + RepoB: gitv1alpha1.SyncRepoRef{Name: "repo-b"}, + BranchName: "main", + }, + Status: gitv1alpha1.GitRepoSyncStatus{ + Phase: gitv1alpha1.SyncPhaseConflicted, + // All commit hashes empty. + }, + } + + err := r.ReconcileKind(context.Background(), syncObj) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + if syncObj.Status.Phase != gitv1alpha1.SyncPhaseRequiresManualIntervention { + t.Errorf("Phase = %q, want %q", syncObj.Status.Phase, gitv1alpha1.SyncPhaseRequiresManualIntervention) + } +} + +func TestMarkManualIntervention(t *testing.T) { + syncObjU := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepoSync", + "metadata": map[string]interface{}{"name": "sync-1", "namespace": "default"}, + "spec": map[string]interface{}{ + "repoA": map[string]interface{}{"name": "repo-a"}, + "repoB": map[string]interface{}{"name": "repo-b"}, + "branchName": "main", + }, + "status": map[string]interface{}{"phase": "Conflicted"}, + }, + } + + r := newFakeReconciler(t, syncObjU) + + syncObj := &gitv1alpha1.GitRepoSync{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepoSync", + }, + ObjectMeta: metav1.ObjectMeta{Name: "sync-1", Namespace: "default"}, + Status: gitv1alpha1.GitRepoSyncStatus{Phase: gitv1alpha1.SyncPhaseConflicted}, + } + + err := r.markManualIntervention(context.Background(), syncObj, "merge conflict in file.txt") + if err != nil { + t.Fatalf("markManualIntervention() error = %v", err) + } + if syncObj.Status.Phase != gitv1alpha1.SyncPhaseRequiresManualIntervention { + t.Errorf("Phase = %q, want %q", syncObj.Status.Phase, gitv1alpha1.SyncPhaseRequiresManualIntervention) + } + if syncObj.Status.Message != "merge conflict in file.txt" { + t.Errorf("Message = %q, want %q", syncObj.Status.Message, "merge conflict in file.txt") + } +} + +func TestCreateMergePushTransactions(t *testing.T) { + // The fake dynamic client does not support GenerateName, so both + // transactions end up with name "". We use a reactor to simulate + // unique naming by assigning sequential names. + scheme := newFakeScheme() + dynClient := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(scheme, customListKinds) + + counter := 0 + dynClient.PrependReactor("create", "gitpushtransactions", func(action ktesting.Action) (bool, runtime.Object, error) { + createAction := action.(ktesting.CreateAction) + obj := createAction.GetObject().(*unstructured.Unstructured) + if obj.GetName() == "" && obj.GetGenerateName() != "" { + counter++ + obj.SetName(fmt.Sprintf("%s%d", obj.GetGenerateName(), counter)) + } + return false, obj, nil + }) + + gc := gitclient.NewFromDynamic(dynClient) + r := &Reconciler{ + dynamicClient: dynClient, + gitClient: gc, + } + + syncObj := &gitv1alpha1.GitRepoSync{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sync-1", + Namespace: "default", + UID: "uid-sync", + }, + Spec: gitv1alpha1.GitRepoSyncSpec{ + RepoA: gitv1alpha1.SyncRepoRef{Name: "repo-a"}, + RepoB: gitv1alpha1.SyncRepoRef{Name: "repo-b"}, + BranchName: "main", + }, + } + + err := r.createMergePushTransactions(context.Background(), "default", syncObj, "merge-sha", "old-a", "old-b") + if err != nil { + t.Fatalf("createMergePushTransactions() error = %v", err) + } + + // Verify two transactions were created. + txns, err := r.gitClient.GitPushTransactions("default").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("listing transactions: %v", err) + } + if len(txns.Items) != 2 { + t.Fatalf("expected 2 transactions, got %d", len(txns.Items)) + } + + // Find txn targeting repo-a and repo-b. + var txnA, txnB *gitv1alpha1.GitPushTransaction + for i := range txns.Items { + txn := &txns.Items[i] + switch txn.Spec.RepositoryRef { + case "repo-a": + txnA = txn + case "repo-b": + txnB = txn + } + } + + if txnA == nil { + t.Fatal("no transaction found targeting repo-a") + } + if txnB == nil { + t.Fatal("no transaction found targeting repo-b") + } + + // Verify txn A. + if len(txnA.Spec.RefSpecs) != 1 { + t.Fatalf("txnA RefSpecs length = %d, want 1", len(txnA.Spec.RefSpecs)) + } + if txnA.Spec.RefSpecs[0].Source != "merge-sha" { + t.Errorf("txnA Source = %q, want %q", txnA.Spec.RefSpecs[0].Source, "merge-sha") + } + if txnA.Spec.RefSpecs[0].ExpectedOldCommit != "old-a" { + t.Errorf("txnA ExpectedOldCommit = %q, want %q", txnA.Spec.RefSpecs[0].ExpectedOldCommit, "old-a") + } + if txnA.Labels["git-k8s.imjasonh.com/merge"] != "true" { + t.Error("txnA should have merge=true label") + } + + // Verify txn B. + if txnB.Spec.RefSpecs[0].ExpectedOldCommit != "old-b" { + t.Errorf("txnB ExpectedOldCommit = %q, want %q", txnB.Spec.RefSpecs[0].ExpectedOldCommit, "old-b") + } + if txnB.Labels["git-k8s.imjasonh.com/target"] != "repo-b" { + t.Errorf("txnB target label = %q, want %q", txnB.Labels["git-k8s.imjasonh.com/target"], "repo-b") + } +} diff --git a/pkg/reconciler/sync/sync_test.go b/pkg/reconciler/sync/sync_test.go index 1ca2a85..030222c 100644 --- a/pkg/reconciler/sync/sync_test.go +++ b/pkg/reconciler/sync/sync_test.go @@ -1 +1,378 @@ package sync + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakedynamic "k8s.io/client-go/dynamic/fake" + + gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1" + gitclient "github.com/imjasonh/git-k8s/pkg/client" +) + +func newFakeScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + for _, kind := range []string{ + "GitRepository", "GitBranch", "GitPushTransaction", "GitRepoSync", + } { + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind}, + &unstructured.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Kind: kind + "List"}, + &unstructured.UnstructuredList{}, + ) + } + return scheme +} + +var customListKinds = map[schema.GroupVersionResource]string{ + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}: "GitRepositoryList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}: "GitBranchList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}: "GitPushTransactionList", + {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}: "GitRepoSyncList", +} + +func newFakeReconciler(t *testing.T, objects ...*unstructured.Unstructured) *Reconciler { + t.Helper() + dynClient := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(newFakeScheme(), customListKinds) + gc := gitclient.NewFromDynamic(dynClient) + for _, obj := range objects { + gvr, ok := gvrForKind(obj.GetKind()) + if !ok { + t.Fatalf("unknown kind %q", obj.GetKind()) + } + if _, err := dynClient.Resource(gvr).Namespace(obj.GetNamespace()).Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + t.Fatalf("seeding %s/%s: %v", obj.GetNamespace(), obj.GetName(), err) + } + } + return &Reconciler{ + dynamicClient: dynClient, + gitClient: gc, + } +} + +func gvrForKind(kind string) (schema.GroupVersionResource, bool) { + m := map[string]schema.GroupVersionResource{ + "GitRepository": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitrepositories"}, + "GitBranch": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitbranches"}, + "GitPushTransaction": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitpushtransactions"}, + "GitRepoSync": {Group: "git-k8s.imjasonh.com", Version: "v1alpha1", Resource: "gitreposyncs"}, + } + gvr, ok := m[kind] + return gvr, ok +} + +func TestFindBranch_Found(t *testing.T) { + branchObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "repo-a-main", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "repositoryRef": "repo-a", + "branchName": "main", + }, + "status": map[string]interface{}{ + "headCommit": "abc123", + }, + }, + } + + r := newFakeReconciler(t, branchObj) + branch, err := r.findBranch(context.Background(), "default", "repo-a", "main") + if err != nil { + t.Fatalf("findBranch() error = %v", err) + } + if branch.Spec.RepositoryRef != "repo-a" { + t.Errorf("RepositoryRef = %q, want %q", branch.Spec.RepositoryRef, "repo-a") + } + if branch.Spec.BranchName != "main" { + t.Errorf("BranchName = %q, want %q", branch.Spec.BranchName, "main") + } + if branch.Status.HeadCommit != "abc123" { + t.Errorf("HeadCommit = %q, want %q", branch.Status.HeadCommit, "abc123") + } +} + +func TestFindBranch_NotFound(t *testing.T) { + r := newFakeReconciler(t) + _, err := r.findBranch(context.Background(), "default", "repo-a", "main") + if err == nil { + t.Fatal("findBranch() should return error when branch not found") + } +} + +func TestFindBranch_WrongRepo(t *testing.T) { + // Branch exists but for a different repo. + branchObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{ + "name": "repo-b-main", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "repositoryRef": "repo-b", + "branchName": "main", + }, + }, + } + + r := newFakeReconciler(t, branchObj) + _, err := r.findBranch(context.Background(), "default", "repo-a", "main") + if err == nil { + t.Fatal("findBranch() should return error when branch belongs to different repo") + } +} + +func TestUpdateSyncStatus(t *testing.T) { + syncObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepoSync", + "metadata": map[string]interface{}{ + "name": "sync-1", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "repoA": map[string]interface{}{"name": "repo-a"}, + "repoB": map[string]interface{}{"name": "repo-b"}, + "branchName": "main", + }, + "status": map[string]interface{}{}, + }, + } + + tests := []struct { + name string + phase gitv1alpha1.SyncPhase + message string + }{ + {"InSync", gitv1alpha1.SyncPhaseInSync, "Repos are in sync"}, + {"Syncing", gitv1alpha1.SyncPhaseSyncing, "Pushing to A"}, + {"Conflicted", gitv1alpha1.SyncPhaseConflicted, "Repos diverged"}, + {"ManualIntervention", gitv1alpha1.SyncPhaseRequiresManualIntervention, "Merge failed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newFakeReconciler(t, syncObj.DeepCopy()) + + sync := &gitv1alpha1.GitRepoSync{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepoSync", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "sync-1", + Namespace: "default", + }, + } + + err := r.updateSyncStatus(context.Background(), sync, tt.phase, tt.message, "aaa", "bbb", "ccc") + if err != nil { + t.Fatalf("updateSyncStatus() error = %v", err) + } + if sync.Status.Phase != tt.phase { + t.Errorf("Phase = %q, want %q", sync.Status.Phase, tt.phase) + } + if sync.Status.Message != tt.message { + t.Errorf("Message = %q, want %q", sync.Status.Message, tt.message) + } + if sync.Status.RepoACommit != "aaa" { + t.Errorf("RepoACommit = %q, want %q", sync.Status.RepoACommit, "aaa") + } + if sync.Status.RepoBCommit != "bbb" { + t.Errorf("RepoBCommit = %q, want %q", sync.Status.RepoBCommit, "bbb") + } + if sync.Status.MergeBase != "ccc" { + t.Errorf("MergeBase = %q, want %q", sync.Status.MergeBase, "ccc") + } + + // InSync should set LastSyncTime. + if tt.phase == gitv1alpha1.SyncPhaseInSync { + if sync.Status.LastSyncTime == nil { + t.Error("LastSyncTime should be set for InSync phase") + } + } + }) + } +} + +func TestCreatePushTransaction(t *testing.T) { + r := newFakeReconciler(t) + + syncObj := &gitv1alpha1.GitRepoSync{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sync-1", + Namespace: "default", + UID: "uid-123", + }, + Spec: gitv1alpha1.GitRepoSyncSpec{ + RepoA: gitv1alpha1.SyncRepoRef{Name: "repo-a"}, + RepoB: gitv1alpha1.SyncRepoRef{Name: "repo-b"}, + BranchName: "main", + }, + } + + err := r.createPushTransaction(context.Background(), "default", syncObj, "repo-a", "abc123", "main", "old-sha") + if err != nil { + t.Fatalf("createPushTransaction() error = %v", err) + } + + // Verify the push transaction was created. + txns, err := r.gitClient.GitPushTransactions("default").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("listing transactions: %v", err) + } + if len(txns.Items) != 1 { + t.Fatalf("expected 1 transaction, got %d", len(txns.Items)) + } + + txn := txns.Items[0] + if txn.Spec.RepositoryRef != "repo-a" { + t.Errorf("RepositoryRef = %q, want %q", txn.Spec.RepositoryRef, "repo-a") + } + if !txn.Spec.Atomic { + t.Error("Atomic should be true") + } + if len(txn.Spec.RefSpecs) != 1 { + t.Fatalf("RefSpecs length = %d, want 1", len(txn.Spec.RefSpecs)) + } + if txn.Spec.RefSpecs[0].Source != "abc123" { + t.Errorf("Source = %q, want %q", txn.Spec.RefSpecs[0].Source, "abc123") + } + if txn.Spec.RefSpecs[0].Destination != "refs/heads/main" { + t.Errorf("Destination = %q, want %q", txn.Spec.RefSpecs[0].Destination, "refs/heads/main") + } + if txn.Spec.RefSpecs[0].ExpectedOldCommit != "old-sha" { + t.Errorf("ExpectedOldCommit = %q, want %q", txn.Spec.RefSpecs[0].ExpectedOldCommit, "old-sha") + } + + // Verify owner references. + if len(txn.OwnerReferences) != 1 { + t.Fatalf("OwnerReferences length = %d, want 1", len(txn.OwnerReferences)) + } + if txn.OwnerReferences[0].Name != "sync-1" { + t.Errorf("OwnerRef Name = %q, want %q", txn.OwnerReferences[0].Name, "sync-1") + } + if txn.OwnerReferences[0].Kind != "GitRepoSync" { + t.Errorf("OwnerRef Kind = %q, want %q", txn.OwnerReferences[0].Kind, "GitRepoSync") + } + + // Verify labels. + if txn.Labels["git-k8s.imjasonh.com/repo-sync"] != "sync-1" { + t.Errorf("label repo-sync = %q, want %q", txn.Labels["git-k8s.imjasonh.com/repo-sync"], "sync-1") + } + if txn.Labels["git-k8s.imjasonh.com/target"] != "repo-a" { + t.Errorf("label target = %q, want %q", txn.Labels["git-k8s.imjasonh.com/target"], "repo-a") + } +} + +func TestReconcileKind_BothInSync(t *testing.T) { + // When both branches point to the same commit, status should be InSync. + branchA := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{"name": "repo-a-main", "namespace": "default"}, + "spec": map[string]interface{}{"repositoryRef": "repo-a", "branchName": "main"}, + "status": map[string]interface{}{"headCommit": "same-sha"}, + }, + } + branchB := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitBranch", + "metadata": map[string]interface{}{"name": "repo-b-main", "namespace": "default"}, + "spec": map[string]interface{}{"repositoryRef": "repo-b", "branchName": "main"}, + "status": map[string]interface{}{"headCommit": "same-sha"}, + }, + } + syncObjU := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepoSync", + "metadata": map[string]interface{}{"name": "sync-1", "namespace": "default"}, + "spec": map[string]interface{}{ + "repoA": map[string]interface{}{"name": "repo-a"}, + "repoB": map[string]interface{}{"name": "repo-b"}, + "branchName": "main", + }, + "status": map[string]interface{}{}, + }, + } + + r := newFakeReconciler(t, branchA, branchB, syncObjU) + + syncObj := &gitv1alpha1.GitRepoSync{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepoSync", + }, + ObjectMeta: metav1.ObjectMeta{Name: "sync-1", Namespace: "default"}, + Spec: gitv1alpha1.GitRepoSyncSpec{ + RepoA: gitv1alpha1.SyncRepoRef{Name: "repo-a"}, + RepoB: gitv1alpha1.SyncRepoRef{Name: "repo-b"}, + BranchName: "main", + }, + } + + err := r.ReconcileKind(context.Background(), syncObj) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + if syncObj.Status.Phase != gitv1alpha1.SyncPhaseInSync { + t.Errorf("Phase = %q, want %q", syncObj.Status.Phase, gitv1alpha1.SyncPhaseInSync) + } +} + +func TestReconcileKind_BranchNotFound(t *testing.T) { + // When a branch CRD is not found, should set Conflicted status. + syncObjU := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "git-k8s.imjasonh.com/v1alpha1", + "kind": "GitRepoSync", + "metadata": map[string]interface{}{"name": "sync-1", "namespace": "default"}, + "spec": map[string]interface{}{ + "repoA": map[string]interface{}{"name": "repo-a"}, + "repoB": map[string]interface{}{"name": "repo-b"}, + "branchName": "main", + }, + "status": map[string]interface{}{}, + }, + } + + r := newFakeReconciler(t, syncObjU) + + syncObj := &gitv1alpha1.GitRepoSync{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "git-k8s.imjasonh.com/v1alpha1", + Kind: "GitRepoSync", + }, + ObjectMeta: metav1.ObjectMeta{Name: "sync-1", Namespace: "default"}, + Spec: gitv1alpha1.GitRepoSyncSpec{ + RepoA: gitv1alpha1.SyncRepoRef{Name: "repo-a"}, + RepoB: gitv1alpha1.SyncRepoRef{Name: "repo-b"}, + BranchName: "main", + }, + } + + err := r.ReconcileKind(context.Background(), syncObj) + if err != nil { + t.Fatalf("ReconcileKind() error = %v", err) + } + if syncObj.Status.Phase != gitv1alpha1.SyncPhaseConflicted { + t.Errorf("Phase = %q, want %q", syncObj.Status.Phase, gitv1alpha1.SyncPhaseConflicted) + } +}