From 201fff2ace921267c00e0261e2a08f1f6df2f5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=83=9C=ED=99=98=20=EB=B0=95?= Date: Sat, 1 Aug 2026 12:46:45 +0900 Subject: [PATCH] =?UTF-8?q?test(controller):=20reconcile=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EA=B2=BD=EB=A1=9C=20=ED=9A=8C=EA=B7=80=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=208=EA=B1=B4=20+=20=EB=9E=98=EC=B9=AB=20=ED=95=98?= =?UTF-8?q?=ED=95=9C=2029=E2=86=9230%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커버리지 실측에서 internal/controller 가 37.8% 였고 0% 함수가 647 개였다. 비율을 채우려 훑는 테스트는 없느니만 못하므로, **실패해도 증상이 조용한** 경로부터 골라 진짜 검증을 붙인다: - getAdminPassword — 시크릿 부재 / password 키 부재 / 정상. 여기서 조용히 빈 문자열을 반환하면 인증 매니저가 빈 비밀번호로 접속을 시도해 원인이 안 드러난다. - createOrUpdateSecret — NotFound 분기. 잘못 타면 매 reconcile 마다 AlreadyExists 로 실패하거나 최초 생성이 안 된다. - updateBackupStatus — Job 완료/실패 전이 + Job 부재. **실패한 백업이 성공으로 보이는 것**이 이 프로젝트에서 가장 비싼 오류다. 전부 fake client 라 envtest 불필요·결정론적. verify: - go test ./internal/controller/ -run 'TestGetAdminPassword|TestCreateOrUpdateSecret|TestUpdateBackupStatus' → ok - 대상 함수 커버리지: getAdminPassword 100% / createOrUpdateSecret 87.5% / updateBackupStatus 90% - make test 총계 29.7% → 30.1% → 래칫 하한 30 으로 상향 Signed-off-by: 태환 박 Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- internal/controller/core_paths_unit_test.go | 210 ++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 internal/controller/core_paths_unit_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 188a6f38..9bd86b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: run: make test - name: Coverage floor (ratchet) env: - COVERAGE_FLOOR: "29" + COVERAGE_FLOOR: "30" run: bash hack/coverage-floor.sh cover.out - name: Upload coverage if: always() diff --git a/internal/controller/core_paths_unit_test.go b/internal/controller/core_paths_unit_test.go new file mode 100644 index 00000000..ebd9822f --- /dev/null +++ b/internal/controller/core_paths_unit_test.go @@ -0,0 +1,210 @@ +/* +Copyright 2024 Keiailab. + +SPDX-License-Identifier: MIT +*/ + +// 본 파일은 reconcile 핵심 경로 중 **fake client 로 결정론 재현이 가능한** 헬퍼를 +// 회귀 가드한다. 이 함수들은 전부 커버리지 0% 였는데, 실패 시 증상이 조용하다는 +// 공통점이 있다 — admin 시크릿을 못 읽으면 인증 매니저가 빈 비밀번호로 접속을 +// 시도하고, 백업 status 전이가 어긋나면 실패한 백업이 성공으로 보인다. +// envtest 불필요 (fake client 만 사용). +package controller + +import ( + "context" + "strings" + "testing" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mongodbv1alpha1 "github.com/keiailab/mongodb-operator/api/v1alpha1" +) + +func newMongoDBWithAdminSecret(name, ns, secretName string) *mongodbv1alpha1.MongoDB { + mdb := &mongodbv1alpha1.MongoDB{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, UID: types.UID(name + "-uid")}, + Spec: mongodbv1alpha1.MongoDBSpec{Members: 3}, + } + mdb.Spec.Auth.AdminCredentialsSecretRef = corev1.LocalObjectReference{Name: secretName} + return mdb +} + +// TestGetAdminPassword_시크릿이_없으면_에러를_반환한다 — 시크릿 부재를 조용히 +// 빈 문자열로 넘기면 이후 인증이 실패해도 원인이 드러나지 않는다. +func TestGetAdminPassword_시크릿이_없으면_에러를_반환한다(t *testing.T) { + s := newTestScheme(t) + mdb := newMongoDBWithAdminSecret("rs", "ns", "missing-secret") + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(mdb).Build() + r := &MongoDBReconciler{Client: cl, Scheme: s} + + pw, err := r.getAdminPassword(context.Background(), mdb) + if err == nil { + t.Fatalf("시크릿이 없는데 에러가 없다 (pw=%q)", pw) + } + if pw != "" { + t.Fatalf("에러 시 비밀번호는 비어 있어야 한다, got %q", pw) + } + if !strings.Contains(err.Error(), "admin credentials secret") { + t.Fatalf("에러 메시지가 원인을 지목하지 않는다: %v", err) + } +} + +// TestGetAdminPassword_password_키가_없으면_에러를_반환한다 — 시크릿은 있는데 +// 키 이름이 틀린 경우가 실제로 가장 흔한 오배선이다. +func TestGetAdminPassword_password_키가_없으면_에러를_반환한다(t *testing.T) { + s := newTestScheme(t) + mdb := newMongoDBWithAdminSecret("rs", "ns", "admin") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "admin", Namespace: "ns"}, + Data: map[string][]byte{"username": []byte("admin")}, // password 누락 + } + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(mdb, secret).Build() + r := &MongoDBReconciler{Client: cl, Scheme: s} + + if _, err := r.getAdminPassword(context.Background(), mdb); err == nil { + t.Fatal("password 키가 없는데 에러가 없다") + } else if !strings.Contains(err.Error(), "password key not found") { + t.Fatalf("에러 메시지가 원인을 지목하지 않는다: %v", err) + } +} + +func TestGetAdminPassword_정상_시크릿에서_값을_읽는다(t *testing.T) { + s := newTestScheme(t) + mdb := newMongoDBWithAdminSecret("rs", "ns", "admin") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "admin", Namespace: "ns"}, + Data: map[string][]byte{"username": []byte("admin"), "password": []byte("s3cr3t")}, + } + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(mdb, secret).Build() + r := &MongoDBReconciler{Client: cl, Scheme: s} + + pw, err := r.getAdminPassword(context.Background(), mdb) + if err != nil { + t.Fatalf("정상 경로에서 에러: %v", err) + } + if pw != "s3cr3t" { + t.Fatalf("비밀번호 불일치: got %q, want %q", pw, "s3cr3t") + } +} + +func newBackup(name, ns string) *mongodbv1alpha1.MongoDBBackup { + return &mongodbv1alpha1.MongoDBBackup{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, UID: types.UID(name + "-uid")}, + } +} + +// TestCreateOrUpdateSecret_없으면_생성하고_있으면_갱신한다 — 이 헬퍼는 Get 이 +// NotFound 일 때만 Create 로 분기한다. 분기를 잘못 타면 매 reconcile 마다 +// AlreadyExists 로 실패하거나(생성만), 최초 생성이 안 된다(갱신만). +func TestCreateOrUpdateSecret_없으면_생성하고_있으면_갱신한다(t *testing.T) { + s := newTestScheme(t) + cl := fake.NewClientBuilder().WithScheme(s).Build() + r := &MongoDBBackupReconciler{Client: cl, Scheme: s} + ctx := context.Background() + + want := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "backup-creds", Namespace: "ns"}, + StringData: map[string]string{"key": "v1"}, + } + if err := r.createOrUpdateSecret(ctx, want); err != nil { + t.Fatalf("최초 생성 실패: %v", err) + } + + got := &corev1.Secret{} + if err := cl.Get(ctx, types.NamespacedName{Name: "backup-creds", Namespace: "ns"}, got); err != nil { + t.Fatalf("생성 후 조회 실패: %v", err) + } + + // 두 번째 호출은 Create 가 아니라 Update 경로여야 한다 (AlreadyExists 로 죽지 않음). + updated := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "backup-creds", Namespace: "ns"}, + StringData: map[string]string{"key": "v2"}, + } + if err := r.createOrUpdateSecret(ctx, updated); err != nil { + t.Fatalf("갱신 실패: %v", err) + } + + got = &corev1.Secret{} + if err := cl.Get(ctx, types.NamespacedName{Name: "backup-creds", Namespace: "ns"}, got); err != nil { + t.Fatalf("갱신 후 조회 실패: %v", err) + } + if got.StringData["key"] != "v2" { + t.Fatalf("갱신된 값이 반영되지 않았다: got %q, want %q", got.StringData["key"], "v2") + } +} + +// TestUpdateBackupStatus_Job_완료를_Completed_로_전이한다 — 이 전이가 깨지면 +// 완료된 백업이 영원히 Running 으로 남아 다음 스케줄을 막는다. +func TestUpdateBackupStatus_Job_완료를_Completed_로_전이한다(t *testing.T) { + s := newTestScheme(t) + backup := newBackup("b1", "ns") + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "b1-job", Namespace: "ns"}, + Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{{ + Type: batchv1.JobComplete, + Status: corev1.ConditionTrue, + LastTransitionTime: metav1.Now(), + }}}, + } + cl := fake.NewClientBuilder().WithScheme(s). + WithObjects(backup, job).WithStatusSubresource(backup).Build() + r := &MongoDBBackupReconciler{Client: cl, Scheme: s} + + if err := r.updateBackupStatus(context.Background(), backup, "b1-job"); err != nil { + t.Fatalf("status 갱신 실패: %v", err) + } + if backup.Status.Phase != backupPhaseCompleted { + t.Fatalf("phase 불일치: got %q, want %q", backup.Status.Phase, backupPhaseCompleted) + } + if backup.Status.CompletionTime == nil { + t.Fatal("완료 시각이 기록되지 않았다") + } +} + +// TestUpdateBackupStatus_Job_실패를_Failed_로_전이하고_사유를_남긴다 — 실패한 +// 백업이 성공으로 보이는 것은 이 프로젝트에서 가장 비싼 오류다. +func TestUpdateBackupStatus_Job_실패를_Failed_로_전이하고_사유를_남긴다(t *testing.T) { + s := newTestScheme(t) + backup := newBackup("b2", "ns") + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "b2-job", Namespace: "ns"}, + Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{{ + Type: batchv1.JobFailed, + Status: corev1.ConditionTrue, + Message: "BackoffLimitExceeded", + LastTransitionTime: metav1.Now(), + }}}, + } + cl := fake.NewClientBuilder().WithScheme(s). + WithObjects(backup, job).WithStatusSubresource(backup).Build() + r := &MongoDBBackupReconciler{Client: cl, Scheme: s} + + if err := r.updateBackupStatus(context.Background(), backup, "b2-job"); err != nil { + t.Fatalf("status 갱신 실패: %v", err) + } + if backup.Status.Phase != backupPhaseFailed { + t.Fatalf("phase 불일치: got %q, want %q", backup.Status.Phase, backupPhaseFailed) + } + if backup.Status.Error != "BackoffLimitExceeded" { + t.Fatalf("실패 사유가 보존되지 않았다: got %q", backup.Status.Error) + } +} + +// TestUpdateBackupStatus_Job_이_없으면_에러를_반환한다 — 조용히 넘기면 status 가 +// 낡은 값으로 고정된다. +func TestUpdateBackupStatus_Job_이_없으면_에러를_반환한다(t *testing.T) { + s := newTestScheme(t) + backup := newBackup("b3", "ns") + cl := fake.NewClientBuilder().WithScheme(s). + WithObjects(backup).WithStatusSubresource(backup).Build() + r := &MongoDBBackupReconciler{Client: cl, Scheme: s} + + if err := r.updateBackupStatus(context.Background(), backup, "nonexistent-job"); err == nil { + t.Fatal("Job 이 없는데 에러가 없다") + } +}