From f5f2c03fe89457e9215d43b4680af548fb1859d6 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Wed, 17 Dec 2025 16:39:49 -0700 Subject: [PATCH 1/3] Add comprehensive unit tests and update Go version Added unit tests for cmd, internal/config, internal/export, and internal/project packages to improve test coverage. Updated GitHub Actions workflows to use Go 1.25 and enhanced test workflow with race detection. Updated go.mod and go.sum to include testify and related dependencies for testing. --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 10 +- cmd/init_test.go | 119 +++++++++ cmd/manual_test.go | 298 ++++++++++++++++++++++ cmd/start_test.go | 99 ++++++++ cmd/version_test.go | 104 ++++++++ go.mod | 4 + go.sum | 8 + internal/config/config_test.go | 315 +++++++++++++++++++++++ internal/export/export_test.go | 290 +++++++++++++++++++++ internal/project/detect_test.go | 193 ++++++++++++++ internal/storage/db_test.go | 436 ++++++++++++++++++++++++++++++++ internal/ui/ui_test.go | 199 +++++++++++++++ 13 files changed, 2074 insertions(+), 3 deletions(-) create mode 100644 cmd/init_test.go create mode 100644 cmd/manual_test.go create mode 100644 cmd/start_test.go create mode 100644 cmd/version_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/export/export_test.go create mode 100644 internal/project/detect_test.go create mode 100644 internal/storage/db_test.go create mode 100644 internal/ui/ui_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 965d6ce..513af2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.25' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b624ab1..2bb661a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,11 +19,17 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.25' - name: Build run: go build -v ./... - + + - name: Run unit tests + run: go test -v ./... + + - name: Run tests with race detection + run: go test -race -v ./... + - name: Test basic commands run: | go build -o tmpo${{ matrix.os == 'windows-latest' && '.exe' || '' }} . diff --git a/cmd/init_test.go b/cmd/init_test.go new file mode 100644 index 0000000..4b0948e --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectDefaultProjectName(t *testing.T) { + t.Run("returns git repository name when in git repo", func(t *testing.T) { + // This test would require setting up a real git repo + // We'll test the fallback behavior instead + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Initialize a minimal git repo + err = os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755) + require.NoError(t, err) + + name := detectDefaultProjectName() + assert.NotEmpty(t, name) + }) + + t.Run("returns directory name when not in git repo", func(t *testing.T) { + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + name := detectDefaultProjectName() + assert.NotEmpty(t, name) + // The name should be the base of the temp directory + assert.Equal(t, filepath.Base(tmpDir), name) + }) +} + +func TestValidateHourlyRate(t *testing.T) { + tests := []struct { + name string + input string + wantError bool + errorMsg string + }{ + { + name: "empty string is valid (optional field)", + input: "", + wantError: false, + }, + { + name: "whitespace only is valid", + input: " ", + wantError: false, + }, + { + name: "valid positive number", + input: "75.50", + wantError: false, + }, + { + name: "valid integer", + input: "100", + wantError: false, + }, + { + name: "zero is valid", + input: "0", + wantError: false, + }, + { + name: "negative number is invalid", + input: "-50", + wantError: true, + errorMsg: "hourly rate cannot be negative", + }, + { + name: "non-numeric string is invalid", + input: "not-a-number", + wantError: true, + errorMsg: "must be a valid number", + }, + { + name: "mixed alphanumeric is invalid", + input: "50abc", + wantError: true, + errorMsg: "must be a valid number", + }, + { + name: "special characters are invalid", + input: "$100", + wantError: true, + errorMsg: "must be a valid number", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateHourlyRate(tt.input) + if tt.wantError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/manual_test.go b/cmd/manual_test.go new file mode 100644 index 0000000..390588a --- /dev/null +++ b/cmd/manual_test.go @@ -0,0 +1,298 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestValidateDate(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid date", + input: "12-25-2024", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "invalid format", + input: "2024-12-25", + wantErr: true, + }, + { + name: "invalid date", + input: "13-32-2024", + wantErr: true, + }, + { + name: "far future date", + input: time.Now().Add(72 * time.Hour).Format("01-02-2006"), + wantErr: true, + }, + { + name: "past date", + input: "01-01-2020", + wantErr: false, + }, + { + name: "today", + input: time.Now().Format("01-02-2006"), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDate(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateTime(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + // 12-hour format + { + name: "12-hour with AM", + input: "9:30 AM", + wantErr: false, + }, + { + name: "12-hour with PM", + input: "5:45 PM", + wantErr: false, + }, + { + name: "12-hour lowercase am", + input: "9:30 am", + wantErr: false, + }, + { + name: "12-hour with leading zero", + input: "09:30 AM", + wantErr: false, + }, + // 24-hour format + { + name: "24-hour format", + input: "14:30", + wantErr: false, + }, + { + name: "24-hour midnight", + input: "00:00", + wantErr: false, + }, + { + name: "24-hour late night", + input: "23:59", + wantErr: false, + }, + // Invalid inputs + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "invalid format", + input: "9.30 AM", + wantErr: true, // Wrong separator + }, + { + name: "invalid hour", + input: "25:00", + wantErr: true, + }, + { + name: "invalid minute", + input: "9:60 AM", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTime(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateEndDateTime(t *testing.T) { + tests := []struct { + name string + startDate string + startTime string + endDate string + endTime string + wantErr bool + }{ + { + name: "valid range same day", + startDate: "12-25-2024", + startTime: "9:00 AM", + endDate: "12-25-2024", + endTime: "5:00 PM", + wantErr: false, + }, + { + name: "valid range next day", + startDate: "12-25-2024", + startTime: "11:00 PM", + endDate: "12-26-2024", + endTime: "1:00 AM", + wantErr: false, + }, + { + name: "end before start", + startDate: "12-25-2024", + startTime: "5:00 PM", + endDate: "12-25-2024", + endTime: "9:00 AM", + wantErr: true, + }, + { + name: "same time", + startDate: "12-25-2024", + startTime: "9:00 AM", + endDate: "12-25-2024", + endTime: "9:00 AM", + wantErr: true, + }, + { + name: "24-hour format", + startDate: "12-25-2024", + startTime: "09:00", + endDate: "12-25-2024", + endTime: "17:00", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEndDateTime(tt.startDate, tt.startTime, tt.endDate, tt.endTime) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestParseDateTime(t *testing.T) { + tests := []struct { + name string + date string + timeStr string + wantErr bool + wantHour int + wantMin int + }{ + { + name: "12-hour AM", + date: "12-25-2024", + timeStr: "9:30 AM", + wantErr: false, + wantHour: 9, + wantMin: 30, + }, + { + name: "12-hour PM", + date: "12-25-2024", + timeStr: "5:45 PM", + wantErr: false, + wantHour: 17, + wantMin: 45, + }, + { + name: "24-hour format", + date: "12-25-2024", + timeStr: "14:30", + wantErr: false, + wantHour: 14, + wantMin: 30, + }, + { + name: "midnight", + date: "12-25-2024", + timeStr: "12:00 AM", + wantErr: false, + wantHour: 0, + wantMin: 0, + }, + { + name: "noon", + date: "12-25-2024", + timeStr: "12:00 PM", + wantErr: false, + wantHour: 12, + wantMin: 0, + }, + { + name: "lowercase am/pm", + date: "12-25-2024", + timeStr: "9:30 am", + wantErr: false, + wantHour: 9, + wantMin: 30, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseDateTime(tt.date, tt.timeStr) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantHour, result.Hour()) + assert.Equal(t, tt.wantMin, result.Minute()) + } + }) + } +} + +func TestNormalizeAMPM(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"9:30 am", "9:30 AM"}, + {"9:30 AM", "9:30 AM"}, + {"9:30 pm", "9:30 PM"}, + {"9:30 PM", "9:30 PM"}, + {"14:30", "14:30"}, + {"MIXED case Am", "MIXED CASE AM"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeAMPM(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/cmd/start_test.go b/cmd/start_test.go new file mode 100644 index 0000000..5f233e5 --- /dev/null +++ b/cmd/start_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectProjectName(t *testing.T) { + t.Run("returns project name from .tmporc config", func(t *testing.T) { + // Create a temporary directory with a .tmporc file + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Create a .tmporc file with a project name + cfg := &config.Config{ + ProjectName: "test-project-from-config", + HourlyRate: 75.0, + } + err = cfg.Save(filepath.Join(tmpDir, ".tmporc")) + require.NoError(t, err) + + // Test + projectName, err := DetectProjectName() + assert.NoError(t, err) + assert.Equal(t, "test-project-from-config", projectName) + }) + + t.Run("falls back to git repository name when no .tmporc", func(t *testing.T) { + // Create a temporary directory + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Initialize a git repository + err = os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755) + require.NoError(t, err) + + // Test - should use directory name as fallback since it's not a real git repo + projectName, err := DetectProjectName() + assert.NoError(t, err) + assert.NotEmpty(t, projectName) + }) + + t.Run("falls back to directory name when no .tmporc and not in git repo", func(t *testing.T) { + // Create a temporary directory + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Test + projectName, err := DetectProjectName() + assert.NoError(t, err) + // Should use the directory name + assert.NotEmpty(t, projectName) + assert.Contains(t, tmpDir, projectName) // The project name should be part of the temp dir path + }) + + t.Run("empty project name in .tmporc falls back to detection", func(t *testing.T) { + // Create a temporary directory with a .tmporc file that has empty project name + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(origDir) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Create a .tmporc file with empty project name + cfg := &config.Config{ + ProjectName: "", + HourlyRate: 50.0, + } + err = cfg.Save(filepath.Join(tmpDir, ".tmporc")) + require.NoError(t, err) + + // Test + projectName, err := DetectProjectName() + assert.NoError(t, err) + assert.NotEmpty(t, projectName) + }) +} diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..29942bf --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetFormattedDate(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid RFC3339 date", + input: "2024-01-15T10:30:00Z", + expected: "(01-15-2024)", + }, + { + name: "valid RFC3339 date with timezone", + input: "2024-12-25T15:45:30-05:00", + expected: "(12-25-2024)", + }, + { + name: "empty string returns empty", + input: "", + expected: "", + }, + { + name: "invalid date format returns empty", + input: "2024-01-15", + expected: "", + }, + { + name: "invalid date string returns empty", + input: "not a date", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetFormattedDate(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetChangelogUrl(t *testing.T) { + tests := []struct { + name string + version string + expected string + }{ + { + name: "valid version without v prefix", + version: "1.0.0", + expected: "https://github.com/DylanDevelops/tmpo/releases/tag/v1.0.0", + }, + { + name: "valid version with v prefix", + version: "v2.3.4", + expected: "https://github.com/DylanDevelops/tmpo/releases/tag/v2.3.4", + }, + { + name: "version with prerelease tag", + version: "1.0.0-beta.1", + expected: "https://github.com/DylanDevelops/tmpo/releases/tag/v1.0.0-beta.1", + }, + { + name: "version with v prefix and prerelease", + version: "v1.0.0-rc.2", + expected: "https://github.com/DylanDevelops/tmpo/releases/tag/v1.0.0-rc.2", + }, + { + name: "dev version returns latest", + version: "dev", + expected: "https://github.com/DylanDevelops/tmpo/releases/latest", + }, + { + name: "empty version returns latest", + version: "", + expected: "https://github.com/DylanDevelops/tmpo/releases/latest", + }, + { + name: "invalid version format returns latest", + version: "1.0", + expected: "https://github.com/DylanDevelops/tmpo/releases/latest", + }, + { + name: "invalid version string returns latest", + version: "not-a-version", + expected: "https://github.com/DylanDevelops/tmpo/releases/latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetChangelogUrl(tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/go.mod b/go.mod index 2e5420a..2fdfe68 100644 --- a/go.mod +++ b/go.mod @@ -5,21 +5,25 @@ go 1.25.5 require ( github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v3 v3.0.4 modernc.org/sqlite v1.40.1 ) require ( github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sys v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index a522fee..2200621 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -19,6 +21,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -27,6 +31,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= @@ -43,6 +49,8 @@ golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..413667a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,315 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoad(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("loads valid config", func(t *testing.T) { + configPath := filepath.Join(tmpDir, "test.tmporc") + content := `project_name: test-project +hourly_rate: 100.5 +description: "Test description" +` + err := os.WriteFile(configPath, []byte(content), 0644) + assert.NoError(t, err) + + cfg, err := Load(configPath) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, "test-project", cfg.ProjectName) + assert.Equal(t, 100.5, cfg.HourlyRate) + assert.Equal(t, "Test description", cfg.Description) + }) + + t.Run("handles minimal config", func(t *testing.T) { + configPath := filepath.Join(tmpDir, "minimal.tmporc") + content := `project_name: minimal-project +` + err := os.WriteFile(configPath, []byte(content), 0644) + assert.NoError(t, err) + + cfg, err := Load(configPath) + assert.NoError(t, err) + assert.Equal(t, "minimal-project", cfg.ProjectName) + assert.Equal(t, float64(0), cfg.HourlyRate) + assert.Empty(t, cfg.Description) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + _, err := Load(filepath.Join(tmpDir, "nonexistent.tmporc")) + assert.Error(t, err) + }) + + t.Run("returns error for invalid YAML", func(t *testing.T) { + configPath := filepath.Join(tmpDir, "invalid.tmporc") + content := `project_name: test +invalid yaml syntax: [ unclosed +` + err := os.WriteFile(configPath, []byte(content), 0644) + assert.NoError(t, err) + + _, err = Load(configPath) + assert.Error(t, err) + }) +} + +func TestSave(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("saves config successfully", func(t *testing.T) { + cfg := &Config{ + ProjectName: "save-test", + HourlyRate: 75.0, + Description: "Saved config", + } + + configPath := filepath.Join(tmpDir, "saved.tmporc") + err := cfg.Save(configPath) + assert.NoError(t, err) + + // Verify file was created and can be loaded + loaded, err := Load(configPath) + assert.NoError(t, err) + assert.Equal(t, cfg.ProjectName, loaded.ProjectName) + assert.Equal(t, cfg.HourlyRate, loaded.HourlyRate) + assert.Equal(t, cfg.Description, loaded.Description) + }) + + t.Run("overwrites existing file", func(t *testing.T) { + configPath := filepath.Join(tmpDir, "overwrite.tmporc") + + // Create initial config + cfg1 := &Config{ProjectName: "original"} + err := cfg1.Save(configPath) + assert.NoError(t, err) + + // Overwrite with new config + cfg2 := &Config{ProjectName: "updated"} + err = cfg2.Save(configPath) + assert.NoError(t, err) + + // Verify updated content + loaded, err := Load(configPath) + assert.NoError(t, err) + assert.Equal(t, "updated", loaded.ProjectName) + }) + + t.Run("omits empty optional fields", func(t *testing.T) { + cfg := &Config{ + ProjectName: "minimal", + HourlyRate: 0, + Description: "", + } + + configPath := filepath.Join(tmpDir, "minimal.tmporc") + err := cfg.Save(configPath) + assert.NoError(t, err) + + // Read the raw file content + content, err := os.ReadFile(configPath) + assert.NoError(t, err) + + // Should only contain project_name + assert.Contains(t, string(content), "project_name:") + // Should omit hourly_rate and description when they're zero/empty + assert.NotContains(t, string(content), "hourly_rate:") + assert.NotContains(t, string(content), "description:") + }) +} + +func TestCreate(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + t.Run("creates config file", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.Chdir(tmpDir) + assert.NoError(t, err) + + err = Create("new-project", 125.0) + assert.NoError(t, err) + + // Verify file was created + tmporc := filepath.Join(tmpDir, ".tmporc") + _, err = os.Stat(tmporc) + assert.NoError(t, err) + + // Verify content + cfg, err := Load(tmporc) + assert.NoError(t, err) + assert.Equal(t, "new-project", cfg.ProjectName) + assert.Equal(t, 125.0, cfg.HourlyRate) + }) + + t.Run("returns error if file exists", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.Chdir(tmpDir) + assert.NoError(t, err) + + // Create initial file + err = Create("first", 100.0) + assert.NoError(t, err) + + // Try to create again + err = Create("second", 200.0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) +} + +func TestCreateWithTemplate(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + t.Run("creates templated config file", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.Chdir(tmpDir) + assert.NoError(t, err) + + err = CreateWithTemplate("templated-project", 99.99, "Test description") + assert.NoError(t, err) + + // Verify file was created + tmporc := filepath.Join(tmpDir, ".tmporc") + content, err := os.ReadFile(tmporc) + assert.NoError(t, err) + + // Verify template includes comments and formatting + assert.Contains(t, string(content), "# tmpo project configuration") + assert.Contains(t, string(content), "project_name: templated-project") + assert.Contains(t, string(content), "hourly_rate: 99.99") + assert.Contains(t, string(content), "description: \"Test description\"") + assert.Contains(t, string(content), "# [OPTIONAL]") + + // Verify it can be loaded + cfg, err := Load(tmporc) + assert.NoError(t, err) + assert.Equal(t, "templated-project", cfg.ProjectName) + assert.Equal(t, 99.99, cfg.HourlyRate) + assert.Equal(t, "Test description", cfg.Description) + }) + + t.Run("returns error if file exists", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.Chdir(tmpDir) + assert.NoError(t, err) + + // Create initial file + err = CreateWithTemplate("first", 100.0, "desc") + assert.NoError(t, err) + + // Try to create again + err = CreateWithTemplate("second", 200.0, "desc2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) +} + +func TestFindAndLoad(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + t.Run("finds config in current directory", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".tmporc") + cfg := &Config{ProjectName: "current-dir"} + err := cfg.Save(configPath) + assert.NoError(t, err) + + err = os.Chdir(tmpDir) + assert.NoError(t, err) + + found, path, err := FindAndLoad() + assert.NoError(t, err) + assert.NotNil(t, found) + assert.Equal(t, "current-dir", found.ProjectName) + + // Resolve both paths to handle symlinks (e.g., /var -> /private/var on macOS) + expectedPath, _ := filepath.EvalSymlinks(configPath) + actualPath, _ := filepath.EvalSymlinks(path) + assert.Equal(t, expectedPath, actualPath) + }) + + t.Run("finds config in parent directory", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".tmporc") + cfg := &Config{ProjectName: "parent-dir"} + err := cfg.Save(configPath) + assert.NoError(t, err) + + // Create and change to subdirectory + subDir := filepath.Join(tmpDir, "subdir", "nested") + err = os.MkdirAll(subDir, 0755) + assert.NoError(t, err) + err = os.Chdir(subDir) + assert.NoError(t, err) + + found, path, err := FindAndLoad() + assert.NoError(t, err) + assert.NotNil(t, found) + assert.Equal(t, "parent-dir", found.ProjectName) + + // Resolve both paths to handle symlinks + expectedPath, _ := filepath.EvalSymlinks(configPath) + actualPath, _ := filepath.EvalSymlinks(path) + assert.Equal(t, expectedPath, actualPath) + }) + + t.Run("returns error when not found", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.Chdir(tmpDir) + assert.NoError(t, err) + + found, path, err := FindAndLoad() + assert.Error(t, err) + assert.Nil(t, found) + assert.Empty(t, path) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("uses nearest config file", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config in root + rootConfig := filepath.Join(tmpDir, ".tmporc") + cfg := &Config{ProjectName: "root"} + err := cfg.Save(rootConfig) + assert.NoError(t, err) + + // Create config in subdirectory + subDir := filepath.Join(tmpDir, "project") + err = os.MkdirAll(subDir, 0755) + assert.NoError(t, err) + subConfig := filepath.Join(subDir, ".tmporc") + cfg = &Config{ProjectName: "project"} + err = cfg.Save(subConfig) + assert.NoError(t, err) + + // Change to subdirectory + err = os.Chdir(subDir) + assert.NoError(t, err) + + // Should find the nearest one (in current dir) + found, path, err := FindAndLoad() + assert.NoError(t, err) + assert.Equal(t, "project", found.ProjectName) + + // Resolve both paths to handle symlinks + expectedPath, _ := filepath.EvalSymlinks(subConfig) + actualPath, _ := filepath.EvalSymlinks(path) + assert.Equal(t, expectedPath, actualPath) + }) +} diff --git a/internal/export/export_test.go b/internal/export/export_test.go new file mode 100644 index 0000000..632808c --- /dev/null +++ b/internal/export/export_test.go @@ -0,0 +1,290 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/stretchr/testify/assert" +) + +func TestToCSV(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("exports entries to CSV", func(t *testing.T) { + startTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) + endTime := time.Date(2024, 1, 1, 17, 0, 0, 0, time.UTC) + + entries := []*storage.TimeEntry{ + { + ID: 1, + ProjectName: "test-project", + StartTime: startTime, + EndTime: &endTime, + Description: "Test work", + }, + { + ID: 2, + ProjectName: "another-project", + StartTime: startTime, + EndTime: &endTime, + Description: "More work", + }, + } + + filename := filepath.Join(tmpDir, "test.csv") + err := ToCSV(entries, filename) + assert.NoError(t, err) + + // Verify file exists + _, err = os.Stat(filename) + assert.NoError(t, err) + + // Read and verify CSV content + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + assert.NoError(t, err) + + // Should have header + 2 entries + assert.Len(t, records, 3) + + // Verify header + assert.Equal(t, []string{"Project", "Start Time", "End Time", "Duration (hours)", "Description"}, records[0]) + + // Verify first entry + assert.Equal(t, "test-project", records[1][0]) + assert.Equal(t, "2024-01-01 09:00:00", records[1][1]) + assert.Equal(t, "2024-01-01 17:00:00", records[1][2]) + assert.Equal(t, "8.00", records[1][3]) // 8 hours + assert.Equal(t, "Test work", records[1][4]) + }) + + t.Run("handles running entries", func(t *testing.T) { + startTime := time.Now().Add(-1 * time.Hour) + + entries := []*storage.TimeEntry{ + { + ID: 1, + ProjectName: "running-project", + StartTime: startTime, + EndTime: nil, // Running + Description: "Ongoing work", + }, + } + + filename := filepath.Join(tmpDir, "running.csv") + err := ToCSV(entries, filename) + assert.NoError(t, err) + + // Read CSV + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + assert.NoError(t, err) + + // End time should be empty string + assert.Empty(t, records[1][2]) + }) + + t.Run("handles empty entries", func(t *testing.T) { + entries := []*storage.TimeEntry{} + + filename := filepath.Join(tmpDir, "empty.csv") + err := ToCSV(entries, filename) + assert.NoError(t, err) + + // Read CSV + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + assert.NoError(t, err) + + // Should only have header + assert.Len(t, records, 1) + }) + + t.Run("handles entries without description", func(t *testing.T) { + startTime := time.Now().Add(-1 * time.Hour) + endTime := time.Now() + + entries := []*storage.TimeEntry{ + { + ID: 1, + ProjectName: "test", + StartTime: startTime, + EndTime: &endTime, + Description: "", // Empty + }, + } + + filename := filepath.Join(tmpDir, "no-desc.csv") + err := ToCSV(entries, filename) + assert.NoError(t, err) + + // Read CSV + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + assert.NoError(t, err) + + // Description should be empty string + assert.Empty(t, records[1][4]) + }) +} + +func TestToJson(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("exports entries to JSON", func(t *testing.T) { + startTime := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) + endTime := time.Date(2024, 1, 1, 17, 0, 0, 0, time.UTC) + + entries := []*storage.TimeEntry{ + { + ID: 1, + ProjectName: "test-project", + StartTime: startTime, + EndTime: &endTime, + Description: "Test work", + }, + { + ID: 2, + ProjectName: "another-project", + StartTime: startTime, + EndTime: &endTime, + Description: "More work", + }, + } + + filename := filepath.Join(tmpDir, "test.json") + err := ToJson(entries, filename) + assert.NoError(t, err) + + // Verify file exists + _, err = os.Stat(filename) + assert.NoError(t, err) + + // Read and verify JSON content + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + var exportedEntries []ExportEntry + decoder := json.NewDecoder(file) + err = decoder.Decode(&exportedEntries) + assert.NoError(t, err) + + // Should have 2 entries + assert.Len(t, exportedEntries, 2) + + // Verify first entry + assert.Equal(t, "test-project", exportedEntries[0].Project) + assert.Equal(t, "2024-01-01T09:00:00Z", exportedEntries[0].StartTime) + assert.Equal(t, "2024-01-01T17:00:00Z", exportedEntries[0].EndTime) + assert.Equal(t, 8.0, exportedEntries[0].Duration) + assert.Equal(t, "Test work", exportedEntries[0].Description) + }) + + t.Run("handles running entries", func(t *testing.T) { + startTime := time.Now().Add(-1 * time.Hour) + + entries := []*storage.TimeEntry{ + { + ID: 1, + ProjectName: "running-project", + StartTime: startTime, + EndTime: nil, // Running + Description: "Ongoing work", + }, + } + + filename := filepath.Join(tmpDir, "running.json") + err := ToJson(entries, filename) + assert.NoError(t, err) + + // Read JSON + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + var exportedEntries []ExportEntry + decoder := json.NewDecoder(file) + err = decoder.Decode(&exportedEntries) + assert.NoError(t, err) + + // End time should be omitted (zero value) + assert.Empty(t, exportedEntries[0].EndTime) + }) + + t.Run("handles empty entries", func(t *testing.T) { + entries := []*storage.TimeEntry{} + + filename := filepath.Join(tmpDir, "empty.json") + err := ToJson(entries, filename) + assert.NoError(t, err) + + // Read JSON + file, err := os.Open(filename) + assert.NoError(t, err) + defer file.Close() + + var exportedEntries []ExportEntry + decoder := json.NewDecoder(file) + err = decoder.Decode(&exportedEntries) + assert.NoError(t, err) + + // Should be empty array + assert.Len(t, exportedEntries, 0) + }) + + t.Run("omits empty description", func(t *testing.T) { + startTime := time.Now().Add(-1 * time.Hour) + endTime := time.Now() + + entries := []*storage.TimeEntry{ + { + ID: 1, + ProjectName: "test", + StartTime: startTime, + EndTime: &endTime, + Description: "", // Empty - should be omitted + }, + } + + filename := filepath.Join(tmpDir, "no-desc.json") + err := ToJson(entries, filename) + assert.NoError(t, err) + + // Read raw JSON to verify omission + content, err := os.ReadFile(filename) + assert.NoError(t, err) + + // Description field should be omitted when empty + // (Note: Go's JSON encoder may still include it as empty string depending on omitempty behavior) + var rawData []map[string]interface{} + err = json.Unmarshal(content, &rawData) + assert.NoError(t, err) + + // Description should either be omitted or empty + if desc, exists := rawData[0]["description"]; exists { + assert.Empty(t, desc) + } + }) +} diff --git a/internal/project/detect_test.go b/internal/project/detect_test.go new file mode 100644 index 0000000..8a70bd3 --- /dev/null +++ b/internal/project/detect_test.go @@ -0,0 +1,193 @@ +package project + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindTmporc(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + // Create a temporary directory structure + tmpDir := t.TempDir() + + // Create nested directories + projectDir := filepath.Join(tmpDir, "project") + subDir := filepath.Join(projectDir, "subdir") + err = os.MkdirAll(subDir, 0755) + assert.NoError(t, err) + + // Create .tmporc in project root + tmporc := filepath.Join(projectDir, ".tmporc") + err = os.WriteFile(tmporc, []byte("project_name: test\n"), 0644) + assert.NoError(t, err) + + t.Run("finds tmporc in current directory", func(t *testing.T) { + err := os.Chdir(projectDir) + assert.NoError(t, err) + + path, err := FindTmporc() + assert.NoError(t, err) + + // Resolve both paths to handle symlinks (e.g., /var -> /private/var on macOS) + expectedPath, _ := filepath.EvalSymlinks(tmporc) + actualPath, _ := filepath.EvalSymlinks(path) + assert.Equal(t, expectedPath, actualPath) + }) + + t.Run("finds tmporc in parent directory", func(t *testing.T) { + err := os.Chdir(subDir) + assert.NoError(t, err) + + path, err := FindTmporc() + assert.NoError(t, err) + + // Resolve both paths to handle symlinks + expectedPath, _ := filepath.EvalSymlinks(tmporc) + actualPath, _ := filepath.EvalSymlinks(path) + assert.Equal(t, expectedPath, actualPath) + }) + + t.Run("returns empty string when not found", func(t *testing.T) { + noConfigDir := filepath.Join(tmpDir, "no-config") + err := os.MkdirAll(noConfigDir, 0755) + assert.NoError(t, err) + + err = os.Chdir(noConfigDir) + assert.NoError(t, err) + + path, err := FindTmporc() + assert.NoError(t, err) + assert.Empty(t, path) + }) +} + +func TestGetGitRepoName(t *testing.T) { + // This test depends on running in a git repository + // It will work in the tmpo repository itself + + t.Run("returns repo name when in git repo", func(t *testing.T) { + if !IsInGitRepo() { + t.Skip("Not in a git repository") + } + + name, err := GetGitRepoName() + assert.NoError(t, err) + assert.NotEmpty(t, name) + // Should be "tmpo" when running in tmpo repo + assert.Equal(t, "tmpo", name) + }) +} + +func TestIsInGitRepo(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + t.Run("returns true when in git repo", func(t *testing.T) { + // This test assumes we're running in the tmpo git repo + result := IsInGitRepo() + assert.True(t, result) + }) + + t.Run("returns false when not in git repo", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.Chdir(tmpDir) + assert.NoError(t, err) + + result := IsInGitRepo() + assert.False(t, result) + }) +} + +func TestGetGitRoot(t *testing.T) { + t.Run("returns git root when in git repo", func(t *testing.T) { + if !IsInGitRepo() { + t.Skip("Not in a git repository") + } + + root, err := GetGitRoot() + assert.NoError(t, err) + assert.NotEmpty(t, root) + // Should end with "tmpo" + assert.Equal(t, "tmpo", filepath.Base(root)) + }) + + t.Run("returns error when not in git repo", func(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + tmpDir := t.TempDir() + err = os.Chdir(tmpDir) + assert.NoError(t, err) + + _, err = GetGitRoot() + assert.Error(t, err) + }) +} + +func TestDetectProject(t *testing.T) { + // Save original directory + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + t.Run("detects from tmporc", func(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-cool-project") + err := os.MkdirAll(projectDir, 0755) + assert.NoError(t, err) + + // Create .tmporc + tmporc := filepath.Join(projectDir, ".tmporc") + err = os.WriteFile(tmporc, []byte("project_name: test\n"), 0644) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + name, err := DetectProject() + assert.NoError(t, err) + assert.Equal(t, "my-cool-project", name) + }) + + t.Run("detects from git when no tmporc", func(t *testing.T) { + if !IsInGitRepo() { + t.Skip("Not in a git repository") + } + + // Change to git root (which shouldn't have .tmporc in this test) + root, err := GetGitRoot() + assert.NoError(t, err) + + err = os.Chdir(root) + assert.NoError(t, err) + + name, err := DetectProject() + assert.NoError(t, err) + assert.NotEmpty(t, name) + }) + + t.Run("falls back to directory name", func(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "fallback-project") + err := os.MkdirAll(projectDir, 0755) + assert.NoError(t, err) + + err = os.Chdir(projectDir) + assert.NoError(t, err) + + name, err := DetectProject() + assert.NoError(t, err) + assert.Equal(t, "fallback-project", name) + }) +} diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go new file mode 100644 index 0000000..c5995d6 --- /dev/null +++ b/internal/storage/db_test.go @@ -0,0 +1,436 @@ +package storage + +import ( + "database/sql" + "testing" + "time" + + "github.com/stretchr/testify/assert" + _ "modernc.org/sqlite" +) + +// setupTestDB creates an in-memory SQLite database for testing +func setupTestDB(t *testing.T) *Database { + db, err := sql.Open("sqlite", ":memory:") + assert.NoError(t, err) + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_name TEXT NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME, + description TEXT, + hourly_rate REAL + ) + `) + assert.NoError(t, err) + + return &Database{db: db} +} + +func TestCreateEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + tests := []struct { + name string + projectName string + description string + hourlyRate *float64 + }{ + { + name: "entry without rate", + projectName: "test-project", + description: "test description", + hourlyRate: nil, + }, + { + name: "entry with rate", + projectName: "paid-project", + description: "billable work", + hourlyRate: floatPtr(150.0), + }, + { + name: "entry without description", + projectName: "quick-task", + description: "", + hourlyRate: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entry, err := db.CreateEntry(tt.projectName, tt.description, tt.hourlyRate) + + assert.NoError(t, err) + assert.NotNil(t, entry) + assert.Greater(t, entry.ID, int64(0)) + assert.Equal(t, tt.projectName, entry.ProjectName) + assert.Equal(t, tt.description, entry.Description) + assert.Nil(t, entry.EndTime) + + if tt.hourlyRate != nil { + assert.NotNil(t, entry.HourlyRate) + assert.Equal(t, *tt.hourlyRate, *entry.HourlyRate) + } else { + assert.Nil(t, entry.HourlyRate) + } + }) + } +} + +func TestCreateManualEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + startTime := time.Now().Add(-2 * time.Hour) + endTime := time.Now().Add(-1 * time.Hour) + rate := 100.0 + + entry, err := db.CreateManualEntry("manual-project", "manual work", startTime, endTime, &rate) + + assert.NoError(t, err) + assert.NotNil(t, entry) + assert.Equal(t, "manual-project", entry.ProjectName) + assert.Equal(t, "manual work", entry.Description) + assert.NotNil(t, entry.EndTime) + assert.WithinDuration(t, startTime, entry.StartTime, time.Second) + assert.WithinDuration(t, endTime, *entry.EndTime, time.Second) + assert.Equal(t, rate, *entry.HourlyRate) +} + +func TestGetRunningEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // No running entry initially + running, err := db.GetRunningEntry() + assert.NoError(t, err) + assert.Nil(t, running) + + // Create a running entry + entry, err := db.CreateEntry("test-project", "test", nil) + assert.NoError(t, err) + + // Should return the running entry + running, err = db.GetRunningEntry() + assert.NoError(t, err) + assert.NotNil(t, running) + assert.Equal(t, entry.ID, running.ID) + assert.Nil(t, running.EndTime) + + // Stop the entry + err = db.StopEntry(entry.ID) + assert.NoError(t, err) + + // No running entry after stopping + running, err = db.GetRunningEntry() + assert.NoError(t, err) + assert.Nil(t, running) +} + +func TestStopEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + entry, err := db.CreateEntry("test-project", "test", nil) + assert.NoError(t, err) + assert.Nil(t, entry.EndTime) + + err = db.StopEntry(entry.ID) + assert.NoError(t, err) + + // Verify entry was stopped + stopped, err := db.GetEntry(entry.ID) + assert.NoError(t, err) + assert.NotNil(t, stopped.EndTime) +} + +func TestGetEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + rate := 75.5 + created, err := db.CreateEntry("test-project", "test description", &rate) + assert.NoError(t, err) + + // Get the entry + entry, err := db.GetEntry(created.ID) + assert.NoError(t, err) + assert.NotNil(t, entry) + assert.Equal(t, created.ID, entry.ID) + assert.Equal(t, "test-project", entry.ProjectName) + assert.Equal(t, "test description", entry.Description) + assert.Equal(t, rate, *entry.HourlyRate) + + // Non-existent entry + _, err = db.GetEntry(9999) + assert.Error(t, err) +} + +func TestGetEntries(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create multiple entries + for i := 0; i < 5; i++ { + _, err := db.CreateEntry("test-project", "", nil) + assert.NoError(t, err) + time.Sleep(10 * time.Millisecond) // Ensure different timestamps + } + + // Get all entries + entries, err := db.GetEntries(0) + assert.NoError(t, err) + assert.Len(t, entries, 5) + + // Entries should be sorted by start_time DESC (newest first) + for i := 0; i < len(entries)-1; i++ { + assert.True(t, entries[i].StartTime.After(entries[i+1].StartTime)) + } + + // Get limited entries + entries, err = db.GetEntries(3) + assert.NoError(t, err) + assert.Len(t, entries, 3) +} + +func TestGetEntriesByProject(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create entries for different projects + _, err := db.CreateEntry("project-a", "task 1", nil) + assert.NoError(t, err) + _, err = db.CreateEntry("project-b", "task 2", nil) + assert.NoError(t, err) + _, err = db.CreateEntry("project-a", "task 3", nil) + assert.NoError(t, err) + + // Get entries for project-a + entries, err := db.GetEntriesByProject("project-a") + assert.NoError(t, err) + assert.Len(t, entries, 2) + for _, entry := range entries { + assert.Equal(t, "project-a", entry.ProjectName) + } + + // Get entries for project-b + entries, err = db.GetEntriesByProject("project-b") + assert.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "project-b", entries[0].ProjectName) + + // Get entries for non-existent project + entries, err = db.GetEntriesByProject("non-existent") + assert.NoError(t, err) + assert.Len(t, entries, 0) +} + +func TestGetEntriesByDateRange(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + twoDaysAgo := now.Add(-48 * time.Hour) + + // Create entries with different start times + _, err := db.CreateManualEntry("project", "old", twoDaysAgo, twoDaysAgo.Add(1*time.Hour), nil) + assert.NoError(t, err) + _, err = db.CreateManualEntry("project", "recent", yesterday, yesterday.Add(1*time.Hour), nil) + assert.NoError(t, err) + _, err = db.CreateManualEntry("project", "today", now.Add(-1*time.Hour), now, nil) + assert.NoError(t, err) + + // Get entries from yesterday onwards + entries, err := db.GetEntriesByDateRange(yesterday.Add(-1*time.Hour), now.Add(1*time.Hour)) + assert.NoError(t, err) + assert.Len(t, entries, 2) // Should get "recent" and "today" +} + +func TestGetAllProjects(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // No projects initially + projects, err := db.GetAllProjects() + assert.NoError(t, err) + assert.Len(t, projects, 0) + + // Create entries for different projects + _, err = db.CreateEntry("zebra-project", "", nil) + assert.NoError(t, err) + _, err = db.CreateEntry("alpha-project", "", nil) + assert.NoError(t, err) + _, err = db.CreateEntry("zebra-project", "", nil) // Duplicate + assert.NoError(t, err) + + // Get all projects + projects, err = db.GetAllProjects() + assert.NoError(t, err) + assert.Len(t, projects, 2) + + // Should be sorted alphabetically + assert.Equal(t, "alpha-project", projects[0]) + assert.Equal(t, "zebra-project", projects[1]) +} + +func TestGetProjectsWithCompletedEntries(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create running entry + _, err := db.CreateEntry("running-project", "", nil) + assert.NoError(t, err) + + // Create completed entry + entry, err := db.CreateEntry("completed-project", "", nil) + assert.NoError(t, err) + err = db.StopEntry(entry.ID) + assert.NoError(t, err) + + // Should only return projects with completed entries + projects, err := db.GetProjectsWithCompletedEntries() + assert.NoError(t, err) + assert.Len(t, projects, 1) + assert.Equal(t, "completed-project", projects[0]) +} + +func TestGetCompletedEntriesByProject(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create running entry + _, err := db.CreateEntry("test-project", "running", nil) + assert.NoError(t, err) + + // Create completed entries + entry1, err := db.CreateEntry("test-project", "completed 1", nil) + assert.NoError(t, err) + err = db.StopEntry(entry1.ID) + assert.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + entry2, err := db.CreateEntry("test-project", "completed 2", nil) + assert.NoError(t, err) + err = db.StopEntry(entry2.ID) + assert.NoError(t, err) + + // Get completed entries + entries, err := db.GetCompletedEntriesByProject("test-project") + assert.NoError(t, err) + assert.Len(t, entries, 2) // Should not include running entry + + // All should have end times + for _, entry := range entries { + assert.NotNil(t, entry.EndTime) + } +} + +func TestUpdateTimeEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create an entry + rate := 100.0 + entry, err := db.CreateEntry("original-project", "original description", &rate) + assert.NoError(t, err) + + // Update the entry + newStartTime := time.Now().Add(-2 * time.Hour) + newEndTime := time.Now().Add(-1 * time.Hour) + newRate := 150.0 + + entry.ProjectName = "updated-project" + entry.Description = "updated description" + entry.StartTime = newStartTime + entry.EndTime = &newEndTime + entry.HourlyRate = &newRate + + err = db.UpdateTimeEntry(entry.ID, entry) + assert.NoError(t, err) + + // Verify update + updated, err := db.GetEntry(entry.ID) + assert.NoError(t, err) + assert.Equal(t, "updated-project", updated.ProjectName) + assert.Equal(t, "updated description", updated.Description) + assert.WithinDuration(t, newStartTime, updated.StartTime, time.Second) + assert.NotNil(t, updated.EndTime) + assert.WithinDuration(t, newEndTime, *updated.EndTime, time.Second) + assert.Equal(t, newRate, *updated.HourlyRate) +} + +func TestDeleteTimeEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create an entry + entry, err := db.CreateEntry("test-project", "to be deleted", nil) + assert.NoError(t, err) + + // Delete it + err = db.DeleteTimeEntry(entry.ID) + assert.NoError(t, err) + + // Verify deletion + _, err = db.GetEntry(entry.ID) + assert.Error(t, err) + + // Delete non-existent entry (should not error) + err = db.DeleteTimeEntry(9999) + assert.NoError(t, err) +} + +func TestTimeEntryDuration(t *testing.T) { + entry := &TimeEntry{ + StartTime: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 1, 11, 30, 0, 0, time.UTC)), + } + + duration := entry.Duration() + assert.Equal(t, 90*time.Minute, duration) +} + +func TestTimeEntryIsRunning(t *testing.T) { + tests := []struct { + name string + entry *TimeEntry + expected bool + }{ + { + name: "running entry", + entry: &TimeEntry{ + StartTime: time.Now(), + EndTime: nil, + }, + expected: true, + }, + { + name: "stopped entry", + entry: &TimeEntry{ + StartTime: time.Now().Add(-1 * time.Hour), + EndTime: timePtr(time.Now()), + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.entry.IsRunning()) + }) + } +} + +// Helper functions +func floatPtr(f float64) *float64 { + return &f +} + +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..49444f2 --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,199 @@ +package ui + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestColorFunctions(t *testing.T) { + t.Run("Success adds green color", func(t *testing.T) { + result := Success("test") + assert.Contains(t, result, ColorGreen) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Error adds red color", func(t *testing.T) { + result := Error("test") + assert.Contains(t, result, ColorRed) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Info adds blue color", func(t *testing.T) { + result := Info("test") + assert.Contains(t, result, ColorBlue) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Warning adds yellow color", func(t *testing.T) { + result := Warning("test") + assert.Contains(t, result, ColorYellow) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Muted adds gray color", func(t *testing.T) { + result := Muted("test") + assert.Contains(t, result, ColorGray) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) +} + +func TestFormattingFunctions(t *testing.T) { + t.Run("Bold adds bold formatting", func(t *testing.T) { + result := Bold("test") + assert.Contains(t, result, FormatBold) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Dim adds dim formatting", func(t *testing.T) { + result := Dim("test") + assert.Contains(t, result, FormatDim) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Italic adds italic formatting", func(t *testing.T) { + result := Italic("test") + assert.Contains(t, result, FormatItalic) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("Underline adds underline formatting", func(t *testing.T) { + result := Underline("test") + assert.Contains(t, result, FormatUnderline) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) +} + +func TestCombinedFormattingFunctions(t *testing.T) { + t.Run("BoldSuccess adds bold and green", func(t *testing.T) { + result := BoldSuccess("test") + assert.Contains(t, result, FormatBold) + assert.Contains(t, result, ColorGreen) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("BoldError adds bold and red", func(t *testing.T) { + result := BoldError("test") + assert.Contains(t, result, FormatBold) + assert.Contains(t, result, ColorRed) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("BoldInfo adds bold and blue", func(t *testing.T) { + result := BoldInfo("test") + assert.Contains(t, result, FormatBold) + assert.Contains(t, result, ColorBlue) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) + + t.Run("BoldWarning adds bold and yellow", func(t *testing.T) { + result := BoldWarning("test") + assert.Contains(t, result, FormatBold) + assert.Contains(t, result, ColorYellow) + assert.Contains(t, result, "test") + assert.Contains(t, result, ColorReset) + }) +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "seconds only", + duration: 45 * time.Second, + expected: "45s", + }, + { + name: "minutes and seconds", + duration: 5*time.Minute + 30*time.Second, + expected: "5m 30s", + }, + { + name: "hours, minutes, and seconds", + duration: 2*time.Hour + 15*time.Minute + 45*time.Second, + expected: "2h 15m 45s", + }, + { + name: "exact hours", + duration: 3 * time.Hour, + expected: "3h 0m 0s", + }, + { + name: "exact minutes", + duration: 10 * time.Minute, + expected: "10m 0s", + }, + { + name: "zero duration", + duration: 0, + expected: "0s", + }, + { + name: "one second", + duration: 1 * time.Second, + expected: "1s", + }, + { + name: "large duration", + duration: 25*time.Hour + 45*time.Minute + 30*time.Second, + expected: "25h 45m 30s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatDuration(tt.duration) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestConstants(t *testing.T) { + t.Run("Color constants are defined", func(t *testing.T) { + assert.NotEmpty(t, ColorReset) + assert.NotEmpty(t, ColorGreen) + assert.NotEmpty(t, ColorRed) + assert.NotEmpty(t, ColorBlue) + assert.NotEmpty(t, ColorYellow) + assert.NotEmpty(t, ColorCyan) + assert.NotEmpty(t, ColorGray) + }) + + t.Run("Format constants are defined", func(t *testing.T) { + assert.NotEmpty(t, FormatBold) + assert.NotEmpty(t, FormatDim) + assert.NotEmpty(t, FormatItalic) + assert.NotEmpty(t, FormatUnderline) + }) + + t.Run("Emoji constants are defined", func(t *testing.T) { + assert.NotEmpty(t, EmojiStart) + assert.NotEmpty(t, EmojiStop) + assert.NotEmpty(t, EmojiStatus) + assert.NotEmpty(t, EmojiStats) + assert.NotEmpty(t, EmojiLog) + assert.NotEmpty(t, EmojiManual) + assert.NotEmpty(t, EmojiInit) + assert.NotEmpty(t, EmojiExport) + assert.NotEmpty(t, EmojiSuccess) + assert.NotEmpty(t, EmojiError) + assert.NotEmpty(t, EmojiWarning) + assert.NotEmpty(t, EmojiInfo) + }) +} From 76b631b2df06ad2bdec4bf83d1d0a3957f6dc68b Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Wed, 17 Dec 2025 17:10:16 -0700 Subject: [PATCH 2/3] Update Go version requirement to 1.25 in docs Raised the minimum required Go version from 1.21 to 1.25 in CONTRIBUTING.md and all installation guides. Updated Linux installation instructions to reference Go 1.25 in download commands. --- CONTRIBUTING.md | 2 +- docs/installation/linux_installation.md | 8 ++++---- docs/installation/macos_installation.md | 2 +- docs/installation/windows_installation.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c380339..c397c28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for your interest in contributing to tmpo! This document provides guid ### Prerequisites -- Go 1.21 or higher +- Go 1.25 or higher - Git ### Setting Up Your Development Environment diff --git a/docs/installation/linux_installation.md b/docs/installation/linux_installation.md index c4958f2..19b00eb 100644 --- a/docs/installation/linux_installation.md +++ b/docs/installation/linux_installation.md @@ -5,7 +5,7 @@ This guide will walk you through installing tmpo on Linux. ## Prerequisites - Linux kernel 3.10 or later (most modern distributions) -- For building from source: Go 1.21 or later +- For building from source: Go 1.25 or later ## Method 1: Download Pre-built Binary (Recommended) @@ -96,9 +96,9 @@ sudo pacman -S go **Or download from [golang.org/dl](https://golang.org/dl/):** ```bash -# Download and install the latest version -wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz -sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz +# Download and install Go 1.25 or later +wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz +sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc source ~/.bashrc diff --git a/docs/installation/macos_installation.md b/docs/installation/macos_installation.md index dd16543..cf9b181 100644 --- a/docs/installation/macos_installation.md +++ b/docs/installation/macos_installation.md @@ -5,7 +5,7 @@ This guide will walk you through installing tmpo on macOS. ## Prerequisites - macOS 11 (Big Sur) or later -- For building from source: Go 1.21 or later +- For building from source: Go 1.25 or later ## Method 1: Download Pre-built Binary (Recommended) diff --git a/docs/installation/windows_installation.md b/docs/installation/windows_installation.md index 6eb30c5..2c9cb38 100644 --- a/docs/installation/windows_installation.md +++ b/docs/installation/windows_installation.md @@ -5,7 +5,7 @@ This guide will walk you through installing tmpo on Windows. ## Prerequisites - Windows 10 or later -- For building from source: Go 1.21 or later +- For building from source: Go 1.25 or later ## Method 1: Download Pre-built Binary (Recommended) From 6e5770051e4229d0d417cb3c0a9c7f277e03ba0f Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Wed, 17 Dec 2025 17:24:36 -0700 Subject: [PATCH 3/3] Refactor test setup to restore working directory Moved saving and restoring of the original working directory into each test subcase instead of the parent test function. This ensures isolation between subtests and prevents side effects from directory changes. --- internal/config/config_test.go | 62 +++++++++++++++++++++------------ internal/project/detect_test.go | 23 +++++++----- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 413667a..84f273c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -125,14 +125,13 @@ func TestSave(t *testing.T) { } func TestCreate(t *testing.T) { - // Save original directory - originalDir, err := os.Getwd() - assert.NoError(t, err) - defer os.Chdir(originalDir) - t.Run("creates config file", func(t *testing.T) { tmpDir := t.TempDir() - err := os.Chdir(tmpDir) + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) assert.NoError(t, err) err = Create("new-project", 125.0) @@ -152,7 +151,11 @@ func TestCreate(t *testing.T) { t.Run("returns error if file exists", func(t *testing.T) { tmpDir := t.TempDir() - err := os.Chdir(tmpDir) + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) assert.NoError(t, err) // Create initial file @@ -167,14 +170,13 @@ func TestCreate(t *testing.T) { } func TestCreateWithTemplate(t *testing.T) { - // Save original directory - originalDir, err := os.Getwd() - assert.NoError(t, err) - defer os.Chdir(originalDir) - t.Run("creates templated config file", func(t *testing.T) { tmpDir := t.TempDir() - err := os.Chdir(tmpDir) + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) assert.NoError(t, err) err = CreateWithTemplate("templated-project", 99.99, "Test description") @@ -202,7 +204,11 @@ func TestCreateWithTemplate(t *testing.T) { t.Run("returns error if file exists", func(t *testing.T) { tmpDir := t.TempDir() - err := os.Chdir(tmpDir) + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) assert.NoError(t, err) // Create initial file @@ -217,16 +223,15 @@ func TestCreateWithTemplate(t *testing.T) { } func TestFindAndLoad(t *testing.T) { - // Save original directory - originalDir, err := os.Getwd() - assert.NoError(t, err) - defer os.Chdir(originalDir) - t.Run("finds config in current directory", func(t *testing.T) { tmpDir := t.TempDir() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + configPath := filepath.Join(tmpDir, ".tmporc") cfg := &Config{ProjectName: "current-dir"} - err := cfg.Save(configPath) + err = cfg.Save(configPath) assert.NoError(t, err) err = os.Chdir(tmpDir) @@ -245,9 +250,13 @@ func TestFindAndLoad(t *testing.T) { t.Run("finds config in parent directory", func(t *testing.T) { tmpDir := t.TempDir() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + configPath := filepath.Join(tmpDir, ".tmporc") cfg := &Config{ProjectName: "parent-dir"} - err := cfg.Save(configPath) + err = cfg.Save(configPath) assert.NoError(t, err) // Create and change to subdirectory @@ -270,7 +279,11 @@ func TestFindAndLoad(t *testing.T) { t.Run("returns error when not found", func(t *testing.T) { tmpDir := t.TempDir() - err := os.Chdir(tmpDir) + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) assert.NoError(t, err) found, path, err := FindAndLoad() @@ -282,11 +295,14 @@ func TestFindAndLoad(t *testing.T) { t.Run("uses nearest config file", func(t *testing.T) { tmpDir := t.TempDir() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) // Create config in root rootConfig := filepath.Join(tmpDir, ".tmporc") cfg := &Config{ProjectName: "root"} - err := cfg.Save(rootConfig) + err = cfg.Save(rootConfig) assert.NoError(t, err) // Create config in subdirectory diff --git a/internal/project/detect_test.go b/internal/project/detect_test.go index 8a70bd3..c62b442 100644 --- a/internal/project/detect_test.go +++ b/internal/project/detect_test.go @@ -99,7 +99,11 @@ func TestIsInGitRepo(t *testing.T) { t.Run("returns false when not in git repo", func(t *testing.T) { tmpDir := t.TempDir() - err := os.Chdir(tmpDir) + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + err = os.Chdir(tmpDir) assert.NoError(t, err) result := IsInGitRepo() @@ -136,15 +140,14 @@ func TestGetGitRoot(t *testing.T) { } func TestDetectProject(t *testing.T) { - // Save original directory - originalDir, err := os.Getwd() - assert.NoError(t, err) - defer os.Chdir(originalDir) - t.Run("detects from tmporc", func(t *testing.T) { tmpDir := t.TempDir() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + projectDir := filepath.Join(tmpDir, "my-cool-project") - err := os.MkdirAll(projectDir, 0755) + err = os.MkdirAll(projectDir, 0755) assert.NoError(t, err) // Create .tmporc @@ -179,8 +182,12 @@ func TestDetectProject(t *testing.T) { t.Run("falls back to directory name", func(t *testing.T) { tmpDir := t.TempDir() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + projectDir := filepath.Join(tmpDir, "fallback-project") - err := os.MkdirAll(projectDir, 0755) + err = os.MkdirAll(projectDir, 0755) assert.NoError(t, err) err = os.Chdir(projectDir)