diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index 23e04c2a30..23bd37ec18 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -9,194 +9,207 @@ import ( "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" ) -// TestActionInvocationHooksValidation demonstrates how to validate that -// action invocation status hooks are being called during apply operations. -// -// This test shows all three levels of validation: -// 1. Hooks are captured via CapturedHooks helper -// 2. Multiple hooks fire for a single action (state transitions) -// 3. Hook data contains all required fields +// TestActionInvocationHooksValidation validates that action invocation status +// hooks work correctly, including enum values, hook data structure, and lifecycle ordering. func TestActionInvocationHooksValidation(t *testing.T) { - t.Run("validate_hook_capture_mechanism", func(t *testing.T) { - // Level 1: Verify CapturedHooks mechanism works - capturedHooks := NewCapturedHooks(false) // false = apply phase, true = planning phase + t.Run("hook_capture_mechanism", func(t *testing.T) { + // Verify CapturedHooks mechanism initializes correctly + capturedHooks := NewCapturedHooks(false) // false = apply phase if capturedHooks == nil { t.Fatal("CapturedHooks should not be nil") } - // Verify the hooks object exists and has expected fields + // Verify the hooks slice starts empty (nil or zero length) if len(capturedHooks.ReportActionInvocationStatus) != 0 { - t.Fatalf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + t.Errorf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) } - t.Log("✓ CapturedHooks mechanism is properly set up") - }) - - t.Run("validate_hook_structure", func(t *testing.T) { - // Level 3: Validate ActionInvocationStatusHookData structure + // Verify we can append to it + capturedHooks.ReportActionInvocationStatus = append( + capturedHooks.ReportActionInvocationStatus, + &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + }, + ) - // This should be the structure of each hook: - _ = &hooks.ActionInvocationStatusHookData{ - // Addr: stackaddrs.AbsActionInvocationInstance - the action address - // ProviderAddr: addrs.Provider - the provider address - // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) + if len(capturedHooks.ReportActionInvocationStatus) != 1 { + t.Errorf("after append, expected 1 hook, got %d", len(capturedHooks.ReportActionInvocationStatus)) } - - t.Log("✓ ActionInvocationStatusHookData structure is properly defined") }) - t.Run("validate_action_invocation_status_enum", func(t *testing.T) { - // Verify that ActionInvocationStatus enum values exist - validStatuses := map[string]bool{ - // These are the valid status values an action can have - "Invalid": true, // ActionInvocationInvalid (0) - "Pending": true, // ActionInvocationPending (1) - "Running": true, // ActionInvocationRunning (2) - "Completed": true, // ActionInvocationCompleted (3) - "Errored": true, // ActionInvocationErrored (4) + t.Run("action_invocation_status_enum", func(t *testing.T) { + // Test that all enum constants are defined and have valid string representations + statuses := []hooks.ActionInvocationStatus{ + hooks.ActionInvocationStatusInvalid, + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + hooks.ActionInvocationErrored, } - if len(validStatuses) != 5 { - t.Fatalf("expected 5 status values, got %d", len(validStatuses)) + expectedStrings := map[hooks.ActionInvocationStatus]string{ + hooks.ActionInvocationStatusInvalid: "ActionInvocationStatusInvalid", + hooks.ActionInvocationPending: "ActionInvocationPending", + hooks.ActionInvocationRunning: "ActionInvocationRunning", + hooks.ActionInvocationCompleted: "ActionInvocationCompleted", + hooks.ActionInvocationErrored: "ActionInvocationErrored", } - t.Logf("✓ Action invocation status enum has %d valid values: %v", - len(validStatuses), validStatuses) - }) - - t.Run("validate_hook_firing_pattern", func(t *testing.T) { - // Level 2: Demonstrate expected hook firing pattern - // For a successful action invocation, we expect: - // 1. StartAction() fires with Running status - // 2. ProgressAction() optionally fires with intermediate status - // 3. CompleteAction() fires with Completed or Errored status - - expectedSequence := []string{ - "Running", // StartAction called - "Completed", // CompleteAction called successfully + // Verify String() returns expected values + for _, status := range statuses { + str := status.String() + expected, ok := expectedStrings[status] + if !ok { + t.Errorf("unexpected status constant: %v", status) + continue + } + if str != expected { + t.Errorf("status %v: expected String() = %q, got %q", status, expected, str) + } } - alternativeSequence := []string{ - "Running", // StartAction called - "Errored", // CompleteAction called with error + // Verify ForProtobuf() returns valid values (non-negative) + for _, status := range statuses { + proto := status.ForProtobuf() + if proto < 0 { + t.Errorf("status %v has invalid protobuf value: %v", status, proto) + } } - t.Logf("Expected hook sequence 1 (success): %v", expectedSequence) - t.Logf("Expected hook sequence 2 (error): %v", alternativeSequence) - - t.Log("✓ Hook firing pattern documented") + // Verify we have exactly 5 status values + if len(statuses) != 5 { + t.Errorf("expected 5 status constants, got %d", len(statuses)) + } }) - t.Run("logging_points_exist", func(t *testing.T) { - // This test documents where logging has been added for validation + t.Run("hook_data_structure", func(t *testing.T) { + // Validate ActionInvocationStatusHookData structure and methods + hookData := &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + } - loggingLocations := map[string]string{ - "terraform_hook.go:StartAction": "Logs action address and Running status", - "terraform_hook.go:ProgressAction": "Logs progress mapping and status transition", - "terraform_hook.go:CompleteAction": "Logs completion with Completed/Errored status", - "stacks.go:ReportActionInvocationStatus": "Logs at gRPC boundary with proto status value", + // Verify fields are set + if hookData.Addr.String() == "" { + t.Error("Addr should not be empty") + } + if hookData.ProviderAddr.String() == "" { + t.Error("ProviderAddr should not be empty") + } + if hookData.Status == hooks.ActionInvocationStatusInvalid { + t.Error("Status should not be Invalid when explicitly set to Running") } - for location, purpose := range loggingLocations { - t.Logf(" %s: %s", location, purpose) + // Verify String() method + str := hookData.String() + if str == "" || str == "" { + t.Errorf("String() should return valid representation, got: %q", str) } - t.Logf("✓ %d logging points have been added for debugging", len(loggingLocations)) + // Verify String() contains address + if !contains(str, "component.test") { + t.Errorf("String() should contain address, got: %q", str) + } + + // Verify nil handling + var nilHook *hooks.ActionInvocationStatusHookData + if nilHook.String() != "" { + t.Errorf("nil hook String() should return , got: %q", nilHook.String()) + } }) - t.Run("validation_checklist", func(t *testing.T) { - // Use this checklist to verify the complete setup - checklist := []struct { - name string - validate func() bool + t.Run("hook_status_lifecycle_ordering", func(t *testing.T) { + // Test expected hook status sequences for different scenarios + testCases := []struct { + name string + capturedStatuses []hooks.ActionInvocationStatus + wantValid bool + description string }{ { - name: "Logging imports added to terraform_hook.go", - validate: func() bool { - // Check: log.Printf should be called in hook methods - return true - }, - }, - { - name: "Logging imports added to stacks.go", - validate: func() bool { - // Check: log.Printf should be called in ReportActionInvocationStatus - return true - }, - }, - { - name: "Binary rebuilt with logging", - validate: func() bool { - // Check: Run `make install` after logging additions - return true + name: "successful_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, }, + wantValid: true, + description: "Action starts running and completes successfully", }, { - name: "Log contains hook method entries", - validate: func() bool { - // Check: grep "terraform_hook.*Action\|ReportActionInvocationStatus" terraform.log - return true + name: "failed_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationErrored, }, + wantValid: true, + description: "Action starts running but encounters an error", }, { - name: "Unit tests capture hooks via CapturedHooks", - validate: func() bool { - // Check: Test uses NewCapturedHooks() and captureHooks() - return true + name: "pending_then_running_then_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, }, + wantValid: true, + description: "Action goes through all states including pending", }, { - name: "Hook status values match enum", - validate: func() bool { - // Check: Running, Completed, Errored are valid values - return true + name: "invalid_only_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationCompleted, }, + wantValid: false, + description: "Invalid: completed without running", }, } - t.Logf("Validation Checklist (%d items):", len(checklist)) - for i, item := range checklist { - t.Logf(" %d. %s", i+1, item.name) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Verify we captured the expected number of statuses + if len(tc.capturedStatuses) == 0 { + t.Error("test case should have at least one status") + return + } + + // For valid sequences, verify terminal state is at the end + if tc.wantValid && len(tc.capturedStatuses) > 0 { + lastStatus := tc.capturedStatuses[len(tc.capturedStatuses)-1] + isTerminal := lastStatus == hooks.ActionInvocationCompleted || + lastStatus == hooks.ActionInvocationErrored + + if !isTerminal { + t.Errorf("valid sequence should end in terminal state (Completed/Errored), got %v", lastStatus) + } + } + + // For invalid sequences starting with Completed, verify it's actually invalid + if !tc.wantValid && len(tc.capturedStatuses) > 0 { + firstStatus := tc.capturedStatuses[0] + if firstStatus == hooks.ActionInvocationCompleted && len(tc.capturedStatuses) == 1 { + // This is indeed invalid - can't complete without running + t.Logf("correctly identified invalid sequence: %v", tc.capturedStatuses) + } + } + }) } }) } -// TestActionInvocationHooksLoggingOutput demonstrates what the logging output -// should look like when action invocation hooks are fired during apply. -// -// Expected log output pattern: -// -// [DEBUG] terraform_hook.StartAction called for action: component.nulls.action.bufo_print.success -// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Running) -// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Running, Provider=registry.terraform.io/austinvalle/bufo -// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=2 (proto) -// [DEBUG] ActionInvocationStatus event successfully sent to client -// [DEBUG] terraform_hook.CompleteAction called for action: component.nulls.action.bufo_print.success, error= -// [DEBUG] Action completed successfully - reporting Completed status -// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Completed) -// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Completed, Provider=registry.terraform.io/austinvalle/bufo -// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=3 (proto) -// [DEBUG] ActionInvocationStatus event successfully sent to client -func TestActionInvocationHooksLoggingOutput(t *testing.T) { - t.Run("logging_documentation", func(t *testing.T) { - expectedLogPatterns := []string{ - "terraform_hook.StartAction called for action", - "ReportActionInvocationStatus called", - "Sending ActionInvocationStatus to gRPC client", - "ActionInvocationStatus event successfully sent to client", - "terraform_hook.CompleteAction called for action", - } - - t.Logf("When action invocation hooks fire, you should see these log patterns:") - for i, pattern := range expectedLogPatterns { - t.Logf(" %d. [DEBUG] %s", i+1, pattern) - } - - t.Log("\nStatus enum values in logs:") - t.Log(" Status=1 (proto) = Pending") - t.Log(" Status=2 (proto) = Running") - t.Log(" Status=3 (proto) = Completed") - t.Log(" Status=4 (proto) = Errored") - }) +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false }