feat(infrastructure): implement adapters for all domain ports with comprehensive tests
- commit
- f4189441c7cdfd7ef895e5628466c9e2c654291e
- author
- bnema · · 2025-10-31 13:53 UTC
- parent
- 8f279e5
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
14 files · +1831 -0
A internal/infrastructure/executor/chroot.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package executor | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ChrootExecutor implements the ChrootExecutor port using chroot commands | |
| 13 | + | type ChrootExecutor struct { | |
| 14 | + | cmdExecutor *ShellExecutor | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewChrootExecutor creates a new chroot executor | |
| 18 | + | func NewChrootExecutor() *ChrootExecutor { | |
| 19 | + | return &ChrootExecutor{ | |
| 20 | + | cmdExecutor: NewShellExecutor(), | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ExecuteInChroot runs a command inside a chroot environment | |
| 25 | + | func (ce *ChrootExecutor) ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error) { | |
| 26 | + | // Verify chroot path exists | |
| 27 | + | if _, err := os.Stat(chrootPath); err != nil { | |
| 28 | + | return nil, fmt.Errorf("chroot path does not exist: %w", err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Build chroot command: arch-chroot <path> <command> <args...> | |
| 32 | + | allArgs := append([]string{chrootPath, command}, args...) | |
| 33 | + | cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...) | |
| 34 | + | ||
| 35 | + | var stdout, stderr bytes.Buffer | |
| 36 | + | cmd.Stdout = &stdout | |
| 37 | + | cmd.Stderr = &stderr | |
| 38 | + | ||
| 39 | + | if err := cmd.Run(); err != nil { | |
| 40 | + | return nil, fmt.Errorf("chroot command failed: %w (stderr: %s)", err, stderr.String()) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | return stdout.Bytes(), nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // ChrootSystemctl runs systemctl commands in chroot | |
| 47 | + | // This is a convenience method that ensures proper logging of systemctl operations | |
| 48 | + | func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, args ...string) error { | |
| 49 | + | // Verify paths exist | |
| 50 | + | if _, err := os.Stat(chrootPath); err != nil { | |
| 51 | + | return fmt.Errorf("chroot path does not exist: %w", err) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if logPath != "" { | |
| 55 | + | if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { | |
| 56 | + | return fmt.Errorf("failed to create log directory: %w", err) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Build chroot command with systemctl | |
| 61 | + | allArgs := append([]string{chrootPath, "systemctl"}, args...) | |
| 62 | + | cmd := exec.Command("arch-chroot", allArgs...) | |
| 63 | + | ||
| 64 | + | var stdout, stderr bytes.Buffer | |
| 65 | + | cmd.Stdout = &stdout | |
| 66 | + | cmd.Stderr = &stderr | |
| 67 | + | ||
| 68 | + | if err := cmd.Run(); err != nil { | |
| 69 | + | errMsg := fmt.Sprintf("chroot systemctl failed: %v (stderr: %s)", err, stderr.String()) | |
| 70 | + | ||
| 71 | + | // Log to file if specified | |
| 72 | + | if logPath != "" { | |
| 73 | + | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |
| 74 | + | if err == nil { | |
| 75 | + | defer logFile.Close() | |
| 76 | + | logFile.WriteString(errMsg + "\n") | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | return fmt.Errorf("%s", errMsg) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Log success to file if specified | |
| 84 | + | if logPath != "" { | |
| 85 | + | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |
| 86 | + | if err == nil { | |
| 87 | + | defer logFile.Close() | |
| 88 | + | logFile.WriteString(fmt.Sprintf("systemctl %v executed successfully\n", args)) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | return nil | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package executor | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // ChrootExecutor implements the ChrootExecutor port using chroot commands | |||
| 13 | + | type ChrootExecutor struct { | |||
| 14 | + | cmdExecutor *ShellExecutor | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewChrootExecutor creates a new chroot executor | |||
| 18 | + | func NewChrootExecutor() *ChrootExecutor { | |||
| 19 | + | return &ChrootExecutor{ | |||
| 20 | + | cmdExecutor: NewShellExecutor(), | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ExecuteInChroot runs a command inside a chroot environment | |||
| 25 | + | func (ce *ChrootExecutor) ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error) { | |||
| 26 | + | // Verify chroot path exists | |||
| 27 | + | if _, err := os.Stat(chrootPath); err != nil { | |||
| 28 | + | return nil, fmt.Errorf("chroot path does not exist: %w", err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Build chroot command: arch-chroot <path> <command> <args...> | |||
| 32 | + | allArgs := append([]string{chrootPath, command}, args...) | |||
| 33 | + | cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...) | |||
| 34 | + | ||||
| 35 | + | var stdout, stderr bytes.Buffer | |||
| 36 | + | cmd.Stdout = &stdout | |||
| 37 | + | cmd.Stderr = &stderr | |||
| 38 | + | ||||
| 39 | + | if err := cmd.Run(); err != nil { | |||
| 40 | + | return nil, fmt.Errorf("chroot command failed: %w (stderr: %s)", err, stderr.String()) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | return stdout.Bytes(), nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // ChrootSystemctl runs systemctl commands in chroot | |||
| 47 | + | // This is a convenience method that ensures proper logging of systemctl operations | |||
| 48 | + | func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, args ...string) error { | |||
| 49 | + | // Verify paths exist | |||
| 50 | + | if _, err := os.Stat(chrootPath); err != nil { | |||
| 51 | + | return fmt.Errorf("chroot path does not exist: %w", err) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | if logPath != "" { | |||
| 55 | + | if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { | |||
| 56 | + | return fmt.Errorf("failed to create log directory: %w", err) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Build chroot command with systemctl | |||
| 61 | + | allArgs := append([]string{chrootPath, "systemctl"}, args...) | |||
| 62 | + | cmd := exec.Command("arch-chroot", allArgs...) | |||
| 63 | + | ||||
| 64 | + | var stdout, stderr bytes.Buffer | |||
| 65 | + | cmd.Stdout = &stdout | |||
| 66 | + | cmd.Stderr = &stderr | |||
| 67 | + | ||||
| 68 | + | if err := cmd.Run(); err != nil { | |||
| 69 | + | errMsg := fmt.Sprintf("chroot systemctl failed: %v (stderr: %s)", err, stderr.String()) | |||
| 70 | + | ||||
| 71 | + | // Log to file if specified | |||
| 72 | + | if logPath != "" { | |||
| 73 | + | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |||
| 74 | + | if err == nil { | |||
| 75 | + | defer logFile.Close() | |||
| 76 | + | logFile.WriteString(errMsg + "\n") | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | return fmt.Errorf("%s", errMsg) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Log success to file if specified | |||
| 84 | + | if logPath != "" { | |||
| 85 | + | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |||
| 86 | + | if err == nil { | |||
| 87 | + | defer logFile.Close() | |||
| 88 | + | logFile.WriteString(fmt.Sprintf("systemctl %v executed successfully\n", args)) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | return nil | |||
| 93 | + | } | |||
A internal/infrastructure/executor/chroot_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package executor | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestChrootExecutor_NewChrootExecutor(t *testing.T) { | |
| 11 | + | executor := NewChrootExecutor() | |
| 12 | + | if executor == nil { | |
| 13 | + | t.Fatal("expected non-nil executor") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | if executor.cmdExecutor == nil { | |
| 17 | + | t.Fatal("expected non-nil cmdExecutor") | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestChrootExecutor_ExecuteInChroot_PathNotExists(t *testing.T) { | |
| 22 | + | executor := NewChrootExecutor() | |
| 23 | + | ctx := context.Background() | |
| 24 | + | ||
| 25 | + | _, err := executor.ExecuteInChroot(ctx, "/nonexistent/path", "echo", "test") | |
| 26 | + | if err == nil { | |
| 27 | + | t.Fatal("expected error for nonexistent chroot path") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestChrootExecutor_ExecuteInChroot_CommandNotFound(t *testing.T) { | |
| 32 | + | executor := NewChrootExecutor() | |
| 33 | + | ctx := context.Background() | |
| 34 | + | ||
| 35 | + | // Create temporary directory to use as chroot | |
| 36 | + | tmpDir := t.TempDir() | |
| 37 | + | ||
| 38 | + | // arch-chroot requires proper setup which we can't do in unit tests, | |
| 39 | + | // so we expect this to fail when trying to execute arch-chroot | |
| 40 | + | _, err := executor.ExecuteInChroot(ctx, tmpDir, "echo", "test") | |
| 41 | + | if err == nil { | |
| 42 | + | t.Fatal("expected error when executing in non-functional chroot") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestChrootExecutor_ChrootSystemctl_PathNotExists(t *testing.T) { | |
| 47 | + | executor := NewChrootExecutor() | |
| 48 | + | ||
| 49 | + | err := executor.ChrootSystemctl("", "/nonexistent/path", "enable", "service") | |
| 50 | + | if err == nil { | |
| 51 | + | t.Fatal("expected error for nonexistent chroot path") | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestChrootExecutor_ChrootSystemctl_CreatesLogDirectory(t *testing.T) { | |
| 56 | + | executor := NewChrootExecutor() | |
| 57 | + | tmpDir := t.TempDir() | |
| 58 | + | logPath := filepath.Join(tmpDir, "subdir", "log.txt") | |
| 59 | + | ||
| 60 | + | // This will fail due to arch-chroot not being available, | |
| 61 | + | // but the directory should be created | |
| 62 | + | executor.ChrootSystemctl(logPath, tmpDir, "enable", "service") | |
| 63 | + | ||
| 64 | + | // Check if log directory was created | |
| 65 | + | logDir := filepath.Dir(logPath) | |
| 66 | + | if _, err := os.Stat(logDir); os.IsNotExist(err) { | |
| 67 | + | t.Error("expected log directory to be created") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestChrootExecutor_ChrootSystemctl_LogsErrors(t *testing.T) { | |
| 72 | + | executor := NewChrootExecutor() | |
| 73 | + | tmpDir := t.TempDir() | |
| 74 | + | logPath := filepath.Join(tmpDir, "log.txt") | |
| 75 | + | ||
| 76 | + | // This will fail due to arch-chroot not being available | |
| 77 | + | executor.ChrootSystemctl(logPath, tmpDir, "enable", "service") | |
| 78 | + | ||
| 79 | + | // Check if log file was created | |
| 80 | + | if _, err := os.Stat(logPath); os.IsNotExist(err) { | |
| 81 | + | // It's OK if log file wasn't created due to error | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestChrootExecutor_ChrootSystemctl_EmptyLogPath(t *testing.T) { | |
| 86 | + | executor := NewChrootExecutor() | |
| 87 | + | tmpDir := t.TempDir() | |
| 88 | + | ||
| 89 | + | // This should not panic even with empty log path | |
| 90 | + | _ = executor.ChrootSystemctl("", tmpDir, "enable", "service") | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | package executor | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestChrootExecutor_NewChrootExecutor(t *testing.T) { | |||
| 11 | + | executor := NewChrootExecutor() | |||
| 12 | + | if executor == nil { | |||
| 13 | + | t.Fatal("expected non-nil executor") | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | if executor.cmdExecutor == nil { | |||
| 17 | + | t.Fatal("expected non-nil cmdExecutor") | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestChrootExecutor_ExecuteInChroot_PathNotExists(t *testing.T) { | |||
| 22 | + | executor := NewChrootExecutor() | |||
| 23 | + | ctx := context.Background() | |||
| 24 | + | ||||
| 25 | + | _, err := executor.ExecuteInChroot(ctx, "/nonexistent/path", "echo", "test") | |||
| 26 | + | if err == nil { | |||
| 27 | + | t.Fatal("expected error for nonexistent chroot path") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestChrootExecutor_ExecuteInChroot_CommandNotFound(t *testing.T) { | |||
| 32 | + | executor := NewChrootExecutor() | |||
| 33 | + | ctx := context.Background() | |||
| 34 | + | ||||
| 35 | + | // Create temporary directory to use as chroot | |||
| 36 | + | tmpDir := t.TempDir() | |||
| 37 | + | ||||
| 38 | + | // arch-chroot requires proper setup which we can't do in unit tests, | |||
| 39 | + | // so we expect this to fail when trying to execute arch-chroot | |||
| 40 | + | _, err := executor.ExecuteInChroot(ctx, tmpDir, "echo", "test") | |||
| 41 | + | if err == nil { | |||
| 42 | + | t.Fatal("expected error when executing in non-functional chroot") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestChrootExecutor_ChrootSystemctl_PathNotExists(t *testing.T) { | |||
| 47 | + | executor := NewChrootExecutor() | |||
| 48 | + | ||||
| 49 | + | err := executor.ChrootSystemctl("", "/nonexistent/path", "enable", "service") | |||
| 50 | + | if err == nil { | |||
| 51 | + | t.Fatal("expected error for nonexistent chroot path") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestChrootExecutor_ChrootSystemctl_CreatesLogDirectory(t *testing.T) { | |||
| 56 | + | executor := NewChrootExecutor() | |||
| 57 | + | tmpDir := t.TempDir() | |||
| 58 | + | logPath := filepath.Join(tmpDir, "subdir", "log.txt") | |||
| 59 | + | ||||
| 60 | + | // This will fail due to arch-chroot not being available, | |||
| 61 | + | // but the directory should be created | |||
| 62 | + | executor.ChrootSystemctl(logPath, tmpDir, "enable", "service") | |||
| 63 | + | ||||
| 64 | + | // Check if log directory was created | |||
| 65 | + | logDir := filepath.Dir(logPath) | |||
| 66 | + | if _, err := os.Stat(logDir); os.IsNotExist(err) { | |||
| 67 | + | t.Error("expected log directory to be created") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestChrootExecutor_ChrootSystemctl_LogsErrors(t *testing.T) { | |||
| 72 | + | executor := NewChrootExecutor() | |||
| 73 | + | tmpDir := t.TempDir() | |||
| 74 | + | logPath := filepath.Join(tmpDir, "log.txt") | |||
| 75 | + | ||||
| 76 | + | // This will fail due to arch-chroot not being available | |||
| 77 | + | executor.ChrootSystemctl(logPath, tmpDir, "enable", "service") | |||
| 78 | + | ||||
| 79 | + | // Check if log file was created | |||
| 80 | + | if _, err := os.Stat(logPath); os.IsNotExist(err) { | |||
| 81 | + | // It's OK if log file wasn't created due to error | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestChrootExecutor_ChrootSystemctl_EmptyLogPath(t *testing.T) { | |||
| 86 | + | executor := NewChrootExecutor() | |||
| 87 | + | tmpDir := t.TempDir() | |||
| 88 | + | ||||
| 89 | + | // This should not panic even with empty log path | |||
| 90 | + | _ = executor.ChrootSystemctl("", tmpDir, "enable", "service") | |||
| 91 | + | } | |||
A internal/infrastructure/executor/script.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package executor | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ScriptExecutor implements the ScriptExecutor port for shell scripts | |
| 12 | + | type ScriptExecutor struct{} | |
| 13 | + | ||
| 14 | + | // NewScriptExecutor creates a new script executor | |
| 15 | + | func NewScriptExecutor() *ScriptExecutor { | |
| 16 | + | return &ScriptExecutor{} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // ExecuteScript runs a shell script with environment variables | |
| 20 | + | func (se *ScriptExecutor) ExecuteScript(ctx context.Context, scriptPath string, env map[string]string) error { | |
| 21 | + | // Verify script exists | |
| 22 | + | if _, err := os.Stat(scriptPath); err != nil { | |
| 23 | + | return fmt.Errorf("script not found: %w", err) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | cmd := exec.CommandContext(ctx, "bash", scriptPath) | |
| 27 | + | var stdout, stderr bytes.Buffer | |
| 28 | + | cmd.Stdout = &stdout | |
| 29 | + | cmd.Stderr = &stderr | |
| 30 | + | ||
| 31 | + | // Build environment: start with current environment, then add/override with provided env | |
| 32 | + | cmdEnv := os.Environ() | |
| 33 | + | for key, value := range env { | |
| 34 | + | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |
| 35 | + | } | |
| 36 | + | cmd.Env = cmdEnv | |
| 37 | + | ||
| 38 | + | if err := cmd.Run(); err != nil { | |
| 39 | + | return fmt.Errorf("script execution failed: %w (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String()) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | return nil | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package executor | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ScriptExecutor implements the ScriptExecutor port for shell scripts | |||
| 12 | + | type ScriptExecutor struct{} | |||
| 13 | + | ||||
| 14 | + | // NewScriptExecutor creates a new script executor | |||
| 15 | + | func NewScriptExecutor() *ScriptExecutor { | |||
| 16 | + | return &ScriptExecutor{} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // ExecuteScript runs a shell script with environment variables | |||
| 20 | + | func (se *ScriptExecutor) ExecuteScript(ctx context.Context, scriptPath string, env map[string]string) error { | |||
| 21 | + | // Verify script exists | |||
| 22 | + | if _, err := os.Stat(scriptPath); err != nil { | |||
| 23 | + | return fmt.Errorf("script not found: %w", err) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | cmd := exec.CommandContext(ctx, "bash", scriptPath) | |||
| 27 | + | var stdout, stderr bytes.Buffer | |||
| 28 | + | cmd.Stdout = &stdout | |||
| 29 | + | cmd.Stderr = &stderr | |||
| 30 | + | ||||
| 31 | + | // Build environment: start with current environment, then add/override with provided env | |||
| 32 | + | cmdEnv := os.Environ() | |||
| 33 | + | for key, value := range env { | |||
| 34 | + | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |||
| 35 | + | } | |||
| 36 | + | cmd.Env = cmdEnv | |||
| 37 | + | ||||
| 38 | + | if err := cmd.Run(); err != nil { | |||
| 39 | + | return fmt.Errorf("script execution failed: %w (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String()) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | return nil | |||
| 43 | + | } | |||
A internal/infrastructure/executor/script_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package executor | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestScriptExecutor_NewScriptExecutor(t *testing.T) { | |
| 11 | + | executor := NewScriptExecutor() | |
| 12 | + | if executor == nil { | |
| 13 | + | t.Fatal("expected non-nil executor") | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestScriptExecutor_ExecuteScript_Success(t *testing.T) { | |
| 18 | + | executor := NewScriptExecutor() | |
| 19 | + | tmpDir := t.TempDir() | |
| 20 | + | ||
| 21 | + | // Create a simple test script | |
| 22 | + | scriptPath := filepath.Join(tmpDir, "test.sh") | |
| 23 | + | scriptContent := "#!/bin/bash\nexit 0" | |
| 24 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |
| 25 | + | t.Fatalf("failed to create test script: %v", err) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | ctx := context.Background() | |
| 29 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatalf("expected no error, got %v", err) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestScriptExecutor_ExecuteScript_NotFound(t *testing.T) { | |
| 36 | + | executor := NewScriptExecutor() | |
| 37 | + | ctx := context.Background() | |
| 38 | + | ||
| 39 | + | err := executor.ExecuteScript(ctx, "/nonexistent/script.sh", nil) | |
| 40 | + | if err == nil { | |
| 41 | + | t.Fatal("expected error for nonexistent script") | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestScriptExecutor_ExecuteScript_Failure(t *testing.T) { | |
| 46 | + | executor := NewScriptExecutor() | |
| 47 | + | tmpDir := t.TempDir() | |
| 48 | + | ||
| 49 | + | // Create a script that fails | |
| 50 | + | scriptPath := filepath.Join(tmpDir, "fail.sh") | |
| 51 | + | scriptContent := "#!/bin/bash\nexit 1" | |
| 52 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |
| 53 | + | t.Fatalf("failed to create test script: %v", err) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | ctx := context.Background() | |
| 57 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |
| 58 | + | if err == nil { | |
| 59 | + | t.Fatal("expected error for failed script") | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestScriptExecutor_ExecuteScript_WithEnv(t *testing.T) { | |
| 64 | + | executor := NewScriptExecutor() | |
| 65 | + | tmpDir := t.TempDir() | |
| 66 | + | ||
| 67 | + | // Create a script that uses environment variables | |
| 68 | + | scriptPath := filepath.Join(tmpDir, "env.sh") | |
| 69 | + | scriptContent := "#!/bin/bash\ntest \"$TEST_VAR\" = \"test_value\" || exit 1" | |
| 70 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |
| 71 | + | t.Fatalf("failed to create test script: %v", err) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | ctx := context.Background() | |
| 75 | + | env := map[string]string{ | |
| 76 | + | "TEST_VAR": "test_value", | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | err := executor.ExecuteScript(ctx, scriptPath, env) | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatalf("expected no error, got %v", err) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestScriptExecutor_ExecuteScript_WithMultipleEnv(t *testing.T) { | |
| 86 | + | executor := NewScriptExecutor() | |
| 87 | + | tmpDir := t.TempDir() | |
| 88 | + | ||
| 89 | + | // Create a script that checks multiple environment variables | |
| 90 | + | scriptPath := filepath.Join(tmpDir, "multienv.sh") | |
| 91 | + | scriptContent := "#!/bin/bash\ntest \"$VAR1\" = \"value1\" && test \"$VAR2\" = \"value2\" || exit 1" | |
| 92 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |
| 93 | + | t.Fatalf("failed to create test script: %v", err) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | ctx := context.Background() | |
| 97 | + | env := map[string]string{ | |
| 98 | + | "VAR1": "value1", | |
| 99 | + | "VAR2": "value2", | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | err := executor.ExecuteScript(ctx, scriptPath, env) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatalf("expected no error, got %v", err) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestScriptExecutor_ExecuteScript_Stderr(t *testing.T) { | |
| 109 | + | executor := NewScriptExecutor() | |
| 110 | + | tmpDir := t.TempDir() | |
| 111 | + | ||
| 112 | + | // Create a script that writes to stderr | |
| 113 | + | scriptPath := filepath.Join(tmpDir, "stderr.sh") | |
| 114 | + | scriptContent := "#!/bin/bash\necho error >&2\nexit 1" | |
| 115 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |
| 116 | + | t.Fatalf("failed to create test script: %v", err) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | ctx := context.Background() | |
| 120 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |
| 121 | + | if err == nil { | |
| 122 | + | t.Fatal("expected error") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Verify stderr is captured in error message | |
| 126 | + | if err != nil && err.Error() == "" { | |
| 127 | + | t.Error("expected error message to contain stderr") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestScriptExecutor_ExecuteScript_ContextCancellation(t *testing.T) { | |
| 132 | + | executor := NewScriptExecutor() | |
| 133 | + | tmpDir := t.TempDir() | |
| 134 | + | ||
| 135 | + | // Create a script that sleeps | |
| 136 | + | scriptPath := filepath.Join(tmpDir, "sleep.sh") | |
| 137 | + | scriptContent := "#!/bin/bash\nsleep 10" | |
| 138 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |
| 139 | + | t.Fatalf("failed to create test script: %v", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 143 | + | cancel() // Cancel immediately | |
| 144 | + | ||
| 145 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |
| 146 | + | if err == nil { | |
| 147 | + | t.Fatal("expected error due to context cancellation") | |
| 148 | + | } | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | package executor | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestScriptExecutor_NewScriptExecutor(t *testing.T) { | |||
| 11 | + | executor := NewScriptExecutor() | |||
| 12 | + | if executor == nil { | |||
| 13 | + | t.Fatal("expected non-nil executor") | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestScriptExecutor_ExecuteScript_Success(t *testing.T) { | |||
| 18 | + | executor := NewScriptExecutor() | |||
| 19 | + | tmpDir := t.TempDir() | |||
| 20 | + | ||||
| 21 | + | // Create a simple test script | |||
| 22 | + | scriptPath := filepath.Join(tmpDir, "test.sh") | |||
| 23 | + | scriptContent := "#!/bin/bash\nexit 0" | |||
| 24 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |||
| 25 | + | t.Fatalf("failed to create test script: %v", err) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | ctx := context.Background() | |||
| 29 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Fatalf("expected no error, got %v", err) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestScriptExecutor_ExecuteScript_NotFound(t *testing.T) { | |||
| 36 | + | executor := NewScriptExecutor() | |||
| 37 | + | ctx := context.Background() | |||
| 38 | + | ||||
| 39 | + | err := executor.ExecuteScript(ctx, "/nonexistent/script.sh", nil) | |||
| 40 | + | if err == nil { | |||
| 41 | + | t.Fatal("expected error for nonexistent script") | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestScriptExecutor_ExecuteScript_Failure(t *testing.T) { | |||
| 46 | + | executor := NewScriptExecutor() | |||
| 47 | + | tmpDir := t.TempDir() | |||
| 48 | + | ||||
| 49 | + | // Create a script that fails | |||
| 50 | + | scriptPath := filepath.Join(tmpDir, "fail.sh") | |||
| 51 | + | scriptContent := "#!/bin/bash\nexit 1" | |||
| 52 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |||
| 53 | + | t.Fatalf("failed to create test script: %v", err) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | ctx := context.Background() | |||
| 57 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |||
| 58 | + | if err == nil { | |||
| 59 | + | t.Fatal("expected error for failed script") | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestScriptExecutor_ExecuteScript_WithEnv(t *testing.T) { | |||
| 64 | + | executor := NewScriptExecutor() | |||
| 65 | + | tmpDir := t.TempDir() | |||
| 66 | + | ||||
| 67 | + | // Create a script that uses environment variables | |||
| 68 | + | scriptPath := filepath.Join(tmpDir, "env.sh") | |||
| 69 | + | scriptContent := "#!/bin/bash\ntest \"$TEST_VAR\" = \"test_value\" || exit 1" | |||
| 70 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |||
| 71 | + | t.Fatalf("failed to create test script: %v", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | ctx := context.Background() | |||
| 75 | + | env := map[string]string{ | |||
| 76 | + | "TEST_VAR": "test_value", | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | err := executor.ExecuteScript(ctx, scriptPath, env) | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatalf("expected no error, got %v", err) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestScriptExecutor_ExecuteScript_WithMultipleEnv(t *testing.T) { | |||
| 86 | + | executor := NewScriptExecutor() | |||
| 87 | + | tmpDir := t.TempDir() | |||
| 88 | + | ||||
| 89 | + | // Create a script that checks multiple environment variables | |||
| 90 | + | scriptPath := filepath.Join(tmpDir, "multienv.sh") | |||
| 91 | + | scriptContent := "#!/bin/bash\ntest \"$VAR1\" = \"value1\" && test \"$VAR2\" = \"value2\" || exit 1" | |||
| 92 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |||
| 93 | + | t.Fatalf("failed to create test script: %v", err) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | ctx := context.Background() | |||
| 97 | + | env := map[string]string{ | |||
| 98 | + | "VAR1": "value1", | |||
| 99 | + | "VAR2": "value2", | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | err := executor.ExecuteScript(ctx, scriptPath, env) | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatalf("expected no error, got %v", err) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestScriptExecutor_ExecuteScript_Stderr(t *testing.T) { | |||
| 109 | + | executor := NewScriptExecutor() | |||
| 110 | + | tmpDir := t.TempDir() | |||
| 111 | + | ||||
| 112 | + | // Create a script that writes to stderr | |||
| 113 | + | scriptPath := filepath.Join(tmpDir, "stderr.sh") | |||
| 114 | + | scriptContent := "#!/bin/bash\necho error >&2\nexit 1" | |||
| 115 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |||
| 116 | + | t.Fatalf("failed to create test script: %v", err) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | ctx := context.Background() | |||
| 120 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |||
| 121 | + | if err == nil { | |||
| 122 | + | t.Fatal("expected error") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Verify stderr is captured in error message | |||
| 126 | + | if err != nil && err.Error() == "" { | |||
| 127 | + | t.Error("expected error message to contain stderr") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestScriptExecutor_ExecuteScript_ContextCancellation(t *testing.T) { | |||
| 132 | + | executor := NewScriptExecutor() | |||
| 133 | + | tmpDir := t.TempDir() | |||
| 134 | + | ||||
| 135 | + | // Create a script that sleeps | |||
| 136 | + | scriptPath := filepath.Join(tmpDir, "sleep.sh") | |||
| 137 | + | scriptContent := "#!/bin/bash\nsleep 10" | |||
| 138 | + | if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { | |||
| 139 | + | t.Fatalf("failed to create test script: %v", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 143 | + | cancel() // Cancel immediately | |||
| 144 | + | ||||
| 145 | + | err := executor.ExecuteScript(ctx, scriptPath, nil) | |||
| 146 | + | if err == nil { | |||
| 147 | + | t.Fatal("expected error due to context cancellation") | |||
| 148 | + | } | |||
| 149 | + | } | |||
A internal/infrastructure/executor/shell.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package executor | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ShellExecutor implements the CommandExecutor port using system shell commands | |
| 12 | + | type ShellExecutor struct{} | |
| 13 | + | ||
| 14 | + | // NewShellExecutor creates a new shell executor | |
| 15 | + | func NewShellExecutor() *ShellExecutor { | |
| 16 | + | return &ShellExecutor{} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Execute runs a command and returns output | |
| 20 | + | func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) { | |
| 21 | + | cmd := exec.CommandContext(ctx, command, args...) | |
| 22 | + | var stdout, stderr bytes.Buffer | |
| 23 | + | cmd.Stdout = &stdout | |
| 24 | + | cmd.Stderr = &stderr | |
| 25 | + | ||
| 26 | + | if err := cmd.Run(); err != nil { | |
| 27 | + | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | return stdout.Bytes(), nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // ExecuteWithEnv runs a command with custom environment variables | |
| 34 | + | func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | |
| 35 | + | cmd := exec.CommandContext(ctx, command, args...) | |
| 36 | + | var stdout, stderr bytes.Buffer | |
| 37 | + | cmd.Stdout = &stdout | |
| 38 | + | cmd.Stderr = &stderr | |
| 39 | + | ||
| 40 | + | // Build environment: start with current environment, then add/override with provided env | |
| 41 | + | cmdEnv := os.Environ() | |
| 42 | + | for key, value := range env { | |
| 43 | + | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |
| 44 | + | } | |
| 45 | + | cmd.Env = cmdEnv | |
| 46 | + | ||
| 47 | + | if err := cmd.Run(); err != nil { | |
| 48 | + | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | return stdout.Bytes(), nil | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package executor | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ShellExecutor implements the CommandExecutor port using system shell commands | |||
| 12 | + | type ShellExecutor struct{} | |||
| 13 | + | ||||
| 14 | + | // NewShellExecutor creates a new shell executor | |||
| 15 | + | func NewShellExecutor() *ShellExecutor { | |||
| 16 | + | return &ShellExecutor{} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Execute runs a command and returns output | |||
| 20 | + | func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) { | |||
| 21 | + | cmd := exec.CommandContext(ctx, command, args...) | |||
| 22 | + | var stdout, stderr bytes.Buffer | |||
| 23 | + | cmd.Stdout = &stdout | |||
| 24 | + | cmd.Stderr = &stderr | |||
| 25 | + | ||||
| 26 | + | if err := cmd.Run(); err != nil { | |||
| 27 | + | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | return stdout.Bytes(), nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // ExecuteWithEnv runs a command with custom environment variables | |||
| 34 | + | func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) { | |||
| 35 | + | cmd := exec.CommandContext(ctx, command, args...) | |||
| 36 | + | var stdout, stderr bytes.Buffer | |||
| 37 | + | cmd.Stdout = &stdout | |||
| 38 | + | cmd.Stderr = &stderr | |||
| 39 | + | ||||
| 40 | + | // Build environment: start with current environment, then add/override with provided env | |||
| 41 | + | cmdEnv := os.Environ() | |||
| 42 | + | for key, value := range env { | |||
| 43 | + | cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value)) | |||
| 44 | + | } | |||
| 45 | + | cmd.Env = cmdEnv | |||
| 46 | + | ||||
| 47 | + | if err := cmd.Run(); err != nil { | |||
| 48 | + | return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | return stdout.Bytes(), nil | |||
| 52 | + | } | |||
A internal/infrastructure/executor/shell_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package executor | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestShellExecutor_Execute_Success(t *testing.T) { | |
| 11 | + | executor := NewShellExecutor() | |
| 12 | + | ctx := context.Background() | |
| 13 | + | ||
| 14 | + | output, err := executor.Execute(ctx, "echo", "hello world") | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatalf("expected no error, got %v", err) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | expected := "hello world\n" | |
| 20 | + | if string(output) != expected { | |
| 21 | + | t.Errorf("expected %q, got %q", expected, string(output)) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestShellExecutor_Execute_WithArgs(t *testing.T) { | |
| 26 | + | executor := NewShellExecutor() | |
| 27 | + | ctx := context.Background() | |
| 28 | + | ||
| 29 | + | // Test with multiple arguments | |
| 30 | + | output, err := executor.Execute(ctx, "echo", "arg1", "arg2", "arg3") | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatalf("expected no error, got %v", err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | if !strings.Contains(string(output), "arg1") { | |
| 36 | + | t.Errorf("expected arg1 in output, got %s", string(output)) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestShellExecutor_Execute_CommandNotFound(t *testing.T) { | |
| 41 | + | executor := NewShellExecutor() | |
| 42 | + | ctx := context.Background() | |
| 43 | + | ||
| 44 | + | _, err := executor.Execute(ctx, "nonexistentcommand12345", "arg") | |
| 45 | + | if err == nil { | |
| 46 | + | t.Fatal("expected error for nonexistent command") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestShellExecutor_Execute_CommandFailure(t *testing.T) { | |
| 51 | + | executor := NewShellExecutor() | |
| 52 | + | ctx := context.Background() | |
| 53 | + | ||
| 54 | + | // false command always exits with code 1 | |
| 55 | + | _, err := executor.Execute(ctx, "false") | |
| 56 | + | if err == nil { | |
| 57 | + | t.Fatal("expected error for failed command") | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestShellExecutor_ExecuteWithEnv_Success(t *testing.T) { | |
| 62 | + | executor := NewShellExecutor() | |
| 63 | + | ctx := context.Background() | |
| 64 | + | ||
| 65 | + | env := map[string]string{ | |
| 66 | + | "TEST_VAR": "test_value", | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Echo the environment variable | |
| 70 | + | output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $TEST_VAR") | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("expected no error, got %v", err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | if !strings.Contains(string(output), "test_value") { | |
| 76 | + | t.Errorf("expected test_value in output, got %s", string(output)) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) { | |
| 81 | + | executor := NewShellExecutor() | |
| 82 | + | ctx := context.Background() | |
| 83 | + | ||
| 84 | + | env := map[string]string{ | |
| 85 | + | "VAR1": "value1", | |
| 86 | + | "VAR2": "value2", | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $VAR1 $VAR2") | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatalf("expected no error, got %v", err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | output_str := string(output) | |
| 95 | + | if !strings.Contains(output_str, "value1") || !strings.Contains(output_str, "value2") { | |
| 96 | + | t.Errorf("expected both values in output, got %s", output_str) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestShellExecutor_Execute_ContextCancellation(t *testing.T) { | |
| 101 | + | executor := NewShellExecutor() | |
| 102 | + | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 103 | + | defer cancel() | |
| 104 | + | ||
| 105 | + | // sleep command will be interrupted | |
| 106 | + | _, err := executor.Execute(ctx, "sleep", "10") | |
| 107 | + | if err == nil { | |
| 108 | + | t.Fatal("expected error due to context cancellation") | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestShellExecutor_Execute_EmptyOutput(t *testing.T) { | |
| 113 | + | executor := NewShellExecutor() | |
| 114 | + | ctx := context.Background() | |
| 115 | + | ||
| 116 | + | // true command produces no output | |
| 117 | + | output, err := executor.Execute(ctx, "true") | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatalf("expected no error, got %v", err) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | if len(output) != 0 { | |
| 123 | + | t.Errorf("expected empty output, got %s", string(output)) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestShellExecutor_Execute_StderrCapture(t *testing.T) { | |
| 128 | + | executor := NewShellExecutor() | |
| 129 | + | ctx := context.Background() | |
| 130 | + | ||
| 131 | + | // sh command that writes to stderr | |
| 132 | + | _, err := executor.Execute(ctx, "sh", "-c", "echo error >&2; exit 1") | |
| 133 | + | if err == nil { | |
| 134 | + | t.Fatal("expected error") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Verify stderr is captured in error message | |
| 138 | + | if !strings.Contains(err.Error(), "error") { | |
| 139 | + | t.Errorf("expected 'error' in error message, got %s", err.Error()) | |
| 140 | + | } | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package executor | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestShellExecutor_Execute_Success(t *testing.T) { | |||
| 11 | + | executor := NewShellExecutor() | |||
| 12 | + | ctx := context.Background() | |||
| 13 | + | ||||
| 14 | + | output, err := executor.Execute(ctx, "echo", "hello world") | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatalf("expected no error, got %v", err) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | expected := "hello world\n" | |||
| 20 | + | if string(output) != expected { | |||
| 21 | + | t.Errorf("expected %q, got %q", expected, string(output)) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestShellExecutor_Execute_WithArgs(t *testing.T) { | |||
| 26 | + | executor := NewShellExecutor() | |||
| 27 | + | ctx := context.Background() | |||
| 28 | + | ||||
| 29 | + | // Test with multiple arguments | |||
| 30 | + | output, err := executor.Execute(ctx, "echo", "arg1", "arg2", "arg3") | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatalf("expected no error, got %v", err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | if !strings.Contains(string(output), "arg1") { | |||
| 36 | + | t.Errorf("expected arg1 in output, got %s", string(output)) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestShellExecutor_Execute_CommandNotFound(t *testing.T) { | |||
| 41 | + | executor := NewShellExecutor() | |||
| 42 | + | ctx := context.Background() | |||
| 43 | + | ||||
| 44 | + | _, err := executor.Execute(ctx, "nonexistentcommand12345", "arg") | |||
| 45 | + | if err == nil { | |||
| 46 | + | t.Fatal("expected error for nonexistent command") | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestShellExecutor_Execute_CommandFailure(t *testing.T) { | |||
| 51 | + | executor := NewShellExecutor() | |||
| 52 | + | ctx := context.Background() | |||
| 53 | + | ||||
| 54 | + | // false command always exits with code 1 | |||
| 55 | + | _, err := executor.Execute(ctx, "false") | |||
| 56 | + | if err == nil { | |||
| 57 | + | t.Fatal("expected error for failed command") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestShellExecutor_ExecuteWithEnv_Success(t *testing.T) { | |||
| 62 | + | executor := NewShellExecutor() | |||
| 63 | + | ctx := context.Background() | |||
| 64 | + | ||||
| 65 | + | env := map[string]string{ | |||
| 66 | + | "TEST_VAR": "test_value", | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Echo the environment variable | |||
| 70 | + | output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $TEST_VAR") | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("expected no error, got %v", err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | if !strings.Contains(string(output), "test_value") { | |||
| 76 | + | t.Errorf("expected test_value in output, got %s", string(output)) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) { | |||
| 81 | + | executor := NewShellExecutor() | |||
| 82 | + | ctx := context.Background() | |||
| 83 | + | ||||
| 84 | + | env := map[string]string{ | |||
| 85 | + | "VAR1": "value1", | |||
| 86 | + | "VAR2": "value2", | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $VAR1 $VAR2") | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatalf("expected no error, got %v", err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | output_str := string(output) | |||
| 95 | + | if !strings.Contains(output_str, "value1") || !strings.Contains(output_str, "value2") { | |||
| 96 | + | t.Errorf("expected both values in output, got %s", output_str) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestShellExecutor_Execute_ContextCancellation(t *testing.T) { | |||
| 101 | + | executor := NewShellExecutor() | |||
| 102 | + | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |||
| 103 | + | defer cancel() | |||
| 104 | + | ||||
| 105 | + | // sleep command will be interrupted | |||
| 106 | + | _, err := executor.Execute(ctx, "sleep", "10") | |||
| 107 | + | if err == nil { | |||
| 108 | + | t.Fatal("expected error due to context cancellation") | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestShellExecutor_Execute_EmptyOutput(t *testing.T) { | |||
| 113 | + | executor := NewShellExecutor() | |||
| 114 | + | ctx := context.Background() | |||
| 115 | + | ||||
| 116 | + | // true command produces no output | |||
| 117 | + | output, err := executor.Execute(ctx, "true") | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatalf("expected no error, got %v", err) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if len(output) != 0 { | |||
| 123 | + | t.Errorf("expected empty output, got %s", string(output)) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestShellExecutor_Execute_StderrCapture(t *testing.T) { | |||
| 128 | + | executor := NewShellExecutor() | |||
| 129 | + | ctx := context.Background() | |||
| 130 | + | ||||
| 131 | + | // sh command that writes to stderr | |||
| 132 | + | _, err := executor.Execute(ctx, "sh", "-c", "echo error >&2; exit 1") | |||
| 133 | + | if err == nil { | |||
| 134 | + | t.Fatal("expected error") | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Verify stderr is captured in error message | |||
| 138 | + | if !strings.Contains(err.Error(), "error") { | |||
| 139 | + | t.Errorf("expected 'error' in error message, got %s", err.Error()) | |||
| 140 | + | } | |||
| 141 | + | } | |||
A internal/infrastructure/filesystem/local.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package filesystem | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // LocalFileSystem implements the FileSystem port using the OS filesystem | |
| 9 | + | type LocalFileSystem struct{} | |
| 10 | + | ||
| 11 | + | // NewLocalFileSystem creates a new local filesystem adapter | |
| 12 | + | func NewLocalFileSystem() *LocalFileSystem { | |
| 13 | + | return &LocalFileSystem{} | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Stat returns file info for the given path | |
| 17 | + | func (lfs *LocalFileSystem) Stat(name string) (os.FileInfo, error) { | |
| 18 | + | return os.Stat(name) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // ReadFile reads entire file content | |
| 22 | + | func (lfs *LocalFileSystem) ReadFile(name string) ([]byte, error) { | |
| 23 | + | return os.ReadFile(name) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // WriteFile writes data to a file with the given permissions | |
| 27 | + | func (lfs *LocalFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error { | |
| 28 | + | return os.WriteFile(name, data, perm) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Create creates or truncates a file | |
| 32 | + | func (lfs *LocalFileSystem) Create(name string) (LocalFile, error) { | |
| 33 | + | file, err := os.Create(name) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, err | |
| 36 | + | } | |
| 37 | + | return &localFileHandle{file: file}, nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Chmod changes file permissions | |
| 41 | + | func (lfs *LocalFileSystem) Chmod(name string, perm os.FileMode) error { | |
| 42 | + | return os.Chmod(name, perm) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // MkdirAll creates directory path with parents | |
| 46 | + | func (lfs *LocalFileSystem) MkdirAll(path string, perm os.FileMode) error { | |
| 47 | + | return os.MkdirAll(path, perm) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // RemoveAll removes a file or directory recursively | |
| 51 | + | func (lfs *LocalFileSystem) RemoveAll(path string) error { | |
| 52 | + | return os.RemoveAll(path) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Exists checks if a file/directory exists | |
| 56 | + | func (lfs *LocalFileSystem) Exists(path string) (bool, error) { | |
| 57 | + | _, err := os.Stat(path) | |
| 58 | + | if err == nil { | |
| 59 | + | return true, nil | |
| 60 | + | } | |
| 61 | + | if os.IsNotExist(err) { | |
| 62 | + | return false, nil | |
| 63 | + | } | |
| 64 | + | return false, fmt.Errorf("failed to check file existence: %w", err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // LocalFile interface type for file operations | |
| 68 | + | type LocalFile interface { | |
| 69 | + | Read(b []byte) (n int, err error) | |
| 70 | + | Write(b []byte) (n int, err error) | |
| 71 | + | Close() error | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // localFileHandle wraps os.File to implement the File port | |
| 75 | + | type localFileHandle struct { | |
| 76 | + | file *os.File | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Read reads up to len(b) bytes from the file | |
| 80 | + | func (lfh *localFileHandle) Read(b []byte) (n int, err error) { | |
| 81 | + | return lfh.file.Read(b) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Write writes len(b) bytes to the file | |
| 85 | + | func (lfh *localFileHandle) Write(b []byte) (n int, err error) { | |
| 86 | + | return lfh.file.Write(b) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Close closes the file | |
| 90 | + | func (lfh *localFileHandle) Close() error { | |
| 91 | + | return lfh.file.Close() | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // LocalFileSystem implements the FileSystem port using the OS filesystem | |||
| 9 | + | type LocalFileSystem struct{} | |||
| 10 | + | ||||
| 11 | + | // NewLocalFileSystem creates a new local filesystem adapter | |||
| 12 | + | func NewLocalFileSystem() *LocalFileSystem { | |||
| 13 | + | return &LocalFileSystem{} | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // Stat returns file info for the given path | |||
| 17 | + | func (lfs *LocalFileSystem) Stat(name string) (os.FileInfo, error) { | |||
| 18 | + | return os.Stat(name) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // ReadFile reads entire file content | |||
| 22 | + | func (lfs *LocalFileSystem) ReadFile(name string) ([]byte, error) { | |||
| 23 | + | return os.ReadFile(name) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // WriteFile writes data to a file with the given permissions | |||
| 27 | + | func (lfs *LocalFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error { | |||
| 28 | + | return os.WriteFile(name, data, perm) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Create creates or truncates a file | |||
| 32 | + | func (lfs *LocalFileSystem) Create(name string) (LocalFile, error) { | |||
| 33 | + | file, err := os.Create(name) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return nil, err | |||
| 36 | + | } | |||
| 37 | + | return &localFileHandle{file: file}, nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Chmod changes file permissions | |||
| 41 | + | func (lfs *LocalFileSystem) Chmod(name string, perm os.FileMode) error { | |||
| 42 | + | return os.Chmod(name, perm) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // MkdirAll creates directory path with parents | |||
| 46 | + | func (lfs *LocalFileSystem) MkdirAll(path string, perm os.FileMode) error { | |||
| 47 | + | return os.MkdirAll(path, perm) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // RemoveAll removes a file or directory recursively | |||
| 51 | + | func (lfs *LocalFileSystem) RemoveAll(path string) error { | |||
| 52 | + | return os.RemoveAll(path) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Exists checks if a file/directory exists | |||
| 56 | + | func (lfs *LocalFileSystem) Exists(path string) (bool, error) { | |||
| 57 | + | _, err := os.Stat(path) | |||
| 58 | + | if err == nil { | |||
| 59 | + | return true, nil | |||
| 60 | + | } | |||
| 61 | + | if os.IsNotExist(err) { | |||
| 62 | + | return false, nil | |||
| 63 | + | } | |||
| 64 | + | return false, fmt.Errorf("failed to check file existence: %w", err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // LocalFile interface type for file operations | |||
| 68 | + | type LocalFile interface { | |||
| 69 | + | Read(b []byte) (n int, err error) | |||
| 70 | + | Write(b []byte) (n int, err error) | |||
| 71 | + | Close() error | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // localFileHandle wraps os.File to implement the File port | |||
| 75 | + | type localFileHandle struct { | |||
| 76 | + | file *os.File | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Read reads up to len(b) bytes from the file | |||
| 80 | + | func (lfh *localFileHandle) Read(b []byte) (n int, err error) { | |||
| 81 | + | return lfh.file.Read(b) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Write writes len(b) bytes to the file | |||
| 85 | + | func (lfh *localFileHandle) Write(b []byte) (n int, err error) { | |||
| 86 | + | return lfh.file.Write(b) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Close closes the file | |||
| 90 | + | func (lfh *localFileHandle) Close() error { | |||
| 91 | + | return lfh.file.Close() | |||
| 92 | + | } | |||
A internal/infrastructure/filesystem/local_test.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | package filesystem | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestLocalFileSystem_Stat(t *testing.T) { | |
| 10 | + | fs := NewLocalFileSystem() | |
| 11 | + | tmpDir := t.TempDir() | |
| 12 | + | ||
| 13 | + | // Create a test file | |
| 14 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 15 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |
| 16 | + | t.Fatalf("failed to create test file: %v", err) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | info, err := fs.Stat(testFile) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("expected no error, got %v", err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | if info.Name() != "test.txt" { | |
| 25 | + | t.Errorf("expected filename test.txt, got %s", info.Name()) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestLocalFileSystem_StatNotFound(t *testing.T) { | |
| 30 | + | fs := NewLocalFileSystem() | |
| 31 | + | ||
| 32 | + | _, err := fs.Stat("/nonexistent/path/file.txt") | |
| 33 | + | if err == nil { | |
| 34 | + | t.Fatal("expected error for nonexistent file") | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestLocalFileSystem_ReadFile(t *testing.T) { | |
| 39 | + | fs := NewLocalFileSystem() | |
| 40 | + | tmpDir := t.TempDir() | |
| 41 | + | ||
| 42 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 43 | + | testContent := []byte("hello world") | |
| 44 | + | if err := os.WriteFile(testFile, testContent, 0644); err != nil { | |
| 45 | + | t.Fatalf("failed to create test file: %v", err) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | content, err := fs.ReadFile(testFile) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatalf("expected no error, got %v", err) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if string(content) != "hello world" { | |
| 54 | + | t.Errorf("expected 'hello world', got %s", string(content)) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestLocalFileSystem_ReadFileNotFound(t *testing.T) { | |
| 59 | + | fs := NewLocalFileSystem() | |
| 60 | + | ||
| 61 | + | _, err := fs.ReadFile("/nonexistent/file.txt") | |
| 62 | + | if err == nil { | |
| 63 | + | t.Fatal("expected error for nonexistent file") | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestLocalFileSystem_WriteFile(t *testing.T) { | |
| 68 | + | fs := NewLocalFileSystem() | |
| 69 | + | tmpDir := t.TempDir() | |
| 70 | + | ||
| 71 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 72 | + | content := []byte("test content") | |
| 73 | + | ||
| 74 | + | err := fs.WriteFile(testFile, content, 0644) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatalf("expected no error, got %v", err) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Verify file was created | |
| 80 | + | data, err := os.ReadFile(testFile) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatalf("failed to read file: %v", err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | if string(data) != "test content" { | |
| 86 | + | t.Errorf("expected 'test content', got %s", string(data)) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestLocalFileSystem_WriteFileOverwrite(t *testing.T) { | |
| 91 | + | fs := NewLocalFileSystem() | |
| 92 | + | tmpDir := t.TempDir() | |
| 93 | + | ||
| 94 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 95 | + | ||
| 96 | + | // Write first time | |
| 97 | + | if err := fs.WriteFile(testFile, []byte("first"), 0644); err != nil { | |
| 98 | + | t.Fatalf("expected no error, got %v", err) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Overwrite | |
| 102 | + | if err := fs.WriteFile(testFile, []byte("second"), 0644); err != nil { | |
| 103 | + | t.Fatalf("expected no error, got %v", err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | data, _ := os.ReadFile(testFile) | |
| 107 | + | if string(data) != "second" { | |
| 108 | + | t.Errorf("expected 'second', got %s", string(data)) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestLocalFileSystem_Create(t *testing.T) { | |
| 113 | + | fs := NewLocalFileSystem() | |
| 114 | + | tmpDir := t.TempDir() | |
| 115 | + | ||
| 116 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 117 | + | file, err := fs.Create(testFile) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatalf("expected no error, got %v", err) | |
| 120 | + | } | |
| 121 | + | defer file.Close() | |
| 122 | + | ||
| 123 | + | n, err := file.Write([]byte("test content")) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatalf("expected no error, got %v", err) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if n != len("test content") { | |
| 129 | + | t.Errorf("expected to write 12 bytes, got %d", n) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Verify file exists | |
| 133 | + | if _, err := os.Stat(testFile); os.IsNotExist(err) { | |
| 134 | + | t.Error("expected file to exist") | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestLocalFileSystem_CreateTruncates(t *testing.T) { | |
| 139 | + | fs := NewLocalFileSystem() | |
| 140 | + | tmpDir := t.TempDir() | |
| 141 | + | ||
| 142 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 143 | + | ||
| 144 | + | // Create file with content | |
| 145 | + | if err := os.WriteFile(testFile, []byte("old content"), 0644); err != nil { | |
| 146 | + | t.Fatalf("failed to create test file: %v", err) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Create again should truncate | |
| 150 | + | file, err := fs.Create(testFile) | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatalf("expected no error, got %v", err) | |
| 153 | + | } | |
| 154 | + | file.Close() | |
| 155 | + | ||
| 156 | + | // Verify file is empty | |
| 157 | + | info, _ := os.Stat(testFile) | |
| 158 | + | if info.Size() != 0 { | |
| 159 | + | t.Errorf("expected file size 0, got %d", info.Size()) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestLocalFileSystem_Chmod(t *testing.T) { | |
| 164 | + | fs := NewLocalFileSystem() | |
| 165 | + | tmpDir := t.TempDir() | |
| 166 | + | ||
| 167 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 168 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |
| 169 | + | t.Fatalf("failed to create test file: %v", err) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | err := fs.Chmod(testFile, 0755) | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatalf("expected no error, got %v", err) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | info, _ := os.Stat(testFile) | |
| 178 | + | if info.Mode()&0755 != 0755 { | |
| 179 | + | t.Errorf("expected mode 0755, got %o", info.Mode()&0777) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestLocalFileSystem_MkdirAll(t *testing.T) { | |
| 184 | + | fs := NewLocalFileSystem() | |
| 185 | + | tmpDir := t.TempDir() | |
| 186 | + | ||
| 187 | + | path := filepath.Join(tmpDir, "a", "b", "c") | |
| 188 | + | err := fs.MkdirAll(path, 0755) | |
| 189 | + | if err != nil { | |
| 190 | + | t.Fatalf("expected no error, got %v", err) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | info, err := os.Stat(path) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatalf("expected directory to exist: %v", err) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | if !info.IsDir() { | |
| 199 | + | t.Error("expected directory") | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestLocalFileSystem_RemoveAll(t *testing.T) { | |
| 204 | + | fs := NewLocalFileSystem() | |
| 205 | + | tmpDir := t.TempDir() | |
| 206 | + | ||
| 207 | + | // Create directory with files | |
| 208 | + | testDir := filepath.Join(tmpDir, "test_dir") | |
| 209 | + | if err := os.MkdirAll(testDir, 0755); err != nil { | |
| 210 | + | t.Fatalf("failed to create test directory: %v", err) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | testFile := filepath.Join(testDir, "test.txt") | |
| 214 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |
| 215 | + | t.Fatalf("failed to create test file: %v", err) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | err := fs.RemoveAll(testDir) | |
| 219 | + | if err != nil { | |
| 220 | + | t.Fatalf("expected no error, got %v", err) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Verify directory was removed | |
| 224 | + | if _, err := os.Stat(testDir); !os.IsNotExist(err) { | |
| 225 | + | t.Error("expected directory to be removed") | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestLocalFileSystem_Exists_FileExists(t *testing.T) { | |
| 230 | + | fs := NewLocalFileSystem() | |
| 231 | + | tmpDir := t.TempDir() | |
| 232 | + | ||
| 233 | + | testFile := filepath.Join(tmpDir, "test.txt") | |
| 234 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |
| 235 | + | t.Fatalf("failed to create test file: %v", err) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | exists, err := fs.Exists(testFile) | |
| 239 | + | if err != nil { | |
| 240 | + | t.Fatalf("expected no error, got %v", err) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | if !exists { | |
| 244 | + | t.Error("expected file to exist") | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestLocalFileSystem_Exists_FileNotExists(t *testing.T) { | |
| 249 | + | fs := NewLocalFileSystem() | |
| 250 | + | ||
| 251 | + | exists, err := fs.Exists("/nonexistent/file.txt") | |
| 252 | + | if err != nil { | |
| 253 | + | t.Fatalf("expected no error, got %v", err) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | if exists { | |
| 257 | + | t.Error("expected file to not exist") | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestLocalFileSystem_Exists_DirectoryExists(t *testing.T) { | |
| 262 | + | fs := NewLocalFileSystem() | |
| 263 | + | tmpDir := t.TempDir() | |
| 264 | + | ||
| 265 | + | exists, err := fs.Exists(tmpDir) | |
| 266 | + | if err != nil { | |
| 267 | + | t.Fatalf("expected no error, got %v", err) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | if !exists { | |
| 271 | + | t.Error("expected directory to exist") | |
| 272 | + | } | |
| 273 | + | } | |
| @@ -0,0 +1,273 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestLocalFileSystem_Stat(t *testing.T) { | |||
| 10 | + | fs := NewLocalFileSystem() | |||
| 11 | + | tmpDir := t.TempDir() | |||
| 12 | + | ||||
| 13 | + | // Create a test file | |||
| 14 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 15 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |||
| 16 | + | t.Fatalf("failed to create test file: %v", err) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | info, err := fs.Stat(testFile) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("expected no error, got %v", err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | if info.Name() != "test.txt" { | |||
| 25 | + | t.Errorf("expected filename test.txt, got %s", info.Name()) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestLocalFileSystem_StatNotFound(t *testing.T) { | |||
| 30 | + | fs := NewLocalFileSystem() | |||
| 31 | + | ||||
| 32 | + | _, err := fs.Stat("/nonexistent/path/file.txt") | |||
| 33 | + | if err == nil { | |||
| 34 | + | t.Fatal("expected error for nonexistent file") | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestLocalFileSystem_ReadFile(t *testing.T) { | |||
| 39 | + | fs := NewLocalFileSystem() | |||
| 40 | + | tmpDir := t.TempDir() | |||
| 41 | + | ||||
| 42 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 43 | + | testContent := []byte("hello world") | |||
| 44 | + | if err := os.WriteFile(testFile, testContent, 0644); err != nil { | |||
| 45 | + | t.Fatalf("failed to create test file: %v", err) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | content, err := fs.ReadFile(testFile) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatalf("expected no error, got %v", err) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | if string(content) != "hello world" { | |||
| 54 | + | t.Errorf("expected 'hello world', got %s", string(content)) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestLocalFileSystem_ReadFileNotFound(t *testing.T) { | |||
| 59 | + | fs := NewLocalFileSystem() | |||
| 60 | + | ||||
| 61 | + | _, err := fs.ReadFile("/nonexistent/file.txt") | |||
| 62 | + | if err == nil { | |||
| 63 | + | t.Fatal("expected error for nonexistent file") | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestLocalFileSystem_WriteFile(t *testing.T) { | |||
| 68 | + | fs := NewLocalFileSystem() | |||
| 69 | + | tmpDir := t.TempDir() | |||
| 70 | + | ||||
| 71 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 72 | + | content := []byte("test content") | |||
| 73 | + | ||||
| 74 | + | err := fs.WriteFile(testFile, content, 0644) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatalf("expected no error, got %v", err) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Verify file was created | |||
| 80 | + | data, err := os.ReadFile(testFile) | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatalf("failed to read file: %v", err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | if string(data) != "test content" { | |||
| 86 | + | t.Errorf("expected 'test content', got %s", string(data)) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestLocalFileSystem_WriteFileOverwrite(t *testing.T) { | |||
| 91 | + | fs := NewLocalFileSystem() | |||
| 92 | + | tmpDir := t.TempDir() | |||
| 93 | + | ||||
| 94 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 95 | + | ||||
| 96 | + | // Write first time | |||
| 97 | + | if err := fs.WriteFile(testFile, []byte("first"), 0644); err != nil { | |||
| 98 | + | t.Fatalf("expected no error, got %v", err) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Overwrite | |||
| 102 | + | if err := fs.WriteFile(testFile, []byte("second"), 0644); err != nil { | |||
| 103 | + | t.Fatalf("expected no error, got %v", err) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | data, _ := os.ReadFile(testFile) | |||
| 107 | + | if string(data) != "second" { | |||
| 108 | + | t.Errorf("expected 'second', got %s", string(data)) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestLocalFileSystem_Create(t *testing.T) { | |||
| 113 | + | fs := NewLocalFileSystem() | |||
| 114 | + | tmpDir := t.TempDir() | |||
| 115 | + | ||||
| 116 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 117 | + | file, err := fs.Create(testFile) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatalf("expected no error, got %v", err) | |||
| 120 | + | } | |||
| 121 | + | defer file.Close() | |||
| 122 | + | ||||
| 123 | + | n, err := file.Write([]byte("test content")) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatalf("expected no error, got %v", err) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | if n != len("test content") { | |||
| 129 | + | t.Errorf("expected to write 12 bytes, got %d", n) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Verify file exists | |||
| 133 | + | if _, err := os.Stat(testFile); os.IsNotExist(err) { | |||
| 134 | + | t.Error("expected file to exist") | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestLocalFileSystem_CreateTruncates(t *testing.T) { | |||
| 139 | + | fs := NewLocalFileSystem() | |||
| 140 | + | tmpDir := t.TempDir() | |||
| 141 | + | ||||
| 142 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 143 | + | ||||
| 144 | + | // Create file with content | |||
| 145 | + | if err := os.WriteFile(testFile, []byte("old content"), 0644); err != nil { | |||
| 146 | + | t.Fatalf("failed to create test file: %v", err) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Create again should truncate | |||
| 150 | + | file, err := fs.Create(testFile) | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatalf("expected no error, got %v", err) | |||
| 153 | + | } | |||
| 154 | + | file.Close() | |||
| 155 | + | ||||
| 156 | + | // Verify file is empty | |||
| 157 | + | info, _ := os.Stat(testFile) | |||
| 158 | + | if info.Size() != 0 { | |||
| 159 | + | t.Errorf("expected file size 0, got %d", info.Size()) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestLocalFileSystem_Chmod(t *testing.T) { | |||
| 164 | + | fs := NewLocalFileSystem() | |||
| 165 | + | tmpDir := t.TempDir() | |||
| 166 | + | ||||
| 167 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 168 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |||
| 169 | + | t.Fatalf("failed to create test file: %v", err) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | err := fs.Chmod(testFile, 0755) | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatalf("expected no error, got %v", err) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | info, _ := os.Stat(testFile) | |||
| 178 | + | if info.Mode()&0755 != 0755 { | |||
| 179 | + | t.Errorf("expected mode 0755, got %o", info.Mode()&0777) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestLocalFileSystem_MkdirAll(t *testing.T) { | |||
| 184 | + | fs := NewLocalFileSystem() | |||
| 185 | + | tmpDir := t.TempDir() | |||
| 186 | + | ||||
| 187 | + | path := filepath.Join(tmpDir, "a", "b", "c") | |||
| 188 | + | err := fs.MkdirAll(path, 0755) | |||
| 189 | + | if err != nil { | |||
| 190 | + | t.Fatalf("expected no error, got %v", err) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | info, err := os.Stat(path) | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Fatalf("expected directory to exist: %v", err) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | if !info.IsDir() { | |||
| 199 | + | t.Error("expected directory") | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestLocalFileSystem_RemoveAll(t *testing.T) { | |||
| 204 | + | fs := NewLocalFileSystem() | |||
| 205 | + | tmpDir := t.TempDir() | |||
| 206 | + | ||||
| 207 | + | // Create directory with files | |||
| 208 | + | testDir := filepath.Join(tmpDir, "test_dir") | |||
| 209 | + | if err := os.MkdirAll(testDir, 0755); err != nil { | |||
| 210 | + | t.Fatalf("failed to create test directory: %v", err) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | testFile := filepath.Join(testDir, "test.txt") | |||
| 214 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |||
| 215 | + | t.Fatalf("failed to create test file: %v", err) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | err := fs.RemoveAll(testDir) | |||
| 219 | + | if err != nil { | |||
| 220 | + | t.Fatalf("expected no error, got %v", err) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // Verify directory was removed | |||
| 224 | + | if _, err := os.Stat(testDir); !os.IsNotExist(err) { | |||
| 225 | + | t.Error("expected directory to be removed") | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestLocalFileSystem_Exists_FileExists(t *testing.T) { | |||
| 230 | + | fs := NewLocalFileSystem() | |||
| 231 | + | tmpDir := t.TempDir() | |||
| 232 | + | ||||
| 233 | + | testFile := filepath.Join(tmpDir, "test.txt") | |||
| 234 | + | if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { | |||
| 235 | + | t.Fatalf("failed to create test file: %v", err) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | exists, err := fs.Exists(testFile) | |||
| 239 | + | if err != nil { | |||
| 240 | + | t.Fatalf("expected no error, got %v", err) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | if !exists { | |||
| 244 | + | t.Error("expected file to exist") | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func TestLocalFileSystem_Exists_FileNotExists(t *testing.T) { | |||
| 249 | + | fs := NewLocalFileSystem() | |||
| 250 | + | ||||
| 251 | + | exists, err := fs.Exists("/nonexistent/file.txt") | |||
| 252 | + | if err != nil { | |||
| 253 | + | t.Fatalf("expected no error, got %v", err) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | if exists { | |||
| 257 | + | t.Error("expected file to not exist") | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestLocalFileSystem_Exists_DirectoryExists(t *testing.T) { | |||
| 262 | + | fs := NewLocalFileSystem() | |||
| 263 | + | tmpDir := t.TempDir() | |||
| 264 | + | ||||
| 265 | + | exists, err := fs.Exists(tmpDir) | |||
| 266 | + | if err != nil { | |||
| 267 | + | t.Fatalf("expected no error, got %v", err) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | if !exists { | |||
| 271 | + | t.Error("expected directory to exist") | |||
| 272 | + | } | |||
| 273 | + | } | |||
A internal/infrastructure/http/client.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | nethttp "net/http" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // HTTPClient implements the HTTPClient port using the standard library | |
| 11 | + | type HTTPClient struct { | |
| 12 | + | client *nethttp.Client | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // NewHTTPClient creates a new HTTP client with a default timeout | |
| 16 | + | func NewHTTPClient() *HTTPClient { | |
| 17 | + | return &HTTPClient{ | |
| 18 | + | client: &nethttp.Client{ | |
| 19 | + | Timeout: 30 * time.Second, | |
| 20 | + | }, | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewHTTPClientWithTimeout creates a new HTTP client with a custom timeout | |
| 25 | + | func NewHTTPClientWithTimeout(timeout time.Duration) *HTTPClient { | |
| 26 | + | return &HTTPClient{ | |
| 27 | + | client: &nethttp.Client{ | |
| 28 | + | Timeout: timeout, | |
| 29 | + | }, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Get performs an HTTP GET request | |
| 34 | + | func (hc *HTTPClient) Get(url string) (HTTPResponse, error) { | |
| 35 | + | resp, err := hc.client.Get(url) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, fmt.Errorf("GET request failed: %w", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Read body into memory | |
| 41 | + | body, err := io.ReadAll(resp.Body) | |
| 42 | + | if err != nil { | |
| 43 | + | resp.Body.Close() | |
| 44 | + | return nil, fmt.Errorf("failed to read response body: %w", err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | return &httpResponse{ | |
| 48 | + | statusCode: resp.StatusCode, | |
| 49 | + | body: body, | |
| 50 | + | rawResp: resp, | |
| 51 | + | }, nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Post performs an HTTP POST request | |
| 55 | + | func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPResponse, error) { | |
| 56 | + | resp, err := hc.client.Post(url, contentType, nil) | |
| 57 | + | if err != nil { | |
| 58 | + | return nil, fmt.Errorf("POST request failed: %w", err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Read body into memory | |
| 62 | + | respBody, err := io.ReadAll(resp.Body) | |
| 63 | + | if err != nil { | |
| 64 | + | resp.Body.Close() | |
| 65 | + | return nil, fmt.Errorf("failed to read response body: %w", err) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | return &httpResponse{ | |
| 69 | + | statusCode: resp.StatusCode, | |
| 70 | + | body: respBody, | |
| 71 | + | rawResp: resp, | |
| 72 | + | }, nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // HTTPResponse interface for HTTP responses | |
| 76 | + | type HTTPResponse interface { | |
| 77 | + | StatusCode() int | |
| 78 | + | Body() []byte | |
| 79 | + | Close() error | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // httpResponse implements the Response port | |
| 83 | + | type httpResponse struct { | |
| 84 | + | statusCode int | |
| 85 | + | body []byte | |
| 86 | + | rawResp *nethttp.Response | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // StatusCode returns the HTTP status code | |
| 90 | + | func (hr *httpResponse) StatusCode() int { | |
| 91 | + | return hr.statusCode | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Body returns the response body | |
| 95 | + | func (hr *httpResponse) Body() []byte { | |
| 96 | + | return hr.body | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Close closes the response | |
| 100 | + | func (hr *httpResponse) Close() error { | |
| 101 | + | if hr.rawResp != nil { | |
| 102 | + | return hr.rawResp.Body.Close() | |
| 103 | + | } | |
| 104 | + | return nil | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | nethttp "net/http" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // HTTPClient implements the HTTPClient port using the standard library | |||
| 11 | + | type HTTPClient struct { | |||
| 12 | + | client *nethttp.Client | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // NewHTTPClient creates a new HTTP client with a default timeout | |||
| 16 | + | func NewHTTPClient() *HTTPClient { | |||
| 17 | + | return &HTTPClient{ | |||
| 18 | + | client: &nethttp.Client{ | |||
| 19 | + | Timeout: 30 * time.Second, | |||
| 20 | + | }, | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewHTTPClientWithTimeout creates a new HTTP client with a custom timeout | |||
| 25 | + | func NewHTTPClientWithTimeout(timeout time.Duration) *HTTPClient { | |||
| 26 | + | return &HTTPClient{ | |||
| 27 | + | client: &nethttp.Client{ | |||
| 28 | + | Timeout: timeout, | |||
| 29 | + | }, | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Get performs an HTTP GET request | |||
| 34 | + | func (hc *HTTPClient) Get(url string) (HTTPResponse, error) { | |||
| 35 | + | resp, err := hc.client.Get(url) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, fmt.Errorf("GET request failed: %w", err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Read body into memory | |||
| 41 | + | body, err := io.ReadAll(resp.Body) | |||
| 42 | + | if err != nil { | |||
| 43 | + | resp.Body.Close() | |||
| 44 | + | return nil, fmt.Errorf("failed to read response body: %w", err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | return &httpResponse{ | |||
| 48 | + | statusCode: resp.StatusCode, | |||
| 49 | + | body: body, | |||
| 50 | + | rawResp: resp, | |||
| 51 | + | }, nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Post performs an HTTP POST request | |||
| 55 | + | func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPResponse, error) { | |||
| 56 | + | resp, err := hc.client.Post(url, contentType, nil) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return nil, fmt.Errorf("POST request failed: %w", err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Read body into memory | |||
| 62 | + | respBody, err := io.ReadAll(resp.Body) | |||
| 63 | + | if err != nil { | |||
| 64 | + | resp.Body.Close() | |||
| 65 | + | return nil, fmt.Errorf("failed to read response body: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | return &httpResponse{ | |||
| 69 | + | statusCode: resp.StatusCode, | |||
| 70 | + | body: respBody, | |||
| 71 | + | rawResp: resp, | |||
| 72 | + | }, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // HTTPResponse interface for HTTP responses | |||
| 76 | + | type HTTPResponse interface { | |||
| 77 | + | StatusCode() int | |||
| 78 | + | Body() []byte | |||
| 79 | + | Close() error | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // httpResponse implements the Response port | |||
| 83 | + | type httpResponse struct { | |||
| 84 | + | statusCode int | |||
| 85 | + | body []byte | |||
| 86 | + | rawResp *nethttp.Response | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // StatusCode returns the HTTP status code | |||
| 90 | + | func (hr *httpResponse) StatusCode() int { | |||
| 91 | + | return hr.statusCode | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Body returns the response body | |||
| 95 | + | func (hr *httpResponse) Body() []byte { | |||
| 96 | + | return hr.body | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Close closes the response | |||
| 100 | + | func (hr *httpResponse) Close() error { | |||
| 101 | + | if hr.rawResp != nil { | |||
| 102 | + | return hr.rawResp.Body.Close() | |||
| 103 | + | } | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
A internal/infrastructure/http/client_test.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHTTPClient_NewHTTPClient(t *testing.T) { | |
| 12 | + | client := NewHTTPClient() | |
| 13 | + | if client == nil { | |
| 14 | + | t.Fatal("expected non-nil client") | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | if client.client == nil { | |
| 18 | + | t.Fatal("expected non-nil internal client") | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestHTTPClient_NewHTTPClientWithTimeout(t *testing.T) { | |
| 23 | + | timeout := 5 * time.Second | |
| 24 | + | client := NewHTTPClientWithTimeout(timeout) | |
| 25 | + | ||
| 26 | + | if client == nil { | |
| 27 | + | t.Fatal("expected non-nil client") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | if client.client.Timeout != timeout { | |
| 31 | + | t.Errorf("expected timeout %v, got %v", timeout, client.client.Timeout) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestHTTPClient_Get_Success(t *testing.T) { | |
| 36 | + | // Create a test server | |
| 37 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 38 | + | w.WriteHeader(200) | |
| 39 | + | w.Write([]byte("test response")) | |
| 40 | + | })) | |
| 41 | + | defer server.Close() | |
| 42 | + | ||
| 43 | + | client := NewHTTPClient() | |
| 44 | + | resp, err := client.Get(server.URL) | |
| 45 | + | defer resp.Close() | |
| 46 | + | ||
| 47 | + | if err != nil { | |
| 48 | + | t.Fatalf("expected no error, got %v", err) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | if resp.StatusCode() != 200 { | |
| 52 | + | t.Errorf("expected status 200, got %d", resp.StatusCode()) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if string(resp.Body()) != "test response" { | |
| 56 | + | t.Errorf("expected 'test response', got %s", string(resp.Body())) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestHTTPClient_Get_StatusCode(t *testing.T) { | |
| 61 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 62 | + | w.WriteHeader(404) | |
| 63 | + | })) | |
| 64 | + | defer server.Close() | |
| 65 | + | ||
| 66 | + | client := NewHTTPClient() | |
| 67 | + | resp, err := client.Get(server.URL) | |
| 68 | + | defer resp.Close() | |
| 69 | + | ||
| 70 | + | if err != nil { | |
| 71 | + | t.Fatalf("expected no error, got %v", err) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if resp.StatusCode() != 404 { | |
| 75 | + | t.Errorf("expected status 404, got %d", resp.StatusCode()) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestHTTPClient_Get_EmptyBody(t *testing.T) { | |
| 80 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 81 | + | w.WriteHeader(204) | |
| 82 | + | })) | |
| 83 | + | defer server.Close() | |
| 84 | + | ||
| 85 | + | client := NewHTTPClient() | |
| 86 | + | resp, err := client.Get(server.URL) | |
| 87 | + | defer resp.Close() | |
| 88 | + | ||
| 89 | + | if err != nil { | |
| 90 | + | t.Fatalf("expected no error, got %v", err) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | if len(resp.Body()) != 0 { | |
| 94 | + | t.Errorf("expected empty body, got %s", string(resp.Body())) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestHTTPClient_Get_LargeBody(t *testing.T) { | |
| 99 | + | largeBody := strings.Repeat("a", 10000) | |
| 100 | + | ||
| 101 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 102 | + | w.WriteHeader(200) | |
| 103 | + | w.Write([]byte(largeBody)) | |
| 104 | + | })) | |
| 105 | + | defer server.Close() | |
| 106 | + | ||
| 107 | + | client := NewHTTPClient() | |
| 108 | + | resp, err := client.Get(server.URL) | |
| 109 | + | defer resp.Close() | |
| 110 | + | ||
| 111 | + | if err != nil { | |
| 112 | + | t.Fatalf("expected no error, got %v", err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | if string(resp.Body()) != largeBody { | |
| 116 | + | t.Error("expected body to match large response") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestHTTPClient_Get_InvalidURL(t *testing.T) { | |
| 121 | + | client := NewHTTPClient() | |
| 122 | + | _, err := client.Get("http://nonexistent.invalid.domain.example/") | |
| 123 | + | ||
| 124 | + | if err == nil { | |
| 125 | + | t.Fatal("expected error for invalid URL") | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestHTTPClient_Post_Success(t *testing.T) { | |
| 130 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 131 | + | w.WriteHeader(201) | |
| 132 | + | w.Write([]byte("created")) | |
| 133 | + | })) | |
| 134 | + | defer server.Close() | |
| 135 | + | ||
| 136 | + | client := NewHTTPClient() | |
| 137 | + | resp, err := client.Post(server.URL, "application/json", []byte(`{"key":"value"}`)) | |
| 138 | + | defer resp.Close() | |
| 139 | + | ||
| 140 | + | if err != nil { | |
| 141 | + | t.Fatalf("expected no error, got %v", err) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | if resp.StatusCode() != 201 { | |
| 145 | + | t.Errorf("expected status 201, got %d", resp.StatusCode()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if string(resp.Body()) != "created" { | |
| 149 | + | t.Errorf("expected 'created', got %s", string(resp.Body())) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestHTTPClient_Post_InvalidURL(t *testing.T) { | |
| 154 | + | client := NewHTTPClient() | |
| 155 | + | _, err := client.Post("http://nonexistent.invalid.domain.example/", "application/json", []byte("test")) | |
| 156 | + | ||
| 157 | + | if err == nil { | |
| 158 | + | t.Fatal("expected error for invalid URL") | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestHTTPClient_Response_Close(t *testing.T) { | |
| 163 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 164 | + | w.WriteHeader(200) | |
| 165 | + | w.Write([]byte("test")) | |
| 166 | + | })) | |
| 167 | + | defer server.Close() | |
| 168 | + | ||
| 169 | + | client := NewHTTPClient() | |
| 170 | + | resp, _ := client.Get(server.URL) | |
| 171 | + | ||
| 172 | + | err := resp.Close() | |
| 173 | + | if err != nil { | |
| 174 | + | t.Errorf("expected no error closing response, got %v", err) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestHTTPClient_Get_Timeout(t *testing.T) { | |
| 179 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 180 | + | time.Sleep(2 * time.Second) | |
| 181 | + | w.WriteHeader(200) | |
| 182 | + | })) | |
| 183 | + | defer server.Close() | |
| 184 | + | ||
| 185 | + | client := NewHTTPClientWithTimeout(100 * time.Millisecond) | |
| 186 | + | _, err := client.Get(server.URL) | |
| 187 | + | ||
| 188 | + | if err == nil { | |
| 189 | + | t.Fatal("expected error due to timeout") | |
| 190 | + | } | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHTTPClient_NewHTTPClient(t *testing.T) { | |||
| 12 | + | client := NewHTTPClient() | |||
| 13 | + | if client == nil { | |||
| 14 | + | t.Fatal("expected non-nil client") | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | if client.client == nil { | |||
| 18 | + | t.Fatal("expected non-nil internal client") | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestHTTPClient_NewHTTPClientWithTimeout(t *testing.T) { | |||
| 23 | + | timeout := 5 * time.Second | |||
| 24 | + | client := NewHTTPClientWithTimeout(timeout) | |||
| 25 | + | ||||
| 26 | + | if client == nil { | |||
| 27 | + | t.Fatal("expected non-nil client") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | if client.client.Timeout != timeout { | |||
| 31 | + | t.Errorf("expected timeout %v, got %v", timeout, client.client.Timeout) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestHTTPClient_Get_Success(t *testing.T) { | |||
| 36 | + | // Create a test server | |||
| 37 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 38 | + | w.WriteHeader(200) | |||
| 39 | + | w.Write([]byte("test response")) | |||
| 40 | + | })) | |||
| 41 | + | defer server.Close() | |||
| 42 | + | ||||
| 43 | + | client := NewHTTPClient() | |||
| 44 | + | resp, err := client.Get(server.URL) | |||
| 45 | + | defer resp.Close() | |||
| 46 | + | ||||
| 47 | + | if err != nil { | |||
| 48 | + | t.Fatalf("expected no error, got %v", err) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | if resp.StatusCode() != 200 { | |||
| 52 | + | t.Errorf("expected status 200, got %d", resp.StatusCode()) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | if string(resp.Body()) != "test response" { | |||
| 56 | + | t.Errorf("expected 'test response', got %s", string(resp.Body())) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestHTTPClient_Get_StatusCode(t *testing.T) { | |||
| 61 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 62 | + | w.WriteHeader(404) | |||
| 63 | + | })) | |||
| 64 | + | defer server.Close() | |||
| 65 | + | ||||
| 66 | + | client := NewHTTPClient() | |||
| 67 | + | resp, err := client.Get(server.URL) | |||
| 68 | + | defer resp.Close() | |||
| 69 | + | ||||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatalf("expected no error, got %v", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if resp.StatusCode() != 404 { | |||
| 75 | + | t.Errorf("expected status 404, got %d", resp.StatusCode()) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestHTTPClient_Get_EmptyBody(t *testing.T) { | |||
| 80 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 81 | + | w.WriteHeader(204) | |||
| 82 | + | })) | |||
| 83 | + | defer server.Close() | |||
| 84 | + | ||||
| 85 | + | client := NewHTTPClient() | |||
| 86 | + | resp, err := client.Get(server.URL) | |||
| 87 | + | defer resp.Close() | |||
| 88 | + | ||||
| 89 | + | if err != nil { | |||
| 90 | + | t.Fatalf("expected no error, got %v", err) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | if len(resp.Body()) != 0 { | |||
| 94 | + | t.Errorf("expected empty body, got %s", string(resp.Body())) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestHTTPClient_Get_LargeBody(t *testing.T) { | |||
| 99 | + | largeBody := strings.Repeat("a", 10000) | |||
| 100 | + | ||||
| 101 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 102 | + | w.WriteHeader(200) | |||
| 103 | + | w.Write([]byte(largeBody)) | |||
| 104 | + | })) | |||
| 105 | + | defer server.Close() | |||
| 106 | + | ||||
| 107 | + | client := NewHTTPClient() | |||
| 108 | + | resp, err := client.Get(server.URL) | |||
| 109 | + | defer resp.Close() | |||
| 110 | + | ||||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatalf("expected no error, got %v", err) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | if string(resp.Body()) != largeBody { | |||
| 116 | + | t.Error("expected body to match large response") | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestHTTPClient_Get_InvalidURL(t *testing.T) { | |||
| 121 | + | client := NewHTTPClient() | |||
| 122 | + | _, err := client.Get("http://nonexistent.invalid.domain.example/") | |||
| 123 | + | ||||
| 124 | + | if err == nil { | |||
| 125 | + | t.Fatal("expected error for invalid URL") | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestHTTPClient_Post_Success(t *testing.T) { | |||
| 130 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 131 | + | w.WriteHeader(201) | |||
| 132 | + | w.Write([]byte("created")) | |||
| 133 | + | })) | |||
| 134 | + | defer server.Close() | |||
| 135 | + | ||||
| 136 | + | client := NewHTTPClient() | |||
| 137 | + | resp, err := client.Post(server.URL, "application/json", []byte(`{"key":"value"}`)) | |||
| 138 | + | defer resp.Close() | |||
| 139 | + | ||||
| 140 | + | if err != nil { | |||
| 141 | + | t.Fatalf("expected no error, got %v", err) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | if resp.StatusCode() != 201 { | |||
| 145 | + | t.Errorf("expected status 201, got %d", resp.StatusCode()) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | if string(resp.Body()) != "created" { | |||
| 149 | + | t.Errorf("expected 'created', got %s", string(resp.Body())) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestHTTPClient_Post_InvalidURL(t *testing.T) { | |||
| 154 | + | client := NewHTTPClient() | |||
| 155 | + | _, err := client.Post("http://nonexistent.invalid.domain.example/", "application/json", []byte("test")) | |||
| 156 | + | ||||
| 157 | + | if err == nil { | |||
| 158 | + | t.Fatal("expected error for invalid URL") | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestHTTPClient_Response_Close(t *testing.T) { | |||
| 163 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 164 | + | w.WriteHeader(200) | |||
| 165 | + | w.Write([]byte("test")) | |||
| 166 | + | })) | |||
| 167 | + | defer server.Close() | |||
| 168 | + | ||||
| 169 | + | client := NewHTTPClient() | |||
| 170 | + | resp, _ := client.Get(server.URL) | |||
| 171 | + | ||||
| 172 | + | err := resp.Close() | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Errorf("expected no error closing response, got %v", err) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestHTTPClient_Get_Timeout(t *testing.T) { | |||
| 179 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 180 | + | time.Sleep(2 * time.Second) | |||
| 181 | + | w.WriteHeader(200) | |||
| 182 | + | })) | |||
| 183 | + | defer server.Close() | |||
| 184 | + | ||||
| 185 | + | client := NewHTTPClientWithTimeout(100 * time.Millisecond) | |||
| 186 | + | _, err := client.Get(server.URL) | |||
| 187 | + | ||||
| 188 | + | if err == nil { | |||
| 189 | + | t.Fatal("expected error due to timeout") | |||
| 190 | + | } | |||
| 191 | + | } | |||
A internal/infrastructure/logger/slog_adapter.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package logger | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "log/slog" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // SlogAdapter implements the Logger port using Go's standard log/slog | |
| 11 | + | type SlogAdapter struct { | |
| 12 | + | logger *slog.Logger | |
| 13 | + | logPath string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewSlogAdapter creates a new slog-based logger | |
| 17 | + | // If logPath is empty, logs are written to stderr only | |
| 18 | + | func NewSlogAdapter(logPath string) (*SlogAdapter, error) { | |
| 19 | + | var logger *slog.Logger | |
| 20 | + | ||
| 21 | + | if logPath != "" { | |
| 22 | + | // Ensure parent directory exists | |
| 23 | + | if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { | |
| 24 | + | return nil, fmt.Errorf("failed to create log directory: %w", err) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Open log file for appending | |
| 28 | + | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |
| 29 | + | if err != nil { | |
| 30 | + | return nil, fmt.Errorf("failed to open log file: %w", err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Create slog handler with both stderr and file output | |
| 34 | + | handler := slog.NewTextHandler(file, &slog.HandlerOptions{ | |
| 35 | + | Level: slog.LevelDebug, | |
| 36 | + | }) | |
| 37 | + | logger = slog.New(handler) | |
| 38 | + | } else { | |
| 39 | + | // Log to stderr only | |
| 40 | + | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ | |
| 41 | + | Level: slog.LevelDebug, | |
| 42 | + | }) | |
| 43 | + | logger = slog.New(handler) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | return &SlogAdapter{ | |
| 47 | + | logger: logger, | |
| 48 | + | logPath: logPath, | |
| 49 | + | }, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Info logs an informational message | |
| 53 | + | func (sa *SlogAdapter) Info(msg string, keysAndValues ...interface{}) { | |
| 54 | + | sa.logger.Info(msg, keysAndValues...) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Warn logs a warning message | |
| 58 | + | func (sa *SlogAdapter) Warn(msg string, keysAndValues ...interface{}) { | |
| 59 | + | sa.logger.Warn(msg, keysAndValues...) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Error logs an error message | |
| 63 | + | func (sa *SlogAdapter) Error(msg string, keysAndValues ...interface{}) { | |
| 64 | + | sa.logger.Error(msg, keysAndValues...) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Debug logs a debug message | |
| 68 | + | func (sa *SlogAdapter) Debug(msg string, keysAndValues ...interface{}) { | |
| 69 | + | sa.logger.Debug(msg, keysAndValues...) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // LogPath returns the path to the log file | |
| 73 | + | func (sa *SlogAdapter) LogPath() string { | |
| 74 | + | return sa.logPath | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package logger | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "log/slog" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // SlogAdapter implements the Logger port using Go's standard log/slog | |||
| 11 | + | type SlogAdapter struct { | |||
| 12 | + | logger *slog.Logger | |||
| 13 | + | logPath string | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewSlogAdapter creates a new slog-based logger | |||
| 17 | + | // If logPath is empty, logs are written to stderr only | |||
| 18 | + | func NewSlogAdapter(logPath string) (*SlogAdapter, error) { | |||
| 19 | + | var logger *slog.Logger | |||
| 20 | + | ||||
| 21 | + | if logPath != "" { | |||
| 22 | + | // Ensure parent directory exists | |||
| 23 | + | if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { | |||
| 24 | + | return nil, fmt.Errorf("failed to create log directory: %w", err) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Open log file for appending | |||
| 28 | + | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return nil, fmt.Errorf("failed to open log file: %w", err) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Create slog handler with both stderr and file output | |||
| 34 | + | handler := slog.NewTextHandler(file, &slog.HandlerOptions{ | |||
| 35 | + | Level: slog.LevelDebug, | |||
| 36 | + | }) | |||
| 37 | + | logger = slog.New(handler) | |||
| 38 | + | } else { | |||
| 39 | + | // Log to stderr only | |||
| 40 | + | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ | |||
| 41 | + | Level: slog.LevelDebug, | |||
| 42 | + | }) | |||
| 43 | + | logger = slog.New(handler) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | return &SlogAdapter{ | |||
| 47 | + | logger: logger, | |||
| 48 | + | logPath: logPath, | |||
| 49 | + | }, nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Info logs an informational message | |||
| 53 | + | func (sa *SlogAdapter) Info(msg string, keysAndValues ...interface{}) { | |||
| 54 | + | sa.logger.Info(msg, keysAndValues...) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Warn logs a warning message | |||
| 58 | + | func (sa *SlogAdapter) Warn(msg string, keysAndValues ...interface{}) { | |||
| 59 | + | sa.logger.Warn(msg, keysAndValues...) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Error logs an error message | |||
| 63 | + | func (sa *SlogAdapter) Error(msg string, keysAndValues ...interface{}) { | |||
| 64 | + | sa.logger.Error(msg, keysAndValues...) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Debug logs a debug message | |||
| 68 | + | func (sa *SlogAdapter) Debug(msg string, keysAndValues ...interface{}) { | |||
| 69 | + | sa.logger.Debug(msg, keysAndValues...) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // LogPath returns the path to the log file | |||
| 73 | + | func (sa *SlogAdapter) LogPath() string { | |||
| 74 | + | return sa.logPath | |||
| 75 | + | } | |||
A internal/infrastructure/logger/slog_adapter_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package logger | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSlogAdapter_NewSlogAdapterWithoutFile(t *testing.T) { | |
| 10 | + | logger, err := NewSlogAdapter("") | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatalf("expected no error, got %v", err) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if logger == nil { | |
| 16 | + | t.Fatal("expected non-nil logger") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | if logger.LogPath() != "" { | |
| 20 | + | t.Errorf("expected empty log path, got %s", logger.LogPath()) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestSlogAdapter_NewSlogAdapterWithFile(t *testing.T) { | |
| 25 | + | tmpDir := t.TempDir() | |
| 26 | + | logPath := filepath.Join(tmpDir, "test.log") | |
| 27 | + | ||
| 28 | + | logger, err := NewSlogAdapter(logPath) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatalf("expected no error, got %v", err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | if logger == nil { | |
| 34 | + | t.Fatal("expected non-nil logger") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | if logger.LogPath() != logPath { | |
| 38 | + | t.Errorf("expected log path %s, got %s", logPath, logger.LogPath()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Verify file was created | |
| 42 | + | if _, err := os.Stat(logPath); os.IsNotExist(err) { | |
| 43 | + | t.Error("expected log file to be created") | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestSlogAdapter_NewSlogAdapterCreatesDirs(t *testing.T) { | |
| 48 | + | tmpDir := t.TempDir() | |
| 49 | + | logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log") | |
| 50 | + | ||
| 51 | + | logger, err := NewSlogAdapter(logPath) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatalf("expected no error, got %v", err) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | if logger == nil { | |
| 57 | + | t.Fatal("expected non-nil logger") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Verify file was created with parent directories | |
| 61 | + | if _, err := os.Stat(logPath); os.IsNotExist(err) { | |
| 62 | + | t.Error("expected log file to be created with parent directories") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestSlogAdapter_Info(t *testing.T) { | |
| 67 | + | tmpDir := t.TempDir() | |
| 68 | + | logPath := filepath.Join(tmpDir, "test.log") | |
| 69 | + | ||
| 70 | + | logger, err := NewSlogAdapter(logPath) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("expected no error, got %v", err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Should not panic | |
| 76 | + | logger.Info("test message", "key", "value") | |
| 77 | + | ||
| 78 | + | // Verify content was written | |
| 79 | + | content, err := os.ReadFile(logPath) | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatalf("failed to read log file: %v", err) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if len(content) == 0 { | |
| 85 | + | t.Error("expected log content") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestSlogAdapter_Warn(t *testing.T) { | |
| 90 | + | tmpDir := t.TempDir() | |
| 91 | + | logPath := filepath.Join(tmpDir, "test.log") | |
| 92 | + | ||
| 93 | + | logger, err := NewSlogAdapter(logPath) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatalf("expected no error, got %v", err) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Should not panic | |
| 99 | + | logger.Warn("warning message", "warn_key", "warn_value") | |
| 100 | + | ||
| 101 | + | content, err := os.ReadFile(logPath) | |
| 102 | + | if err != nil { | |
| 103 | + | t.Fatalf("failed to read log file: %v", err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if len(content) == 0 { | |
| 107 | + | t.Error("expected log content") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestSlogAdapter_Error(t *testing.T) { | |
| 112 | + | tmpDir := t.TempDir() | |
| 113 | + | logPath := filepath.Join(tmpDir, "test.log") | |
| 114 | + | ||
| 115 | + | logger, err := NewSlogAdapter(logPath) | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatalf("expected no error, got %v", err) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Should not panic | |
| 121 | + | logger.Error("error message", "error_key", "error_value") | |
| 122 | + | ||
| 123 | + | content, err := os.ReadFile(logPath) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatalf("failed to read log file: %v", err) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if len(content) == 0 { | |
| 129 | + | t.Error("expected log content") | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestSlogAdapter_Debug(t *testing.T) { | |
| 134 | + | tmpDir := t.TempDir() | |
| 135 | + | logPath := filepath.Join(tmpDir, "test.log") | |
| 136 | + | ||
| 137 | + | logger, err := NewSlogAdapter(logPath) | |
| 138 | + | if err != nil { | |
| 139 | + | t.Fatalf("expected no error, got %v", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Should not panic | |
| 143 | + | logger.Debug("debug message", "debug_key", "debug_value") | |
| 144 | + | ||
| 145 | + | content, err := os.ReadFile(logPath) | |
| 146 | + | if err != nil { | |
| 147 | + | t.Fatalf("failed to read log file: %v", err) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | if len(content) == 0 { | |
| 151 | + | t.Error("expected log content") | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestSlogAdapter_MultipleLogCalls(t *testing.T) { | |
| 156 | + | tmpDir := t.TempDir() | |
| 157 | + | logPath := filepath.Join(tmpDir, "test.log") | |
| 158 | + | ||
| 159 | + | logger, err := NewSlogAdapter(logPath) | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatalf("expected no error, got %v", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | logger.Info("first message") | |
| 165 | + | logger.Warn("second message") | |
| 166 | + | logger.Error("third message") | |
| 167 | + | logger.Debug("fourth message") | |
| 168 | + | ||
| 169 | + | content, err := os.ReadFile(logPath) | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatalf("failed to read log file: %v", err) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // All messages should be present | |
| 175 | + | if len(content) == 0 { | |
| 176 | + | t.Error("expected log content") | |
| 177 | + | } | |
| 178 | + | } | |
| @@ -0,0 +1,178 @@ | |||||
| 1 | + | package logger | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSlogAdapter_NewSlogAdapterWithoutFile(t *testing.T) { | |||
| 10 | + | logger, err := NewSlogAdapter("") | |||
| 11 | + | if err != nil { | |||
| 12 | + | t.Fatalf("expected no error, got %v", err) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | if logger == nil { | |||
| 16 | + | t.Fatal("expected non-nil logger") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | if logger.LogPath() != "" { | |||
| 20 | + | t.Errorf("expected empty log path, got %s", logger.LogPath()) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestSlogAdapter_NewSlogAdapterWithFile(t *testing.T) { | |||
| 25 | + | tmpDir := t.TempDir() | |||
| 26 | + | logPath := filepath.Join(tmpDir, "test.log") | |||
| 27 | + | ||||
| 28 | + | logger, err := NewSlogAdapter(logPath) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatalf("expected no error, got %v", err) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | if logger == nil { | |||
| 34 | + | t.Fatal("expected non-nil logger") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | if logger.LogPath() != logPath { | |||
| 38 | + | t.Errorf("expected log path %s, got %s", logPath, logger.LogPath()) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Verify file was created | |||
| 42 | + | if _, err := os.Stat(logPath); os.IsNotExist(err) { | |||
| 43 | + | t.Error("expected log file to be created") | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestSlogAdapter_NewSlogAdapterCreatesDirs(t *testing.T) { | |||
| 48 | + | tmpDir := t.TempDir() | |||
| 49 | + | logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log") | |||
| 50 | + | ||||
| 51 | + | logger, err := NewSlogAdapter(logPath) | |||
| 52 | + | if err != nil { | |||
| 53 | + | t.Fatalf("expected no error, got %v", err) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | if logger == nil { | |||
| 57 | + | t.Fatal("expected non-nil logger") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Verify file was created with parent directories | |||
| 61 | + | if _, err := os.Stat(logPath); os.IsNotExist(err) { | |||
| 62 | + | t.Error("expected log file to be created with parent directories") | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestSlogAdapter_Info(t *testing.T) { | |||
| 67 | + | tmpDir := t.TempDir() | |||
| 68 | + | logPath := filepath.Join(tmpDir, "test.log") | |||
| 69 | + | ||||
| 70 | + | logger, err := NewSlogAdapter(logPath) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("expected no error, got %v", err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Should not panic | |||
| 76 | + | logger.Info("test message", "key", "value") | |||
| 77 | + | ||||
| 78 | + | // Verify content was written | |||
| 79 | + | content, err := os.ReadFile(logPath) | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatalf("failed to read log file: %v", err) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if len(content) == 0 { | |||
| 85 | + | t.Error("expected log content") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestSlogAdapter_Warn(t *testing.T) { | |||
| 90 | + | tmpDir := t.TempDir() | |||
| 91 | + | logPath := filepath.Join(tmpDir, "test.log") | |||
| 92 | + | ||||
| 93 | + | logger, err := NewSlogAdapter(logPath) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatalf("expected no error, got %v", err) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Should not panic | |||
| 99 | + | logger.Warn("warning message", "warn_key", "warn_value") | |||
| 100 | + | ||||
| 101 | + | content, err := os.ReadFile(logPath) | |||
| 102 | + | if err != nil { | |||
| 103 | + | t.Fatalf("failed to read log file: %v", err) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if len(content) == 0 { | |||
| 107 | + | t.Error("expected log content") | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestSlogAdapter_Error(t *testing.T) { | |||
| 112 | + | tmpDir := t.TempDir() | |||
| 113 | + | logPath := filepath.Join(tmpDir, "test.log") | |||
| 114 | + | ||||
| 115 | + | logger, err := NewSlogAdapter(logPath) | |||
| 116 | + | if err != nil { | |||
| 117 | + | t.Fatalf("expected no error, got %v", err) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Should not panic | |||
| 121 | + | logger.Error("error message", "error_key", "error_value") | |||
| 122 | + | ||||
| 123 | + | content, err := os.ReadFile(logPath) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatalf("failed to read log file: %v", err) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | if len(content) == 0 { | |||
| 129 | + | t.Error("expected log content") | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestSlogAdapter_Debug(t *testing.T) { | |||
| 134 | + | tmpDir := t.TempDir() | |||
| 135 | + | logPath := filepath.Join(tmpDir, "test.log") | |||
| 136 | + | ||||
| 137 | + | logger, err := NewSlogAdapter(logPath) | |||
| 138 | + | if err != nil { | |||
| 139 | + | t.Fatalf("expected no error, got %v", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Should not panic | |||
| 143 | + | logger.Debug("debug message", "debug_key", "debug_value") | |||
| 144 | + | ||||
| 145 | + | content, err := os.ReadFile(logPath) | |||
| 146 | + | if err != nil { | |||
| 147 | + | t.Fatalf("failed to read log file: %v", err) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | if len(content) == 0 { | |||
| 151 | + | t.Error("expected log content") | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestSlogAdapter_MultipleLogCalls(t *testing.T) { | |||
| 156 | + | tmpDir := t.TempDir() | |||
| 157 | + | logPath := filepath.Join(tmpDir, "test.log") | |||
| 158 | + | ||||
| 159 | + | logger, err := NewSlogAdapter(logPath) | |||
| 160 | + | if err != nil { | |||
| 161 | + | t.Fatalf("expected no error, got %v", err) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | logger.Info("first message") | |||
| 165 | + | logger.Warn("second message") | |||
| 166 | + | logger.Error("third message") | |||
| 167 | + | logger.Debug("fourth message") | |||
| 168 | + | ||||
| 169 | + | content, err := os.ReadFile(logPath) | |||
| 170 | + | if err != nil { | |||
| 171 | + | t.Fatalf("failed to read log file: %v", err) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // All messages should be present | |||
| 175 | + | if len(content) == 0 { | |||
| 176 | + | t.Error("expected log content") | |||
| 177 | + | } | |||
| 178 | + | } | |||
A internal/infrastructure/persistence/file_repository.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package persistence | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // FileRepository implements the InstallationRepository port using filesystem storage | |
| 11 | + | type FileRepository struct { | |
| 12 | + | basePath string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // NewFileRepository creates a new file-based repository | |
| 16 | + | // basePath is the directory where installation state files will be stored | |
| 17 | + | func NewFileRepository(basePath string) (*FileRepository, error) { | |
| 18 | + | // Ensure base path exists | |
| 19 | + | if err := os.MkdirAll(basePath, 0755); err != nil { | |
| 20 | + | return nil, fmt.Errorf("failed to create repository base path: %w", err) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | return &FileRepository{ | |
| 24 | + | basePath: basePath, | |
| 25 | + | }, nil | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Save persists the installation state to a file | |
| 29 | + | func (fr *FileRepository) Save(ctx context.Context, installationID string, state string) error { | |
| 30 | + | select { | |
| 31 | + | case <-ctx.Done(): | |
| 32 | + | return ctx.Err() | |
| 33 | + | default: | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | filePath := filepath.Join(fr.basePath, installationID+".state") | |
| 37 | + | ||
| 38 | + | // Write state to file | |
| 39 | + | if err := os.WriteFile(filePath, []byte(state), 0644); err != nil { | |
| 40 | + | return fmt.Errorf("failed to save installation state: %w", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Load retrieves the installation state from a file | |
| 47 | + | func (fr *FileRepository) Load(ctx context.Context, installationID string) (string, error) { | |
| 48 | + | select { | |
| 49 | + | case <-ctx.Done(): | |
| 50 | + | return "", ctx.Err() | |
| 51 | + | default: | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | filePath := filepath.Join(fr.basePath, installationID+".state") | |
| 55 | + | ||
| 56 | + | data, err := os.ReadFile(filePath) | |
| 57 | + | if err != nil { | |
| 58 | + | if os.IsNotExist(err) { | |
| 59 | + | return "", fmt.Errorf("installation state not found: %w", err) | |
| 60 | + | } | |
| 61 | + | return "", fmt.Errorf("failed to load installation state: %w", err) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | return string(data), nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Exists checks if an installation state exists | |
| 68 | + | func (fr *FileRepository) Exists(ctx context.Context, installationID string) (bool, error) { | |
| 69 | + | select { | |
| 70 | + | case <-ctx.Done(): | |
| 71 | + | return false, ctx.Err() | |
| 72 | + | default: | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | filePath := filepath.Join(fr.basePath, installationID+".state") | |
| 76 | + | ||
| 77 | + | _, err := os.Stat(filePath) | |
| 78 | + | if err == nil { | |
| 79 | + | return true, nil | |
| 80 | + | } | |
| 81 | + | if os.IsNotExist(err) { | |
| 82 | + | return false, nil | |
| 83 | + | } | |
| 84 | + | return false, fmt.Errorf("failed to check installation existence: %w", err) | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package persistence | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // FileRepository implements the InstallationRepository port using filesystem storage | |||
| 11 | + | type FileRepository struct { | |||
| 12 | + | basePath string | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // NewFileRepository creates a new file-based repository | |||
| 16 | + | // basePath is the directory where installation state files will be stored | |||
| 17 | + | func NewFileRepository(basePath string) (*FileRepository, error) { | |||
| 18 | + | // Ensure base path exists | |||
| 19 | + | if err := os.MkdirAll(basePath, 0755); err != nil { | |||
| 20 | + | return nil, fmt.Errorf("failed to create repository base path: %w", err) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | return &FileRepository{ | |||
| 24 | + | basePath: basePath, | |||
| 25 | + | }, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Save persists the installation state to a file | |||
| 29 | + | func (fr *FileRepository) Save(ctx context.Context, installationID string, state string) error { | |||
| 30 | + | select { | |||
| 31 | + | case <-ctx.Done(): | |||
| 32 | + | return ctx.Err() | |||
| 33 | + | default: | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | filePath := filepath.Join(fr.basePath, installationID+".state") | |||
| 37 | + | ||||
| 38 | + | // Write state to file | |||
| 39 | + | if err := os.WriteFile(filePath, []byte(state), 0644); err != nil { | |||
| 40 | + | return fmt.Errorf("failed to save installation state: %w", err) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | return nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Load retrieves the installation state from a file | |||
| 47 | + | func (fr *FileRepository) Load(ctx context.Context, installationID string) (string, error) { | |||
| 48 | + | select { | |||
| 49 | + | case <-ctx.Done(): | |||
| 50 | + | return "", ctx.Err() | |||
| 51 | + | default: | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | filePath := filepath.Join(fr.basePath, installationID+".state") | |||
| 55 | + | ||||
| 56 | + | data, err := os.ReadFile(filePath) | |||
| 57 | + | if err != nil { | |||
| 58 | + | if os.IsNotExist(err) { | |||
| 59 | + | return "", fmt.Errorf("installation state not found: %w", err) | |||
| 60 | + | } | |||
| 61 | + | return "", fmt.Errorf("failed to load installation state: %w", err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | return string(data), nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Exists checks if an installation state exists | |||
| 68 | + | func (fr *FileRepository) Exists(ctx context.Context, installationID string) (bool, error) { | |||
| 69 | + | select { | |||
| 70 | + | case <-ctx.Done(): | |||
| 71 | + | return false, ctx.Err() | |||
| 72 | + | default: | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | filePath := filepath.Join(fr.basePath, installationID+".state") | |||
| 76 | + | ||||
| 77 | + | _, err := os.Stat(filePath) | |||
| 78 | + | if err == nil { | |||
| 79 | + | return true, nil | |||
| 80 | + | } | |||
| 81 | + | if os.IsNotExist(err) { | |||
| 82 | + | return false, nil | |||
| 83 | + | } | |||
| 84 | + | return false, fmt.Errorf("failed to check installation existence: %w", err) | |||
| 85 | + | } | |||
A internal/infrastructure/persistence/file_repository_test.go +263 -0
| @@ -0,0 +1,263 @@ | |||
| 1 | + | package persistence | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestFileRepository_NewFileRepository(t *testing.T) { | |
| 12 | + | tmpDir := t.TempDir() | |
| 13 | + | ||
| 14 | + | repo, err := NewFileRepository(tmpDir) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatalf("expected no error, got %v", err) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | if repo == nil { | |
| 20 | + | t.Fatal("expected non-nil repository") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | if repo.basePath != tmpDir { | |
| 24 | + | t.Errorf("expected basePath %s, got %s", tmpDir, repo.basePath) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestFileRepository_NewFileRepository_CreatesDirectory(t *testing.T) { | |
| 29 | + | tmpDir := t.TempDir() | |
| 30 | + | nestedPath := filepath.Join(tmpDir, "nested", "path") | |
| 31 | + | ||
| 32 | + | repo, err := NewFileRepository(nestedPath) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatalf("expected no error, got %v", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | if repo == nil { | |
| 38 | + | t.Fatal("expected non-nil repository") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Verify directory was created | |
| 42 | + | info, err := os.Stat(nestedPath) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatalf("expected directory to exist: %v", err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | if !info.IsDir() { | |
| 48 | + | t.Error("expected directory") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestFileRepository_Save_Success(t *testing.T) { | |
| 53 | + | tmpDir := t.TempDir() | |
| 54 | + | repo, _ := NewFileRepository(tmpDir) | |
| 55 | + | ||
| 56 | + | ctx := context.Background() | |
| 57 | + | state := "test installation state" | |
| 58 | + | ||
| 59 | + | err := repo.Save(ctx, "install-001", state) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatalf("expected no error, got %v", err) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Verify file was created | |
| 65 | + | filePath := filepath.Join(tmpDir, "install-001.state") | |
| 66 | + | if _, err := os.Stat(filePath); os.IsNotExist(err) { | |
| 67 | + | t.Error("expected state file to be created") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestFileRepository_Save_Content(t *testing.T) { | |
| 72 | + | tmpDir := t.TempDir() | |
| 73 | + | repo, _ := NewFileRepository(tmpDir) | |
| 74 | + | ||
| 75 | + | ctx := context.Background() | |
| 76 | + | state := "test installation state with content" | |
| 77 | + | ||
| 78 | + | repo.Save(ctx, "install-001", state) | |
| 79 | + | ||
| 80 | + | // Verify file content | |
| 81 | + | filePath := filepath.Join(tmpDir, "install-001.state") | |
| 82 | + | content, _ := os.ReadFile(filePath) | |
| 83 | + | ||
| 84 | + | if string(content) != state { | |
| 85 | + | t.Errorf("expected %s, got %s", state, string(content)) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestFileRepository_Save_Overwrite(t *testing.T) { | |
| 90 | + | tmpDir := t.TempDir() | |
| 91 | + | repo, _ := NewFileRepository(tmpDir) | |
| 92 | + | ||
| 93 | + | ctx := context.Background() | |
| 94 | + | ||
| 95 | + | // Save first state | |
| 96 | + | repo.Save(ctx, "install-001", "first state") | |
| 97 | + | ||
| 98 | + | // Overwrite with second state | |
| 99 | + | secondState := "second state" | |
| 100 | + | repo.Save(ctx, "install-001", secondState) | |
| 101 | + | ||
| 102 | + | // Verify file content is updated | |
| 103 | + | filePath := filepath.Join(tmpDir, "install-001.state") | |
| 104 | + | content, _ := os.ReadFile(filePath) | |
| 105 | + | ||
| 106 | + | if string(content) != secondState { | |
| 107 | + | t.Errorf("expected %s, got %s", secondState, string(content)) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestFileRepository_Load_Success(t *testing.T) { | |
| 112 | + | tmpDir := t.TempDir() | |
| 113 | + | repo, _ := NewFileRepository(tmpDir) | |
| 114 | + | ||
| 115 | + | // Save first | |
| 116 | + | ctx := context.Background() | |
| 117 | + | state := "test installation state" | |
| 118 | + | repo.Save(ctx, "install-001", state) | |
| 119 | + | ||
| 120 | + | // Load | |
| 121 | + | loaded, err := repo.Load(ctx, "install-001") | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatalf("expected no error, got %v", err) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | if loaded != state { | |
| 127 | + | t.Errorf("expected %s, got %s", state, loaded) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestFileRepository_Load_NotFound(t *testing.T) { | |
| 132 | + | tmpDir := t.TempDir() | |
| 133 | + | repo, _ := NewFileRepository(tmpDir) | |
| 134 | + | ||
| 135 | + | ctx := context.Background() | |
| 136 | + | _, err := repo.Load(ctx, "nonexistent") | |
| 137 | + | ||
| 138 | + | if err == nil { | |
| 139 | + | t.Fatal("expected error for nonexistent installation") | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestFileRepository_Exists_True(t *testing.T) { | |
| 144 | + | tmpDir := t.TempDir() | |
| 145 | + | repo, _ := NewFileRepository(tmpDir) | |
| 146 | + | ||
| 147 | + | // Save first | |
| 148 | + | ctx := context.Background() | |
| 149 | + | repo.Save(ctx, "install-001", "state") | |
| 150 | + | ||
| 151 | + | // Check exists | |
| 152 | + | exists, err := repo.Exists(ctx, "install-001") | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatalf("expected no error, got %v", err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | if !exists { | |
| 158 | + | t.Error("expected installation to exist") | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestFileRepository_Exists_False(t *testing.T) { | |
| 163 | + | tmpDir := t.TempDir() | |
| 164 | + | repo, _ := NewFileRepository(tmpDir) | |
| 165 | + | ||
| 166 | + | ctx := context.Background() | |
| 167 | + | exists, err := repo.Exists(ctx, "nonexistent") | |
| 168 | + | ||
| 169 | + | if err != nil { | |
| 170 | + | t.Fatalf("expected no error, got %v", err) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | if exists { | |
| 174 | + | t.Error("expected installation to not exist") | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestFileRepository_Save_ContextCancelled(t *testing.T) { | |
| 179 | + | tmpDir := t.TempDir() | |
| 180 | + | repo, _ := NewFileRepository(tmpDir) | |
| 181 | + | ||
| 182 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 183 | + | cancel() | |
| 184 | + | ||
| 185 | + | err := repo.Save(ctx, "install-001", "state") | |
| 186 | + | if err == nil { | |
| 187 | + | t.Fatal("expected error due to context cancellation") | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestFileRepository_Load_ContextCancelled(t *testing.T) { | |
| 192 | + | tmpDir := t.TempDir() | |
| 193 | + | repo, _ := NewFileRepository(tmpDir) | |
| 194 | + | ||
| 195 | + | // Save first | |
| 196 | + | ctx := context.Background() | |
| 197 | + | repo.Save(ctx, "install-001", "state") | |
| 198 | + | ||
| 199 | + | // Load with cancelled context | |
| 200 | + | ctx2, cancel := context.WithCancel(context.Background()) | |
| 201 | + | cancel() | |
| 202 | + | ||
| 203 | + | _, err := repo.Load(ctx2, "install-001") | |
| 204 | + | if err == nil { | |
| 205 | + | t.Fatal("expected error due to context cancellation") | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestFileRepository_Exists_ContextCancelled(t *testing.T) { | |
| 210 | + | tmpDir := t.TempDir() | |
| 211 | + | repo, _ := NewFileRepository(tmpDir) | |
| 212 | + | ||
| 213 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 214 | + | cancel() | |
| 215 | + | ||
| 216 | + | _, err := repo.Exists(ctx, "install-001") | |
| 217 | + | if err == nil { | |
| 218 | + | t.Fatal("expected error due to context cancellation") | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestFileRepository_MultipleInstallations(t *testing.T) { | |
| 223 | + | tmpDir := t.TempDir() | |
| 224 | + | repo, _ := NewFileRepository(tmpDir) | |
| 225 | + | ||
| 226 | + | ctx := context.Background() | |
| 227 | + | ||
| 228 | + | // Save multiple installations | |
| 229 | + | repo.Save(ctx, "install-001", "state 1") | |
| 230 | + | repo.Save(ctx, "install-002", "state 2") | |
| 231 | + | repo.Save(ctx, "install-003", "state 3") | |
| 232 | + | ||
| 233 | + | // Load each one | |
| 234 | + | state1, _ := repo.Load(ctx, "install-001") | |
| 235 | + | state2, _ := repo.Load(ctx, "install-002") | |
| 236 | + | state3, _ := repo.Load(ctx, "install-003") | |
| 237 | + | ||
| 238 | + | if state1 != "state 1" { | |
| 239 | + | t.Errorf("expected 'state 1', got %s", state1) | |
| 240 | + | } | |
| 241 | + | if state2 != "state 2" { | |
| 242 | + | t.Errorf("expected 'state 2', got %s", state2) | |
| 243 | + | } | |
| 244 | + | if state3 != "state 3" { | |
| 245 | + | t.Errorf("expected 'state 3', got %s", state3) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestFileRepository_Save_ContextTimeout(t *testing.T) { | |
| 250 | + | tmpDir := t.TempDir() | |
| 251 | + | repo, _ := NewFileRepository(tmpDir) | |
| 252 | + | ||
| 253 | + | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) | |
| 254 | + | defer cancel() | |
| 255 | + | ||
| 256 | + | // Give it time to timeout | |
| 257 | + | time.Sleep(10 * time.Millisecond) | |
| 258 | + | ||
| 259 | + | err := repo.Save(ctx, "install-001", "state") | |
| 260 | + | if err == nil { | |
| 261 | + | t.Fatal("expected error due to context timeout") | |
| 262 | + | } | |
| 263 | + | } | |
| @@ -0,0 +1,263 @@ | |||||
| 1 | + | package persistence | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestFileRepository_NewFileRepository(t *testing.T) { | |||
| 12 | + | tmpDir := t.TempDir() | |||
| 13 | + | ||||
| 14 | + | repo, err := NewFileRepository(tmpDir) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatalf("expected no error, got %v", err) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | if repo == nil { | |||
| 20 | + | t.Fatal("expected non-nil repository") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | if repo.basePath != tmpDir { | |||
| 24 | + | t.Errorf("expected basePath %s, got %s", tmpDir, repo.basePath) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestFileRepository_NewFileRepository_CreatesDirectory(t *testing.T) { | |||
| 29 | + | tmpDir := t.TempDir() | |||
| 30 | + | nestedPath := filepath.Join(tmpDir, "nested", "path") | |||
| 31 | + | ||||
| 32 | + | repo, err := NewFileRepository(nestedPath) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatalf("expected no error, got %v", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | if repo == nil { | |||
| 38 | + | t.Fatal("expected non-nil repository") | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Verify directory was created | |||
| 42 | + | info, err := os.Stat(nestedPath) | |||
| 43 | + | if err != nil { | |||
| 44 | + | t.Fatalf("expected directory to exist: %v", err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | if !info.IsDir() { | |||
| 48 | + | t.Error("expected directory") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestFileRepository_Save_Success(t *testing.T) { | |||
| 53 | + | tmpDir := t.TempDir() | |||
| 54 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 55 | + | ||||
| 56 | + | ctx := context.Background() | |||
| 57 | + | state := "test installation state" | |||
| 58 | + | ||||
| 59 | + | err := repo.Save(ctx, "install-001", state) | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Fatalf("expected no error, got %v", err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Verify file was created | |||
| 65 | + | filePath := filepath.Join(tmpDir, "install-001.state") | |||
| 66 | + | if _, err := os.Stat(filePath); os.IsNotExist(err) { | |||
| 67 | + | t.Error("expected state file to be created") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestFileRepository_Save_Content(t *testing.T) { | |||
| 72 | + | tmpDir := t.TempDir() | |||
| 73 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 74 | + | ||||
| 75 | + | ctx := context.Background() | |||
| 76 | + | state := "test installation state with content" | |||
| 77 | + | ||||
| 78 | + | repo.Save(ctx, "install-001", state) | |||
| 79 | + | ||||
| 80 | + | // Verify file content | |||
| 81 | + | filePath := filepath.Join(tmpDir, "install-001.state") | |||
| 82 | + | content, _ := os.ReadFile(filePath) | |||
| 83 | + | ||||
| 84 | + | if string(content) != state { | |||
| 85 | + | t.Errorf("expected %s, got %s", state, string(content)) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestFileRepository_Save_Overwrite(t *testing.T) { | |||
| 90 | + | tmpDir := t.TempDir() | |||
| 91 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 92 | + | ||||
| 93 | + | ctx := context.Background() | |||
| 94 | + | ||||
| 95 | + | // Save first state | |||
| 96 | + | repo.Save(ctx, "install-001", "first state") | |||
| 97 | + | ||||
| 98 | + | // Overwrite with second state | |||
| 99 | + | secondState := "second state" | |||
| 100 | + | repo.Save(ctx, "install-001", secondState) | |||
| 101 | + | ||||
| 102 | + | // Verify file content is updated | |||
| 103 | + | filePath := filepath.Join(tmpDir, "install-001.state") | |||
| 104 | + | content, _ := os.ReadFile(filePath) | |||
| 105 | + | ||||
| 106 | + | if string(content) != secondState { | |||
| 107 | + | t.Errorf("expected %s, got %s", secondState, string(content)) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestFileRepository_Load_Success(t *testing.T) { | |||
| 112 | + | tmpDir := t.TempDir() | |||
| 113 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 114 | + | ||||
| 115 | + | // Save first | |||
| 116 | + | ctx := context.Background() | |||
| 117 | + | state := "test installation state" | |||
| 118 | + | repo.Save(ctx, "install-001", state) | |||
| 119 | + | ||||
| 120 | + | // Load | |||
| 121 | + | loaded, err := repo.Load(ctx, "install-001") | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatalf("expected no error, got %v", err) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | if loaded != state { | |||
| 127 | + | t.Errorf("expected %s, got %s", state, loaded) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestFileRepository_Load_NotFound(t *testing.T) { | |||
| 132 | + | tmpDir := t.TempDir() | |||
| 133 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 134 | + | ||||
| 135 | + | ctx := context.Background() | |||
| 136 | + | _, err := repo.Load(ctx, "nonexistent") | |||
| 137 | + | ||||
| 138 | + | if err == nil { | |||
| 139 | + | t.Fatal("expected error for nonexistent installation") | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestFileRepository_Exists_True(t *testing.T) { | |||
| 144 | + | tmpDir := t.TempDir() | |||
| 145 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 146 | + | ||||
| 147 | + | // Save first | |||
| 148 | + | ctx := context.Background() | |||
| 149 | + | repo.Save(ctx, "install-001", "state") | |||
| 150 | + | ||||
| 151 | + | // Check exists | |||
| 152 | + | exists, err := repo.Exists(ctx, "install-001") | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Fatalf("expected no error, got %v", err) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | if !exists { | |||
| 158 | + | t.Error("expected installation to exist") | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestFileRepository_Exists_False(t *testing.T) { | |||
| 163 | + | tmpDir := t.TempDir() | |||
| 164 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 165 | + | ||||
| 166 | + | ctx := context.Background() | |||
| 167 | + | exists, err := repo.Exists(ctx, "nonexistent") | |||
| 168 | + | ||||
| 169 | + | if err != nil { | |||
| 170 | + | t.Fatalf("expected no error, got %v", err) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | if exists { | |||
| 174 | + | t.Error("expected installation to not exist") | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestFileRepository_Save_ContextCancelled(t *testing.T) { | |||
| 179 | + | tmpDir := t.TempDir() | |||
| 180 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 181 | + | ||||
| 182 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 183 | + | cancel() | |||
| 184 | + | ||||
| 185 | + | err := repo.Save(ctx, "install-001", "state") | |||
| 186 | + | if err == nil { | |||
| 187 | + | t.Fatal("expected error due to context cancellation") | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestFileRepository_Load_ContextCancelled(t *testing.T) { | |||
| 192 | + | tmpDir := t.TempDir() | |||
| 193 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 194 | + | ||||
| 195 | + | // Save first | |||
| 196 | + | ctx := context.Background() | |||
| 197 | + | repo.Save(ctx, "install-001", "state") | |||
| 198 | + | ||||
| 199 | + | // Load with cancelled context | |||
| 200 | + | ctx2, cancel := context.WithCancel(context.Background()) | |||
| 201 | + | cancel() | |||
| 202 | + | ||||
| 203 | + | _, err := repo.Load(ctx2, "install-001") | |||
| 204 | + | if err == nil { | |||
| 205 | + | t.Fatal("expected error due to context cancellation") | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestFileRepository_Exists_ContextCancelled(t *testing.T) { | |||
| 210 | + | tmpDir := t.TempDir() | |||
| 211 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 212 | + | ||||
| 213 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 214 | + | cancel() | |||
| 215 | + | ||||
| 216 | + | _, err := repo.Exists(ctx, "install-001") | |||
| 217 | + | if err == nil { | |||
| 218 | + | t.Fatal("expected error due to context cancellation") | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestFileRepository_MultipleInstallations(t *testing.T) { | |||
| 223 | + | tmpDir := t.TempDir() | |||
| 224 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 225 | + | ||||
| 226 | + | ctx := context.Background() | |||
| 227 | + | ||||
| 228 | + | // Save multiple installations | |||
| 229 | + | repo.Save(ctx, "install-001", "state 1") | |||
| 230 | + | repo.Save(ctx, "install-002", "state 2") | |||
| 231 | + | repo.Save(ctx, "install-003", "state 3") | |||
| 232 | + | ||||
| 233 | + | // Load each one | |||
| 234 | + | state1, _ := repo.Load(ctx, "install-001") | |||
| 235 | + | state2, _ := repo.Load(ctx, "install-002") | |||
| 236 | + | state3, _ := repo.Load(ctx, "install-003") | |||
| 237 | + | ||||
| 238 | + | if state1 != "state 1" { | |||
| 239 | + | t.Errorf("expected 'state 1', got %s", state1) | |||
| 240 | + | } | |||
| 241 | + | if state2 != "state 2" { | |||
| 242 | + | t.Errorf("expected 'state 2', got %s", state2) | |||
| 243 | + | } | |||
| 244 | + | if state3 != "state 3" { | |||
| 245 | + | t.Errorf("expected 'state 3', got %s", state3) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestFileRepository_Save_ContextTimeout(t *testing.T) { | |||
| 250 | + | tmpDir := t.TempDir() | |||
| 251 | + | repo, _ := NewFileRepository(tmpDir) | |||
| 252 | + | ||||
| 253 | + | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) | |||
| 254 | + | defer cancel() | |||
| 255 | + | ||||
| 256 | + | // Give it time to timeout | |||
| 257 | + | time.Sleep(10 * time.Millisecond) | |||
| 258 | + | ||||
| 259 | + | err := repo.Save(ctx, "install-001", "state") | |||
| 260 | + | if err == nil { | |||
| 261 | + | t.Fatal("expected error due to context timeout") | |||
| 262 | + | } | |||
| 263 | + | } | |||