Skip to content

Commit 7e4024b

Browse files
authored
test: add comprehensive unit tests and CI workflow (#146)
* test: add comprehensive unit tests and CI workflow Add unit tests for: - Branch name validation and sanitization (git adapter) - Session name sanitization (domain) - Git URL parsing and comparison (git adapter) - Notification service event handling and execution ID resolution - Token stats service caching and aggregation - Session service delete and rename operations - JSONL parser for token usage extraction Also includes: - GitHub Actions workflow for running tests on PRs - Updated mockery config with new interfaces - Updated testing.md with unit test guidelines * fix: regenerate mocks and use go-version-file in CI - Regenerate mocks to include UpdateExecutionID method - Use go-version-file instead of hardcoded version in workflow - Make lint job continue-on-error for Go version compatibility * chore: remove lint job from CI workflow
1 parent 4f14154 commit 7e4024b

14 files changed

Lines changed: 2582 additions & 1 deletion

.claude/rules/testing.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,59 @@
1+
# Testing Guidelines
12

2-
This is a proof of concept, you don't need to implement unit tests.
3+
## Running Unit Tests
4+
5+
```bash
6+
# Run all unit tests
7+
go test ./internal/...
8+
9+
# Run tests with verbose output
10+
go test ./internal/... -v
11+
12+
# Run tests with coverage
13+
go test ./internal/... -cover
14+
15+
# Run specific test
16+
go test ./internal/services/... -run TestCreateSession
17+
```
18+
19+
## Test Patterns
20+
21+
Follow the established patterns from existing tests:
22+
23+
```go
24+
func TestFunctionName_Scenario(t *testing.T) {
25+
// Create mocks
26+
gitRepo := portsmocks.NewMockGitRepository(t)
27+
28+
// Setup expectations
29+
gitRepo.EXPECT().Method(mock.Anything).Return(value, nil)
30+
31+
// Create service
32+
service := NewService(gitRepo)
33+
34+
// Execute
35+
result, err := service.Method(...)
36+
37+
// Assert
38+
require.NoError(t, err)
39+
assert.Equal(t, expected, result)
40+
}
41+
```
42+
43+
## Mocks
44+
45+
Mocks are generated using mockery. Config is in `.mockery.yaml`.
46+
47+
```bash
48+
# Regenerate mocks after adding interfaces
49+
mockery
50+
```
51+
52+
## Test Coverage
53+
54+
Focus unit tests on:
55+
- Pure functions (validation, sanitization, parsing)
56+
- Service layer with mocked dependencies
57+
- Error handling paths
58+
59+
Integration tests run in Docker (see testing_safety.md).

.github/workflows/test.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Test
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout code
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Go
17+
uses: actions/setup-go@v5
18+
with:
19+
go-version-file: 'go.mod'
20+
21+
- name: Download dependencies
22+
run: go mod download
23+
24+
- name: Run tests
25+
run: go test ./internal/... -v -race -coverprofile=coverage.out
26+
27+
- name: Upload coverage
28+
uses: codecov/codecov-action@v4
29+
with:
30+
files: coverage.out
31+
fail_ci_if_error: false
32+
continue-on-error: true
33+
34+
build:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- name: Checkout code
38+
uses: actions/checkout@v4
39+
40+
- name: Set up Go
41+
uses: actions/setup-go@v5
42+
with:
43+
go-version-file: 'go.mod'
44+
45+
- name: Build
46+
run: go build -o rocha ./cmd

.mockery.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ packages:
1212
ProcessInspector: {}
1313
SessionReader: {}
1414
SessionRepository: {}
15+
SessionStateUpdater: {}
1516
SessionWriter: {}
17+
SoundPlayer: {}
1618
TmuxClient: {}
1719
TmuxSessionLifecycle: {}
20+
TokenUsageReader: {}
1821
github.com/renato0307/rocha/internal/services:
1922
interfaces:
2023
ClaudeDirResolver: {}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package claude
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestGetTodayUsage_EmptyDirectory(t *testing.T) {
14+
tempDir := t.TempDir()
15+
parser := NewSessionParserWithDir(tempDir)
16+
17+
usage, err := parser.GetTodayUsage()
18+
19+
require.NoError(t, err)
20+
assert.Empty(t, usage)
21+
}
22+
23+
func TestGetTodayUsage_NonExistentDirectory(t *testing.T) {
24+
parser := NewSessionParserWithDir("/non/existent/path")
25+
26+
usage, err := parser.GetTodayUsage()
27+
28+
require.NoError(t, err)
29+
assert.Empty(t, usage)
30+
}
31+
32+
func TestGetTodayUsage_ParsesTokenUsage(t *testing.T) {
33+
tempDir := t.TempDir()
34+
projectDir := filepath.Join(tempDir, "test-project")
35+
require.NoError(t, os.MkdirAll(projectDir, 0755))
36+
37+
// Create JSONL file with today's timestamp
38+
now := time.Now()
39+
timestamp := now.Format(time.RFC3339)
40+
content := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":5}}}
41+
`
42+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
43+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
44+
45+
parser := NewSessionParserWithDir(tempDir)
46+
47+
usage, err := parser.GetTodayUsage()
48+
49+
require.NoError(t, err)
50+
require.Len(t, usage, 1)
51+
assert.Equal(t, 100, usage[0].InputTokens)
52+
assert.Equal(t, 50, usage[0].OutputTokens)
53+
assert.Equal(t, 10, usage[0].CacheCreation)
54+
assert.Equal(t, 5, usage[0].CacheRead)
55+
}
56+
57+
func TestGetTodayUsage_FiltersOldEntries(t *testing.T) {
58+
tempDir := t.TempDir()
59+
projectDir := filepath.Join(tempDir, "test-project")
60+
require.NoError(t, os.MkdirAll(projectDir, 0755))
61+
62+
// Create entries from today and yesterday
63+
now := time.Now()
64+
todayTimestamp := now.Format(time.RFC3339)
65+
yesterdayTimestamp := now.AddDate(0, 0, -1).Format(time.RFC3339)
66+
67+
content := `{"type":"assistant","timestamp":"` + yesterdayTimestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
68+
{"type":"assistant","timestamp":"` + todayTimestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
69+
`
70+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
71+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
72+
73+
parser := NewSessionParserWithDir(tempDir)
74+
75+
usage, err := parser.GetTodayUsage()
76+
77+
require.NoError(t, err)
78+
require.Len(t, usage, 1)
79+
assert.Equal(t, 200, usage[0].InputTokens)
80+
}
81+
82+
func TestGetTodayUsage_SkipsNonAssistantMessages(t *testing.T) {
83+
tempDir := t.TempDir()
84+
projectDir := filepath.Join(tempDir, "test-project")
85+
require.NoError(t, os.MkdirAll(projectDir, 0755))
86+
87+
now := time.Now()
88+
timestamp := now.Format(time.RFC3339)
89+
content := `{"type":"user","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100}}}
90+
{"type":"system","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200}}}
91+
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":300,"output_tokens":150}}}
92+
`
93+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
94+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
95+
96+
parser := NewSessionParserWithDir(tempDir)
97+
98+
usage, err := parser.GetTodayUsage()
99+
100+
require.NoError(t, err)
101+
require.Len(t, usage, 1)
102+
assert.Equal(t, 300, usage[0].InputTokens)
103+
}
104+
105+
func TestGetTodayUsage_SkipsEntriesWithoutUsage(t *testing.T) {
106+
tempDir := t.TempDir()
107+
projectDir := filepath.Join(tempDir, "test-project")
108+
require.NoError(t, os.MkdirAll(projectDir, 0755))
109+
110+
now := time.Now()
111+
timestamp := now.Format(time.RFC3339)
112+
content := `{"type":"assistant","timestamp":"` + timestamp + `","message":{}}
113+
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
114+
{"type":"assistant","timestamp":"` + timestamp + `"}
115+
`
116+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
117+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
118+
119+
parser := NewSessionParserWithDir(tempDir)
120+
121+
usage, err := parser.GetTodayUsage()
122+
123+
require.NoError(t, err)
124+
require.Len(t, usage, 1)
125+
assert.Equal(t, 100, usage[0].InputTokens)
126+
}
127+
128+
func TestGetTodayUsage_HandlesInvalidJSON(t *testing.T) {
129+
tempDir := t.TempDir()
130+
projectDir := filepath.Join(tempDir, "test-project")
131+
require.NoError(t, os.MkdirAll(projectDir, 0755))
132+
133+
now := time.Now()
134+
timestamp := now.Format(time.RFC3339)
135+
content := `not valid json
136+
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
137+
{invalid
138+
`
139+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
140+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
141+
142+
parser := NewSessionParserWithDir(tempDir)
143+
144+
usage, err := parser.GetTodayUsage()
145+
146+
require.NoError(t, err)
147+
require.Len(t, usage, 1)
148+
assert.Equal(t, 100, usage[0].InputTokens)
149+
}
150+
151+
func TestGetTodayUsage_HandlesInvalidTimestamp(t *testing.T) {
152+
tempDir := t.TempDir()
153+
projectDir := filepath.Join(tempDir, "test-project")
154+
require.NoError(t, os.MkdirAll(projectDir, 0755))
155+
156+
now := time.Now()
157+
timestamp := now.Format(time.RFC3339)
158+
content := `{"type":"assistant","timestamp":"invalid-timestamp","message":{"usage":{"input_tokens":100}}}
159+
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
160+
`
161+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
162+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
163+
164+
parser := NewSessionParserWithDir(tempDir)
165+
166+
usage, err := parser.GetTodayUsage()
167+
168+
require.NoError(t, err)
169+
require.Len(t, usage, 1)
170+
assert.Equal(t, 200, usage[0].InputTokens)
171+
}
172+
173+
func TestGetTodayUsage_ProcessesMultipleProjects(t *testing.T) {
174+
tempDir := t.TempDir()
175+
now := time.Now()
176+
timestamp := now.Format(time.RFC3339)
177+
178+
// Create two project directories
179+
project1 := filepath.Join(tempDir, "project1")
180+
project2 := filepath.Join(tempDir, "project2")
181+
require.NoError(t, os.MkdirAll(project1, 0755))
182+
require.NoError(t, os.MkdirAll(project2, 0755))
183+
184+
content1 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
185+
`
186+
content2 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
187+
`
188+
require.NoError(t, os.WriteFile(filepath.Join(project1, "session.jsonl"), []byte(content1), 0644))
189+
require.NoError(t, os.WriteFile(filepath.Join(project2, "session.jsonl"), []byte(content2), 0644))
190+
191+
parser := NewSessionParserWithDir(tempDir)
192+
193+
usage, err := parser.GetTodayUsage()
194+
195+
require.NoError(t, err)
196+
require.Len(t, usage, 2)
197+
// Check total tokens
198+
totalInput := 0
199+
for _, u := range usage {
200+
totalInput += u.InputTokens
201+
}
202+
assert.Equal(t, 300, totalInput)
203+
}
204+
205+
func TestGetTodayUsage_ProcessesMultipleJSONLFiles(t *testing.T) {
206+
tempDir := t.TempDir()
207+
projectDir := filepath.Join(tempDir, "test-project")
208+
require.NoError(t, os.MkdirAll(projectDir, 0755))
209+
210+
now := time.Now()
211+
timestamp := now.Format(time.RFC3339)
212+
213+
content1 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
214+
`
215+
content2 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
216+
`
217+
require.NoError(t, os.WriteFile(filepath.Join(projectDir, "session1.jsonl"), []byte(content1), 0644))
218+
require.NoError(t, os.WriteFile(filepath.Join(projectDir, "session2.jsonl"), []byte(content2), 0644))
219+
220+
parser := NewSessionParserWithDir(tempDir)
221+
222+
usage, err := parser.GetTodayUsage()
223+
224+
require.NoError(t, err)
225+
require.Len(t, usage, 2)
226+
}
227+
228+
func TestGetTodayUsage_SkipsNonDirectories(t *testing.T) {
229+
tempDir := t.TempDir()
230+
231+
// Create a file (not a directory) in the projects dir
232+
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "not-a-dir.txt"), []byte("hello"), 0644))
233+
234+
parser := NewSessionParserWithDir(tempDir)
235+
236+
usage, err := parser.GetTodayUsage()
237+
238+
require.NoError(t, err)
239+
assert.Empty(t, usage)
240+
}
241+
242+
func TestGetTodayUsage_SkipsEmptyLines(t *testing.T) {
243+
tempDir := t.TempDir()
244+
projectDir := filepath.Join(tempDir, "test-project")
245+
require.NoError(t, os.MkdirAll(projectDir, 0755))
246+
247+
now := time.Now()
248+
timestamp := now.Format(time.RFC3339)
249+
content := `
250+
251+
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
252+
253+
`
254+
jsonlPath := filepath.Join(projectDir, "session.jsonl")
255+
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))
256+
257+
parser := NewSessionParserWithDir(tempDir)
258+
259+
usage, err := parser.GetTodayUsage()
260+
261+
require.NoError(t, err)
262+
require.Len(t, usage, 1)
263+
assert.Equal(t, 100, usage[0].InputTokens)
264+
}
265+
266+
func TestNewSessionParser_DefaultDirectory(t *testing.T) {
267+
parser := NewSessionParser()
268+
269+
// Just verify it doesn't panic and creates a parser
270+
assert.NotNil(t, parser)
271+
}

0 commit comments

Comments
 (0)