From 2e70e36d35e30744034143f852525642c672e4b9 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Tue, 13 Oct 2026 14:12:39 +0200 Subject: [PATCH] tighten action tests --- internal/action/delete_test.go | 180 ++++++++++++++++++++++++---- internal/action/deploy_test.go | 126 +++++++++++++++---- internal/action/list_test.go | 213 +++++++++++++++++++++++++++++---- internal/action/status_test.go | 148 ++++++++++++++++++++++- 4 files changed, 593 insertions(+), 74 deletions(-) diff --git a/internal/action/delete_test.go b/internal/action/delete_test.go index 4020371..04248f0 100644 --- a/internal/action/delete_test.go +++ b/internal/action/delete_test.go @@ -2,6 +2,7 @@ package action_test import ( "context" + "errors" "fmt" "testing" @@ -15,79 +16,208 @@ import ( ) type mockDeleter struct { - release *v1.Release - getErr error - delErr error - gotWait bool + release *v1.Release + getErr error + delErr error + gotProject string + gotEnv string + gotWait bool + getCalls int + deleteCalls int + ctx context.Context } -func (m *mockDeleter) GetRelease(_ context.Context, _, _ string) (*v1.Release, error) { +func (m *mockDeleter) GetRelease(ctx context.Context, project, environment string) (*v1.Release, error) { + m.getCalls++ + m.ctx = ctx + m.gotProject = project + m.gotEnv = environment if m.getErr != nil { return nil, m.getErr } + if err := ctx.Err(); err != nil { + return nil, err + } return m.release, nil } -func (m *mockDeleter) DeleteRelease(_ context.Context, _, _ string, wait bool) error { +func (m *mockDeleter) DeleteRelease(ctx context.Context, project, environment string, wait bool) error { + m.deleteCalls++ + m.ctx = ctx + m.gotProject = project + m.gotEnv = environment m.gotWait = wait - return m.delErr + if m.delErr != nil { + return m.delErr + } + return ctx.Err() } // Check returns ErrReleaseNotFound when the release is missing and yes is false. func TestDelete_Check_NotFoundWithoutYes(t *testing.T) { - del := action.NewDelete(&mockDeleter{getErr: fmt.Errorf("release 'x-prod': %w", helm.ErrReleaseNotFound)}) - _, err := del.Check(t.Context(), action.DeleteParams{Project: "x", Environment: "prod", Yes: false}) - require.Error(t, err) - assert.ErrorIs(t, err, helm.ErrReleaseNotFound) + t.Parallel() + m := &mockDeleter{getErr: fmt.Errorf("release 'x-prod': %w", helm.ErrReleaseNotFound)} + del := action.NewDelete(m) + result, err := del.Check(t.Context(), action.DeleteParams{Project: "x", Environment: "prod", Yes: false}) + require.ErrorIs(t, err, helm.ErrReleaseNotFound) + assert.ErrorContains(t, err, "project 'x' in environment 'prod'") + assert.Nil(t, result) + assert.Equal(t, "x", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.Equal(t, 1, m.getCalls) + assert.Zero(t, m.deleteCalls) } // Check succeeds with NotFound when the release is missing and yes is true. func TestDelete_Check_NotFoundWithYes(t *testing.T) { - del := action.NewDelete(&mockDeleter{getErr: fmt.Errorf("release 'x-prod': %w", helm.ErrReleaseNotFound)}) + t.Parallel() + m := &mockDeleter{getErr: fmt.Errorf("release 'x-prod': %w", helm.ErrReleaseNotFound)} + del := action.NewDelete(m) result, err := del.Check(t.Context(), action.DeleteParams{Project: "x", Environment: "prod", Yes: true}) require.NoError(t, err) + require.NotNil(t, result) assert.True(t, result.NotFound) + assert.Nil(t, result.Release) + assert.Equal(t, "x", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.Zero(t, m.deleteCalls) } -// Check returns the release when it exists. +// Check returns the release when it exists, whether or not yes is set. func TestDelete_Check_Found(t *testing.T) { + t.Parallel() rel := &v1.Release{Name: "x-prod"} - del := action.NewDelete(&mockDeleter{release: rel}) - result, err := del.Check(t.Context(), action.DeleteParams{Project: "x", Environment: "prod"}) - require.NoError(t, err) - assert.Equal(t, rel, result.Release) - assert.False(t, result.NotFound) + tests := []struct { + name string + yes bool + }{ + {name: "without yes", yes: false}, + {name: "yes still returns the release", yes: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := &mockDeleter{release: rel} + del := action.NewDelete(m) + result, err := del.Check(t.Context(), action.DeleteParams{ + Project: "x", Environment: "prod", Yes: tt.yes, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, rel, result.Release) + assert.False(t, result.NotFound) + assert.Equal(t, "x", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.Equal(t, 1, m.getCalls) + assert.Zero(t, m.deleteCalls) + }) + } +} + +// Check wraps lookup errors that are not a missing release. +func TestDelete_Check_OtherGetError(t *testing.T) { + t.Parallel() + errGet := errors.New("api unavailable") + tests := []struct { + name string + yes bool + }{ + {name: "without yes", yes: false}, + {name: "yes does not skip other errors", yes: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := &mockDeleter{getErr: errGet} + del := action.NewDelete(m) + result, err := del.Check(t.Context(), action.DeleteParams{ + Project: "shop", Environment: "prod", Yes: tt.yes, + }) + require.ErrorIs(t, err, errGet) + assert.ErrorContains(t, err, "check project status") + assert.Nil(t, result) + assert.Equal(t, "shop", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.Zero(t, m.deleteCalls) + }) + } +} + +// Check returns a canceled lookup without deleting. +func TestDelete_Check_Canceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + m := &mockDeleter{} + del := action.NewDelete(m) + result, err := del.Check(ctx, action.DeleteParams{Project: "shop", Environment: "prod"}) + require.ErrorIs(t, err, context.Canceled) + assert.ErrorContains(t, err, "check project status") + assert.Nil(t, result) + assert.Zero(t, m.deleteCalls) } // Execute deletes the release successfully. func TestDelete_Execute_Success(t *testing.T) { - del := action.NewDelete(&mockDeleter{}) - err := del.Execute(t.Context(), "x", "prod", false) + t.Parallel() + ctx := context.WithValue(t.Context(), ctxMarker{}, "delete") + m := &mockDeleter{} + del := action.NewDelete(m) + err := del.Execute(ctx, "x", "prod", false) require.NoError(t, err) + assert.Equal(t, "x", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.Equal(t, "delete", m.ctx.Value(ctxMarker{})) + assert.Equal(t, 1, m.deleteCalls) + assert.Zero(t, m.getCalls) } // Execute wraps deleter errors. func TestDelete_Execute_Error(t *testing.T) { - del := action.NewDelete(&mockDeleter{delErr: fmt.Errorf("helm delete failed")}) + t.Parallel() + errDelete := errors.New("helm delete failed") + m := &mockDeleter{delErr: errDelete} + del := action.NewDelete(m) err := del.Execute(t.Context(), "x", "prod", false) - require.Error(t, err) - assert.Contains(t, err.Error(), "delete release") + require.ErrorIs(t, err, errDelete) + assert.ErrorContains(t, err, "delete release") + assert.Equal(t, "x", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.Zero(t, m.getCalls) +} + +// Execute returns a canceled delete. +func TestDelete_Execute_Canceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + m := &mockDeleter{} + del := action.NewDelete(m) + err := del.Execute(ctx, "shop", "prod", true) + require.ErrorIs(t, err, context.Canceled) + assert.ErrorContains(t, err, "delete release") + assert.Equal(t, "shop", m.gotProject) + assert.Equal(t, "prod", m.gotEnv) + assert.True(t, m.gotWait) + assert.Zero(t, m.getCalls) } // Execute passes wait=false to the deleter by default. func TestDelete_Execute_WaitFalse(t *testing.T) { + t.Parallel() m := &mockDeleter{} del := action.NewDelete(m) err := del.Execute(t.Context(), "x", "prod", false) require.NoError(t, err) - assert.False(t, m.gotWait, "expected wait=false to be forwarded to deleter") + assert.False(t, m.gotWait) } // Execute passes wait=true to the deleter when requested. func TestDelete_Execute_WaitTrue(t *testing.T) { + t.Parallel() m := &mockDeleter{} del := action.NewDelete(m) err := del.Execute(t.Context(), "x", "prod", true) require.NoError(t, err) - assert.True(t, m.gotWait, "expected wait=true to be forwarded to deleter") + assert.True(t, m.gotWait) } diff --git a/internal/action/deploy_test.go b/internal/action/deploy_test.go index f1e02df..d880232 100644 --- a/internal/action/deploy_test.go +++ b/internal/action/deploy_test.go @@ -2,7 +2,7 @@ package action_test import ( "context" - "fmt" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -13,31 +13,56 @@ import ( "deployah.dev/deployah/internal/spec" ) +// ctxMarker identifies the context a dependency received. +type ctxMarker struct{} + type mockDeployer struct { - err error - resolved *spec.ResolvedSpec + err error + resolved *spec.ResolvedSpec + dryRun bool + postRenderer postrenderer.PostRenderer + called bool + ctx context.Context } -func (m *mockDeployer) InstallApp(_ context.Context, _ bool, resolved *spec.ResolvedSpec, _ postrenderer.PostRenderer) error { +func (m *mockDeployer) InstallApp(ctx context.Context, dryRun bool, resolved *spec.ResolvedSpec, pr postrenderer.PostRenderer) error { + m.called = true + m.ctx = ctx + m.dryRun = dryRun m.resolved = resolved - return m.err + m.postRenderer = pr + if m.err != nil { + return m.err + } + return ctx.Err() } type mockSpecLoader struct { - m *spec.Spec - err error + m *spec.Spec + err error + environment string + ctx context.Context + calls int } -func (m *mockSpecLoader) Spec(_ context.Context, _ string) (*spec.Spec, spec.SubstitutionReport, error) { +func (m *mockSpecLoader) Spec(ctx context.Context, environment string) (*spec.Spec, spec.SubstitutionReport, error) { + m.calls++ + m.ctx = ctx + m.environment = environment if m.err != nil { return nil, spec.SubstitutionReport{}, m.err } + if err := ctx.Err(); err != nil { + return nil, spec.SubstitutionReport{}, err + } return m.m, spec.SubstitutionReport{}, nil } -var testManifest = &spec.Spec{ - APIVersion: spec.CurrentManifestVersion, - Project: "my-app", +func testSpec() *spec.Spec { + return &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "my-app", + } } // Run loads the manifest and delegates to the deployer. @@ -45,31 +70,85 @@ func TestDeploy_Run(t *testing.T) { t.Parallel() t.Run("succeeds when deployer and loader succeed", func(t *testing.T) { t.Parallel() + ctx := context.WithValue(t.Context(), ctxMarker{}, "deploy") deployer := &mockDeployer{} - d := action.NewDeploy(deployer, &mockSpecLoader{m: testManifest}) - m, err := d.Run(t.Context(), "prod", false) + loader := &mockSpecLoader{m: testSpec()} + d := action.NewDeploy(deployer, loader) + m, err := d.Run(ctx, "prod", false) require.NoError(t, err) assert.Equal(t, "my-app", m.Project) + assert.Equal(t, 1, loader.calls) + assert.Equal(t, "prod", loader.environment) + assert.Equal(t, "deploy", loader.ctx.Value(ctxMarker{})) + require.True(t, deployer.called) + assert.False(t, deployer.dryRun) + assert.Nil(t, deployer.postRenderer) + assert.Equal(t, "deploy", deployer.ctx.Value(ctxMarker{})) require.NotNil(t, deployer.resolved) require.NotNil(t, deployer.resolved.Components) require.NotNil(t, deployer.resolved.Tasks) assert.Equal(t, "prod", deployer.resolved.Env.Original) }) + t.Run("dry run still resolves and forwards the flag", func(t *testing.T) { + t.Parallel() + deployer := &mockDeployer{} + loader := &mockSpecLoader{m: testSpec()} + d := action.NewDeploy(deployer, loader) + m, err := d.Run(t.Context(), "prod", true) + require.NoError(t, err) + assert.Equal(t, "my-app", m.Project) + assert.Equal(t, "prod", loader.environment) + require.True(t, deployer.called) + assert.True(t, deployer.dryRun) + assert.Nil(t, deployer.postRenderer) + require.NotNil(t, deployer.resolved) + assert.Equal(t, "prod", deployer.resolved.Env.Original) + }) + + t.Run("wildcard environment keeps the instance identity", func(t *testing.T) { + t.Parallel() + deployer := &mockDeployer{} + loader := &mockSpecLoader{m: testSpec()} + d := action.NewDeploy(deployer, loader) + _, err := d.Run(t.Context(), "review/pr-123", false) + require.NoError(t, err) + assert.Equal(t, "review/pr-123", loader.environment) + require.NotNil(t, deployer.resolved) + assert.Equal(t, "review/pr-123", deployer.resolved.Env.Original) + assert.Equal(t, "review", deployer.resolved.Env.MapKey) + }) + t.Run("returns error when manifest loader fails", func(t *testing.T) { t.Parallel() - d := action.NewDeploy(&mockDeployer{}, &mockSpecLoader{err: fmt.Errorf("not found")}) + errLoad := errors.New("not found") + deployer := &mockDeployer{} + d := action.NewDeploy(deployer, &mockSpecLoader{err: errLoad}) _, err := d.Run(t.Context(), "prod", false) - require.Error(t, err) - assert.Contains(t, err.Error(), "load spec") + require.ErrorIs(t, err, errLoad) + assert.ErrorContains(t, err, "load spec") + assert.False(t, deployer.called) }) t.Run("returns error when deployer fails", func(t *testing.T) { t.Parallel() - d := action.NewDeploy(&mockDeployer{err: fmt.Errorf("helm error")}, &mockSpecLoader{m: testManifest}) + errInstall := errors.New("helm error") + d := action.NewDeploy(&mockDeployer{err: errInstall}, &mockSpecLoader{m: testSpec()}) _, err := d.Run(t.Context(), "prod", false) - require.Error(t, err) - assert.Contains(t, err.Error(), "install") + require.ErrorIs(t, err, errInstall) + assert.ErrorContains(t, err, "install") + }) + + t.Run("returns canceled when the loader context is done", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + deployer := &mockDeployer{} + d := action.NewDeploy(deployer, &mockSpecLoader{m: testSpec()}) + _, err := d.Run(ctx, "prod", false) + require.ErrorIs(t, err, context.Canceled) + assert.ErrorContains(t, err, "load spec") + assert.False(t, deployer.called) }) t.Run("returns error when resolve fails", func(t *testing.T) { @@ -92,9 +171,14 @@ func TestDeploy_Run(t *testing.T) { "prod": {}, }, } - d := action.NewDeploy(&mockDeployer{}, &mockSpecLoader{m: m}) + deployer := &mockDeployer{} + d := action.NewDeploy(deployer, &mockSpecLoader{m: m}) _, err := d.Run(t.Context(), "prod", false) require.Error(t, err) - assert.Contains(t, err.Error(), "resolve spec") + assert.ErrorContains(t, err, "resolve spec") + cycle, ok := errors.AsType[*spec.HookCycleError](err) + require.True(t, ok) + assert.Equal(t, []string{"migrate"}, cycle.Tasks) + assert.False(t, deployer.called) }) } diff --git a/internal/action/list_test.go b/internal/action/list_test.go index 9aa0373..079f30c 100644 --- a/internal/action/list_test.go +++ b/internal/action/list_test.go @@ -2,7 +2,7 @@ package action_test import ( "context" - "fmt" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +11,7 @@ import ( "deployah.dev/deployah/internal/action" "deployah.dev/deployah/internal/helm" + "deployah.dev/deployah/internal/spec" v1 "helm.sh/helm/v4/pkg/release/v1" ) @@ -18,22 +19,38 @@ import ( type mockLister struct { releases []*v1.Release err error + selector labels.Selector + called bool + ctx context.Context } -func (m *mockLister) ListReleases(_ context.Context, _ labels.Selector) ([]*v1.Release, error) { - return m.releases, m.err +func (m *mockLister) ListReleases(ctx context.Context, selector labels.Selector) ([]*v1.Release, error) { + m.called = true + m.ctx = ctx + m.selector = selector + if m.err != nil { + return nil, m.err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return m.releases, nil } // Run returns an empty slice when no releases match. func TestList_Run_NoReleases(t *testing.T) { - l := action.NewList(&mockLister{releases: nil}) + t.Parallel() + lister := &mockLister{releases: nil} + l := action.NewList(lister) releases, err := l.Run(t.Context(), action.ListParams{Project: "missing"}) require.NoError(t, err) - assert.Empty(t, releases) + assert.Equal(t, []*v1.Release{}, releases) + assert.True(t, lister.called) } -// Run filters nil entries from lister results. +// Run filters nil entries from lister results and keeps input order. func TestList_Run_WithReleases(t *testing.T) { + t.Parallel() rels := []*v1.Release{ {Name: "my-app-prod"}, nil, // nil entries must be filtered @@ -42,7 +59,9 @@ func TestList_Run_WithReleases(t *testing.T) { l := action.NewList(&mockLister{releases: rels}) releases, err := l.Run(t.Context(), action.ListParams{Project: "my-app"}) require.NoError(t, err) - assert.Len(t, releases, 2) + require.Len(t, releases, 2) + assert.Equal(t, "my-app-prod", releases[0].Name) + assert.Equal(t, "my-app-staging", releases[1].Name) } func TestList_Run_WildcardInstanceIsolated(t *testing.T) { @@ -52,11 +71,30 @@ func TestList_Run_WildcardInstanceIsolated(t *testing.T) { {Name: helm.GenerateReleaseName("shop", "review/pr-123")}, {Name: helm.GenerateReleaseName("shop", "review/pr-456")}, } - l := action.NewList(&mockLister{releases: rels}) - got, err := l.Run(t.Context(), action.ListParams{Project: "shop", Environment: "review/pr-123"}) + lister := &mockLister{releases: rels} + got, err := action.NewList(lister).Run(t.Context(), action.ListParams{Project: "shop", Environment: "review/pr-123"}) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, helm.GenerateReleaseName("shop", "review/pr-123"), got[0].Name) + assertSelector(t, lister, map[string]string{ + "deployah.dev/project": "shop", + "deployah.dev/environment": "review", + }) +} + +func TestList_Run_WildcardInstanceMissing(t *testing.T) { + t.Parallel() + rels := []*v1.Release{ + {Name: helm.GenerateReleaseName("shop", "review/pr-456")}, + nil, + } + lister := &mockLister{releases: rels} + got, err := action.NewList(lister).Run(t.Context(), action.ListParams{ + Project: "shop", Environment: "review/pr-123", + }) + require.NoError(t, err) + assert.Equal(t, []*v1.Release{}, got) + assert.True(t, lister.called) } func TestList_Run_NoProjectWildcardInstanceIsolated(t *testing.T) { @@ -66,35 +104,47 @@ func TestList_Run_NoProjectWildcardInstanceIsolated(t *testing.T) { { Name: helm.GenerateReleaseName("shop", "review/pr-123"), Labels: map[string]string{ - "deployah.dev/project": "shop", - "deployah.dev/environment": "review", + spec.LabelProject: "shop", + spec.LabelEnvironment: "review", }, }, { Name: helm.GenerateReleaseName("shop", "review/pr-456"), Labels: map[string]string{ - "deployah.dev/project": "shop", - "deployah.dev/environment": "review", + spec.LabelProject: "shop", + spec.LabelEnvironment: "review", }, }, { Name: helm.GenerateReleaseName("billing", "review/pr-123"), Labels: map[string]string{ - "deployah.dev/project": "billing", - "deployah.dev/environment": "review", + spec.LabelProject: "billing", + spec.LabelEnvironment: "review", }, }, { Name: helm.GenerateReleaseName("billing", "review/pr-456"), Labels: map[string]string{ - "deployah.dev/project": "billing", - "deployah.dev/environment": "review", + spec.LabelProject: "billing", + spec.LabelEnvironment: "review", + }, + }, + { + // Name matches shop's instance, but there is no project label + // to confirm it. + Name: helm.GenerateReleaseName("shop", "review/pr-123"), + }, + { + Name: "shop-review--pr-123-old", + Labels: map[string]string{ + spec.LabelProject: "shop", + spec.LabelEnvironment: "review", }, }, nil, } - l := action.NewList(&mockLister{releases: rels}) - got, err := l.Run(t.Context(), action.ListParams{Environment: "review/pr-123"}) + lister := &mockLister{releases: rels} + got, err := action.NewList(lister).Run(t.Context(), action.ListParams{Environment: "review/pr-123"}) require.NoError(t, err) require.Len(t, got, 2) names := []string{got[0].Name, got[1].Name} @@ -102,6 +152,9 @@ func TestList_Run_NoProjectWildcardInstanceIsolated(t *testing.T) { helm.GenerateReleaseName("shop", "review/pr-123"), helm.GenerateReleaseName("billing", "review/pr-123"), }, names) + assertSelector(t, lister, map[string]string{ + "deployah.dev/environment": "review", + }) } func TestList_Run_LogicalEnvironmentKeepsAllInstances(t *testing.T) { @@ -111,16 +164,128 @@ func TestList_Run_LogicalEnvironmentKeepsAllInstances(t *testing.T) { {Name: helm.GenerateReleaseName("shop", "review/pr-123")}, {Name: helm.GenerateReleaseName("shop", "review/pr-456")}, } - l := action.NewList(&mockLister{releases: rels}) - got, err := l.Run(t.Context(), action.ListParams{Project: "shop", Environment: "review"}) + lister := &mockLister{releases: rels} + got, err := action.NewList(lister).Run(t.Context(), action.ListParams{Project: "shop", Environment: "review"}) require.NoError(t, err) require.Len(t, got, 2) + assert.Equal(t, rels[0].Name, got[0].Name) + assert.Equal(t, rels[1].Name, got[1].Name) + assertSelector(t, lister, map[string]string{ + "deployah.dev/project": "shop", + "deployah.dev/environment": "review", + }) } // Run wraps lister errors. func TestList_Run_ListerError(t *testing.T) { - l := action.NewList(&mockLister{err: fmt.Errorf("k8s unavailable")}) + t.Parallel() + errList := errors.New("k8s unavailable") + lister := &mockLister{err: errList} + l := action.NewList(lister) _, err := l.Run(t.Context(), action.ListParams{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "list releases") + require.ErrorIs(t, err, errList) + assert.ErrorContains(t, err, "list releases") + assert.True(t, lister.called) +} + +func TestList_Run_Selector(t *testing.T) { + t.Parallel() + tests := []struct { + name string + project string + environment string + want map[string]string + }{ + {name: "no filters"}, + {name: "project only", project: "shop", want: map[string]string{"deployah.dev/project": "shop"}}, + {name: "environment only", environment: "production", want: map[string]string{"deployah.dev/environment": "production"}}, + { + name: "project and environment", + project: "shop", + environment: "production", + want: map[string]string{ + "deployah.dev/project": "shop", + "deployah.dev/environment": "production", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lister := &mockLister{} + _, err := action.NewList(lister).Run(t.Context(), action.ListParams{ + Project: tt.project, + Environment: tt.environment, + }) + require.NoError(t, err) + assertSelector(t, lister, tt.want) + }) + } +} + +// assertSelector checks that the lister was queried with exactly these +// label requirements. +func assertSelector(t *testing.T, lister *mockLister, want map[string]string) { + t.Helper() + require.True(t, lister.called) + // labels.NewSelector returns a nil slice in a non-nil interface. + // require.NotNil treats that slice as nil, so an empty selector + // has to be checked with ==. + if lister.selector == nil { + t.Fatalf("ListReleases selector = nil, want %#v", want) + } + reqs, selectable := lister.selector.Requirements() + require.True(t, selectable) + assert.Len(t, reqs, len(want)) + for key, value := range want { + got, found := lister.selector.RequiresExactMatch(key) + assert.True(t, found, "selector %q missing %s", lister.selector, key) + assert.Equal(t, value, got, "selector %q label %s", lister.selector, key) + } +} + +func TestList_Run_RejectsBadFilters(t *testing.T) { + t.Parallel() + tests := []struct { + name string + project string + environment string + want string + }{ + { + name: "nested wildcard", + project: "shop", + environment: "review/foo/bar", + want: `environment "review/foo/bar" is invalid`, + }, + { + name: "project is not a label value", + project: "bad value!", + want: "build selector", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lister := &mockLister{releases: []*v1.Release{{Name: "should-not-be-returned"}}} + _, err := action.NewList(lister).Run(t.Context(), action.ListParams{ + Project: tt.project, + Environment: tt.environment, + }) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + assert.False(t, lister.called) + }) + } +} + +func TestList_Run_Canceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + lister := &mockLister{releases: []*v1.Release{{Name: "shop-prod"}}} + _, err := action.NewList(lister).Run(ctx, action.ListParams{Project: "shop"}) + require.ErrorIs(t, err, context.Canceled) + assert.ErrorContains(t, err, "list releases") + assert.True(t, lister.called) } diff --git a/internal/action/status_test.go b/internal/action/status_test.go index 8d07a5c..4da0aa3 100644 --- a/internal/action/status_test.go +++ b/internal/action/status_test.go @@ -1,6 +1,9 @@ package action_test import ( + "context" + "errors" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -14,16 +17,19 @@ import ( // Run returns an error when no releases match the project. func TestStatus_Run_NotFound(t *testing.T) { + t.Parallel() s := action.NewStatus(&mockLister{releases: nil}) _, err := s.Run(t.Context(), action.StatusParams{Project: "ghost"}) require.ErrorIs(t, err, action.ErrNoReleases) - assert.Contains(t, err.Error(), `project "ghost"`) + assert.ErrorContains(t, err, `project "ghost"`) } // Run returns releases sorted by name. func TestStatus_Run_FoundAndSorted(t *testing.T) { + t.Parallel() rels := []*v1.Release{ {Name: "my-app-staging"}, + nil, {Name: "my-app-prod"}, } s := action.NewStatus(&mockLister{releases: rels}) @@ -36,10 +42,12 @@ func TestStatus_Run_FoundAndSorted(t *testing.T) { // Run includes the environment in the not-found error message. func TestStatus_Run_WithEnvironmentFilter(t *testing.T) { + t.Parallel() s := action.NewStatus(&mockLister{releases: nil}) _, err := s.Run(t.Context(), action.StatusParams{Project: "my-app", Environment: "prod"}) require.ErrorIs(t, err, action.ErrNoReleases) - assert.Contains(t, err.Error(), `in environment "prod"`) + assert.ErrorContains(t, err, `project "my-app"`) + assert.ErrorContains(t, err, `in environment "prod"`) } func TestStatus_Run_WildcardInstanceIsolated(t *testing.T) { @@ -49,9 +57,141 @@ func TestStatus_Run_WildcardInstanceIsolated(t *testing.T) { {Name: helm.GenerateReleaseName("shop", "review/pr-456")}, {Name: helm.GenerateReleaseName("shop", "review/pr-123")}, } - s := action.NewStatus(&mockLister{releases: rels}) - got, err := s.Run(t.Context(), action.StatusParams{Project: "shop", Environment: "review/pr-123"}) + lister := &mockLister{releases: rels} + got, err := action.NewStatus(lister).Run(t.Context(), action.StatusParams{Project: "shop", Environment: "review/pr-123"}) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, helm.GenerateReleaseName("shop", "review/pr-123"), got[0].Name) + assertSelector(t, lister, map[string]string{ + "deployah.dev/project": "shop", + "deployah.dev/environment": "review", + }) +} + +func TestStatus_Run_WildcardInstanceMissing(t *testing.T) { + t.Parallel() + rels := []*v1.Release{ + {Name: helm.GenerateReleaseName("shop", "review/pr-456")}, + nil, + } + _, err := action.NewStatus(&mockLister{releases: rels}).Run(t.Context(), action.StatusParams{ + Project: "shop", Environment: "review/pr-123", + }) + require.ErrorIs(t, err, action.ErrNoReleases) + assert.ErrorContains(t, err, `project "shop"`) + assert.ErrorContains(t, err, `in environment "review/pr-123"`) +} + +func TestStatus_Run_LogicalEnvironmentKeepsInstances(t *testing.T) { + t.Parallel() + later := helm.GenerateReleaseName("shop", "review/pr-456") + earlier := helm.GenerateReleaseName("shop", "review/pr-123") + rels := []*v1.Release{ + {Name: later}, + nil, + {Name: earlier}, + } + lister := &mockLister{releases: rels} + got, err := action.NewStatus(lister).Run(t.Context(), action.StatusParams{ + Project: "shop", Environment: "review", + }) + require.NoError(t, err) + require.Len(t, got, 2) + want := []string{later, earlier} + slices.Sort(want) + assert.Equal(t, want, []string{got[0].Name, got[1].Name}) + assertSelector(t, lister, map[string]string{ + "deployah.dev/project": "shop", + "deployah.dev/environment": "review", + }) +} + +func TestStatus_Run_ListerError(t *testing.T) { + t.Parallel() + errList := errors.New("k8s unavailable") + lister := &mockLister{err: errList} + _, err := action.NewStatus(lister).Run(t.Context(), action.StatusParams{Project: "shop"}) + require.ErrorIs(t, err, errList) + assert.ErrorContains(t, err, "list releases") + assert.True(t, lister.called) +} + +func TestStatus_Run_Selector(t *testing.T) { + t.Parallel() + tests := []struct { + name string + project string + environment string + want map[string]string + }{ + {name: "project only", project: "shop", want: map[string]string{"deployah.dev/project": "shop"}}, + {name: "environment only", environment: "production", want: map[string]string{"deployah.dev/environment": "production"}}, + { + name: "project and environment", + project: "shop", + environment: "production", + want: map[string]string{ + "deployah.dev/project": "shop", + "deployah.dev/environment": "production", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lister := &mockLister{releases: []*v1.Release{{Name: "kept"}}} + _, err := action.NewStatus(lister).Run(t.Context(), action.StatusParams{ + Project: tt.project, + Environment: tt.environment, + }) + require.NoError(t, err) + assertSelector(t, lister, tt.want) + }) + } +} + +func TestStatus_Run_RejectsBadFilters(t *testing.T) { + t.Parallel() + tests := []struct { + name string + project string + environment string + want string + }{ + { + name: "nested wildcard", + project: "shop", + environment: "review/foo/bar", + want: `environment "review/foo/bar" is invalid`, + }, + { + name: "project is not a label value", + project: "bad value!", + want: "build selector", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lister := &mockLister{releases: []*v1.Release{{Name: "should-not-be-returned"}}} + _, err := action.NewStatus(lister).Run(t.Context(), action.StatusParams{ + Project: tt.project, + Environment: tt.environment, + }) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + assert.False(t, lister.called) + }) + } +} + +func TestStatus_Run_Canceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + lister := &mockLister{releases: []*v1.Release{{Name: "shop-prod"}}} + _, err := action.NewStatus(lister).Run(ctx, action.StatusParams{Project: "shop"}) + require.ErrorIs(t, err, context.Canceled) + assert.ErrorContains(t, err, "list releases") + assert.True(t, lister.called) }