diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 51417fb..69bb7b3 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,2 +1,59 @@ +# Testing Guidelines -This is a proof of concept, you don't need to implement unit tests. \ No newline at end of file +## Running Unit Tests + +```bash +# Run all unit tests +go test ./internal/... + +# Run tests with verbose output +go test ./internal/... -v + +# Run tests with coverage +go test ./internal/... -cover + +# Run specific test +go test ./internal/services/... -run TestCreateSession +``` + +## Test Patterns + +Follow the established patterns from existing tests: + +```go +func TestFunctionName_Scenario(t *testing.T) { + // Create mocks + gitRepo := portsmocks.NewMockGitRepository(t) + + // Setup expectations + gitRepo.EXPECT().Method(mock.Anything).Return(value, nil) + + // Create service + service := NewService(gitRepo) + + // Execute + result, err := service.Method(...) + + // Assert + require.NoError(t, err) + assert.Equal(t, expected, result) +} +``` + +## Mocks + +Mocks are generated using mockery. Config is in `.mockery.yaml`. + +```bash +# Regenerate mocks after adding interfaces +mockery +``` + +## Test Coverage + +Focus unit tests on: +- Pure functions (validation, sanitization, parsing) +- Service layer with mocked dependencies +- Error handling paths + +Integration tests run in Docker (see testing_safety.md). diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..87789a1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test ./internal/... -v -race -coverprofile=coverage.out + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage.out + fail_ci_if_error: false + continue-on-error: true + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build + run: go build -o rocha ./cmd diff --git a/.mockery.yaml b/.mockery.yaml index 3181bdb..109e87b 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -12,9 +12,12 @@ packages: ProcessInspector: {} SessionReader: {} SessionRepository: {} + SessionStateUpdater: {} SessionWriter: {} + SoundPlayer: {} TmuxClient: {} TmuxSessionLifecycle: {} + TokenUsageReader: {} github.com/renato0307/rocha/internal/services: interfaces: ClaudeDirResolver: {} diff --git a/internal/adapters/claude/session_parser_test.go b/internal/adapters/claude/session_parser_test.go new file mode 100644 index 0000000..d6b3140 --- /dev/null +++ b/internal/adapters/claude/session_parser_test.go @@ -0,0 +1,271 @@ +package claude + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTodayUsage_EmptyDirectory(t *testing.T) { + tempDir := t.TempDir() + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + assert.Empty(t, usage) +} + +func TestGetTodayUsage_NonExistentDirectory(t *testing.T) { + parser := NewSessionParserWithDir("/non/existent/path") + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + assert.Empty(t, usage) +} + +func TestGetTodayUsage_ParsesTokenUsage(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + // Create JSONL file with today's timestamp + now := time.Now() + timestamp := now.Format(time.RFC3339) + content := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":5}}} +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 100, usage[0].InputTokens) + assert.Equal(t, 50, usage[0].OutputTokens) + assert.Equal(t, 10, usage[0].CacheCreation) + assert.Equal(t, 5, usage[0].CacheRead) +} + +func TestGetTodayUsage_FiltersOldEntries(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + // Create entries from today and yesterday + now := time.Now() + todayTimestamp := now.Format(time.RFC3339) + yesterdayTimestamp := now.AddDate(0, 0, -1).Format(time.RFC3339) + + content := `{"type":"assistant","timestamp":"` + yesterdayTimestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}} +{"type":"assistant","timestamp":"` + todayTimestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}} +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 200, usage[0].InputTokens) +} + +func TestGetTodayUsage_SkipsNonAssistantMessages(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + now := time.Now() + timestamp := now.Format(time.RFC3339) + content := `{"type":"user","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100}}} +{"type":"system","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200}}} +{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":300,"output_tokens":150}}} +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 300, usage[0].InputTokens) +} + +func TestGetTodayUsage_SkipsEntriesWithoutUsage(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + now := time.Now() + timestamp := now.Format(time.RFC3339) + content := `{"type":"assistant","timestamp":"` + timestamp + `","message":{}} +{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}} +{"type":"assistant","timestamp":"` + timestamp + `"} +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 100, usage[0].InputTokens) +} + +func TestGetTodayUsage_HandlesInvalidJSON(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + now := time.Now() + timestamp := now.Format(time.RFC3339) + content := `not valid json +{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}} +{invalid +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 100, usage[0].InputTokens) +} + +func TestGetTodayUsage_HandlesInvalidTimestamp(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + now := time.Now() + timestamp := now.Format(time.RFC3339) + content := `{"type":"assistant","timestamp":"invalid-timestamp","message":{"usage":{"input_tokens":100}}} +{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}} +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 200, usage[0].InputTokens) +} + +func TestGetTodayUsage_ProcessesMultipleProjects(t *testing.T) { + tempDir := t.TempDir() + now := time.Now() + timestamp := now.Format(time.RFC3339) + + // Create two project directories + project1 := filepath.Join(tempDir, "project1") + project2 := filepath.Join(tempDir, "project2") + require.NoError(t, os.MkdirAll(project1, 0755)) + require.NoError(t, os.MkdirAll(project2, 0755)) + + content1 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}} +` + content2 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}} +` + require.NoError(t, os.WriteFile(filepath.Join(project1, "session.jsonl"), []byte(content1), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(project2, "session.jsonl"), []byte(content2), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 2) + // Check total tokens + totalInput := 0 + for _, u := range usage { + totalInput += u.InputTokens + } + assert.Equal(t, 300, totalInput) +} + +func TestGetTodayUsage_ProcessesMultipleJSONLFiles(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + now := time.Now() + timestamp := now.Format(time.RFC3339) + + content1 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}} +` + content2 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "session1.jsonl"), []byte(content1), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "session2.jsonl"), []byte(content2), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 2) +} + +func TestGetTodayUsage_SkipsNonDirectories(t *testing.T) { + tempDir := t.TempDir() + + // Create a file (not a directory) in the projects dir + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "not-a-dir.txt"), []byte("hello"), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + assert.Empty(t, usage) +} + +func TestGetTodayUsage_SkipsEmptyLines(t *testing.T) { + tempDir := t.TempDir() + projectDir := filepath.Join(tempDir, "test-project") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + now := time.Now() + timestamp := now.Format(time.RFC3339) + content := ` + +{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}} + +` + jsonlPath := filepath.Join(projectDir, "session.jsonl") + require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644)) + + parser := NewSessionParserWithDir(tempDir) + + usage, err := parser.GetTodayUsage() + + require.NoError(t, err) + require.Len(t, usage, 1) + assert.Equal(t, 100, usage[0].InputTokens) +} + +func TestNewSessionParser_DefaultDirectory(t *testing.T) { + parser := NewSessionParser() + + // Just verify it doesn't panic and creates a parser + assert.NotNil(t, parser) +} diff --git a/internal/adapters/git/repo_test.go b/internal/adapters/git/repo_test.go new file mode 100644 index 0000000..a739dfb --- /dev/null +++ b/internal/adapters/git/repo_test.go @@ -0,0 +1,304 @@ +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsGitURL_HTTPUrls(t *testing.T) { + tests := []struct { + url string + expected bool + }{ + {"https://github.com/owner/repo", true}, + {"https://github.com/owner/repo.git", true}, + {"http://github.com/owner/repo", true}, + {"https://gitlab.com/owner/repo.git", true}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result := isGitURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsGitURL_SSHUrls(t *testing.T) { + tests := []struct { + url string + expected bool + }{ + {"git@github.com:owner/repo", true}, + {"git@github.com:owner/repo.git", true}, + {"git@gitlab.com:owner/repo.git", true}, + {"ssh://git@github.com/owner/repo", true}, + {"user@host.com:path/repo", true}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result := isGitURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsGitURL_GitProtocol(t *testing.T) { + tests := []struct { + url string + expected bool + }{ + {"git://github.com/owner/repo", true}, + {"git://github.com/owner/repo.git", true}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result := isGitURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsGitURL_FTPUrls(t *testing.T) { + tests := []struct { + url string + expected bool + }{ + {"ftp://example.com/repo.git", true}, + {"ftps://example.com/repo.git", true}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result := isGitURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsGitURL_LocalPaths(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + {"/home/user/repo", false}, + {"./relative/path", false}, + {"~/projects/repo", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := isGitURL(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsGitURL_DotGitSuffix(t *testing.T) { + // Paths ending with .git are considered URLs + tests := []struct { + path string + expected bool + }{ + {"/path/to/repo.git", true}, + {"repo.git", true}, + {"/path/to/repo.git/", true}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := isGitURL(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseRepoSource_EmptySource(t *testing.T) { + _, err := parseRepoSource("") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestParseRepoSource_HTTPSUrl(t *testing.T) { + tests := []struct { + name string + url string + expectedOwner string + expectedRepo string + }{ + {"github", "https://github.com/owner/repo", "owner", "repo"}, + {"github with .git", "https://github.com/owner/repo.git", "owner", "repo"}, + {"gitlab", "https://gitlab.com/owner/repo.git", "owner", "repo"}, + {"nested path", "https://github.com/org/subgroup/repo", "subgroup", "repo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseRepoSource(tt.url) + require.NoError(t, err) + assert.True(t, result.isRemote) + assert.Equal(t, tt.expectedOwner, result.owner) + assert.Equal(t, tt.expectedRepo, result.repo) + }) + } +} + +func TestParseRepoSource_SSHUrl(t *testing.T) { + tests := []struct { + name string + url string + expectedOwner string + expectedRepo string + }{ + {"github", "git@github.com:owner/repo", "owner", "repo"}, + {"github with .git", "git@github.com:owner/repo.git", "owner", "repo"}, + {"gitlab", "git@gitlab.com:owner/repo.git", "owner", "repo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseRepoSource(tt.url) + require.NoError(t, err) + assert.True(t, result.isRemote) + assert.Equal(t, tt.expectedOwner, result.owner) + assert.Equal(t, tt.expectedRepo, result.repo) + }) + } +} + +func TestParseRepoSource_SSHProtocolUrl(t *testing.T) { + tests := []struct { + name string + url string + expectedOwner string + expectedRepo string + }{ + {"with user", "ssh://git@github.com/owner/repo", "owner", "repo"}, + {"with .git", "ssh://git@github.com/owner/repo.git", "owner", "repo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseRepoSource(tt.url) + require.NoError(t, err) + assert.True(t, result.isRemote) + assert.Equal(t, tt.expectedOwner, result.owner) + assert.Equal(t, tt.expectedRepo, result.repo) + }) + } +} + +func TestParseRepoSource_BranchFragment(t *testing.T) { + tests := []struct { + name string + url string + expectedBranch string + expectedPath string + }{ + {"with branch", "https://github.com/owner/repo#feature-branch", "feature-branch", "https://github.com/owner/repo"}, + {"with main", "https://github.com/owner/repo#main", "main", "https://github.com/owner/repo"}, + {"no branch", "https://github.com/owner/repo", "", "https://github.com/owner/repo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseRepoSource(tt.url) + require.NoError(t, err) + assert.Equal(t, tt.expectedBranch, result.branch) + assert.Equal(t, tt.expectedPath, result.path) + }) + } +} + +func TestParseRepoSource_LocalPath(t *testing.T) { + result, err := parseRepoSource("/home/user/repo") + require.NoError(t, err) + assert.False(t, result.isRemote) + assert.Equal(t, "/home/user/repo", result.path) +} + +func TestIsSameRepo_NormalizeGitSuffix(t *testing.T) { + tests := []struct { + name string + url1 string + url2 string + expected bool + }{ + {"same url", "https://github.com/owner/repo", "https://github.com/owner/repo", true}, + {"one with .git", "https://github.com/owner/repo", "https://github.com/owner/repo.git", true}, + {"both with .git", "https://github.com/owner/repo.git", "https://github.com/owner/repo.git", true}, + {"trailing slash", "https://github.com/owner/repo/", "https://github.com/owner/repo", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSameRepo(tt.url1, tt.url2) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsSameRepo_CaseInsensitivity(t *testing.T) { + tests := []struct { + name string + url1 string + url2 string + expected bool + }{ + {"different case", "https://github.com/Owner/Repo", "https://github.com/owner/repo", true}, + {"mixed case", "https://GitHub.COM/owner/repo", "https://github.com/owner/repo", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSameRepo(tt.url1, tt.url2) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsSameRepo_DifferentProtocols(t *testing.T) { + tests := []struct { + name string + url1 string + url2 string + expected bool + }{ + {"https vs ssh", "https://github.com/owner/repo", "git@github.com:owner/repo", true}, + {"https vs ssh .git", "https://github.com/owner/repo.git", "git@github.com:owner/repo.git", true}, + {"http vs https", "http://github.com/owner/repo", "https://github.com/owner/repo", true}, + {"ssh:// vs git@", "ssh://git@github.com/owner/repo", "git@github.com:owner/repo", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSameRepo(tt.url1, tt.url2) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsSameRepo_DifferentRepos(t *testing.T) { + tests := []struct { + name string + url1 string + url2 string + expected bool + }{ + {"different owner", "https://github.com/owner1/repo", "https://github.com/owner2/repo", false}, + {"different repo", "https://github.com/owner/repo1", "https://github.com/owner/repo2", false}, + {"different host", "https://github.com/owner/repo", "https://gitlab.com/owner/repo", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSameRepo(tt.url1, tt.url2) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/adapters/git/sanitize_test.go b/internal/adapters/git/sanitize_test.go new file mode 100644 index 0000000..fd71993 --- /dev/null +++ b/internal/adapters/git/sanitize_test.go @@ -0,0 +1,279 @@ +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateBranchName_EmptyName(t *testing.T) { + err := validateBranchName("") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestValidateBranchName_InvalidPrefix(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"starts with dot", ".hidden", "start with '.'"}, + {"starts with slash", "/path", "start with '/'"}, + {"starts with hyphen", "-feature", "start with '-'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBranchName(tt.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +func TestValidateBranchName_InvalidSuffix(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"ends with .lock", "branch.lock", ".lock"}, + {"ends with dot", "branch.", "end with '.'"}, + {"ends with slash", "branch/", "end with '/'"}, + {"ends with hyphen", "branch-", "end with '-'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBranchName(tt.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +func TestValidateBranchName_InvalidSequences(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"double dot", "feature..branch", "'..'"}, + {"double slash", "feature//branch", "'//'"}, + {"at brace", "branch@{0}", "'@{'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBranchName(tt.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +func TestValidateBranchName_ControlCharacters(t *testing.T) { + err := validateBranchName("feature\x00branch") + require.Error(t, err) + assert.Contains(t, err.Error(), "control characters") +} + +func TestValidateBranchName_InvalidCharacters(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"space", "feature branch"}, + {"tilde", "feature~1"}, + {"caret", "feature^1"}, + {"colon", "feature:1"}, + {"question mark", "feature?"}, + {"asterisk", "feature*"}, + {"bracket", "feature[0]"}, + {"backslash", "feature\\path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBranchName(tt.input) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid characters") + }) + } +} + +func TestValidateBranchName_AtSymbolAlone(t *testing.T) { + err := validateBranchName("@") + require.Error(t, err) + // '@' is not in valid chars, so it fails with invalid characters error first + assert.Contains(t, err.Error(), "invalid characters") +} + +func TestValidateBranchName_ValidNames(t *testing.T) { + tests := []string{ + "main", + "feature/add-tests", + "fix_bug_123", + "release-1.0.0", + "user/feature.name", + "a", + "UPPERCASE", + "MixedCase123", + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + err := validateBranchName(input) + assert.NoError(t, err) + }) + } +} + +func TestSanitizeBranchName_EmptyInput(t *testing.T) { + _, err := sanitizeBranchName("") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestSanitizeBranchName_Lowercase(t *testing.T) { + result, err := sanitizeBranchName("FEATURE-BRANCH") + require.NoError(t, err) + assert.Equal(t, "feature-branch", result) +} + +func TestSanitizeBranchName_InvalidCharReplacement(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"spaces", "feature branch name", "feature-branch-name"}, + {"special chars", "feature@#$branch", "feature-branch"}, + {"shell metachar", "feature&branch", "feature-branch"}, + {"parentheses", "feature(test)", "feature-test"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sanitizeBranchName(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeBranchName_ConsecutiveHyphenCollapse(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"double hyphen", "feature--branch", "feature-branch"}, + {"triple hyphen", "feature---branch", "feature-branch"}, + {"multiple special chars", "feature@#$branch", "feature-branch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sanitizeBranchName(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeBranchName_ControlCharRemoval(t *testing.T) { + result, err := sanitizeBranchName("feature\x00\x01branch") + require.NoError(t, err) + assert.Equal(t, "featurebranch", result) +} + +func TestSanitizeBranchName_TrimLeadingChars(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"leading dot", ".hidden-branch", "hidden-branch"}, + {"leading slash", "/path/branch", "path/branch"}, + {"leading hyphen", "-feature", "feature"}, + {"multiple leading", "./-feature", "feature"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sanitizeBranchName(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeBranchName_TrimTrailingChars(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"trailing .lock", "branch.lock", "branch"}, + {"trailing dot", "branch.", "branch"}, + {"trailing slash", "branch/", "branch"}, + {"trailing hyphen", "branch-", "branch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sanitizeBranchName(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeBranchName_DoubleDotReplacement(t *testing.T) { + result, err := sanitizeBranchName("feature..branch") + require.NoError(t, err) + assert.Equal(t, "feature-branch", result) +} + +func TestSanitizeBranchName_DoubleSlashCollapse(t *testing.T) { + result, err := sanitizeBranchName("feature//branch") + require.NoError(t, err) + assert.Equal(t, "feature/branch", result) +} + +func TestSanitizeBranchName_ResultEmpty(t *testing.T) { + _, err := sanitizeBranchName("...") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestSanitizeBranchName_ResultIsAtSymbol(t *testing.T) { + // "@" is replaced by "-" which then gets trimmed, resulting in empty + _, err := sanitizeBranchName("@") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestSanitizeBranchName_ValidOutput(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple", "feature", "feature"}, + {"with slash", "feature/test", "feature/test"}, + {"with numbers", "release-1.0.0", "release-1.0.0"}, + {"mixed case", "Feature-Branch", "feature-branch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sanitizeBranchName(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/domain/session_test.go b/internal/domain/session_test.go new file mode 100644 index 0000000..c6c3a83 --- /dev/null +++ b/internal/domain/session_test.go @@ -0,0 +1,220 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizeSessionName_KeepsAlphanumeric(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"Session123", "Session123"}, + {"test-name", "test-name"}, + {"with.dot", "with.dot"}, + {"under_score", "under_score"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_SpaceReplacement(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"single space", "hello world", "hello_world"}, + {"multiple spaces", "hello world", "hello_world"}, + {"tabs", "hello\tworld", "hello_world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_ParenthesesReplacement(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"open paren", "test(name", "test_name"}, + {"close paren", "test)name", "test_name"}, + {"both parens", "test(name)", "test_name"}, + {"nested parens", "test((name))", "test_name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_SlashReplacement(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"single slash", "feature/branch", "feature_branch"}, + {"multiple slashes", "path/to/file", "path_to_file"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_ConsecutiveUnderscoreCollapse(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"space then paren", "test (name)", "test_name"}, + {"multiple spaces and parens", "test (name) here", "test_name_here"}, + {"slash space combo", "path/ name", "path_name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_SpecialCharsRemoved(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"brackets", "test[name]", "testname"}, + {"braces", "test{name}", "testname"}, + {"colon", "test:name", "testname"}, + {"semicolon", "test;name", "testname"}, + {"comma", "test,name", "testname"}, + {"exclamation", "test!name", "testname"}, + {"at sign", "test@name", "testname"}, + {"hash", "test#name", "testname"}, + {"dollar", "test$name", "testname"}, + {"percent", "test%name", "testname"}, + {"caret", "test^name", "testname"}, + {"ampersand", "test&name", "testname"}, + {"asterisk", "test*name", "testname"}, + {"plus", "test+name", "testname"}, + {"equals", "test=name", "testname"}, + {"pipe", "test|name", "testname"}, + {"backslash", "test\\name", "testname"}, + {"single quote", "test'name", "testname"}, + {"double quote", "test\"name", "testname"}, + {"less than", "testname", "testname"}, + {"question mark", "test?name", "testname"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_TrailingUnderscoreTrimmed(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"trailing space", "name ", "name"}, + {"trailing paren", "name)", "name"}, + {"trailing slash", "name/", "name"}, + {"trailing underscore", "name_", "name"}, + {"multiple trailing", "name / ", "name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_LeadingSpecialIgnored(t *testing.T) { + // Leading spaces/parens/slashes are not converted to underscore at the start + tests := []struct { + name string + input string + expected string + }{ + {"leading space", " name", "name"}, + {"leading paren", "(name", "name"}, + {"leading slash", "/name", "name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeSessionName_EmptyResult(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"only special chars", "!@#$%^&*"}, + {"only spaces", " "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Empty(t, result) + }) + } +} + +func TestSanitizeSessionName_ComplexExamples(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"feature branch", "feature/add-tests (WIP)", "feature_add-tests_WIP"}, + {"github issue", "Fix bug #123", "Fix_bug_123"}, + {"mixed case", "MySession-Test_123.final", "MySession-Test_123.final"}, + {"unicode kept", "session\u00e9", "session\u00e9"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeSessionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/ports/mocks/mock_session_repository.go b/internal/ports/mocks/mock_session_repository.go index 0658156..ed520c5 100644 --- a/internal/ports/mocks/mock_session_repository.go +++ b/internal/ports/mocks/mock_session_repository.go @@ -955,6 +955,69 @@ func (_c *MockSessionRepository_UpdateDisplayName_Call) RunAndReturn(run func(ct return _c } +// UpdateExecutionID provides a mock function for the type MockSessionRepository +func (_mock *MockSessionRepository) UpdateExecutionID(ctx context.Context, name string, executionID string) error { + ret := _mock.Called(ctx, name, executionID) + + if len(ret) == 0 { + panic("no return value specified for UpdateExecutionID") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, name, executionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSessionRepository_UpdateExecutionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateExecutionID' +type MockSessionRepository_UpdateExecutionID_Call struct { + *mock.Call +} + +// UpdateExecutionID is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - executionID string +func (_e *MockSessionRepository_Expecter) UpdateExecutionID(ctx interface{}, name interface{}, executionID interface{}) *MockSessionRepository_UpdateExecutionID_Call { + return &MockSessionRepository_UpdateExecutionID_Call{Call: _e.mock.On("UpdateExecutionID", ctx, name, executionID)} +} + +func (_c *MockSessionRepository_UpdateExecutionID_Call) Run(run func(ctx context.Context, name string, executionID string)) *MockSessionRepository_UpdateExecutionID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSessionRepository_UpdateExecutionID_Call) Return(err error) *MockSessionRepository_UpdateExecutionID_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSessionRepository_UpdateExecutionID_Call) RunAndReturn(run func(ctx context.Context, name string, executionID string) error) *MockSessionRepository_UpdateExecutionID_Call { + _c.Call.Return(run) + return _c +} + // UpdateRepoSource provides a mock function for the type MockSessionRepository func (_mock *MockSessionRepository) UpdateRepoSource(ctx context.Context, name string, repoSource string) error { ret := _mock.Called(ctx, name, repoSource) diff --git a/internal/ports/mocks/mock_session_state_updater.go b/internal/ports/mocks/mock_session_state_updater.go new file mode 100644 index 0000000..5beb324 --- /dev/null +++ b/internal/ports/mocks/mock_session_state_updater.go @@ -0,0 +1,360 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/renato0307/rocha/internal/domain" + mock "github.com/stretchr/testify/mock" +) + +// NewMockSessionStateUpdater creates a new instance of MockSessionStateUpdater. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSessionStateUpdater(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSessionStateUpdater { + mock := &MockSessionStateUpdater{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockSessionStateUpdater is an autogenerated mock type for the SessionStateUpdater type +type MockSessionStateUpdater struct { + mock.Mock +} + +type MockSessionStateUpdater_Expecter struct { + mock *mock.Mock +} + +func (_m *MockSessionStateUpdater) EXPECT() *MockSessionStateUpdater_Expecter { + return &MockSessionStateUpdater_Expecter{mock: &_m.Mock} +} + +// UpdateClaudeDir provides a mock function for the type MockSessionStateUpdater +func (_mock *MockSessionStateUpdater) UpdateClaudeDir(ctx context.Context, name string, claudeDir string) error { + ret := _mock.Called(ctx, name, claudeDir) + + if len(ret) == 0 { + panic("no return value specified for UpdateClaudeDir") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, name, claudeDir) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSessionStateUpdater_UpdateClaudeDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateClaudeDir' +type MockSessionStateUpdater_UpdateClaudeDir_Call struct { + *mock.Call +} + +// UpdateClaudeDir is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - claudeDir string +func (_e *MockSessionStateUpdater_Expecter) UpdateClaudeDir(ctx interface{}, name interface{}, claudeDir interface{}) *MockSessionStateUpdater_UpdateClaudeDir_Call { + return &MockSessionStateUpdater_UpdateClaudeDir_Call{Call: _e.mock.On("UpdateClaudeDir", ctx, name, claudeDir)} +} + +func (_c *MockSessionStateUpdater_UpdateClaudeDir_Call) Run(run func(ctx context.Context, name string, claudeDir string)) *MockSessionStateUpdater_UpdateClaudeDir_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateClaudeDir_Call) Return(err error) *MockSessionStateUpdater_UpdateClaudeDir_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateClaudeDir_Call) RunAndReturn(run func(ctx context.Context, name string, claudeDir string) error) *MockSessionStateUpdater_UpdateClaudeDir_Call { + _c.Call.Return(run) + return _c +} + +// UpdateExecutionID provides a mock function for the type MockSessionStateUpdater +func (_mock *MockSessionStateUpdater) UpdateExecutionID(ctx context.Context, name string, executionID string) error { + ret := _mock.Called(ctx, name, executionID) + + if len(ret) == 0 { + panic("no return value specified for UpdateExecutionID") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, name, executionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSessionStateUpdater_UpdateExecutionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateExecutionID' +type MockSessionStateUpdater_UpdateExecutionID_Call struct { + *mock.Call +} + +// UpdateExecutionID is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - executionID string +func (_e *MockSessionStateUpdater_Expecter) UpdateExecutionID(ctx interface{}, name interface{}, executionID interface{}) *MockSessionStateUpdater_UpdateExecutionID_Call { + return &MockSessionStateUpdater_UpdateExecutionID_Call{Call: _e.mock.On("UpdateExecutionID", ctx, name, executionID)} +} + +func (_c *MockSessionStateUpdater_UpdateExecutionID_Call) Run(run func(ctx context.Context, name string, executionID string)) *MockSessionStateUpdater_UpdateExecutionID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateExecutionID_Call) Return(err error) *MockSessionStateUpdater_UpdateExecutionID_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateExecutionID_Call) RunAndReturn(run func(ctx context.Context, name string, executionID string) error) *MockSessionStateUpdater_UpdateExecutionID_Call { + _c.Call.Return(run) + return _c +} + +// UpdateRepoSource provides a mock function for the type MockSessionStateUpdater +func (_mock *MockSessionStateUpdater) UpdateRepoSource(ctx context.Context, name string, repoSource string) error { + ret := _mock.Called(ctx, name, repoSource) + + if len(ret) == 0 { + panic("no return value specified for UpdateRepoSource") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = returnFunc(ctx, name, repoSource) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSessionStateUpdater_UpdateRepoSource_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRepoSource' +type MockSessionStateUpdater_UpdateRepoSource_Call struct { + *mock.Call +} + +// UpdateRepoSource is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - repoSource string +func (_e *MockSessionStateUpdater_Expecter) UpdateRepoSource(ctx interface{}, name interface{}, repoSource interface{}) *MockSessionStateUpdater_UpdateRepoSource_Call { + return &MockSessionStateUpdater_UpdateRepoSource_Call{Call: _e.mock.On("UpdateRepoSource", ctx, name, repoSource)} +} + +func (_c *MockSessionStateUpdater_UpdateRepoSource_Call) Run(run func(ctx context.Context, name string, repoSource string)) *MockSessionStateUpdater_UpdateRepoSource_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateRepoSource_Call) Return(err error) *MockSessionStateUpdater_UpdateRepoSource_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateRepoSource_Call) RunAndReturn(run func(ctx context.Context, name string, repoSource string) error) *MockSessionStateUpdater_UpdateRepoSource_Call { + _c.Call.Return(run) + return _c +} + +// UpdateSkipPermissions provides a mock function for the type MockSessionStateUpdater +func (_mock *MockSessionStateUpdater) UpdateSkipPermissions(ctx context.Context, name string, skip bool) error { + ret := _mock.Called(ctx, name, skip) + + if len(ret) == 0 { + panic("no return value specified for UpdateSkipPermissions") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) error); ok { + r0 = returnFunc(ctx, name, skip) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSessionStateUpdater_UpdateSkipPermissions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSkipPermissions' +type MockSessionStateUpdater_UpdateSkipPermissions_Call struct { + *mock.Call +} + +// UpdateSkipPermissions is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - skip bool +func (_e *MockSessionStateUpdater_Expecter) UpdateSkipPermissions(ctx interface{}, name interface{}, skip interface{}) *MockSessionStateUpdater_UpdateSkipPermissions_Call { + return &MockSessionStateUpdater_UpdateSkipPermissions_Call{Call: _e.mock.On("UpdateSkipPermissions", ctx, name, skip)} +} + +func (_c *MockSessionStateUpdater_UpdateSkipPermissions_Call) Run(run func(ctx context.Context, name string, skip bool)) *MockSessionStateUpdater_UpdateSkipPermissions_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateSkipPermissions_Call) Return(err error) *MockSessionStateUpdater_UpdateSkipPermissions_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateSkipPermissions_Call) RunAndReturn(run func(ctx context.Context, name string, skip bool) error) *MockSessionStateUpdater_UpdateSkipPermissions_Call { + _c.Call.Return(run) + return _c +} + +// UpdateState provides a mock function for the type MockSessionStateUpdater +func (_mock *MockSessionStateUpdater) UpdateState(ctx context.Context, name string, state domain.SessionState, executionID string) error { + ret := _mock.Called(ctx, name, state, executionID) + + if len(ret) == 0 { + panic("no return value specified for UpdateState") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.SessionState, string) error); ok { + r0 = returnFunc(ctx, name, state, executionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSessionStateUpdater_UpdateState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateState' +type MockSessionStateUpdater_UpdateState_Call struct { + *mock.Call +} + +// UpdateState is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - state domain.SessionState +// - executionID string +func (_e *MockSessionStateUpdater_Expecter) UpdateState(ctx interface{}, name interface{}, state interface{}, executionID interface{}) *MockSessionStateUpdater_UpdateState_Call { + return &MockSessionStateUpdater_UpdateState_Call{Call: _e.mock.On("UpdateState", ctx, name, state, executionID)} +} + +func (_c *MockSessionStateUpdater_UpdateState_Call) Run(run func(ctx context.Context, name string, state domain.SessionState, executionID string)) *MockSessionStateUpdater_UpdateState_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 domain.SessionState + if args[2] != nil { + arg2 = args[2].(domain.SessionState) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateState_Call) Return(err error) *MockSessionStateUpdater_UpdateState_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSessionStateUpdater_UpdateState_Call) RunAndReturn(run func(ctx context.Context, name string, state domain.SessionState, executionID string) error) *MockSessionStateUpdater_UpdateState_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/ports/mocks/mock_sound_player.go b/internal/ports/mocks/mock_sound_player.go new file mode 100644 index 0000000..c9766f2 --- /dev/null +++ b/internal/ports/mocks/mock_sound_player.go @@ -0,0 +1,131 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockSoundPlayer creates a new instance of MockSoundPlayer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSoundPlayer(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSoundPlayer { + mock := &MockSoundPlayer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockSoundPlayer is an autogenerated mock type for the SoundPlayer type +type MockSoundPlayer struct { + mock.Mock +} + +type MockSoundPlayer_Expecter struct { + mock *mock.Mock +} + +func (_m *MockSoundPlayer) EXPECT() *MockSoundPlayer_Expecter { + return &MockSoundPlayer_Expecter{mock: &_m.Mock} +} + +// PlaySound provides a mock function for the type MockSoundPlayer +func (_mock *MockSoundPlayer) PlaySound() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for PlaySound") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSoundPlayer_PlaySound_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PlaySound' +type MockSoundPlayer_PlaySound_Call struct { + *mock.Call +} + +// PlaySound is a helper method to define mock.On call +func (_e *MockSoundPlayer_Expecter) PlaySound() *MockSoundPlayer_PlaySound_Call { + return &MockSoundPlayer_PlaySound_Call{Call: _e.mock.On("PlaySound")} +} + +func (_c *MockSoundPlayer_PlaySound_Call) Run(run func()) *MockSoundPlayer_PlaySound_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockSoundPlayer_PlaySound_Call) Return(err error) *MockSoundPlayer_PlaySound_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSoundPlayer_PlaySound_Call) RunAndReturn(run func() error) *MockSoundPlayer_PlaySound_Call { + _c.Call.Return(run) + return _c +} + +// PlaySoundForEvent provides a mock function for the type MockSoundPlayer +func (_mock *MockSoundPlayer) PlaySoundForEvent(eventType string) error { + ret := _mock.Called(eventType) + + if len(ret) == 0 { + panic("no return value specified for PlaySoundForEvent") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(eventType) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockSoundPlayer_PlaySoundForEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PlaySoundForEvent' +type MockSoundPlayer_PlaySoundForEvent_Call struct { + *mock.Call +} + +// PlaySoundForEvent is a helper method to define mock.On call +// - eventType string +func (_e *MockSoundPlayer_Expecter) PlaySoundForEvent(eventType interface{}) *MockSoundPlayer_PlaySoundForEvent_Call { + return &MockSoundPlayer_PlaySoundForEvent_Call{Call: _e.mock.On("PlaySoundForEvent", eventType)} +} + +func (_c *MockSoundPlayer_PlaySoundForEvent_Call) Run(run func(eventType string)) *MockSoundPlayer_PlaySoundForEvent_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockSoundPlayer_PlaySoundForEvent_Call) Return(err error) *MockSoundPlayer_PlaySoundForEvent_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockSoundPlayer_PlaySoundForEvent_Call) RunAndReturn(run func(eventType string) error) *MockSoundPlayer_PlaySoundForEvent_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/ports/mocks/mock_token_usage_reader.go b/internal/ports/mocks/mock_token_usage_reader.go new file mode 100644 index 0000000..cfdf105 --- /dev/null +++ b/internal/ports/mocks/mock_token_usage_reader.go @@ -0,0 +1,92 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "github.com/renato0307/rocha/internal/ports" + mock "github.com/stretchr/testify/mock" +) + +// NewMockTokenUsageReader creates a new instance of MockTokenUsageReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockTokenUsageReader(t interface { + mock.TestingT + Cleanup(func()) +}) *MockTokenUsageReader { + mock := &MockTokenUsageReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockTokenUsageReader is an autogenerated mock type for the TokenUsageReader type +type MockTokenUsageReader struct { + mock.Mock +} + +type MockTokenUsageReader_Expecter struct { + mock *mock.Mock +} + +func (_m *MockTokenUsageReader) EXPECT() *MockTokenUsageReader_Expecter { + return &MockTokenUsageReader_Expecter{mock: &_m.Mock} +} + +// GetTodayUsage provides a mock function for the type MockTokenUsageReader +func (_mock *MockTokenUsageReader) GetTodayUsage() ([]ports.TokenUsage, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetTodayUsage") + } + + var r0 []ports.TokenUsage + var r1 error + if returnFunc, ok := ret.Get(0).(func() ([]ports.TokenUsage, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() []ports.TokenUsage); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]ports.TokenUsage) + } + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTokenUsageReader_GetTodayUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTodayUsage' +type MockTokenUsageReader_GetTodayUsage_Call struct { + *mock.Call +} + +// GetTodayUsage is a helper method to define mock.On call +func (_e *MockTokenUsageReader_Expecter) GetTodayUsage() *MockTokenUsageReader_GetTodayUsage_Call { + return &MockTokenUsageReader_GetTodayUsage_Call{Call: _e.mock.On("GetTodayUsage")} +} + +func (_c *MockTokenUsageReader_GetTodayUsage_Call) Run(run func()) *MockTokenUsageReader_GetTodayUsage_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockTokenUsageReader_GetTodayUsage_Call) Return(tokenUsages []ports.TokenUsage, err error) *MockTokenUsageReader_GetTodayUsage_Call { + _c.Call.Return(tokenUsages, err) + return _c +} + +func (_c *MockTokenUsageReader_GetTodayUsage_Call) RunAndReturn(run func() ([]ports.TokenUsage, error)) *MockTokenUsageReader_GetTodayUsage_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/services/notification_test.go b/internal/services/notification_test.go new file mode 100644 index 0000000..ad6d08a --- /dev/null +++ b/internal/services/notification_test.go @@ -0,0 +1,293 @@ +package services + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/renato0307/rocha/internal/domain" + portsmocks "github.com/renato0307/rocha/internal/ports/mocks" +) + +func TestHandleEvent_EventTypeToStateMapping(t *testing.T) { + tests := []struct { + eventType string + expectedState domain.SessionState + }{ + {"stop", domain.StateIdle}, + {"notification", domain.StateWaiting}, + {"permission-request", domain.StateWaiting}, + {"start", domain.StateIdle}, + {"prompt", domain.StateWorking}, + {"working", domain.StateWorking}, + {"tool-complete", domain.StateWorking}, + {"tool-failure", domain.StateWorking}, + {"subagent-start", domain.StateWorking}, + {"subagent-stop", domain.StateWorking}, + {"pre-compact", domain.StateWorking}, + {"setup", domain.StateWorking}, + {"end", domain.StateExited}, + } + + for _, tt := range tests { + t.Run(tt.eventType, func(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + // For non-intermediate events, just expect UpdateState + // For intermediate events, we need to set up Get first + intermediateEvents := map[string]bool{ + "tool-complete": true, + "tool-failure": true, + "subagent-start": true, + "subagent-stop": true, + "pre-compact": true, + "setup": true, + } + + if intermediateEvents[tt.eventType] { + // Return a working state so intermediate event proceeds + sessionReader.EXPECT().Get(mock.Anything, "test-session"). + Return(&domain.Session{State: domain.StateWorking}, nil) + } + + stateUpdater.EXPECT().UpdateState(mock.Anything, "test-session", tt.expectedState, "exec-123"). + Return(nil) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + state, err := service.HandleEvent(context.Background(), "test-session", tt.eventType, "exec-123") + + require.NoError(t, err) + assert.Equal(t, tt.expectedState, state) + }) + } +} + +func TestHandleEvent_UnknownEventType(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + state, err := service.HandleEvent(context.Background(), "test-session", "unknown-event", "exec-123") + + require.NoError(t, err) + assert.Empty(t, state) +} + +func TestHandleEvent_UpdateStateError(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + stateUpdater.EXPECT().UpdateState(mock.Anything, "test-session", domain.StateIdle, "exec-123"). + Return(errors.New("database error")) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + state, err := service.HandleEvent(context.Background(), "test-session", "stop", "exec-123") + + require.Error(t, err) + assert.Equal(t, domain.StateIdle, state) +} + +func TestHandleEvent_IntermediateEventSkipsTerminalState(t *testing.T) { + tests := []struct { + name string + eventType string + currentState domain.SessionState + }{ + {"subagent-stop skips idle", "subagent-stop", domain.StateIdle}, + {"subagent-stop skips exited", "subagent-stop", domain.StateExited}, + {"tool-complete skips idle", "tool-complete", domain.StateIdle}, + {"setup skips exited", "setup", domain.StateExited}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + // Return terminal state - intermediate event should not overwrite + sessionReader.EXPECT().Get(mock.Anything, "test-session"). + Return(&domain.Session{State: tt.currentState}, nil) + + // Note: UpdateState should NOT be called + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + state, err := service.HandleEvent(context.Background(), "test-session", tt.eventType, "exec-123") + + require.NoError(t, err) + assert.Equal(t, tt.currentState, state) + }) + } +} + +func TestHandleEvent_IntermediateEventProceedsOnGetError(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + // Get fails but event should still proceed (fail open) + sessionReader.EXPECT().Get(mock.Anything, "test-session"). + Return(nil, errors.New("not found")) + stateUpdater.EXPECT().UpdateState(mock.Anything, "test-session", domain.StateWorking, "exec-123"). + Return(nil) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + state, err := service.HandleEvent(context.Background(), "test-session", "subagent-stop", "exec-123") + + require.NoError(t, err) + assert.Equal(t, domain.StateWorking, state) +} + +func TestResolveExecutionID_FlagValueTakesPrecedence(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + result := service.ResolveExecutionID(context.Background(), "test-session", "flag-value") + + assert.Equal(t, "flag-value", result) +} + +func TestResolveExecutionID_EnvVarSecondPriority(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + os.Setenv("ROCHA_EXECUTION_ID", "env-value") + defer os.Unsetenv("ROCHA_EXECUTION_ID") + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + result := service.ResolveExecutionID(context.Background(), "test-session", "") + + assert.Equal(t, "env-value", result) +} + +func TestResolveExecutionID_DatabaseThirdPriority(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + // Make sure env var is not set + os.Unsetenv("ROCHA_EXECUTION_ID") + + sessionReader.EXPECT().Get(mock.Anything, "test-session"). + Return(&domain.Session{ExecutionID: "db-value"}, nil) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + result := service.ResolveExecutionID(context.Background(), "test-session", "") + + assert.Equal(t, "db-value", result) +} + +func TestResolveExecutionID_FallbackToUnknown(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + // Make sure env var is not set + os.Unsetenv("ROCHA_EXECUTION_ID") + + sessionReader.EXPECT().Get(mock.Anything, "test-session"). + Return(nil, errors.New("not found")) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + result := service.ResolveExecutionID(context.Background(), "test-session", "") + + assert.Equal(t, "unknown", result) +} + +func TestResolveExecutionID_EmptyDbValueFallsBack(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + // Make sure env var is not set + os.Unsetenv("ROCHA_EXECUTION_ID") + + sessionReader.EXPECT().Get(mock.Anything, "test-session"). + Return(&domain.Session{ExecutionID: ""}, nil) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + result := service.ResolveExecutionID(context.Background(), "test-session", "") + + assert.Equal(t, "unknown", result) +} + +func TestShouldPlaySound(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + tests := []struct { + eventType string + expected bool + }{ + {"stop", true}, + {"start", true}, + {"notification", true}, + {"permission-request", true}, + {"end", true}, + {"tool-failure", false}, + {"subagent-start", false}, + {"subagent-stop", false}, + {"pre-compact", false}, + {"setup", false}, + {"unknown", false}, + } + + for _, tt := range tests { + t.Run(tt.eventType, func(t *testing.T) { + result := service.ShouldPlaySound(tt.eventType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPlaySound(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + soundPlayer.EXPECT().PlaySound().Return(nil) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + err := service.PlaySound() + + require.NoError(t, err) +} + +func TestPlaySoundForEvent(t *testing.T) { + sessionReader := portsmocks.NewMockSessionReader(t) + stateUpdater := portsmocks.NewMockSessionStateUpdater(t) + soundPlayer := portsmocks.NewMockSoundPlayer(t) + + soundPlayer.EXPECT().PlaySoundForEvent("stop").Return(nil) + + service := NewNotificationService(stateUpdater, sessionReader, soundPlayer) + + err := service.PlaySoundForEvent("stop") + + require.NoError(t, err) +} diff --git a/internal/services/session_test.go b/internal/services/session_test.go index 397406a..2bc5eee 100644 --- a/internal/services/session_test.go +++ b/internal/services/session_test.go @@ -124,3 +124,241 @@ func TestCreateSession_ContinuesOnWorktreeLookupError(t *testing.T) { require.NoError(t, err, "should continue even when worktree lookup fails") assert.Equal(t, newWorktreePath, result.WorktreePath) } + +func TestDeleteSession_HappyPath(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + session := &domain.Session{ + Name: "test-session", + WorktreePath: "/path/to/worktree", + RepoPath: "/path/to/repo", + } + + sessionRepo.EXPECT().Get(mock.Anything, "test-session").Return(session, nil) + tmuxClient.EXPECT().KillSession("test-session").Return(nil) + sessionRepo.EXPECT().Delete(mock.Anything, "test-session").Return(nil) + gitRepo.EXPECT().RemoveWorktree("/path/to/repo", "/path/to/worktree").Return(nil) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.DeleteSession(context.Background(), "test-session", DeleteSessionOptions{ + KillTmux: true, + RemoveWorktree: true, + }) + + require.NoError(t, err) +} + +func TestDeleteSession_WithShellSession(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + shellSession := &domain.Session{Name: "test-session-shell"} + session := &domain.Session{ + Name: "test-session", + ShellSession: shellSession, + WorktreePath: "/path/to/worktree", + RepoPath: "/path/to/repo", + } + + sessionRepo.EXPECT().Get(mock.Anything, "test-session").Return(session, nil) + tmuxClient.EXPECT().KillSession("test-session-shell").Return(nil) + tmuxClient.EXPECT().KillSession("test-session").Return(nil) + sessionRepo.EXPECT().Delete(mock.Anything, "test-session").Return(nil) + gitRepo.EXPECT().RemoveWorktree("/path/to/repo", "/path/to/worktree").Return(nil) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.DeleteSession(context.Background(), "test-session", DeleteSessionOptions{ + KillTmux: true, + RemoveWorktree: true, + }) + + require.NoError(t, err) +} + +func TestDeleteSession_NoWorktree(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + session := &domain.Session{ + Name: "test-session", + WorktreePath: "", // No worktree + RepoPath: "", + } + + sessionRepo.EXPECT().Get(mock.Anything, "test-session").Return(session, nil) + sessionRepo.EXPECT().Delete(mock.Anything, "test-session").Return(nil) + // RemoveWorktree should NOT be called since paths are empty + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.DeleteSession(context.Background(), "test-session", DeleteSessionOptions{ + KillTmux: false, + RemoveWorktree: true, // Requested but should be skipped + }) + + require.NoError(t, err) +} + +func TestDeleteSession_GetSessionError(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + sessionRepo.EXPECT().Get(mock.Anything, "test-session").Return(nil, errors.New("not found")) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.DeleteSession(context.Background(), "test-session", DeleteSessionOptions{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get session") +} + +func TestDeleteSession_TmuxKillFailureContinues(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + session := &domain.Session{ + Name: "test-session", + WorktreePath: "/path/to/worktree", + RepoPath: "/path/to/repo", + } + + sessionRepo.EXPECT().Get(mock.Anything, "test-session").Return(session, nil) + // Tmux kill fails but deletion should continue + tmuxClient.EXPECT().KillSession("test-session").Return(errors.New("tmux error")) + sessionRepo.EXPECT().Delete(mock.Anything, "test-session").Return(nil) + gitRepo.EXPECT().RemoveWorktree("/path/to/repo", "/path/to/worktree").Return(nil) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.DeleteSession(context.Background(), "test-session", DeleteSessionOptions{ + KillTmux: true, + RemoveWorktree: true, + }) + + require.NoError(t, err) +} + +func TestDeleteSession_DatabaseDeleteError(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + session := &domain.Session{ + Name: "test-session", + WorktreePath: "/path/to/worktree", + RepoPath: "/path/to/repo", + } + + sessionRepo.EXPECT().Get(mock.Anything, "test-session").Return(session, nil) + sessionRepo.EXPECT().Delete(mock.Anything, "test-session").Return(errors.New("db error")) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.DeleteSession(context.Background(), "test-session", DeleteSessionOptions{ + KillTmux: false, + RemoveWorktree: true, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete session") +} + +func TestRenameSession_HappyPath(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + tmuxClient.EXPECT().RenameSession("old-session", "new-session").Return(nil) + sessionRepo.EXPECT().Rename(mock.Anything, "old-session", "new-session", "New Session").Return(nil) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.RenameSession(context.Background(), "old-session", "new-session", "New Session") + + require.NoError(t, err) +} + +func TestRenameSession_TmuxRenameError(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + tmuxClient.EXPECT().RenameSession("old-session", "new-session").Return(errors.New("tmux error")) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.RenameSession(context.Background(), "old-session", "new-session", "New Session") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to rename tmux session") +} + +func TestRenameSession_DatabaseRenameErrorWithRollback(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + // Tmux rename succeeds + tmuxClient.EXPECT().RenameSession("old-session", "new-session").Return(nil) + // Database rename fails + sessionRepo.EXPECT().Rename(mock.Anything, "old-session", "new-session", "New Session").Return(errors.New("db error")) + // Rollback tmux rename + tmuxClient.EXPECT().RenameSession("new-session", "old-session").Return(nil) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.RenameSession(context.Background(), "old-session", "new-session", "New Session") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to rename in database") +} + +func TestRenameSession_DatabaseRenameErrorRollbackFails(t *testing.T) { + gitRepo := portsmocks.NewMockGitRepository(t) + tmuxClient := portsmocks.NewMockTmuxSessionLifecycle(t) + sessionRepo := portsmocks.NewMockSessionRepository(t) + claudeDirResolver := servicesmocks.NewMockClaudeDirResolver(t) + processInspector := portsmocks.NewMockProcessInspector(t) + + // Tmux rename succeeds + tmuxClient.EXPECT().RenameSession("old-session", "new-session").Return(nil) + // Database rename fails + sessionRepo.EXPECT().Rename(mock.Anything, "old-session", "new-session", "New Session").Return(errors.New("db error")) + // Rollback fails too (but error is logged, not returned) + tmuxClient.EXPECT().RenameSession("new-session", "old-session").Return(errors.New("rollback failed")) + + service := NewSessionService(sessionRepo, gitRepo, tmuxClient, claudeDirResolver, processInspector) + + err := service.RenameSession(context.Background(), "old-session", "new-session", "New Session") + + // Should still return the database error, not the rollback error + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to rename in database") +} diff --git a/internal/services/token_stats_test.go b/internal/services/token_stats_test.go new file mode 100644 index 0000000..e32bcbb --- /dev/null +++ b/internal/services/token_stats_test.go @@ -0,0 +1,224 @@ +package services + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/renato0307/rocha/internal/ports" + portsmocks "github.com/renato0307/rocha/internal/ports/mocks" +) + +func TestGetTodayHourlyUsage_CacheMiss(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + now := time.Now() + usage := []ports.TokenUsage{ + {InputTokens: 100, OutputTokens: 50, Timestamp: now}, + } + reader.EXPECT().GetTodayUsage().Return(usage, nil) + + service := NewTokenStatsService(reader) + + hourly, err := service.GetTodayHourlyUsage() + + require.NoError(t, err) + require.Len(t, hourly, 1) + assert.Equal(t, now.Hour(), hourly[0].Hour) + assert.Equal(t, 100, hourly[0].InputTokens) + assert.Equal(t, 50, hourly[0].OutputTokens) +} + +func TestGetTodayHourlyUsage_CacheHit(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + now := time.Now() + usage := []ports.TokenUsage{ + {InputTokens: 100, OutputTokens: 50, Timestamp: now}, + } + // Only expect one call - second call should use cache + reader.EXPECT().GetTodayUsage().Return(usage, nil) + + service := NewTokenStatsService(reader) + + // First call - cache miss + hourly1, err := service.GetTodayHourlyUsage() + require.NoError(t, err) + + // Second call - should hit cache + hourly2, err := service.GetTodayHourlyUsage() + require.NoError(t, err) + + assert.Equal(t, hourly1, hourly2) +} + +func TestGetTodayHourlyUsage_ReaderError(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + reader.EXPECT().GetTodayUsage().Return(nil, errors.New("read error")) + + service := NewTokenStatsService(reader) + + hourly, err := service.GetTodayHourlyUsage() + + require.Error(t, err) + assert.Nil(t, hourly) +} + +func TestGetTodayHourlyUsage_HourlyAggregation(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + baseTime := time.Date(2025, 1, 15, 0, 0, 0, 0, time.Local) + usage := []ports.TokenUsage{ + {InputTokens: 100, OutputTokens: 50, CacheCreation: 10, CacheRead: 5, Timestamp: baseTime.Add(9 * time.Hour)}, // 9:00 + {InputTokens: 200, OutputTokens: 100, CacheCreation: 20, CacheRead: 10, Timestamp: baseTime.Add(9*time.Hour + 30*time.Minute)}, // 9:30 + {InputTokens: 150, OutputTokens: 75, CacheCreation: 15, CacheRead: 8, Timestamp: baseTime.Add(10 * time.Hour)}, // 10:00 + } + reader.EXPECT().GetTodayUsage().Return(usage, nil) + + service := NewTokenStatsService(reader) + + hourly, err := service.GetTodayHourlyUsage() + + require.NoError(t, err) + require.Len(t, hourly, 2) + + // Hour 9 should have aggregated values + assert.Equal(t, 9, hourly[0].Hour) + assert.Equal(t, 300, hourly[0].InputTokens) // 100 + 200 + assert.Equal(t, 150, hourly[0].OutputTokens) // 50 + 100 + assert.Equal(t, 30, hourly[0].CacheCreation) // 10 + 20 + assert.Equal(t, 15, hourly[0].CacheRead) // 5 + 10 + + // Hour 10 + assert.Equal(t, 10, hourly[1].Hour) + assert.Equal(t, 150, hourly[1].InputTokens) + assert.Equal(t, 75, hourly[1].OutputTokens) +} + +func TestGetTodayHourlyUsage_SortedByHour(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + baseTime := time.Date(2025, 1, 15, 0, 0, 0, 0, time.Local) + // Add out of order to verify sorting + usage := []ports.TokenUsage{ + {InputTokens: 100, Timestamp: baseTime.Add(14 * time.Hour)}, // 14:00 + {InputTokens: 100, Timestamp: baseTime.Add(8 * time.Hour)}, // 8:00 + {InputTokens: 100, Timestamp: baseTime.Add(20 * time.Hour)}, // 20:00 + } + reader.EXPECT().GetTodayUsage().Return(usage, nil) + + service := NewTokenStatsService(reader) + + hourly, err := service.GetTodayHourlyUsage() + + require.NoError(t, err) + require.Len(t, hourly, 3) + assert.Equal(t, 8, hourly[0].Hour) + assert.Equal(t, 14, hourly[1].Hour) + assert.Equal(t, 20, hourly[2].Hour) +} + +func TestGetTodayTotals_CalculatesTotals(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + now := time.Now() + usage := []ports.TokenUsage{ + {InputTokens: 100, OutputTokens: 50, CacheCreation: 10, CacheRead: 5, Timestamp: now}, + {InputTokens: 200, OutputTokens: 100, CacheCreation: 20, CacheRead: 10, Timestamp: now}, + {InputTokens: 150, OutputTokens: 75, CacheCreation: 15, CacheRead: 8, Timestamp: now}, + } + reader.EXPECT().GetTodayUsage().Return(usage, nil) + + service := NewTokenStatsService(reader) + + totals, err := service.GetTodayTotals() + + require.NoError(t, err) + assert.Equal(t, 450, totals.InputTokens) // 100 + 200 + 150 + assert.Equal(t, 225, totals.OutputTokens) // 50 + 100 + 75 + assert.Equal(t, 45, totals.CacheCreation) // 10 + 20 + 15 + assert.Equal(t, 23, totals.CacheRead) // 5 + 10 + 8 +} + +func TestGetTodayTotals_EmptyUsage(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + reader.EXPECT().GetTodayUsage().Return([]ports.TokenUsage{}, nil) + + service := NewTokenStatsService(reader) + + totals, err := service.GetTodayTotals() + + require.NoError(t, err) + assert.Equal(t, 0, totals.InputTokens) + assert.Equal(t, 0, totals.OutputTokens) + assert.Equal(t, 0, totals.CacheCreation) + assert.Equal(t, 0, totals.CacheRead) +} + +func TestGetTodayTotals_ReaderError(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + reader.EXPECT().GetTodayUsage().Return(nil, errors.New("read error")) + + service := NewTokenStatsService(reader) + + totals, err := service.GetTodayTotals() + + require.Error(t, err) + assert.Equal(t, ports.TokenTotals{}, totals) +} + +func TestGetTodayTotals_SharesCacheWithHourly(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + now := time.Now() + usage := []ports.TokenUsage{ + {InputTokens: 100, OutputTokens: 50, Timestamp: now}, + } + // Only one call expected - both methods share cache + reader.EXPECT().GetTodayUsage().Return(usage, nil) + + service := NewTokenStatsService(reader) + + // Call hourly first to populate cache + _, err := service.GetTodayHourlyUsage() + require.NoError(t, err) + + // Call totals - should use same cache + totals, err := service.GetTodayTotals() + + require.NoError(t, err) + assert.Equal(t, 100, totals.InputTokens) + assert.Equal(t, 50, totals.OutputTokens) +} + +func TestGetTodayHourlyUsage_EmptyUsage(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + reader.EXPECT().GetTodayUsage().Return([]ports.TokenUsage{}, nil) + + service := NewTokenStatsService(reader) + + hourly, err := service.GetTodayHourlyUsage() + + require.NoError(t, err) + assert.Empty(t, hourly) +} + +func TestGetTodayHourlyUsage_NilCache(t *testing.T) { + reader := portsmocks.NewMockTokenUsageReader(t) + + reader.EXPECT().GetTodayUsage().Return(nil, nil) + + service := NewTokenStatsService(reader) + + hourly, err := service.GetTodayHourlyUsage() + + require.NoError(t, err) + assert.Nil(t, hourly) +}