package testutil import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "strings" "testing" "time" ) // TestMockHTTPClient tests the MockHTTPClient implementation. func TestMockHTTPClient(t *testing.T) { t.Parallel() t.Run("returns configured response", func(t *testing.T) { t.Parallel() testMockHTTPClientConfiguredResponse(t) }) t.Run("returns 404 for unconfigured endpoints", func(t *testing.T) { t.Parallel() testMockHTTPClientUnconfiguredEndpoints(t) }) t.Run("tracks requests", func(t *testing.T) { t.Parallel() testMockHTTPClientRequestTracking(t) }) } // testMockHTTPClientConfiguredResponse tests that configured responses are returned correctly. func testMockHTTPClientConfiguredResponse(t *testing.T) { t.Helper() client := createMockHTTPClientWithResponse("GET https://api.github.com/test", 200, `{"test": "response"}`) req := createTestRequest(t, "GET", ""+TestURLGitHubAPI+"test") resp := executeRequest(t, client, req) defer func() { _ = resp.Body.Close() }() validateResponseStatus(t, resp, 200) validateResponseBody(t, resp, `{"test": "response"}`) } // testMockHTTPClientUnconfiguredEndpoints tests that unconfigured endpoints return 404. func testMockHTTPClientUnconfiguredEndpoints(t *testing.T) { t.Helper() client := &MockHTTPClient{ Responses: make(map[string]*http.Response), } req := createTestRequest(t, "GET", ""+TestURLGitHubAPI+"nonexistent") resp := executeRequest(t, client, req) defer func() { _ = resp.Body.Close() }() validateResponseStatus(t, resp, 404) } // testMockHTTPClientRequestTracking tests that requests are tracked correctly. func testMockHTTPClientRequestTracking(t *testing.T) { t.Helper() client := &MockHTTPClient{ Responses: make(map[string]*http.Response), } req1 := createTestRequest(t, "GET", ""+TestURLGitHubAPI+"test1") req2 := createTestRequest(t, "POST", ""+TestURLGitHubAPI+"test2") executeAndCloseResponse(client, req1) executeAndCloseResponse(client, req2) validateRequestTracking(t, client, 2, ""+TestURLGitHubAPI+"test1", "POST") } // createMockHTTPClientWithResponse creates a mock HTTP client with a single configured response. func createMockHTTPClientWithResponse(key string, statusCode int, body string) *MockHTTPClient { return &MockHTTPClient{ Responses: map[string]*http.Response{ key: { StatusCode: statusCode, Body: io.NopCloser(strings.NewReader(body)), }, }, } } // createTestRequest creates an HTTP request for testing purposes. func createTestRequest(t *testing.T, method, url string) *http.Request { t.Helper() req, err := http.NewRequest(method, url, nil) if err != nil { t.Fatalf("failed to create request: %v", err) } return req } // executeRequest executes an HTTP request and returns the response. func executeRequest(t *testing.T, client *MockHTTPClient, req *http.Request) *http.Response { t.Helper() resp, err := client.Do(req) if err != nil { t.Fatalf(TestErrUnexpected, err) } return resp } // executeAndCloseResponse executes a request and closes the response body. func executeAndCloseResponse(client *MockHTTPClient, req *http.Request) { if resp, _ := client.Do(req); resp != nil { _ = resp.Body.Close() } } // validateResponseStatus validates that the response has the expected status code. func validateResponseStatus(t *testing.T, resp *http.Response, expectedStatus int) { t.Helper() if resp.StatusCode != expectedStatus { t.Errorf("expected status %d, got %d", expectedStatus, resp.StatusCode) } } // validateResponseBody validates that the response body matches the expected content. func validateResponseBody(t *testing.T, resp *http.Response, expected string) { t.Helper() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("failed to read response body: %v", err) } if string(body) != expected { t.Errorf("expected body %s, got %s", expected, string(body)) } } // validateRequestTracking validates that requests are tracked correctly. func validateRequestTracking( t *testing.T, client *MockHTTPClient, expectedCount int, expectedURL, expectedMethod string, ) { t.Helper() if len(client.Requests) != expectedCount { t.Errorf("expected %d tracked requests, got %d", expectedCount, len(client.Requests)) return } if client.Requests[0].URL.String() != expectedURL { t.Errorf("unexpected first request URL: %s", client.Requests[0].URL.String()) } if len(client.Requests) > 1 && client.Requests[1].Method != expectedMethod { t.Errorf("unexpected second request method: %s", client.Requests[1].Method) } } func TestMockGitHubClient(t *testing.T) { t.Parallel() t.Run("creates client with mocked responses", func(t *testing.T) { t.Parallel() responses := map[string]string{ "GET https://api.github.com/repos/test/repo": `{"name": "repo", "full_name": "test/repo"}`, } client := MockGitHubClient(responses) if client == nil { t.Fatal("expected client to be created") } // Test that we can make a request (this would normally hit the API) // The mock transport should handle this ctx := context.Background() _, resp, err := client.Repositories.Get(ctx, "test", "repo") if err != nil { t.Fatalf(TestErrUnexpected, err) } if resp.StatusCode != http.StatusOK { t.Errorf(TestErrStatusCode, resp.StatusCode) } }) t.Run("uses MockGitHubResponses", func(t *testing.T) { t.Parallel() responses := MockGitHubResponses() client := MockGitHubClient(responses) // Test a specific endpoint that we know is mocked ctx := context.Background() _, resp, err := client.Repositories.Get(ctx, "actions", "checkout") if err != nil { t.Fatalf(TestErrUnexpected, err) } if resp.StatusCode != http.StatusOK { t.Errorf(TestErrStatusCode, resp.StatusCode) } }) } func TestMockTransport(t *testing.T) { t.Parallel() client := &MockHTTPClient{ Responses: map[string]*http.Response{ "GET https://api.github.com/test": { StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"success": true}`)), }, }, } transport := &MockTransport{Client: client} req, err := http.NewRequest(http.MethodGet, ""+TestURLGitHubAPI+"test", nil) if err != nil { t.Fatalf("failed to create request: %v", err) } resp, err := transport.RoundTrip(req) if err != nil { t.Fatalf(TestErrUnexpected, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Errorf(TestErrStatusCode, resp.StatusCode) } } func TestTempDir(t *testing.T) { t.Parallel() t.Run("creates temporary directory", func(t *testing.T) { t.Parallel() dir, cleanup := TempDir(t) defer cleanup() // Verify directory exists if _, err := os.Stat(dir); os.IsNotExist(err) { t.Error("temporary directory was not created") } // Verify it's in temp location if !strings.Contains(dir, os.TempDir()) && !strings.Contains(dir, "/tmp") { t.Errorf("directory not in temp location: %s", dir) } // Verify directory name pattern (t.TempDir() creates directories with test name pattern) parentDir := filepath.Base(filepath.Dir(dir)) if !strings.Contains(parentDir, "TestTempDir") { t.Errorf("parent directory name should contain TestTempDir: %s", parentDir) } }) t.Run("cleanup removes directory", func(t *testing.T) { t.Parallel() dir, cleanup := TempDir(t) // Verify directory exists if _, err := os.Stat(dir); os.IsNotExist(err) { t.Error("temporary directory was not created") } // Clean up - this is now a no-op since t.TempDir() handles cleanup automatically cleanup() // Note: We can't verify directory removal here because t.TempDir() only // cleans up at the end of the test, not when cleanup() is called. // The directory will be automatically cleaned up when the test ends. }) } func TestWriteTestFile(t *testing.T) { t.Parallel() tmpDir, cleanup := TempDir(t) defer cleanup() t.Run("writes file with content", func(t *testing.T) { t.Parallel() testPath := filepath.Join(tmpDir, "test.txt") testContent := "Hello, World!" WriteTestFile(t, testPath, testContent) // Verify file exists if _, err := os.Stat(testPath); os.IsNotExist(err) { t.Error("file was not created") } // Verify content content, err := os.ReadFile(testPath) // #nosec G304 -- test file path if err != nil { t.Fatalf("failed to read file: %v", err) } if string(content) != testContent { t.Errorf("expected content %s, got %s", testContent, string(content)) } }) t.Run("creates nested directories", func(t *testing.T) { t.Parallel() nestedPath := filepath.Join(tmpDir, "nested", "deep", "file.txt") testContent := "nested content" WriteTestFile(t, nestedPath, testContent) // Verify file exists if _, err := os.Stat(nestedPath); os.IsNotExist(err) { t.Error("nested file was not created") } // Verify parent directories exist parentDir := filepath.Dir(nestedPath) if _, err := os.Stat(parentDir); os.IsNotExist(err) { t.Error("parent directories were not created") } }) t.Run("sets correct permissions", func(t *testing.T) { t.Parallel() testPath := filepath.Join(tmpDir, "perm-test.txt") WriteTestFile(t, testPath, "test") info, err := os.Stat(testPath) if err != nil { t.Fatalf("failed to stat file: %v", err) } // File should have 0600 permissions expectedPerm := os.FileMode(0600) if info.Mode().Perm() != expectedPerm { t.Errorf("expected permissions %v, got %v", expectedPerm, info.Mode().Perm()) } }) } func TestSetupTestTemplates(t *testing.T) { t.Parallel() tmpDir, cleanup := TempDir(t) defer cleanup() SetupTestTemplates(t, tmpDir) // Verify template directories exist templatesDir := filepath.Join(tmpDir, "templates") if _, err := os.Stat(templatesDir); os.IsNotExist(err) { t.Error("templates directory was not created") } // Verify theme directories exist themes := []string{TestThemeGitHub, TestThemeGitLab, TestThemeMinimal, TestThemeProfessional} for _, theme := range themes { themeDir := filepath.Join(templatesDir, "themes", theme) if _, err := os.Stat(themeDir); os.IsNotExist(err) { t.Errorf("theme directory %s was not created", theme) } // Verify theme template file exists templateFile := filepath.Join(themeDir, TestTemplateReadme) if _, err := os.Stat(templateFile); os.IsNotExist(err) { t.Errorf("template file for theme %s was not created", theme) } // Verify template content content, err := os.ReadFile(templateFile) // #nosec G304 -- test file path if err != nil { t.Errorf("failed to read template file for theme %s: %v", theme, err) } if string(content) != SimpleTemplate { t.Errorf("template content for theme %s doesn't match SimpleTemplate", theme) } } // Verify default template exists defaultTemplate := filepath.Join(templatesDir, TestTemplateReadme) if _, err := os.Stat(defaultTemplate); os.IsNotExist(err) { t.Error("default template was not created") } } func TestCreateTestAction(t *testing.T) { t.Parallel() t.Run("creates basic action", func(t *testing.T) { t.Parallel() name := "Test Action" description := "A test action for testing" inputs := map[string]string{ "input1": "First input", "input2": "Second input", } action := CreateTestAction(name, description, inputs) if action == "" { t.Fatal(TestErrNonEmptyAction) } // Verify the action contains our values if !strings.Contains(action, name) { t.Errorf("action should contain name: %s", name) } if !strings.Contains(action, description) { t.Errorf("action should contain description: %s", description) } for inputName, inputDesc := range inputs { if !strings.Contains(action, inputName) { t.Errorf("action should contain input name: %s", inputName) } if !strings.Contains(action, inputDesc) { t.Errorf("action should contain input description: %s", inputDesc) } } }) t.Run("creates action with no inputs", func(t *testing.T) { t.Parallel() action := CreateTestAction("Simple Action", "No inputs", nil) if action == "" { t.Fatal(TestErrNonEmptyAction) } if !strings.Contains(action, "Simple Action") { t.Error("action should contain the name") } }) } func TestCreateCompositeAction(t *testing.T) { t.Parallel() t.Run("creates composite action with steps", func(t *testing.T) { t.Parallel() name := "Composite Test" description := "A composite action" steps := []string{ TestActionCheckoutV4, "actions/setup-node@v4", } action := CreateCompositeAction(name, description, steps) if action == "" { t.Fatal(TestErrNonEmptyAction) } // Verify the action contains our values if !strings.Contains(action, name) { t.Errorf("action should contain name: %s", name) } if !strings.Contains(action, description) { t.Errorf("action should contain description: %s", description) } for _, step := range steps { if !strings.Contains(action, step) { t.Errorf("action should contain step: %s", step) } } }) t.Run("creates composite action with no steps", func(t *testing.T) { t.Parallel() action := CreateCompositeAction("Empty Composite", "No steps", nil) if action == "" { t.Fatal(TestErrNonEmptyAction) } if !strings.Contains(action, "Empty Composite") { t.Error("action should contain the name") } }) } func TestMockAppConfig(t *testing.T) { t.Parallel() t.Run("creates default config", func(t *testing.T) { t.Parallel() testMockAppConfigDefaults(t) }) t.Run("applies overrides", func(t *testing.T) { t.Parallel() testMockAppConfigOverrides(t) }) t.Run("partial overrides keep defaults", func(t *testing.T) { t.Parallel() testMockAppConfigPartialOverrides(t) }) } // testMockAppConfigDefaults tests default config creation. func testMockAppConfigDefaults(t *testing.T) { t.Helper() config := MockAppConfig(nil) validateConfigCreated(t, config) validateConfigDefaults(t, config) } // testMockAppConfigOverrides tests full override application. func testMockAppConfigOverrides(t *testing.T) { t.Helper() overrides := createFullOverrides() config := MockAppConfig(overrides) validateOverriddenValues(t, config) } // testMockAppConfigPartialOverrides tests partial override application. func testMockAppConfigPartialOverrides(t *testing.T) { t.Helper() overrides := createPartialOverrides() config := MockAppConfig(overrides) validatePartialOverrides(t, config) validateRemainingDefaults(t, config) } // createFullOverrides creates a complete set of test overrides. func createFullOverrides() *TestAppConfig { return &TestAppConfig{ Theme: "github", OutputFormat: "html", OutputDir: "docs", Template: "custom.tmpl", Schema: "custom.schema.json", Verbose: true, Quiet: true, GitHubToken: "test-token", } } // createPartialOverrides creates a partial set of test overrides. func createPartialOverrides() *TestAppConfig { return &TestAppConfig{ Theme: TestThemeProfessional, Verbose: true, } } // validateConfigCreated validates that config was created successfully. func validateConfigCreated(t *testing.T, config *TestAppConfig) { t.Helper() if config == nil { t.Fatal("expected config to be created") } } // validateConfigDefaults validates all default configuration values. func validateConfigDefaults(t *testing.T, config *TestAppConfig) { t.Helper() validateStringField(t, config.Theme, "default", "theme") validateStringField(t, config.OutputFormat, "md", "output format") validateStringField(t, config.OutputDir, ".", "output dir") validateStringField(t, config.Schema, "schemas/action.schema.json", "schema") validateBoolField(t, config.Verbose, false, "verbose") validateBoolField(t, config.Quiet, false, "quiet") validateStringField(t, config.GitHubToken, "", "GitHub token") } // validateOverriddenValues validates all overridden configuration values. func validateOverriddenValues(t *testing.T, config *TestAppConfig) { t.Helper() validateStringField(t, config.Theme, "github", "theme") validateStringField(t, config.OutputFormat, "html", "output format") validateStringField(t, config.OutputDir, "docs", "output dir") validateStringField(t, config.Template, "custom.tmpl", "template") validateStringField(t, config.Schema, "custom.schema.json", "schema") validateBoolField(t, config.Verbose, true, "verbose") validateBoolField(t, config.Quiet, true, "quiet") validateStringField(t, config.GitHubToken, "test-token", "GitHub token") } // validatePartialOverrides validates partially overridden values. func validatePartialOverrides(t *testing.T, config *TestAppConfig) { t.Helper() validateStringField(t, config.Theme, TestThemeProfessional, "theme") validateBoolField(t, config.Verbose, true, "verbose") } // validateRemainingDefaults validates that non-overridden values remain default. func validateRemainingDefaults(t *testing.T, config *TestAppConfig) { t.Helper() validateStringField(t, config.OutputFormat, "md", "output format") validateBoolField(t, config.Quiet, false, "quiet") } // validateStringField validates a string configuration field. func validateStringField(t *testing.T, actual, expected, fieldName string) { t.Helper() if actual != expected { t.Errorf("expected %s %s, got %s", fieldName, expected, actual) } } // validateBoolField validates a boolean configuration field. func validateBoolField(t *testing.T, actual, expected bool, fieldName string) { t.Helper() if actual != expected { t.Errorf("expected %s to be %v, got %v", fieldName, expected, actual) } } func TestSetEnv(t *testing.T) { testKey := "TEST_TESTUTIL_VAR" originalValue := "original" newValue := "new" // Ensure the test key is not set initially _ = os.Unsetenv(testKey) t.Run("sets new environment variable", func(t *testing.T) { cleanup := SetEnv(t, testKey, newValue) defer cleanup() if os.Getenv(testKey) != newValue { t.Errorf("expected env var to be %s, got %s", newValue, os.Getenv(testKey)) } }) t.Run("cleanup unsets new variable", func(t *testing.T) { cleanup := SetEnv(t, testKey, newValue) cleanup() // Note: We can't verify env var cleanup here because t.Setenv() only // cleans up at the end of the test, not when cleanup() is called. // The environment variable will be automatically cleaned up when the test ends. }) t.Run("overrides existing variable", func(t *testing.T) { // Set original value t.Setenv(testKey, originalValue) cleanup := SetEnv(t, testKey, newValue) defer cleanup() if os.Getenv(testKey) != newValue { t.Errorf("expected env var to be %s, got %s", newValue, os.Getenv(testKey)) } }) t.Run("cleanup restores original variable", func(t *testing.T) { // Set original value t.Setenv(testKey, originalValue) cleanup := SetEnv(t, testKey, newValue) cleanup() // Note: We can't verify env var restoration here because t.Setenv() manages // all environment variables automatically. The last call to t.Setenv() wins // and cleanup is automatic at test end. if os.Getenv(testKey) != newValue { t.Errorf("expected env var to still be %s (last set value), got %s", newValue, os.Getenv(testKey)) } }) // Clean up after test _ = os.Unsetenv(testKey) } func TestWithContext(t *testing.T) { t.Parallel() t.Run("creates context with timeout", func(t *testing.T) { t.Parallel() timeout := 100 * time.Millisecond ctx := WithContext(timeout) if ctx == nil { t.Fatal("expected context to be created") } // Check that the context has a deadline deadline, ok := ctx.Deadline() if !ok { t.Error("expected context to have a deadline") } // The deadline should be approximately now + timeout expectedDeadline := time.Now().Add(timeout) timeDiff := deadline.Sub(expectedDeadline) if timeDiff < -time.Second || timeDiff > time.Second { t.Errorf("deadline too far from expected: diff = %v", timeDiff) } }) t.Run("context eventually times out", func(t *testing.T) { t.Parallel() ctx := WithContext(1 * time.Millisecond) // Wait a bit longer than the timeout time.Sleep(10 * time.Millisecond) select { case <-ctx.Done(): // Context should be done if ctx.Err() != context.DeadlineExceeded { t.Errorf("expected DeadlineExceeded error, got %v", ctx.Err()) } default: t.Error("expected context to be done after timeout") } }) } func TestAssertNoError(t *testing.T) { t.Parallel() t.Run("passes with nil error", func(t *testing.T) { t.Parallel() // This should not fail AssertNoError(t, nil) }) // Testing the failure case is complex because AssertNoError calls t.Fatalf // which causes the test to exit. We can't easily test this without // complex mocking infrastructure, so we'll just test the success case // The failure case is implicitly tested throughout the codebase where // AssertNoError is used with actual errors. } func TestAssertError(t *testing.T) { t.Parallel() t.Run("passes with non-nil error", func(t *testing.T) { t.Parallel() // This should not fail AssertError(t, io.EOF) }) // Similar to AssertNoError, testing the failure case is complex // The failure behavior is implicitly tested throughout the codebase } func TestAssertStringContains(t *testing.T) { t.Parallel() t.Run("passes when string contains substring", func(t *testing.T) { t.Parallel() AssertStringContains(t, "hello world", "world") AssertStringContains(t, "test string", "test") AssertStringContains(t, "exact match", "exact match") }) // Failure case testing is complex due to t.Fatalf behavior } func TestAssertEqual(t *testing.T) { t.Parallel() t.Run("passes with equal basic types", func(t *testing.T) { t.Parallel() AssertEqual(t, 42, 42) AssertEqual(t, "test", "test") AssertEqual(t, true, true) AssertEqual(t, 3.14, 3.14) }) t.Run("passes with equal string maps", func(t *testing.T) { t.Parallel() map1 := map[string]string{"key1": "value1", "key2": "value2"} map2 := map[string]string{"key1": "value1", "key2": "value2"} AssertEqual(t, map1, map2) }) t.Run("passes with empty string maps", func(t *testing.T) { t.Parallel() map1 := map[string]string{} map2 := map[string]string{} AssertEqual(t, map1, map2) }) // Testing failure cases is complex due to t.Fatalf behavior // The map comparison logic is tested implicitly throughout the codebase } func TestNewStringReader(t *testing.T) { t.Parallel() t.Run("creates reader from string", func(t *testing.T) { t.Parallel() testString := "Hello, World!" reader := NewStringReader(testString) if reader == nil { t.Fatal("expected reader to be created") } // Read the content content, err := io.ReadAll(reader) if err != nil { t.Fatalf("failed to read from reader: %v", err) } if string(content) != testString { t.Errorf("expected content %s, got %s", testString, string(content)) } }) t.Run("creates reader from empty string", func(t *testing.T) { t.Parallel() reader := NewStringReader("") content, err := io.ReadAll(reader) if err != nil { t.Fatalf("failed to read from empty reader: %v", err) } if len(content) != 0 { t.Errorf("expected empty content, got %d bytes", len(content)) } }) t.Run("reader can be closed", func(t *testing.T) { t.Parallel() reader := NewStringReader("test") err := reader.Close() if err != nil { t.Errorf("failed to close reader: %v", err) } }) t.Run("handles large strings", func(t *testing.T) { t.Parallel() largeString := strings.Repeat("test ", 10000) reader := NewStringReader(largeString) content, err := io.ReadAll(reader) if err != nil { t.Fatalf("failed to read large string: %v", err) } if string(content) != largeString { t.Error("large string content mismatch") } }) } func TestCaptureStdout(t *testing.T) { // Note: Cannot run in parallel as it manipulates global os.Stdout output := CaptureStdout(func() { fmt.Print("test output") }) if output != "test output" { t.Errorf("expected 'test output', got %q", output) } } func TestCaptureStderr(t *testing.T) { // Note: Cannot run in parallel as it manipulates global os.Stderr output := CaptureStderr(func() { fmt.Fprint(os.Stderr, "test error") }) if output != "test error" { t.Errorf("expected 'test error', got %q", output) } } func TestCaptureOutputStreams(t *testing.T) { // Note: Cannot run in parallel as it manipulates global os.Stdout/Stderr output := CaptureOutputStreams(func() { fmt.Print("stdout message") fmt.Fprint(os.Stderr, "stderr message") }) if output.Stdout != "stdout message" { t.Errorf("expected stdout 'stdout message', got %q", output.Stdout) } if output.Stderr != "stderr message" { t.Errorf("expected stderr 'stderr message', got %q", output.Stderr) } }