refactor: split into packages and add tests

This commit is contained in:
technofab 2025-06-03 12:05:16 +02:00
parent fd58344ca7
commit 11117e0c0e
28 changed files with 2736 additions and 636 deletions

110
internal/nix/service.go Normal file
View file

@ -0,0 +1,110 @@
package nix
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"strings"
apperrors "gitlab.com/technofab/nixtest/internal/errors"
)
// Service defines operations related to Nix
type Service interface {
BuildDerivation(derivation string) (string, error)
BuildAndParseJSON(derivation string) (any, error)
BuildAndRunScript(derivation string, pureEnv bool) (exitCode int, stdout string, stderr string, err error)
}
type DefaultService struct {
commandExecutor func(command string, args ...string) *exec.Cmd
}
func NewDefaultService() *DefaultService {
return &DefaultService{commandExecutor: exec.Command}
}
// BuildDerivation builds a Nix derivation and returns the output path
func (s *DefaultService) BuildDerivation(derivation string) (string, error) {
cmd := s.commandExecutor(
"nix",
"build",
derivation+"^*",
"--print-out-paths",
"--no-link",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return "", &apperrors.NixBuildError{Derivation: derivation, Stderr: stderr.String(), Err: err}
}
path := strings.TrimSpace(stdout.String())
if path == "" {
return "", &apperrors.NixNoOutputPathError{Derivation: derivation, Stderr: stderr.String()}
}
return path, nil
}
// BuildAndParseJSON builds a derivation and parses its output file as JSON
func (s *DefaultService) BuildAndParseJSON(derivation string) (any, error) {
path, err := s.BuildDerivation(derivation)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, &apperrors.FileReadError{Path: path, Err: err}
}
var result any
err = json.Unmarshal(data, &result)
if err != nil {
return nil, &apperrors.JSONUnmarshalError{Source: path, Err: err}
}
return result, nil
}
// BuildAndRunScript builds a derivation and runs it as a script
func (s *DefaultService) BuildAndRunScript(derivation string, pureEnv bool) (exitCode int, stdout string, stderr string, err error) {
exitCode = -1
path, err := s.BuildDerivation(derivation)
if err != nil {
return exitCode, "", "", err
}
var cmdArgs []string
if pureEnv {
cmdArgs = append([]string{"env", "-i"}, "bash", path)
} else {
cmdArgs = []string{"bash", path}
}
cmd := s.commandExecutor(cmdArgs[0], cmdArgs[1:]...)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
if err = cmd.Start(); err != nil {
return exitCode, "", "", &apperrors.ScriptExecutionError{Path: path, Err: err}
}
runErr := cmd.Wait()
stdout = outBuf.String()
stderr = errBuf.String()
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
return exitErr.ExitCode(), stdout, stderr, nil
}
return exitCode, stdout, stderr, &apperrors.ScriptExecutionError{Path: path, Err: runErr}
}
return 0, stdout, stderr, nil
}

View file

@ -0,0 +1,305 @@
package nix
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
apperrors "gitlab.com/technofab/nixtest/internal/errors"
)
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
args := os.Args
for len(args) > 0 && args[0] != "--" {
args = args[1:]
}
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "No command after --")
os.Exit(1)
}
args = args[1:]
cmd, params := args[0], args[1:]
switch cmd {
case "nix":
if len(params) > 0 && params[0] == "build" {
mockOutput := os.Getenv("MOCK_NIX_BUILD_OUTPUT")
mockError := os.Getenv("MOCK_NIX_BUILD_ERROR")
mockExitCode := os.Getenv("MOCK_NIX_BUILD_EXIT_CODE")
if mockError != "" {
fmt.Fprintln(os.Stderr, mockError)
}
if mockExitCode != "" && mockExitCode != "0" {
os.Exit(1) // simplified exit for helper
}
if mockError == "" && (mockExitCode == "" || mockExitCode == "0") {
fmt.Fprintln(os.Stdout, mockOutput)
}
}
case "bash", "env":
scriptPath := params[0]
if cmd == "env" && len(params) > 2 {
scriptPath = params[2]
}
if _, err := os.Stat(scriptPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "mocked script: script path %s could not be statted: %v\n", scriptPath, err)
os.Exit(3)
}
fmt.Fprint(os.Stdout, os.Getenv("MOCK_SCRIPT_STDOUT"))
fmt.Fprint(os.Stderr, os.Getenv("MOCK_SCRIPT_STDERR"))
if code := os.Getenv("MOCK_SCRIPT_EXIT_CODE"); code != "" && code != "0" {
os.Exit(5) // custom exit for script failure
}
default:
fmt.Fprintf(os.Stderr, "mocked command: unknown command %s\n", cmd)
os.Exit(126)
}
}
// mockExecCommand configures the DefaultService to use the test helper
func mockExecCommandForService(service *DefaultService) {
service.commandExecutor = func(command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", command}
cs = append(cs, args...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}
cmd.Env = append(cmd.Env, os.Environ()...)
return cmd
}
}
func TestDefaultService_BuildDerivation(t *testing.T) {
service := NewDefaultService()
mockExecCommandForService(service) // configure service to use helper
tests := []struct {
name string
derivation string
mockOutput string
mockError string
mockExitCode string
wantPath string
wantErr bool
wantErrType any
wantErrMsgContains string
}{
{"Success", "some.drv#attr", "/nix/store/mock-path", "", "0", "/nix/store/mock-path", false, nil, ""},
{
"Nix command error", "error.drv#attr", "", "nix error details", "1", "", true,
(*apperrors.NixBuildError)(nil), "nix error details",
},
{
"Nix command success but no output path", "empty.drv#attr", "", "", "0", "", true,
(*apperrors.NixNoOutputPathError)(nil), "produced no output path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("MOCK_NIX_BUILD_OUTPUT", tt.mockOutput)
os.Setenv("MOCK_NIX_BUILD_ERROR", tt.mockError)
os.Setenv("MOCK_NIX_BUILD_EXIT_CODE", tt.mockExitCode)
defer func() {
os.Unsetenv("MOCK_NIX_BUILD_OUTPUT")
os.Unsetenv("MOCK_NIX_BUILD_ERROR")
os.Unsetenv("MOCK_NIX_BUILD_EXIT_CODE")
}()
gotPath, err := service.BuildDerivation(tt.derivation)
if (err != nil) != tt.wantErr {
t.Fatalf("BuildDerivation() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.wantErrType != nil && !errors.As(err, &tt.wantErrType) {
t.Errorf("BuildDerivation() error type = %T, want %T", err, tt.wantErrType)
}
if tt.wantErrMsgContains != "" && !strings.Contains(err.Error(), tt.wantErrMsgContains) {
t.Errorf("BuildDerivation() error = %q, want error containing %q", err.Error(), tt.wantErrMsgContains)
}
}
if !tt.wantErr && gotPath != tt.wantPath {
t.Errorf("BuildDerivation() gotPath = %v, want %v", gotPath, tt.wantPath)
}
})
}
}
func TestDefaultService_BuildAndParseJSON(t *testing.T) {
service := NewDefaultService()
mockExecCommandForService(service)
tempDir := t.TempDir()
mockDrvOutputPath := filepath.Join(tempDir, "drv_output.json")
tests := []struct {
name string
derivation string
mockBuildOutput string
mockJSONContent string
mockBuildError string
mockBuildExitCode string
want any
wantErr bool
wantErrType any
wantErrMsgContains string
}{
{
"Success", "some.drv#json", mockDrvOutputPath, `{"key": "value"}`, "", "0",
map[string]any{"key": "value"}, false, nil, "",
},
{
"BuildDerivation fails", "error.drv#json", "", "", "nix build error", "1",
nil, true, (*apperrors.NixBuildError)(nil), "nix build error",
},
{
"ReadFile fails", "readfail.drv#json", "/nonexistent/path/output.json", "", "", "0",
nil, true, (*apperrors.FileReadError)(nil), "failed to read file",
},
{
"Unmarshal fails", "badjson.drv#json", mockDrvOutputPath, `{"key": "value"`, "", "0",
nil, true, (*apperrors.JSONUnmarshalError)(nil), "failed to unmarshal JSON",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("MOCK_NIX_BUILD_OUTPUT", tt.mockBuildOutput)
os.Setenv("MOCK_NIX_BUILD_ERROR", tt.mockBuildError)
os.Setenv("MOCK_NIX_BUILD_EXIT_CODE", tt.mockBuildExitCode)
if tt.mockJSONContent != "" && tt.mockBuildOutput == mockDrvOutputPath {
if err := os.WriteFile(mockDrvOutputPath, []byte(tt.mockJSONContent), 0644); err != nil {
t.Fatalf("Failed to write mock JSON content: %v", err)
}
defer os.Remove(mockDrvOutputPath)
}
got, err := service.BuildAndParseJSON(tt.derivation)
if (err != nil) != tt.wantErr {
t.Fatalf("BuildAndParseJSON() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.wantErrType != nil && !errors.As(err, &tt.wantErrType) {
t.Errorf("BuildAndParseJSON() error type = %T, want %T", err, tt.wantErrType)
}
if tt.wantErrMsgContains != "" && !strings.Contains(err.Error(), tt.wantErrMsgContains) {
t.Errorf("BuildAndParseJSON() error = %q, want error containing %q", err.Error(), tt.wantErrMsgContains)
}
}
if !tt.wantErr && !jsonDeepEqual(got, tt.want) {
t.Errorf("BuildAndParseJSON() got = %v, want %v", got, tt.want)
}
})
}
}
func jsonDeepEqual(a, b any) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
jsonA, _ := json.Marshal(a)
jsonB, _ := json.Marshal(b)
return string(jsonA) == string(jsonB)
}
func TestDefaultService_BuildAndRunScript(t *testing.T) {
service := NewDefaultService()
mockExecCommandForService(service)
tempDir := t.TempDir()
mockScriptPath := filepath.Join(tempDir, "mock_script.sh")
if err := os.WriteFile(mockScriptPath, []byte("#!/bin/bash\necho hello"), 0755); err != nil {
t.Fatalf("Failed to create dummy mock script: %v", err)
}
tests := []struct {
name string
derivation string
pureEnv bool
mockBuildDrvOutput string
mockBuildDrvError string
mockBuildDrvExitCode string
mockScriptStdout string
mockScriptStderr string
mockScriptExitCode string
wantExitCode int
wantStdout string
wantStderr string
wantErr bool
wantErrType any
wantErrMsgContains string
}{
{
"Success", "script.drv#sh", false, mockScriptPath, "", "0",
"Hello", "ErrOut", "0",
0, "Hello", "ErrOut", false, nil, "",
},
{
"Success pure", "script.drv#sh", true, mockScriptPath, "", "0",
"Hello", "ErrOut", "0",
0, "Hello", "ErrOut", false, nil, "",
},
{
"Script fails (non-zero exit)", "fail.drv#sh", false, mockScriptPath, "", "0",
"Out", "Err", "custom", // helper uses 5 for script failure
5, "Out", "Err", false, nil, "", // error is nil, non-zero exit code
},
{
"BuildDerivation fails", "buildfail.drv#sh", false, "", "nix error", "1",
"", "", "",
-1, "", "", true, (*apperrors.NixBuildError)(nil), "nix error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("MOCK_NIX_BUILD_OUTPUT", tt.mockBuildDrvOutput)
os.Setenv("MOCK_NIX_BUILD_ERROR", tt.mockBuildDrvError)
os.Setenv("MOCK_NIX_BUILD_EXIT_CODE", tt.mockBuildDrvExitCode)
os.Setenv("MOCK_SCRIPT_STDOUT", tt.mockScriptStdout)
os.Setenv("MOCK_SCRIPT_STDERR", tt.mockScriptStderr)
os.Setenv("MOCK_SCRIPT_EXIT_CODE", tt.mockScriptExitCode)
exitCode, stdout, stderr, err := service.BuildAndRunScript(tt.derivation, tt.pureEnv)
if (err != nil) != tt.wantErr {
t.Fatalf("BuildAndRunScript() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.wantErrType != nil && !errors.As(err, &tt.wantErrType) {
t.Errorf("BuildAndRunScript() error type = %T, want %T", err, tt.wantErrType)
}
if tt.wantErrMsgContains != "" && !strings.Contains(err.Error(), tt.wantErrMsgContains) {
t.Errorf("BuildAndRunScript() error = %q, want error containing %q", err.Error(), tt.wantErrMsgContains)
}
} else {
if exitCode != tt.wantExitCode {
t.Errorf("BuildAndRunScript() exitCode = %v, want %v", exitCode, tt.wantExitCode)
}
if stdout != tt.wantStdout {
t.Errorf("BuildAndRunScript() stdout = %q, want %q", stdout, tt.wantStdout)
}
if stderr != tt.wantStderr {
t.Errorf("BuildAndRunScript() stderr = %q, want %q", stderr, tt.wantStderr)
}
}
})
}
}