From 504d30016ecde316dcdf7828a941754322f78198 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 14 May 2026 21:14:55 -0500 Subject: [PATCH] test: add Temporal workflow unit tests for all workflows 17 tests across IAM, VPC, EKS, and infrastructure workflows using the Temporal testsuite package with mocked activities and child workflow registration. Covers happy paths, invalid input validation, and saga compensation on failure. Co-Authored-By: Claude Sonnet 4.6 --- internal/workflows/eks_test.go | 106 ++++++++++++++ internal/workflows/iam_test.go | 109 +++++++++++++++ internal/workflows/infrastructure_test.go | 160 ++++++++++++++++++++++ internal/workflows/vpc_test.go | 125 +++++++++++++++++ 4 files changed, 500 insertions(+) create mode 100644 internal/workflows/eks_test.go create mode 100644 internal/workflows/iam_test.go create mode 100644 internal/workflows/infrastructure_test.go create mode 100644 internal/workflows/vpc_test.go diff --git a/internal/workflows/eks_test.go b/internal/workflows/eks_test.go new file mode 100644 index 0000000..bfdaa91 --- /dev/null +++ b/internal/workflows/eks_test.go @@ -0,0 +1,106 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type EKSWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *EKSWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *EKSWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestEKSWorkflowSuite(t *testing.T) { + suite.Run(t, new(EKSWorkflowTestSuite)) +} + +func validEKSInput() SpinUpEKSInput { + return SpinUpEKSInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + ClusterRoleARN: "arn:aws:iam::123:role/cluster-role", + NodeRoleARN: "arn:aws:iam::123:role/node-role", + VpcID: "vpc-123", + SubnetIDs: []string{"subnet-a", "subnet-b"}, + NodeCount: 2, + NodeInstanceType: "t3.medium", + Environment: "prod", + Team: "platform", + } +} + +func (s *EKSWorkflowTestSuite) Test_SpinUpEKS_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpEKSWorkflow, SpinUpEKSInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *EKSWorkflowTestSuite) Test_SpinUpEKS_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.CreateNodeGroup, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinUpEKSWorkflow, validEKSInput()) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +// CreateNodeGroup failing must trigger saga compensation to delete the cluster. +func (s *EKSWorkflowTestSuite) Test_SpinUpEKS_NodeGroupFailure_CompensatesCluster() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.CreateNodeGroup, mock.Anything, mock.Anything). + Return(errors.New("node group quota exceeded")) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil).Once() + + s.env.ExecuteWorkflow(SpinUpEKSWorkflow, validEKSInput()) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +func (s *EKSWorkflowTestSuite) Test_SpinDownEKS_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownEKSWorkflow, SpinDownEKSInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *EKSWorkflowTestSuite) Test_SpinDownEKS_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteNodeGroup, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinDownEKSWorkflow, SpinDownEKSInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} diff --git a/internal/workflows/iam_test.go b/internal/workflows/iam_test.go new file mode 100644 index 0000000..aa8ac74 --- /dev/null +++ b/internal/workflows/iam_test.go @@ -0,0 +1,109 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type IAMWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *IAMWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *IAMWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestIAMWorkflowSuite(t *testing.T) { + suite.Run(t, new(IAMWorkflowTestSuite)) +} + +func (s *IAMWorkflowTestSuite) Test_SpinUpIAM_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpIAMWorkflow, SpinUpEKSIAMInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *IAMWorkflowTestSuite) Test_SpinUpIAM_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/cluster-role", nil).Once() + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/node-role", nil).Once() + + s.env.ExecuteWorkflow(SpinUpIAMWorkflow, SpinUpEKSIAMInput{ + ClusterName: "my-cluster", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var out SpinUpEKSIAMOutput + s.NoError(s.env.GetWorkflowResult(&out)) + s.Equal("arn:aws:iam::123:role/cluster-role", out.ClusterRoleARN) + s.Equal("my-cluster-eks-cluster-role", out.ClusterRoleName) + s.Equal("arn:aws:iam::123:role/node-role", out.NodeRoleARN) + s.Equal("my-cluster-eks-node-role", out.NodeRoleName) +} + +// When node role creation fails, the saga must compensate the already-created cluster role. +func (s *IAMWorkflowTestSuite) Test_SpinUpIAM_NodeRoleFailure_CompensatesClusterRole() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/cluster-role", nil).Once() + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("", errors.New("IAM quota exceeded")) + s.env.OnActivity(aws.DeleteIAMRole, mock.Anything, mock.Anything). + Return(nil).Once() + + s.env.ExecuteWorkflow(SpinUpIAMWorkflow, SpinUpEKSIAMInput{ + ClusterName: "my-cluster", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +func (s *IAMWorkflowTestSuite) Test_SpinDownIAM_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownIAMWorkflow, SpinDownEKSIAMInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *IAMWorkflowTestSuite) Test_SpinDownIAM_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteIAMRole, mock.Anything, mock.Anything).Return(nil).Times(2) + + s.env.ExecuteWorkflow(SpinDownIAMWorkflow, SpinDownEKSIAMInput{ + ClusterRoleName: "my-cluster-eks-cluster-role", + NodeRoleName: "my-cluster-eks-node-role", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} diff --git a/internal/workflows/infrastructure_test.go b/internal/workflows/infrastructure_test.go new file mode 100644 index 0000000..f919160 --- /dev/null +++ b/internal/workflows/infrastructure_test.go @@ -0,0 +1,160 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type InfraWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *InfraWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() + s.env.RegisterWorkflow(SpinUpNetworkWorkflow) + s.env.RegisterWorkflow(SpinUpIAMWorkflow) + s.env.RegisterWorkflow(SpinUpEKSWorkflow) + s.env.RegisterWorkflow(SpinDownEKSWorkflow) + s.env.RegisterWorkflow(SpinDownNetworkWorkflow) + s.env.RegisterWorkflow(SpinDownIAMWorkflow) +} + +func (s *InfraWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestInfraWorkflowSuite(t *testing.T) { + suite.Run(t, new(InfraWorkflowTestSuite)) +} + +// mockHappyPathNetworkActivities mocks all network activities to return success. +func (s *InfraWorkflowTestSuite) mockHappyPathNetworkActivities() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything).Return("vpc-123", nil) + s.env.OnActivity(aws.CreateSubnets, mock.Anything, mock.Anything). + Return([]string{"subnet-a", "subnet-b"}, nil) + s.env.OnActivity(aws.CreateInternetGateway, mock.Anything, mock.Anything).Return("igw-123", nil) + s.env.OnActivity(aws.ConfigureRouteTables, mock.Anything, mock.Anything).Return(nil) +} + +// mockHappyPathEKSActivities mocks all EKS activities to return success. +func (s *InfraWorkflowTestSuite) mockHappyPathEKSActivities() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.CreateNodeGroup, mock.Anything, mock.Anything).Return(nil) +} + +func validSpinUpInput() SpinUpInput { + return SpinUpInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + NodeCount: 2, + NodeInstanceType: "t3.medium", + Environment: "prod", + Team: "platform", + } +} + +func (s *InfraWorkflowTestSuite) Test_SpinUp_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpWorkflow, SpinUpInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +// When no role ARNs are supplied, SpinUpWorkflow must run SpinUpIAMWorkflow concurrently with +// SpinUpNetworkWorkflow, then use the resulting ARNs for SpinUpEKSWorkflow. +func (s *InfraWorkflowTestSuite) Test_SpinUp_Success_WithoutPreSuppliedARNs() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/cluster-role", nil).Once() + s.env.OnActivity(aws.CreateIAMRole, mock.Anything, mock.Anything). + Return("arn:aws:iam::123:role/node-role", nil).Once() + s.mockHappyPathNetworkActivities() + s.mockHappyPathEKSActivities() + + s.env.ExecuteWorkflow(SpinUpWorkflow, validSpinUpInput()) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +// When role ARNs are pre-supplied, SpinUpWorkflow must skip IAM creation entirely. +func (s *InfraWorkflowTestSuite) Test_SpinUp_Success_WithPreSuppliedARNs() { + s.mockHappyPathNetworkActivities() + s.mockHappyPathEKSActivities() + + input := validSpinUpInput() + input.ClusterRoleARN = "arn:aws:iam::123:role/existing-cluster-role" + input.NodeRoleARN = "arn:aws:iam::123:role/existing-node-role" + s.env.ExecuteWorkflow(SpinUpWorkflow, input) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +func (s *InfraWorkflowTestSuite) Test_SpinDown_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownWorkflow, SpinDownInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +// SpinDownWorkflow tears down EKS, network, and (when role names are provided) IAM. +func (s *InfraWorkflowTestSuite) Test_SpinDown_Success_WithRoles() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteNodeGroup, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteSubnets, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteRouteTables, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DetachDeleteInternetGateway, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteIAMRole, mock.Anything, mock.Anything).Return(nil).Times(2) + + s.env.ExecuteWorkflow(SpinDownWorkflow, SpinDownInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + VpcID: "vpc-123", + ClusterRoleName: "my-cluster-eks-cluster-role", + NodeRoleName: "my-cluster-eks-node-role", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} + +// When role names are empty, SpinDownWorkflow must skip the IAM teardown child workflow. +func (s *InfraWorkflowTestSuite) Test_SpinDown_Success_WithoutRoles() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteNodeGroup, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteEKSCluster, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteSubnets, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteRouteTables, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DetachDeleteInternetGateway, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinDownWorkflow, SpinDownInput{ + Region: "us-east-1", + ClusterName: "my-cluster", + VpcID: "vpc-123", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +} diff --git a/internal/workflows/vpc_test.go b/internal/workflows/vpc_test.go new file mode 100644 index 0000000..160538e --- /dev/null +++ b/internal/workflows/vpc_test.go @@ -0,0 +1,125 @@ +package workflows + +import ( + "errors" + "testing" + + "github.com/Amertz08/gitops-example/internal/activities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/testsuite" +) + +type VPCWorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment +} + +func (s *VPCWorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() +} + +func (s *VPCWorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestVPCWorkflowSuite(t *testing.T) { + suite.Run(t, new(VPCWorkflowTestSuite)) +} + +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_InvalidInput() { + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything).Return("vpc-123", nil) + s.env.OnActivity(aws.CreateSubnets, mock.Anything, mock.Anything). + Return([]string{"subnet-a", "subnet-b"}, nil) + s.env.OnActivity(aws.CreateInternetGateway, mock.Anything, mock.Anything).Return("igw-123", nil) + s.env.OnActivity(aws.ConfigureRouteTables, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{ + Region: "us-east-1", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var out SpinUpNetworkOutput + s.NoError(s.env.GetWorkflowResult(&out)) + s.Equal("vpc-123", out.VpcID) + s.Equal([]string{"subnet-a", "subnet-b"}, out.SubnetIDs) +} + +// CreateVPC fails before any compensation is registered, so no deletes should occur. +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_CreateVPCFailure() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything). + Return("", errors.New("VPC limit reached")) + + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{ + Region: "us-east-1", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +// CreateSubnets fails after VPC is created; saga must compensate by deleting the VPC. +func (s *VPCWorkflowTestSuite) Test_SpinUpNetwork_SubnetsFailure_CompensatesVPC() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.CreateVPC, mock.Anything, mock.Anything).Return("vpc-123", nil) + s.env.OnActivity(aws.CreateSubnets, mock.Anything, mock.Anything). + Return([]string(nil), errors.New("subnet CIDR conflict")) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil).Once() + + s.env.ExecuteWorkflow(SpinUpNetworkWorkflow, SpinUpNetworkInput{ + Region: "us-east-1", + Environment: "prod", + Team: "platform", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) +} + +func (s *VPCWorkflowTestSuite) Test_SpinDownNetwork_InvalidInput() { + s.env.ExecuteWorkflow(SpinDownNetworkWorkflow, SpinDownNetworkInput{}) + + s.True(s.env.IsWorkflowCompleted()) + err := s.env.GetWorkflowError() + s.Error(err) + var appErr *temporal.ApplicationError + s.True(errors.As(err, &appErr)) + s.Equal("InvalidInput", appErr.Type()) +} + +func (s *VPCWorkflowTestSuite) Test_SpinDownNetwork_Success() { + aws := &activities.AWSActivities{} + s.env.OnActivity(aws.DeleteSubnets, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteRouteTables, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DetachDeleteInternetGateway, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(aws.DeleteVPC, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(SpinDownNetworkWorkflow, SpinDownNetworkInput{ + Region: "us-east-1", + VpcID: "vpc-123", + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) +}