mirror of
https://github.com/imjasonh/git-k8s
synced 2026-07-06 22:12:25 +00:00
Add unit tests and GitRepoSync e2e test
Add unit tests for all packages that previously had zero test coverage: - pkg/apis/git/v1alpha1: defaults, deepcopy, scheme registration, constants - pkg/client: toUnstructured/fromUnstructured round-trips, context injection - pkg/reconciler/push: splitKey, nestedFieldNoCopy, unstructuredNestedStringMap, base64 decoding - pkg/reconciler/sync: splitKey - pkg/reconciler/resolver: splitKey, changeName Add e2e test exercising the sync controller: - Creates second Gitea repo and GitRepository CR - Creates GitBranch CRs with headCommit set via status subresource patch - Creates GitRepoSync CR and verifies the sync controller processes it https://claude.ai/code/session_01QfFzqKQUsxxBsiZUhuJ3pG
This commit is contained in:
parent
3bbfe8b4d4
commit
4e4f5f923f
7 changed files with 1116 additions and 1 deletions
126
.github/workflows/ci.yaml
vendored
126
.github/workflows/ci.yaml
vendored
|
|
@ -306,10 +306,134 @@ jobs:
|
|||
echo ""
|
||||
echo "Branch feature-test verified on git server!"
|
||||
|
||||
# --- GitRepoSync e2e test ---
|
||||
# Create a second repo on Gitea to test sync between two repos.
|
||||
- name: Create second test repository on Gitea
|
||||
run: |
|
||||
kubectl exec -n git-system deploy/gitea -- \
|
||||
curl -sf -X POST http://localhost:3000/api/v1/user/repos \
|
||||
-H 'Content-Type: application/json' \
|
||||
-u testadmin:testpassword123 \
|
||||
-d '{"name":"test-repo-b","auto_init":true,"default_branch":"main"}'
|
||||
echo ""
|
||||
echo "Second test repository created"
|
||||
|
||||
- name: Create second GitRepository CR
|
||||
run: |
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: git-k8s.imjasonh.com/v1alpha1
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: test-repo-b
|
||||
namespace: default
|
||||
spec:
|
||||
url: http://gitea.git-system.svc.cluster.local:3000/testadmin/test-repo-b.git
|
||||
defaultBranch: main
|
||||
auth:
|
||||
secretRef:
|
||||
name: gitea-creds
|
||||
EOF
|
||||
|
||||
- name: Create GitBranch CRs for sync test
|
||||
run: |
|
||||
# Get the head commit from each repo via Gitea API
|
||||
COMMIT_A=$(kubectl exec -n git-system deploy/gitea -- \
|
||||
curl -sf http://localhost:3000/api/v1/repos/testadmin/test-repo/branches/main \
|
||||
-u testadmin:testpassword123 | python3 -c "import sys,json; print(json.load(sys.stdin)['commit']['id'])")
|
||||
echo "Repo A main commit: $COMMIT_A"
|
||||
|
||||
COMMIT_B=$(kubectl exec -n git-system deploy/gitea -- \
|
||||
curl -sf http://localhost:3000/api/v1/repos/testadmin/test-repo-b/branches/main \
|
||||
-u testadmin:testpassword123 | python3 -c "import sys,json; print(json.load(sys.stdin)['commit']['id'])")
|
||||
echo "Repo B main commit: $COMMIT_B"
|
||||
|
||||
# Create GitBranch CRs
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: git-k8s.imjasonh.com/v1alpha1
|
||||
kind: GitBranch
|
||||
metadata:
|
||||
name: test-repo-main
|
||||
namespace: default
|
||||
spec:
|
||||
repositoryRef: test-repo
|
||||
branchName: main
|
||||
EOF
|
||||
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: git-k8s.imjasonh.com/v1alpha1
|
||||
kind: GitBranch
|
||||
metadata:
|
||||
name: test-repo-b-main
|
||||
namespace: default
|
||||
spec:
|
||||
repositoryRef: test-repo-b
|
||||
branchName: main
|
||||
EOF
|
||||
|
||||
# Set headCommit via status subresource patch
|
||||
kubectl patch gitbranch test-repo-main -n default \
|
||||
--type=merge --subresource=status \
|
||||
-p "{\"status\":{\"headCommit\":\"${COMMIT_A}\"}}"
|
||||
|
||||
kubectl patch gitbranch test-repo-b-main -n default \
|
||||
--type=merge --subresource=status \
|
||||
-p "{\"status\":{\"headCommit\":\"${COMMIT_B}\"}}"
|
||||
|
||||
kubectl get gitbranches -A -o wide
|
||||
|
||||
- name: Create GitRepoSync
|
||||
run: |
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: git-k8s.imjasonh.com/v1alpha1
|
||||
kind: GitRepoSync
|
||||
metadata:
|
||||
name: test-sync
|
||||
namespace: default
|
||||
spec:
|
||||
repoA:
|
||||
name: test-repo
|
||||
repoB:
|
||||
name: test-repo-b
|
||||
branchName: main
|
||||
EOF
|
||||
kubectl get gitreposyncs -A
|
||||
|
||||
- name: Wait for sync controller to process
|
||||
run: |
|
||||
echo "Waiting for GitRepoSync to be processed..."
|
||||
for i in $(seq 1 30); do
|
||||
PHASE=$(kubectl get gitreposync test-sync -n default \
|
||||
-o jsonpath='{.status.phase}' 2>/dev/null)
|
||||
MSG=$(kubectl get gitreposync test-sync -n default \
|
||||
-o jsonpath='{.status.message}' 2>/dev/null)
|
||||
echo " attempt ${i}: phase=${PHASE:-empty} message=${MSG:-empty}"
|
||||
if [ -n "$PHASE" ]; then
|
||||
echo "Sync controller has set phase: $PHASE"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Verify GitRepoSync status
|
||||
run: |
|
||||
echo "=== GitRepoSync status ==="
|
||||
kubectl get gitreposync test-sync -n default -o yaml
|
||||
|
||||
PHASE=$(kubectl get gitreposync test-sync -n default \
|
||||
-o jsonpath='{.status.phase}')
|
||||
echo "Phase: $PHASE"
|
||||
# The sync controller should have processed this - phase should be set
|
||||
if [ -z "$PHASE" ]; then
|
||||
echo "FAIL: sync controller did not set a phase"
|
||||
kubectl logs -n git-system deploy/sync-controller --tail=100
|
||||
exit 1
|
||||
fi
|
||||
echo "GitRepoSync e2e test passed! Phase: $PHASE"
|
||||
|
||||
- name: Collect diagnostics
|
||||
if: ${{ failure() }}
|
||||
uses: chainguard-dev/actions/kind-diag@main
|
||||
with:
|
||||
artifact-name: kind-logs
|
||||
cluster-resources: nodes,namespaces,crds
|
||||
namespace-resources: pods,deployments,services,gitrepositories,gitbranches,gitpushtransactions
|
||||
namespace-resources: pods,deployments,services,gitrepositories,gitbranches,gitpushtransactions,gitreposyncs
|
||||
|
|
|
|||
69
pkg/apis/git/v1alpha1/defaults_test.go
Normal file
69
pkg/apis/git/v1alpha1/defaults_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package v1alpha1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetDefaults_GitRepository(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo *GitRepository
|
||||
wantBranch string
|
||||
}{
|
||||
{
|
||||
name: "empty default branch gets set to main",
|
||||
repo: &GitRepository{},
|
||||
wantBranch: "main",
|
||||
},
|
||||
{
|
||||
name: "explicit default branch is preserved",
|
||||
repo: &GitRepository{
|
||||
Spec: GitRepositorySpec{
|
||||
DefaultBranch: "develop",
|
||||
},
|
||||
},
|
||||
wantBranch: "develop",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
SetDefaults_GitRepository(tt.repo)
|
||||
if got := tt.repo.Spec.DefaultBranch; got != tt.wantBranch {
|
||||
t.Errorf("DefaultBranch = %q, want %q", got, tt.wantBranch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaults_GitPushTransaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
txn *GitPushTransaction
|
||||
wantPhase TransactionPhase
|
||||
}{
|
||||
{
|
||||
name: "empty phase gets set to Pending",
|
||||
txn: &GitPushTransaction{},
|
||||
wantPhase: TransactionPhasePending,
|
||||
},
|
||||
{
|
||||
name: "existing phase is preserved",
|
||||
txn: &GitPushTransaction{
|
||||
Status: GitPushTransactionStatus{
|
||||
Phase: TransactionPhaseSucceeded,
|
||||
},
|
||||
},
|
||||
wantPhase: TransactionPhaseSucceeded,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
SetDefaults_GitPushTransaction(tt.txn)
|
||||
if got := tt.txn.Status.Phase; got != tt.wantPhase {
|
||||
t.Errorf("Phase = %q, want %q", got, tt.wantPhase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
216
pkg/apis/git/v1alpha1/types_test.go
Normal file
216
pkg/apis/git/v1alpha1/types_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package v1alpha1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func TestSchemeRegistration(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
if err := AddToScheme(scheme); err != nil {
|
||||
t.Fatalf("AddToScheme() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify all types are registered.
|
||||
types := []runtime.Object{
|
||||
&GitRepository{},
|
||||
&GitRepositoryList{},
|
||||
&GitBranch{},
|
||||
&GitBranchList{},
|
||||
&GitPushTransaction{},
|
||||
&GitPushTransactionList{},
|
||||
&GitRepoSync{},
|
||||
&GitRepoSyncList{},
|
||||
}
|
||||
|
||||
for _, obj := range types {
|
||||
gvks, _, err := scheme.ObjectKinds(obj)
|
||||
if err != nil {
|
||||
t.Errorf("ObjectKinds(%T) error = %v", obj, err)
|
||||
continue
|
||||
}
|
||||
if len(gvks) == 0 {
|
||||
t.Errorf("ObjectKinds(%T) returned no GVKs", obj)
|
||||
continue
|
||||
}
|
||||
if gvks[0].Group != GroupName {
|
||||
t.Errorf("ObjectKinds(%T) group = %q, want %q", obj, gvks[0].Group, GroupName)
|
||||
}
|
||||
if gvks[0].Version != Version {
|
||||
t.Errorf("ObjectKinds(%T) version = %q, want %q", obj, gvks[0].Version, Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupVersionConstants(t *testing.T) {
|
||||
if got := GroupName; got != "git-k8s.imjasonh.com" {
|
||||
t.Errorf("GroupName = %q, want %q", got, "git-k8s.imjasonh.com")
|
||||
}
|
||||
if got := Version; got != "v1alpha1" {
|
||||
t.Errorf("Version = %q, want %q", got, "v1alpha1")
|
||||
}
|
||||
if got := SchemeGroupVersion.String(); got != "git-k8s.imjasonh.com/v1alpha1" {
|
||||
t.Errorf("SchemeGroupVersion = %q, want %q", got, "git-k8s.imjasonh.com/v1alpha1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindAndResource(t *testing.T) {
|
||||
gk := Kind("GitRepository")
|
||||
if gk.Kind != "GitRepository" {
|
||||
t.Errorf("Kind().Kind = %q, want %q", gk.Kind, "GitRepository")
|
||||
}
|
||||
if gk.Group != GroupName {
|
||||
t.Errorf("Kind().Group = %q, want %q", gk.Group, GroupName)
|
||||
}
|
||||
|
||||
gr := Resource("gitrepositories")
|
||||
if gr.Resource != "gitrepositories" {
|
||||
t.Errorf("Resource().Resource = %q, want %q", gr.Resource, "gitrepositories")
|
||||
}
|
||||
if gr.Group != GroupName {
|
||||
t.Errorf("Resource().Group = %q, want %q", gr.Group, GroupName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionPhaseConstants(t *testing.T) {
|
||||
phases := map[TransactionPhase]string{
|
||||
TransactionPhasePending: "Pending",
|
||||
TransactionPhaseInProgress: "InProgress",
|
||||
TransactionPhaseSucceeded: "Succeeded",
|
||||
TransactionPhaseFailed: "Failed",
|
||||
}
|
||||
for phase, want := range phases {
|
||||
if string(phase) != want {
|
||||
t.Errorf("TransactionPhase constant = %q, want %q", phase, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPhaseConstants(t *testing.T) {
|
||||
phases := map[SyncPhase]string{
|
||||
SyncPhaseInSync: "InSync",
|
||||
SyncPhaseSyncing: "Syncing",
|
||||
SyncPhaseConflicted: "Conflicted",
|
||||
SyncPhaseRequiresManualIntervention: "RequiresManualIntervention",
|
||||
}
|
||||
for phase, want := range phases {
|
||||
if string(phase) != want {
|
||||
t.Errorf("SyncPhase constant = %q, want %q", phase, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepCopy_GitRepository(t *testing.T) {
|
||||
orig := &GitRepository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: GitRepositorySpec{
|
||||
URL: "https://github.com/example/repo.git",
|
||||
DefaultBranch: "main",
|
||||
Auth: &GitAuth{
|
||||
SecretRef: &SecretRef{Name: "my-secret"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
copy := orig.DeepCopy()
|
||||
|
||||
// Verify values match.
|
||||
if copy.Name != orig.Name {
|
||||
t.Errorf("DeepCopy Name = %q, want %q", copy.Name, orig.Name)
|
||||
}
|
||||
if copy.Spec.URL != orig.Spec.URL {
|
||||
t.Errorf("DeepCopy URL = %q, want %q", copy.Spec.URL, orig.Spec.URL)
|
||||
}
|
||||
if copy.Spec.Auth.SecretRef.Name != orig.Spec.Auth.SecretRef.Name {
|
||||
t.Errorf("DeepCopy SecretRef = %q, want %q", copy.Spec.Auth.SecretRef.Name, orig.Spec.Auth.SecretRef.Name)
|
||||
}
|
||||
|
||||
// Verify mutation isolation.
|
||||
copy.Spec.Auth.SecretRef.Name = "changed"
|
||||
if orig.Spec.Auth.SecretRef.Name == "changed" {
|
||||
t.Error("DeepCopy did not isolate Auth.SecretRef mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepCopy_GitPushTransaction(t *testing.T) {
|
||||
now := metav1.Now()
|
||||
orig := &GitPushTransaction{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-push",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: GitPushTransactionSpec{
|
||||
RepositoryRef: "my-repo",
|
||||
Atomic: true,
|
||||
RefSpecs: []PushRefSpec{
|
||||
{
|
||||
Source: "refs/heads/main",
|
||||
Destination: "refs/heads/feature",
|
||||
ExpectedOldCommit: "abc123",
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: GitPushTransactionStatus{
|
||||
Phase: TransactionPhaseSucceeded,
|
||||
ResultCommit: "def456",
|
||||
StartTime: &now,
|
||||
CompletionTime: &now,
|
||||
Message: "done",
|
||||
},
|
||||
}
|
||||
|
||||
copy := orig.DeepCopy()
|
||||
|
||||
if len(copy.Spec.RefSpecs) != 1 {
|
||||
t.Fatalf("DeepCopy RefSpecs length = %d, want 1", len(copy.Spec.RefSpecs))
|
||||
}
|
||||
if copy.Spec.RefSpecs[0].Source != "refs/heads/main" {
|
||||
t.Errorf("DeepCopy RefSpec Source = %q, want %q", copy.Spec.RefSpecs[0].Source, "refs/heads/main")
|
||||
}
|
||||
|
||||
// Verify mutation isolation on slice.
|
||||
copy.Spec.RefSpecs[0].Source = "changed"
|
||||
if orig.Spec.RefSpecs[0].Source == "changed" {
|
||||
t.Error("DeepCopy did not isolate RefSpecs slice mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepCopy_GitRepoSync(t *testing.T) {
|
||||
orig := &GitRepoSync{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-sync",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: GitRepoSyncSpec{
|
||||
RepoA: SyncRepoRef{Name: "repo-a"},
|
||||
RepoB: SyncRepoRef{Name: "repo-b"},
|
||||
BranchName: "main",
|
||||
},
|
||||
Status: GitRepoSyncStatus{
|
||||
Phase: SyncPhaseInSync,
|
||||
RepoACommit: "aaa",
|
||||
RepoBCommit: "bbb",
|
||||
MergeBase: "ccc",
|
||||
Message: "synced",
|
||||
},
|
||||
}
|
||||
|
||||
copy := orig.DeepCopy()
|
||||
|
||||
if copy.Spec.RepoA.Name != "repo-a" {
|
||||
t.Errorf("DeepCopy RepoA = %q, want %q", copy.Spec.RepoA.Name, "repo-a")
|
||||
}
|
||||
if copy.Status.Phase != SyncPhaseInSync {
|
||||
t.Errorf("DeepCopy Phase = %q, want %q", copy.Status.Phase, SyncPhaseInSync)
|
||||
}
|
||||
|
||||
copy.Status.Phase = SyncPhaseConflicted
|
||||
if orig.Status.Phase == SyncPhaseConflicted {
|
||||
t.Error("DeepCopy did not isolate Status mutation")
|
||||
}
|
||||
}
|
||||
298
pkg/client/clientset_test.go
Normal file
298
pkg/client/clientset_test.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1"
|
||||
)
|
||||
|
||||
func TestToUnstructured(t *testing.T) {
|
||||
repo := &gitv1alpha1.GitRepository{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "git-k8s.imjasonh.com/v1alpha1",
|
||||
Kind: "GitRepository",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: gitv1alpha1.GitRepositorySpec{
|
||||
URL: "https://github.com/example/repo.git",
|
||||
DefaultBranch: "main",
|
||||
},
|
||||
}
|
||||
|
||||
u, err := toUnstructured(repo)
|
||||
if err != nil {
|
||||
t.Fatalf("toUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify key fields in the unstructured representation.
|
||||
if got := u.GetName(); got != "test-repo" {
|
||||
t.Errorf("Name = %q, want %q", got, "test-repo")
|
||||
}
|
||||
if got := u.GetNamespace(); got != "default" {
|
||||
t.Errorf("Namespace = %q, want %q", got, "default")
|
||||
}
|
||||
|
||||
spec, ok := u.Object["spec"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("spec field not found or wrong type")
|
||||
}
|
||||
if got := spec["url"]; got != "https://github.com/example/repo.git" {
|
||||
t.Errorf("spec.url = %v, want %q", got, "https://github.com/example/repo.git")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromUnstructured(t *testing.T) {
|
||||
u := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "git-k8s.imjasonh.com/v1alpha1",
|
||||
"kind": "GitRepository",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-repo",
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"url": "https://github.com/example/repo.git",
|
||||
"defaultBranch": "main",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
repo := &gitv1alpha1.GitRepository{}
|
||||
if err := fromUnstructured(u, repo); err != nil {
|
||||
t.Fatalf("fromUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
if got := repo.Name; got != "test-repo" {
|
||||
t.Errorf("Name = %q, want %q", got, "test-repo")
|
||||
}
|
||||
if got := repo.Spec.URL; got != "https://github.com/example/repo.git" {
|
||||
t.Errorf("URL = %q, want %q", got, "https://github.com/example/repo.git")
|
||||
}
|
||||
if got := repo.Spec.DefaultBranch; got != "main" {
|
||||
t.Errorf("DefaultBranch = %q, want %q", got, "main")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_GitRepository(t *testing.T) {
|
||||
orig := &gitv1alpha1.GitRepository{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: gitv1alpha1.SchemeGroupVersion.String(),
|
||||
Kind: "GitRepository",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "round-trip",
|
||||
Namespace: "test-ns",
|
||||
},
|
||||
Spec: gitv1alpha1.GitRepositorySpec{
|
||||
URL: "https://github.com/test/repo.git",
|
||||
DefaultBranch: "develop",
|
||||
Auth: &gitv1alpha1.GitAuth{
|
||||
SecretRef: &gitv1alpha1.SecretRef{Name: "creds"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
u, err := toUnstructured(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("toUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
result := &gitv1alpha1.GitRepository{}
|
||||
if err := fromUnstructured(u, result); err != nil {
|
||||
t.Fatalf("fromUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Name != orig.Name {
|
||||
t.Errorf("Name = %q, want %q", result.Name, orig.Name)
|
||||
}
|
||||
if result.Spec.URL != orig.Spec.URL {
|
||||
t.Errorf("URL = %q, want %q", result.Spec.URL, orig.Spec.URL)
|
||||
}
|
||||
if result.Spec.DefaultBranch != orig.Spec.DefaultBranch {
|
||||
t.Errorf("DefaultBranch = %q, want %q", result.Spec.DefaultBranch, orig.Spec.DefaultBranch)
|
||||
}
|
||||
if result.Spec.Auth == nil || result.Spec.Auth.SecretRef == nil {
|
||||
t.Fatal("Auth.SecretRef is nil after round trip")
|
||||
}
|
||||
if result.Spec.Auth.SecretRef.Name != "creds" {
|
||||
t.Errorf("SecretRef.Name = %q, want %q", result.Spec.Auth.SecretRef.Name, "creds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_GitPushTransaction(t *testing.T) {
|
||||
orig := &gitv1alpha1.GitPushTransaction{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: gitv1alpha1.SchemeGroupVersion.String(),
|
||||
Kind: "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/feature",
|
||||
ExpectedOldCommit: "abc123",
|
||||
},
|
||||
{
|
||||
Source: "refs/heads/develop",
|
||||
Destination: "refs/heads/staging",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
u, err := toUnstructured(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("toUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
result := &gitv1alpha1.GitPushTransaction{}
|
||||
if err := fromUnstructured(u, result); err != nil {
|
||||
t.Fatalf("fromUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Spec.RepositoryRef != "my-repo" {
|
||||
t.Errorf("RepositoryRef = %q, want %q", result.Spec.RepositoryRef, "my-repo")
|
||||
}
|
||||
if !result.Spec.Atomic {
|
||||
t.Error("Atomic = false, want true")
|
||||
}
|
||||
if len(result.Spec.RefSpecs) != 2 {
|
||||
t.Fatalf("RefSpecs length = %d, want 2", len(result.Spec.RefSpecs))
|
||||
}
|
||||
if result.Spec.RefSpecs[0].ExpectedOldCommit != "abc123" {
|
||||
t.Errorf("RefSpecs[0].ExpectedOldCommit = %q, want %q", result.Spec.RefSpecs[0].ExpectedOldCommit, "abc123")
|
||||
}
|
||||
if result.Spec.RefSpecs[1].ExpectedOldCommit != "" {
|
||||
t.Errorf("RefSpecs[1].ExpectedOldCommit = %q, want empty", result.Spec.RefSpecs[1].ExpectedOldCommit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_GitRepoSync(t *testing.T) {
|
||||
orig := &gitv1alpha1.GitRepoSync{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: gitv1alpha1.SchemeGroupVersion.String(),
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
u, err := toUnstructured(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("toUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
result := &gitv1alpha1.GitRepoSync{}
|
||||
if err := fromUnstructured(u, result); err != nil {
|
||||
t.Fatalf("fromUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Spec.RepoA.Name != "repo-a" {
|
||||
t.Errorf("RepoA.Name = %q, want %q", result.Spec.RepoA.Name, "repo-a")
|
||||
}
|
||||
if result.Spec.RepoB.Name != "repo-b" {
|
||||
t.Errorf("RepoB.Name = %q, want %q", result.Spec.RepoB.Name, "repo-b")
|
||||
}
|
||||
if result.Spec.BranchName != "main" {
|
||||
t.Errorf("BranchName = %q, want %q", result.Spec.BranchName, "main")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextInjection(t *testing.T) {
|
||||
client := &GitV1alpha1Client{}
|
||||
ctx := context.Background()
|
||||
|
||||
ctx = WithClient(ctx, client)
|
||||
got := Get(ctx)
|
||||
if got != client {
|
||||
t.Error("Get(ctx) returned different client than WithClient stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGVRConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gvr interface{ String() string }
|
||||
resource string
|
||||
}{
|
||||
{"gitrepositories", gitRepositoryGVR, "gitrepositories"},
|
||||
{"gitbranches", gitBranchGVR, "gitbranches"},
|
||||
{"gitpushtransactions", gitPushTransactionGVR, "gitpushtransactions"},
|
||||
{"gitreposyncs", gitRepoSyncGVR, "gitreposyncs"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.gvr.String()
|
||||
want := gitv1alpha1.GroupName + "/" + gitv1alpha1.Version
|
||||
if got != want+", Resource="+tt.resource {
|
||||
t.Errorf("GVR = %q, want group/version = %q resource = %q", got, want, tt.resource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnstructured_PreservesJSON(t *testing.T) {
|
||||
branch := &gitv1alpha1.GitBranch{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: gitv1alpha1.SchemeGroupVersion.String(),
|
||||
Kind: "GitBranch",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-branch",
|
||||
Namespace: "ns",
|
||||
},
|
||||
Spec: gitv1alpha1.GitBranchSpec{
|
||||
RepositoryRef: "my-repo",
|
||||
BranchName: "feature/awesome",
|
||||
},
|
||||
}
|
||||
|
||||
u, err := toUnstructured(branch)
|
||||
if err != nil {
|
||||
t.Fatalf("toUnstructured() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify the JSON is valid by marshalling back.
|
||||
data, err := json.Marshal(u.Object)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var roundTrip map[string]interface{}
|
||||
if err := json.Unmarshal(data, &roundTrip); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
spec, ok := roundTrip["spec"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("spec not found in roundTrip")
|
||||
}
|
||||
if got := spec["repositoryRef"]; got != "my-repo" {
|
||||
t.Errorf("repositoryRef = %v, want %q", got, "my-repo")
|
||||
}
|
||||
if got := spec["branchName"]; got != "feature/awesome" {
|
||||
t.Errorf("branchName = %v, want %q", got, "feature/awesome")
|
||||
}
|
||||
}
|
||||
253
pkg/reconciler/push/push_test.go
Normal file
253
pkg/reconciler/push/push_test.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
wantNS string
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
key: "default/my-resource",
|
||||
wantNS: "default",
|
||||
wantName: "my-resource",
|
||||
},
|
||||
{
|
||||
key: "kube-system/controller",
|
||||
wantNS: "kube-system",
|
||||
wantName: "controller",
|
||||
},
|
||||
{
|
||||
key: "no-namespace",
|
||||
wantNS: "",
|
||||
wantName: "no-namespace",
|
||||
},
|
||||
{
|
||||
key: "ns/name/with/slashes",
|
||||
wantNS: "ns",
|
||||
wantName: "name/with/slashes",
|
||||
},
|
||||
{
|
||||
key: "",
|
||||
wantNS: "",
|
||||
wantName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
ns, name, err := splitKey(tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("splitKey(%q) error = %v", tt.key, err)
|
||||
}
|
||||
if ns != tt.wantNS {
|
||||
t.Errorf("namespace = %q, want %q", ns, tt.wantNS)
|
||||
}
|
||||
if name != tt.wantName {
|
||||
t.Errorf("name = %q, want %q", name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedFieldNoCopy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
obj map[string]interface{}
|
||||
fields []string
|
||||
want interface{}
|
||||
found bool
|
||||
}{
|
||||
{
|
||||
name: "simple field",
|
||||
obj: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
fields: []string{"key"},
|
||||
want: "value",
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "nested field",
|
||||
obj: map[string]interface{}{
|
||||
"level1": map[string]interface{}{
|
||||
"level2": "deep-value",
|
||||
},
|
||||
},
|
||||
fields: []string{"level1", "level2"},
|
||||
want: "deep-value",
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "missing field",
|
||||
obj: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
fields: []string{"missing"},
|
||||
want: nil,
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "non-map intermediate",
|
||||
obj: map[string]interface{}{
|
||||
"key": "not-a-map",
|
||||
},
|
||||
fields: []string{"key", "subkey"},
|
||||
want: nil,
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "empty object",
|
||||
obj: map[string]interface{}{},
|
||||
fields: []string{"key"},
|
||||
want: nil,
|
||||
found: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, found, err := nestedFieldNoCopy(tt.obj, tt.fields...)
|
||||
if err != nil {
|
||||
t.Fatalf("nestedFieldNoCopy() error = %v", err)
|
||||
}
|
||||
if found != tt.found {
|
||||
t.Errorf("found = %v, want %v", found, tt.found)
|
||||
}
|
||||
if found && got != tt.want {
|
||||
t.Errorf("value = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnstructuredNestedStringMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
obj map[string]interface{}
|
||||
fields []string
|
||||
wantMap map[string]string
|
||||
wantFound bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid string map",
|
||||
obj: map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"username": base64.StdEncoding.EncodeToString([]byte("admin")),
|
||||
"password": base64.StdEncoding.EncodeToString([]byte("secret")),
|
||||
},
|
||||
},
|
||||
fields: []string{"data"},
|
||||
wantMap: map[string]string{
|
||||
"username": base64.StdEncoding.EncodeToString([]byte("admin")),
|
||||
"password": base64.StdEncoding.EncodeToString([]byte("secret")),
|
||||
},
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "missing data field",
|
||||
obj: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{},
|
||||
},
|
||||
fields: []string{"data"},
|
||||
wantFound: false,
|
||||
},
|
||||
{
|
||||
name: "non-string values are skipped",
|
||||
obj: map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"string-key": "string-value",
|
||||
"int-key": 42,
|
||||
"bool-key": true,
|
||||
},
|
||||
},
|
||||
fields: []string{"data"},
|
||||
wantMap: map[string]string{
|
||||
"string-key": "string-value",
|
||||
},
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "not a map type",
|
||||
obj: map[string]interface{}{
|
||||
"data": "just-a-string",
|
||||
},
|
||||
fields: []string{"data"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
obj: map[string]interface{}{
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
fields: []string{"data"},
|
||||
wantMap: map[string]string{},
|
||||
wantFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, found, err := unstructuredNestedStringMap(tt.obj, tt.fields...)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if found != tt.wantFound {
|
||||
t.Errorf("found = %v, want %v", found, tt.wantFound)
|
||||
}
|
||||
if tt.wantMap != nil {
|
||||
if len(got) != len(tt.wantMap) {
|
||||
t.Errorf("map length = %d, want %d", len(got), len(tt.wantMap))
|
||||
}
|
||||
for k, v := range tt.wantMap {
|
||||
if got[k] != v {
|
||||
t.Errorf("map[%q] = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64Decoding(t *testing.T) {
|
||||
// Verify that values typically stored in Kubernetes secrets
|
||||
// (base64-encoded via dynamic client) decode correctly.
|
||||
tests := []struct {
|
||||
name string
|
||||
encoded string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "simple username",
|
||||
encoded: base64.StdEncoding.EncodeToString([]byte("admin")),
|
||||
wantText: "admin",
|
||||
},
|
||||
{
|
||||
name: "password with special chars",
|
||||
encoded: base64.StdEncoding.EncodeToString([]byte("p@ss!w0rd#123")),
|
||||
wantText: "p@ss!w0rd#123",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
encoded: base64.StdEncoding.EncodeToString([]byte("")),
|
||||
wantText: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(tt.encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeString() error = %v", err)
|
||||
}
|
||||
if string(decoded) != tt.wantText {
|
||||
t.Errorf("decoded = %q, want %q", string(decoded), tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
101
pkg/reconciler/resolver/resolver_test.go
Normal file
101
pkg/reconciler/resolver/resolver_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
func TestSplitKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
wantNS string
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
key: "default/my-sync",
|
||||
wantNS: "default",
|
||||
wantName: "my-sync",
|
||||
},
|
||||
{
|
||||
key: "git-system/resolver",
|
||||
wantNS: "git-system",
|
||||
wantName: "resolver",
|
||||
},
|
||||
{
|
||||
key: "just-name",
|
||||
wantNS: "",
|
||||
wantName: "just-name",
|
||||
},
|
||||
{
|
||||
key: "",
|
||||
wantNS: "",
|
||||
wantName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
ns, name, err := splitKey(tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("splitKey(%q) error = %v", tt.key, err)
|
||||
}
|
||||
if ns != tt.wantNS {
|
||||
t.Errorf("namespace = %q, want %q", ns, tt.wantNS)
|
||||
}
|
||||
if name != tt.wantName {
|
||||
t.Errorf("name = %q, want %q", name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
change *object.Change
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "from name takes precedence",
|
||||
change: &object.Change{
|
||||
From: object.ChangeEntry{Name: "file-from.txt"},
|
||||
To: object.ChangeEntry{Name: "file-to.txt"},
|
||||
},
|
||||
want: "file-from.txt",
|
||||
},
|
||||
{
|
||||
name: "to name used when from is empty (new file)",
|
||||
change: &object.Change{
|
||||
From: object.ChangeEntry{Name: ""},
|
||||
To: object.ChangeEntry{Name: "new-file.txt"},
|
||||
},
|
||||
want: "new-file.txt",
|
||||
},
|
||||
{
|
||||
name: "rename uses from name",
|
||||
change: &object.Change{
|
||||
From: object.ChangeEntry{Name: "old-name.txt"},
|
||||
To: object.ChangeEntry{Name: "new-name.txt"},
|
||||
},
|
||||
want: "old-name.txt",
|
||||
},
|
||||
{
|
||||
name: "deleted file (no to name)",
|
||||
change: &object.Change{
|
||||
From: object.ChangeEntry{Name: "deleted.txt"},
|
||||
To: object.ChangeEntry{Name: ""},
|
||||
},
|
||||
want: "deleted.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := changeName(tt.change)
|
||||
if got != tt.want {
|
||||
t.Errorf("changeName() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
pkg/reconciler/sync/sync_test.go
Normal file
54
pkg/reconciler/sync/sync_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
wantNS string
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
key: "default/my-sync",
|
||||
wantNS: "default",
|
||||
wantName: "my-sync",
|
||||
},
|
||||
{
|
||||
key: "git-system/sync-controller",
|
||||
wantNS: "git-system",
|
||||
wantName: "sync-controller",
|
||||
},
|
||||
{
|
||||
key: "just-name",
|
||||
wantNS: "",
|
||||
wantName: "just-name",
|
||||
},
|
||||
{
|
||||
key: "",
|
||||
wantNS: "",
|
||||
wantName: "",
|
||||
},
|
||||
{
|
||||
key: "ns/name/extra",
|
||||
wantNS: "ns",
|
||||
wantName: "name/extra",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
ns, name, err := splitKey(tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("splitKey(%q) error = %v", tt.key, err)
|
||||
}
|
||||
if ns != tt.wantNS {
|
||||
t.Errorf("namespace = %q, want %q", ns, tt.wantNS)
|
||||
}
|
||||
if name != tt.wantName {
|
||||
t.Errorf("name = %q, want %q", name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue