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 527134c..99d3235 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,11 +22,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/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/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/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) 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..84f273c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,331 @@ +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) { + t.Run("creates config file", func(t *testing.T) { + tmpDir := t.TempDir() + 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) + 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() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + 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) { + t.Run("creates templated config file", func(t *testing.T) { + tmpDir := t.TempDir() + 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") + 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() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + 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) { + 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) + 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() + 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) + 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() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + 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() + 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) + 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..c62b442 --- /dev/null +++ b/internal/project/detect_test.go @@ -0,0 +1,200 @@ +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() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + 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) { + 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) + 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() + originalDir, err := os.Getwd() + assert.NoError(t, err) + defer os.Chdir(originalDir) + + 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) + }) +}