From 7405d174cf459bcd7cfe693ee776aabac404120c Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 19:58:45 -0500 Subject: [PATCH 01/13] refactor: align Makefile and linter config with STANDARDS.md - Add STANDARDS.md to repo - Add revive, gosec, errorlint, exhaustive linters to .golangci.yml - Add `tidy` and `check` targets to Makefile - Fix errorlint: replace type assertions with errors.As in errors.IsRetryable() and initcmd.errorAs (bug fixes - wrapped errors were silently missed) - Fix gosec: add nolint annotations for legitimate file operations - Fix revive: rename unused parameters to _, fix labelIds -> labelIDs - Add temporary lint exclusions for issues addressed by later commits (package comments, interface stuttering, mock comments) --- .golangci.yml | 24 +- Makefile | 9 +- STANDARDS.md | 1529 +++++++++++++++++++++ internal/auth/auth.go | 4 +- internal/cache/cache.go | 4 +- internal/cmd/calendar/events.go | 2 +- internal/cmd/calendar/get.go | 2 +- internal/cmd/calendar/handlers_test.go | 14 +- internal/cmd/calendar/list.go | 2 +- internal/cmd/calendar/today.go | 2 +- internal/cmd/calendar/week.go | 2 +- internal/cmd/config/cache.go | 6 +- internal/cmd/config/config.go | 6 +- internal/cmd/contacts/get.go | 2 +- internal/cmd/contacts/groups.go | 2 +- internal/cmd/contacts/handlers_test.go | 28 +- internal/cmd/contacts/list.go | 2 +- internal/cmd/contacts/search.go | 2 +- internal/cmd/drive/download.go | 2 +- internal/cmd/drive/drives.go | 2 +- internal/cmd/drive/drives_test.go | 6 +- internal/cmd/drive/get.go | 2 +- internal/cmd/drive/handlers_test.go | 42 +- internal/cmd/drive/list.go | 2 +- internal/cmd/drive/search.go | 2 +- internal/cmd/drive/tree.go | 2 +- internal/cmd/initcmd/init.go | 21 +- internal/cmd/mail/attachments_download.go | 2 +- internal/cmd/mail/attachments_list.go | 2 +- internal/cmd/mail/handlers_test.go | 18 +- internal/cmd/mail/labels.go | 2 +- internal/cmd/mail/read.go | 2 +- internal/cmd/mail/search.go | 2 +- internal/cmd/mail/thread.go | 2 +- internal/cmd/root/root.go | 2 +- internal/config/config.go | 2 +- internal/errors/errors.go | 8 +- internal/gmail/messages.go | 4 +- internal/gmail/messages_test.go | 20 +- internal/keychain/keychain.go | 11 +- internal/zip/extract.go | 4 +- internal/zip/extract_test.go | 8 +- internal/zip/fs.go | 2 +- 43 files changed, 1681 insertions(+), 133 deletions(-) create mode 100644 STANDARDS.md diff --git a/.golangci.yml b/.golangci.yml index 5667a5f..b749d25 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,11 +3,15 @@ version: "2" linters: enable: - errcheck + - errorlint + - exhaustive - govet + - gosec - ineffassign + - misspell + - revive - staticcheck - unused - - misspell settings: errcheck: exclude-functions: @@ -19,9 +23,27 @@ linters: - path: _test\.go linters: - errcheck + - gosec - linters: - errcheck source: "defer.*\\.Close\\(\\)" + # Stuttering names will be fixed when interfaces are relocated (commit 5) + - linters: + - revive + text: "stutters" + # Package comments will be added in commit 9 + - linters: + - revive + text: "package-comments" + # Mock exported methods will be deleted when mocks move to test files (commit 5) + - linters: + - revive + text: "exported" + path: testutil/mocks\.go + # Standard library package name conflicts are intentional + - linters: + - revive + text: "avoid package names that conflict" formatters: enable: diff --git a/Makefile b/Makefile index dcde8b0..81d4a6d 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ LDFLAGS := -ldflags "-s -w \ DIST_DIR = dist -.PHONY: all build test test-cover test-short lint fmt deps verify clean release checksums install uninstall +.PHONY: all build test test-cover test-short lint fmt tidy deps verify check clean release checksums install uninstall all: build @@ -33,6 +33,10 @@ fmt: go fmt ./... goimports -local github.com/open-cli-collective/google-readonly -w . +tidy: + go mod tidy + git diff --exit-code go.mod go.sum + deps: go mod download go mod tidy @@ -40,6 +44,9 @@ deps: verify: go mod verify +# CI gate: everything that must pass before merge +check: fmt lint test build + clean: rm -rf bin/ $(DIST_DIR)/ coverage.out coverage.html $(BINARY) diff --git a/STANDARDS.md b/STANDARDS.md new file mode 100644 index 0000000..7bedb89 --- /dev/null +++ b/STANDARDS.md @@ -0,0 +1,1529 @@ +# Go CLI Style Guide + +This document catalogs coding conventions for Go CLI tools. It is intended for use as an operationalized code review prompt for AI-assisted review, but is also useful as a human reference. + +When reviewing code, flag deviations from these patterns. Be pragmatic: the goal is consistency within a codebase, not pedantic enforcement. If a deviation improves readability or correctness, note it as an intentional departure rather than a defect. + +### Guiding Philosophy + +Prefer clarity, composability, and maintainability over cleverness. Go's strength is boringly readable code — lean into that. Use the standard library aggressively. Resist the urge to abstract prematurely or import a dependency for something you can write in 20 lines. Use judgement, not dogma. + +--- + +## 1. Project Configuration + +### Module Layout + +Every tool gets its own `go.mod`. For a monorepo with shared libraries, use a top-level module with internal packages: + +``` +tools/ +├── go.mod +├── go.sum +├── cmd/ +│ ├── ingest/ +│ │ └── main.go +│ ├── reconcile/ +│ │ └── main.go +│ └── sync/ +│ └── main.go +├── internal/ +│ ├── config/ +│ ├── logging/ +│ └── aws/ +└── pkg/ # only if genuinely intended for external consumption +``` + +`internal/` is the default for shared code. `pkg/` is only for packages explicitly designed as public API for other modules. When in doubt, use `internal/`. + +### Build Configuration + +Pin the Go version in `go.mod` and use a `.tool-versions` or `go.env` for the team: + +``` +go 1.24 +``` + +Use `go.sum` for reproducible builds. Run `go mod tidy` before every commit — CI should fail if `go.mod` and `go.sum` are dirty. + +### Linting + +All projects use `golangci-lint` with a shared `.golangci.yml`. At minimum, enable: + +```yaml +linters: + enable: + - errcheck + - govet + - staticcheck + - unused + - ineffassign + - misspell + - revive + - gosec + - errorlint # enforce error wrapping best practices + - exhaustive # enforce exhaustive switch/select on enums +``` + +`go vet` and `staticcheck` findings are non-negotiable. Treat them as errors in CI. + +### Dependency Hygiene + +Order imports in three groups separated by blank lines: standard library, external dependencies, internal packages: + +```go +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/yourorg/tools/internal/config" +) +``` + +`goimports` enforces this automatically. Run it on save. + +### Makefile + +Every repo has a `Makefile` at the root. This is the answer to "I just cloned this repo, now what." CI runs the same targets developers run locally. + +```makefile +.PHONY: build lint test tidy check + +# Build all binaries into bin/ +build: + go build -o bin/ ./cmd/... + +# Lint with golangci-lint (config in .golangci.yml) +lint: + golangci-lint run ./... + +# Run tests with race detector +test: + go test -race ./... + +# Tidy and verify modules are clean +tidy: + go mod tidy + git diff --exit-code go.mod go.sum + +# CI gate: everything that must pass before merge +check: tidy lint test build +``` + +**Rules:** + +- `make check` is the CI gate. It must pass before merge. Run it locally before pushing. +- `make build` outputs all binaries to `bin/`. Add `bin/` to `.gitignore`. +- `make tidy` fails if `go.mod` or `go.sum` are dirty — this catches forgotten `go mod tidy` runs. +- Add tool-specific targets as needed (`make migrate`, `make generate`, `make integration-test`), but the four core targets (`build`, `lint`, `test`, `tidy`) are non-negotiable. +- Keep targets simple. If a target exceeds ~5 lines of shell, it belongs in a script in `scripts/` that the Makefile calls. + +--- + +## 2. Type Design + +### Structs for Data, Methods for Behavior + +Go doesn't have records, but the same instinct applies: separate data-carrying types from service types. Data structs should be plain, exported fields. Service types hold dependencies and attach methods: + +```go +// Data: plain struct, no methods beyond serialization +type SyncResult struct { + CompanyID string + Success bool + FailedKeys []string + Duration time.Duration +} + +// Service: holds dependencies, has methods +type Reconciler struct { + db *sql.DB + logger *slog.Logger + clock func() time.Time // injectable for testing +} +``` + +### Prefer Value Semantics for Small Types + +Small data types (< ~128 bytes, no mutability needs) should be passed and returned by value, not pointer. This is Go's equivalent of preferring value types: + +```go +// Good: small, immutable-ish, pass by value +type Tenant struct { + ID string + Name string +} + +type DateRange struct { + Start time.Time + End time.Time +} + +// Pointer receiver appropriate: large struct or needs mutation +type IngestionState struct { + // ... many fields, mutated over lifetime +} + +func (s *IngestionState) MarkComplete(key string) { ... } +``` + +### Strongly-Typed Identifiers + +Wrap primitive identifiers in named types to prevent parameter confusion: + +```go +type TenantID string +type CompanyID string +type BusinessID string + +func GetConnection(tenant TenantID, company CompanyID) (*Connection, error) { ... } +``` + +This makes `GetConnection(companyID, tenantID)` a compile error instead of a subtle bug. Use sparingly — only where misorderings are a real risk (multiple string parameters of the same shape). + +### Constructor Functions + +Use `NewX` functions when a type requires initialization, validation, or has unexported fields. Return the concrete type, not an interface: + +```go +func NewReconciler(db *sql.DB, logger *slog.Logger, opts ...Option) *Reconciler { + r := &Reconciler{ + db: db, + logger: logger, + clock: time.Now, + } + for _, opt := range opts { + opt(r) + } + return r +} +``` + +For simple structs with all-exported fields, struct literals are fine — no constructor needed. + +### Enums via Constants + +Go lacks sum types. Use typed constants with `iota`, and always handle the zero value explicitly: + +```go +type PlatformType int + +const ( + PlatformUnknown PlatformType = iota // zero value = unknown + PlatformAccounting + PlatformBanking + PlatformCommerce +) + +func (p PlatformType) String() string { + switch p { + case PlatformAccounting: + return "Accounting" + case PlatformBanking: + return "Banking" + case PlatformCommerce: + return "Commerce" + default: + return fmt.Sprintf("PlatformType(%d)", p) + } +} +``` + +For cases where you need exhaustiveness checking, `exhaustive` lint catches missing switch arms. + +--- + +## 3. Interface Design + +### Accept Interfaces, Return Structs + +This is the single most important Go design principle. Define interfaces at the call site (consumer), not at the implementation: + +```go +// Good: interface defined where it's consumed, not where it's implemented +// In reconciler.go: +type AccountFetcher interface { + GetAccounts(ctx context.Context, tenant TenantID) ([]Account, error) +} + +type Reconciler struct { + accounts AccountFetcher + // ... +} + +// In accounts.go — no interface declared here, just a concrete type +type AccountStore struct { + db *sql.DB +} + +func (s *AccountStore) GetAccounts(ctx context.Context, tenant TenantID) ([]Account, error) { ... } +``` + +### Keep Interfaces Small + +One to three methods is ideal. If an interface has more than five methods, it's probably doing too much. The standard library's `io.Reader` (one method) is the gold standard. + +```go +// Good: focused interface +type TokenRefresher interface { + RefreshToken(ctx context.Context, tenant TenantID) (Token, error) +} + +// Suspicious: interface is a service dump +type BusinessManager interface { + GetBusiness(ctx context.Context, id string) (*Business, error) + CreateBusiness(ctx context.Context, b Business) error + UpdateBusiness(ctx context.Context, b Business) error + DeleteBusiness(ctx context.Context, id string) error + ListBusinesses(ctx context.Context, tenant TenantID) ([]Business, error) + GetBusinessConnections(ctx context.Context, id string) ([]Connection, error) + // ... this is just a struct with extra steps +} +``` + +### The Empty Interface Smell + +`any` (`interface{}`) in function signatures is almost always a design smell. It means "I gave up on types." Acceptable uses: logging arguments, JSON marshaling boundaries, generic containers. Unacceptable: core business logic parameters. + +--- + +## 4. Error Handling + +### Errors Are Values, Not Exceptions + +Every function that can fail returns an `error`. Check it immediately. Never discard errors silently: + +```go +// Good: check immediately, handle or propagate +result, err := store.GetAccounts(ctx, tenant) +if err != nil { + return nil, fmt.Errorf("fetching accounts for %s: %w", tenant, err) +} +``` + +### Wrapping With Context + +Always wrap errors with `fmt.Errorf("context: %w", err)` to build a trace. The message should describe what *this* function was trying to do, not repeat what the callee said: + +```go +// Good: each layer adds its own context +func (r *Reconciler) Run(ctx context.Context, tenant TenantID) error { + accounts, err := r.accounts.GetAccounts(ctx, tenant) + if err != nil { + return fmt.Errorf("reconciling tenant %s: %w", tenant, err) + } + // ... +} + +// Bad: restating the callee's error +if err != nil { + return fmt.Errorf("failed to get accounts: %w", err) // "failed to" is noise +} +``` + +### Sentinel Errors and Error Types + +Define sentinel errors for conditions callers need to match on. Use custom error types when callers need structured data: + +```go +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") + ErrRateLimited = errors.New("rate limited") +) + +// Callers check with errors.Is: +if errors.Is(err, ErrNotFound) { + // handle missing resource +} + +// Custom error type when callers need details: +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation: %s: %s", e.Field, e.Message) +} + +// Callers check with errors.As: +var ve *ValidationError +if errors.As(err, &ve) { + fmt.Printf("bad field: %s\n", ve.Field) +} +``` + +### Don't Panic + +`panic` is for programmer bugs (impossible states, violated invariants in init), never for runtime errors. A CLI tool that panics on bad input is broken. Recover from panics only at the outermost boundary (e.g., a top-level middleware in a server, or the root command's `RunE`). + +### Eliminate `else` After Error Returns + +Go's error handling naturally produces guard clauses. Embrace them — never nest the happy path inside `else`: + +```go +// Good: guard clause, happy path is un-indented +token, err := auth.GetToken(ctx, tenant) +if err != nil { + return fmt.Errorf("getting token: %w", err) +} +// continue with token... + +// Bad: unnecessary nesting +token, err := auth.GetToken(ctx, tenant) +if err == nil { + // happy path buried in a branch +} else { + return err +} +``` + +--- + +## 5. Context Propagation + +### Context Is Always the First Parameter + +Every function that does I/O, calls other services, or could be cancelled takes `context.Context` as its first parameter. Named `ctx`: + +```go +func (s *SyncService) Sync(ctx context.Context, tenant TenantID, companyID CompanyID) error +``` + +### Never Store Context in Structs + +Context is request-scoped. Storing it in a struct means you're holding onto a cancelled context or sharing one across requests: + +```go +// Bad: context outlives the request +type Worker struct { + ctx context.Context // don't do this +} + +// Good: pass per-call +func (w *Worker) Process(ctx context.Context, job Job) error { ... } +``` + +### Respect Cancellation + +Check `ctx.Err()` or use `select` on `ctx.Done()` in loops and before expensive operations: + +```go +for _, batch := range batches { + if err := ctx.Err(); err != nil { + return fmt.Errorf("cancelled during batch processing: %w", err) + } + if err := processBatch(ctx, batch); err != nil { + return err + } +} +``` + +--- + +## 6. CLI Patterns + +### Cobra for All Tools + +Use Cobra for every CLI tool, even single-command ones. The cognitive cost of "which framework did this tool use" is worse than the tiny overhead of Cobra on a simple tool. Cobra gives you consistent `--help`, flag parsing, subcommand structure, and shell completion for free. Standardize on it and stop thinking about it. + +```go +func main() { + root := &cobra.Command{ + Use: "mytool", + Short: "Does the thing", + // No Run on root — forces subcommand usage + } + + root.AddCommand( + newSyncCmd(), + newReconcileCmd(), + newReportCmd(), + ) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} +``` + +### Command Factory Functions + +Each subcommand lives in its own file and returns a `*cobra.Command`. Wire dependencies in the `RunE` closure: + +```go +func newSyncCmd() *cobra.Command { + var ( + tenant string + dryRun bool + workers int + ) + + cmd := &cobra.Command{ + Use: "sync [company-id]", + Short: "Sync data for a company", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + companyID := args[0] + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + logger := logging.New(cfg.LogLevel) + db, err := openDB(ctx, cfg.DatabaseURL) + if err != nil { + return fmt.Errorf("connecting to database: %w", err) + } + defer db.Close() + + svc := NewSyncService(db, logger) + return svc.Run(ctx, TenantID(tenant), CompanyID(companyID), dryRun) + }, + } + + cmd.Flags().StringVar(&tenant, "tenant", "", "tenant identifier (required)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without writing") + cmd.Flags().IntVar(&workers, "workers", 4, "number of parallel workers") + _ = cmd.MarkFlagRequired("tenant") + + return cmd +} +``` + +### Exit Codes + +Use distinct exit codes for different failure modes. Define them as constants: + +```go +const ( + ExitOK = 0 + ExitUsageError = 1 + ExitRuntimeError = 2 + ExitConfigError = 3 + ExitPartialFailure = 4 +) +``` + +Cobra handles exit code 1 for usage errors by default. For other cases, handle in `main`: + +```go +func main() { + if err := root.Execute(); err != nil { + var cfgErr *config.Error + if errors.As(err, &cfgErr) { + os.Exit(ExitConfigError) + } + os.Exit(ExitRuntimeError) + } +} +``` + +### Stdin/Stdout/Stderr Discipline + +Standard output is for *data* (pipeable results). Standard error is for *diagnostics* (logs, progress, errors). Never mix them: + +```go +// Data goes to stdout — can be piped to jq, another tool, etc. +enc := json.NewEncoder(os.Stdout) +enc.Encode(result) + +// Diagnostics go to stderr +logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) +``` + +If the tool's primary output is human-readable (not piped), stdout is fine for both, but design for the pipeable case first. + +### Signal Handling + +CLI tools should handle SIGINT/SIGTERM gracefully. Use `signal.NotifyContext` for cancellation: + +```go +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := run(ctx); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} +``` + +--- + +## 7. Configuration + +### Layered Config: Env > Flags > File > Defaults + +Configuration sources, in precedence order: environment variables override flags, flags override file values, file values override defaults. Use a single config struct: + +```go +type Config struct { + DatabaseURL string `env:"DATABASE_URL" json:"database_url"` + LogLevel string `env:"LOG_LEVEL" json:"log_level"` + Workers int `env:"WORKERS" json:"workers"` + Timeout time.Duration `env:"TIMEOUT" json:"timeout"` + DryRun bool // flag-only, no file/env +} + +func Load() (*Config, error) { + cfg := Config{ + LogLevel: "info", + Workers: 4, + Timeout: 30 * time.Second, + } + + // Load from file if present, then overlay env vars + // ... + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + return &cfg, nil +} +``` + +### Validate Early, Fail Fast + +Validate all configuration at startup before doing any work. A config error 30 minutes into a batch job is a waste: + +```go +func (c *Config) Validate() error { + if c.DatabaseURL == "" { + return errors.New("DATABASE_URL is required") + } + if c.Workers < 1 || c.Workers > 64 { + return fmt.Errorf("workers must be 1-64, got %d", c.Workers) + } + return nil +} +``` + +### Testable Time + +Inject a clock function instead of calling `time.Now()` directly. This is the same principle as C#'s `TimeProvider`: + +```go +// In production +svc := &Service{clock: time.Now} + +// In tests +svc := &Service{clock: func() time.Time { return fixedTime }} +``` + +For more complex time needs, define a small interface: + +```go +type Clock interface { + Now() time.Time +} +``` + +--- + +## 8. Concurrency + +### Start Goroutines, Manage Lifetimes + +Every goroutine must have a clear shutdown path. Use `context.Context` for cancellation and `sync.WaitGroup` or `errgroup.Group` for completion: + +```go +g, ctx := errgroup.WithContext(ctx) + +for _, job := range jobs { + g.Go(func() error { + return processJob(ctx, job) + }) +} + +if err := g.Wait(); err != nil { + return fmt.Errorf("processing jobs: %w", err) +} +``` + +### errgroup for Parallel Tasks + +`errgroup` is the default for parallel work in CLI tools. It handles cancellation on first error and waitgroup semantics in one package: + +```go +g, ctx := errgroup.WithContext(ctx) +g.SetLimit(workers) // bounded parallelism + +for _, item := range items { + g.Go(func() error { + return process(ctx, item) + }) +} +return g.Wait() +``` + +### Channels for Pipelines, Not for Synchronization + +Use channels when data flows between stages. Use `sync.WaitGroup`, `errgroup`, or `sync.Mutex` for synchronization. Don't use a `chan struct{}` when a `WaitGroup` is clearer: + +```go +// Good: channel as a pipeline stage +func produce(ctx context.Context, items []Item) <-chan Item { + ch := make(chan Item) + go func() { + defer close(ch) + for _, item := range items { + select { + case ch <- item: + case <-ctx.Done(): + return + } + } + }() + return ch +} +``` + +### Never Launch Unbounded Goroutines + +Always limit concurrency for I/O-bound work. A CLI tool that launches 10,000 goroutines to hit an API will get rate-limited or OOM. Use `errgroup.SetLimit`, a semaphore channel, or a worker pool: + +```go +// Semaphore pattern +sem := make(chan struct{}, maxConcurrency) +for _, item := range items { + sem <- struct{}{} + go func() { + defer func() { <-sem }() + process(ctx, item) + }() +} +``` + +--- + +## 9. Data Access + +### database/sql with pgx or lib/pq + +Use `database/sql` as the interface layer. `pgx` is preferred as the driver for PostgreSQL (better performance, native types). Dapper-style explicit SQL applies equally here — write your own queries, don't hide behind an ORM: + +```go +const accountsQuery = ` + WITH target AS ( + SELECT b.id AS business_id + FROM business b + JOIN financial_institution fi ON b.tenant_id = fi.tenant_id + WHERE fi.fi_identifier = $1 AND b.company_id = $2 + ) + SELECT a.id, a.name, a.type + FROM account a + JOIN target t ON a.business_id = t.business_id + ORDER BY a.name +` + +func (s *AccountStore) GetAccounts(ctx context.Context, tenant TenantID, company CompanyID) ([]Account, error) { + rows, err := s.db.QueryContext(ctx, accountsQuery, string(tenant), string(company)) + if err != nil { + return nil, fmt.Errorf("querying accounts: %w", err) + } + defer rows.Close() + + var accounts []Account + for rows.Next() { + var a Account + if err := rows.Scan(&a.ID, &a.Name, &a.Type); err != nil { + return nil, fmt.Errorf("scanning account row: %w", err) + } + accounts = append(accounts, a) + } + return accounts, rows.Err() +} +``` + +### SQL Best Practices + +These carry over directly from the C# guide: + +- Always specify columns — avoid `SELECT *` +- Always use parameterized queries (`$1`, `$2`), never `fmt.Sprintf` into SQL +- Use CTEs over subqueries for readability +- Paginate large result sets; prefer cursor-based pagination over `OFFSET`/`LIMIT` +- Batch large `IN` clauses (100+ items) with `ANY($1::text[])` or temp tables + +### Transaction Management + +```go +tx, err := db.BeginTx(ctx, nil) +if err != nil { + return fmt.Errorf("beginning transaction: %w", err) +} +defer tx.Rollback() // no-op if committed + +// ... operations using tx ... + +if err := tx.Commit(); err != nil { + return fmt.Errorf("committing transaction: %w", err) +} +``` + +The `defer tx.Rollback()` pattern is idiomatic — it's a no-op after a successful commit and ensures cleanup on any error path. + +--- + +## 10. Serialization + +### encoding/json from the Standard Library + +Use `encoding/json` by default. For performance-sensitive paths, `json/v2` (when stable) or `github.com/goccy/go-json` are acceptable drop-in replacements. + +### Struct Tags Are the Schema + +```go +type SyncRequest struct { + TenantID string `json:"tenant_id"` + CompanyID string `json:"company_id"` + Platform PlatformType `json:"platform"` + Priority int `json:"priority,omitempty"` +} +``` + +Use `omitempty` deliberately — it means "omit when zero value," which may or may not be what you want. An `int` field with `omitempty` drops `0`, which may be meaningful. + +### Custom Marshaling for Enums + +```go +func (p PlatformType) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +func (p *PlatformType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch s { + case "Accounting": + *p = PlatformAccounting + case "Banking": + *p = PlatformBanking + default: + return fmt.Errorf("unknown platform type: %q", s) + } + return nil +} +``` + +--- + +## 11. Logging + +### slog for CLIs, zap for Services + +Use `log/slog` from the standard library for CLI tools. It's zero-dependency, has the right level of abstraction for console programs, and writes to stderr by default (which is what you want for CLIs — see Section 6 on stdout/stderr discipline). + +For long-running web services where logging is on the hot path, `go.uber.org/zap` is acceptable — it's measurably faster due to pre-allocation and zero-reflection design. But for CLI tools, logging throughput is never the bottleneck, and slog's simplicity wins. + +```go +// CLI: slog with text handler for human-readable output +handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, +}) +logger := slog.New(handler) + +// CLI: JSON handler when output will be ingested by log aggregation +handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, +}) +logger := slog.New(handler) + +// Good: structured key-value pairs +logger.Info("calculating insights", + "tenant", tenant, + "company_id", companyID, + "platform", platform, +) + +logger.Error("sync failed", + "tenant", tenant, + "company_id", companyID, + "error", err, + "elapsed_ms", elapsed.Milliseconds(), +) +``` + +### Named Fields, Not Interpolation + +Same principle as the C# guide — each value must be a discrete, queryable field: + +```go +// Good: each field independently queryable +logger.Info("processing transaction", + "tenant", tenant, + "company_id", companyID, + "txn_id", txnID, +) + +// Bad: opaque string defeats structured logging +logger.Info(fmt.Sprintf("%s::%s - processing transaction %s", tenant, companyID, txnID)) +``` + +### Logger Propagation + +Pass loggers as dependencies, not globals. Use `slog.With` to add context that applies to all messages in a scope: + +```go +func (s *SyncService) Run(ctx context.Context, tenant TenantID, company CompanyID) error { + log := s.logger.With("tenant", tenant, "company_id", company) + log.Info("starting sync") + // all subsequent log calls in this scope include tenant and company_id +} +``` + +### Log Levels + +| Level | Usage | +|-------|-------| +| Info | Start/completion of major operations, business events | +| Warn | Retry attempts, degraded scenarios, non-critical issues | +| Error | Failures (always include the error value) | +| Debug | Detailed operational info, only enabled in dev/troubleshooting | + +### Performance Timing + +```go +start := time.Now() +// ... work +logger.Info("operation complete", + "tenant", tenant, + "elapsed_ms", time.Since(start).Milliseconds(), +) +``` + +### Log Security + +Never log sensitive information: passwords, tokens, PII, full credit card numbers, SSNs. Be cautious with user attributes — only log what's necessary for debugging. + +--- + +## 12. Error Handling & Result Patterns + +### Guard Clauses and Early Returns + +Same as C#: reject invalid states early, keep the happy path at the lowest indentation level: + +```go +func (s *Service) Process(ctx context.Context, req Request) (*Result, error) { + if req.TenantID == "" { + return nil, &ValidationError{Field: "tenant_id", Message: "required"} + } + + conn, err := s.getConnection(ctx, req.TenantID) + if err != nil { + return nil, fmt.Errorf("getting connection: %w", err) + } + + // happy path continues un-indented... +} +``` + +### Multi-Value Returns for Outcome Disambiguation + +Go's multiple return values serve the same role as C#'s tuple returns: + +```go +// Found vs not-found vs error are three different outcomes +func (s *Store) GetAccount(ctx context.Context, id string) (account Account, found bool, err error) { + row := s.db.QueryRowContext(ctx, query, id) + if err := row.Scan(&account.ID, &account.Name); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Account{}, false, nil + } + return Account{}, false, fmt.Errorf("scanning account: %w", err) + } + return account, true, nil +} +``` + +### ok-Pattern for Optional Results + +For lookups that may miss, use the comma-ok pattern familiar from map access: + +```go +val, ok := cache[key] +if !ok { + // handle miss +} +``` + +### Collecting Errors in Batch Operations + +For operations that process multiple items where you want partial results, collect errors rather than failing on the first one: + +```go +var errs []error +for _, item := range items { + if err := process(ctx, item); err != nil { + errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err)) + continue + } +} +if len(errs) > 0 { + return fmt.Errorf("partial failure (%d/%d): %w", len(errs), len(items), errors.Join(errs...)) +} +``` + +--- + +## 13. Collection Patterns + +### Nil Slices Over Empty Slices + +In Go, a nil slice and an empty slice behave identically for `len`, `cap`, `range`, and `append`. Prefer nil (the zero value) — don't allocate when there's nothing to hold: + +```go +// Good: zero value is fine +var accounts []Account +// len(accounts) == 0, range works, append works + +// Unnecessary: allocating for no reason +accounts := make([]Account, 0) +accounts := []Account{} +``` + +Exception: JSON serialization. `json.Marshal(nil)` produces `null`, while `json.Marshal([]Account{})` produces `[]`. If the distinction matters to consumers, initialize explicitly. + +### Pre-Allocate When Size Is Known + +```go +results := make([]Result, 0, len(items)) +for _, item := range items { + results = append(results, transform(item)) +} +``` + +### maps Package for Common Operations + +Use `maps.Keys`, `maps.Values`, `maps.Clone` from the standard library instead of hand-rolling: + +```go +import "maps" + +keys := slices.Sorted(maps.Keys(accountsByID)) +``` + +### slices Package for Transformations + +Use `slices.SortFunc`, `slices.Contains`, `slices.Compact`, etc.: + +```go +import "slices" + +slices.SortFunc(accounts, func(a, b Account) int { + return strings.Compare(a.Name, b.Name) +}) + +hasAdmin := slices.ContainsFunc(roles, func(r Role) bool { + return r.Name == "admin" +}) +``` + +### Chunking for Batch Operations + +Same concept as C#'s `.Chunk()` — batch items for APIs with size limits: + +```go +func Chunk[T any](items []T, size int) [][]T { + var chunks [][]T + for size < len(items) { + items, chunks = items[size:], append(chunks, items[:size]) + } + return append(chunks, items) +} + +// Usage: DynamoDB BatchWriteItem supports max 25 items +for _, batch := range Chunk(writeRequests, 25) { + if err := writeBatch(ctx, batch); err != nil { + return err + } +} +``` + +Or use `slices.Chunk` if available on your Go version. + +--- + +## 14. Testing + +### Framework: Standard Library Only + +Use `testing` from the standard library. No testify, no gomega, no ginkgo. Table-driven tests and `t.Helper()` cover nearly everything. If you need mocks, write them by hand or use a small code generator — a mock framework dependency is almost never worth it. + +### Table-Driven Tests + +The default test structure. Each case is a named struct in a slice: + +```go +func TestGetPrimaryKeyName(t *testing.T) { + tests := []struct { + name string + platform PlatformType + want string + wantErr bool + }{ + {name: "accounting", platform: PlatformAccounting, want: "companyAndInsight"}, + {name: "banking", platform: PlatformBanking, want: "extCompanyId"}, + {name: "unknown panics", platform: PlatformUnknown, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetPrimaryKeyName(tt.platform) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("GetPrimaryKeyName(%v) = %q, want %q", tt.platform, got, tt.want) + } + }) + } +} +``` + +### Test Helpers + +Use `t.Helper()` for functions that report failures on behalf of the caller: + +```go +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func assertEqual[T comparable](t *testing.T, got, want T) { + t.Helper() + if got != want { + t.Errorf("got %v, want %v", got, want) + } +} +``` + +### Test Fixtures and Golden Files + +For complex test data, use `testdata/` directories. For output comparison, use golden files: + +```go +func TestRenderReport(t *testing.T) { + got := renderReport(testInput) + + golden := filepath.Join("testdata", t.Name()+".golden") + if *update { + os.WriteFile(golden, []byte(got), 0644) + } + + want, _ := os.ReadFile(golden) + if got != string(want) { + t.Errorf("output mismatch; run with -update to refresh golden files") + } +} +``` + +### Fake Implementations Over Mocks + +Write simple fake structs that satisfy interfaces. They're more readable and more maintainable than mock framework magic: + +```go +type fakeAccountStore struct { + accounts []Account + err error +} + +func (f *fakeAccountStore) GetAccounts(ctx context.Context, tenant TenantID) ([]Account, error) { + return f.accounts, f.err +} + +// In test: +store := &fakeAccountStore{ + accounts: []Account{{ID: "1", Name: "Test"}}, +} +svc := NewReconciler(store, slog.Default()) +``` + +### Test Naming + +Pattern: `TestFunctionName_Scenario` using sub-tests for cases: + +```go +func TestReconciler_Run(t *testing.T) { + t.Run("empty accounts returns early", func(t *testing.T) { ... }) + t.Run("mismatched totals returns error", func(t *testing.T) { ... }) + t.Run("successful reconciliation", func(t *testing.T) { ... }) +} +``` + +### Parallel Tests + +Mark tests as parallel when they don't share mutable state: + +```go +func TestExpensiveComputation(t *testing.T) { + t.Parallel() + // ... +} +``` + +For table-driven tests, capture the loop variable and run subtests in parallel: + +```go +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // ... + }) +} +``` + +--- + +## 15. Formatting & Layout + +### gofmt Is Non-Negotiable + +All code is formatted with `gofmt`. No exceptions, no arguments, no custom settings. Use `goimports` as a superset (handles import ordering too). Configure your editor to run it on save. + +### Line Length + +Go has no official line limit, but target ~100-120 characters for readability. Wrap function signatures and long expressions: + +```go +func (s *SyncService) ProcessBatch( + ctx context.Context, + tenant TenantID, + companyID CompanyID, + items []SyncItem, + opts ProcessOptions, +) (*BatchResult, error) { +``` + +### File Organization + +Within a file, order declarations: + +1. Package-level constants and variables +2. Types (structs, interfaces) +3. Constructor functions (`NewX`) +4. Methods grouped by receiver type +5. Package-level functions (helpers, utilities) + +### One Type Per File (Usually) + +Major types get their own file. Small related types (a struct and its constructor, an interface and a helper) can share a file. If a file exceeds ~400 lines, consider splitting. + +### Comments: Focus on "Why" + +Same as C#: comments explain *why*, not *what*. If the what/how isn't clear, improve the name: + +```go +// Bad: restating the code +// Check if rate is greater than zero +if rate > 0 { ... } + +// Good: explaining domain knowledge +// Fed data uses VEB (bolivar fuerte) instead of the current ISO code VES (bolivar soberano). +if strings.EqualFold(code, "VES") { + return "VEB" +} +``` + +### Package Comments + +Every package should have a doc comment in a `doc.go` or at the top of the primary file: + +```go +// Package reconcile provides tools for reconciling account data +// between external platforms and the internal ledger. +package reconcile +``` + +### Exported Function Documentation + +All exported functions, types, and methods have doc comments. Start with the name of the thing: + +```go +// GetAccounts returns all accounts for the given tenant and company. +// It returns an empty slice (not nil) if no accounts are found. +func (s *Store) GetAccounts(ctx context.Context, tenant TenantID, company CompanyID) ([]Account, error) { +``` + +--- + +## 16. Naming + +### Go Naming Conventions + +These are non-negotiable — they're enforced by the compiler and community norms: + +- **Exported** identifiers are `PascalCase`: `GetAccounts`, `SyncResult`, `ErrNotFound` +- **Unexported** identifiers are `camelCase`: `processItem`, `accountStore`, `defaultTimeout` +- **Acronyms** are all-caps: `ID`, `URL`, `HTTP`, `API` — not `Id`, `Url`, `Http` +- **Package names** are lowercase, single word when possible: `config`, `sync`, `ledger` — not `ledgerUtils`, `sync_helpers` +- **Interface names**: single-method interfaces use the `-er` suffix: `Reader`, `Writer`, `Closer`, `Fetcher`. Multi-method interfaces describe the role: `AccountStore`, `TokenProvider` + +### Receiver Names + +Use one or two letter abbreviations, consistent across all methods on a type. Never `self` or `this`: + +```go +func (r *Reconciler) Run(ctx context.Context) error { ... } +func (r *Reconciler) validate() error { ... } + +func (s *Store) GetAccounts(ctx context.Context) ([]Account, error) { ... } +``` + +### Variable Names + +Short names for short scopes, descriptive names for long scopes: + +```go +// Good: short scope, short name +for i, a := range accounts { ... } + +// Good: longer scope, descriptive name +var activeAccounts []Account +for _, account := range allAccounts { + if account.IsActive { + activeAccounts = append(activeAccounts, account) + } +} +``` + +### Don't Stutter + +Package names qualify their exports. Don't repeat the package name in the type name: + +```go +// Bad: config.ConfigOptions stutters +package config +type ConfigOptions struct { ... } + +// Good: config.Options reads naturally +package config +type Options struct { ... } +``` + +--- + +## 17. Dependency Management + +### Standard Library First + +Before reaching for a third-party package, check if the standard library covers it. Go's stdlib is unusually comprehensive. Common cases where teams reach for dependencies unnecessarily: + +- HTTP clients: `net/http` is excellent. You rarely need a wrapper. +- JSON: `encoding/json` covers most cases. Only reach for alternatives on hot paths with benchmarks. +- Logging: `log/slog` for CLI tools (see Section 11). `zap` is acceptable for web services. +- Testing: `testing` + table-driven tests covers 95% of needs. + +### Acceptable Common Dependencies + +These are fine to pull in without justification: + +- `github.com/spf13/cobra` — CLI framework (mandatory for all tools, see Section 6) +- `github.com/aws/aws-sdk-go-v2` — AWS API access +- `github.com/jackc/pgx/v5` — PostgreSQL driver +- `golang.org/x/sync/errgroup` — parallel goroutine management +- `go.uber.org/zap` — structured logging for web services (not CLIs) + +Everything else needs a reason. "It's popular" is not a reason. + +### Keeping Dependencies Updated + +Run `go get -u ./...` and `go mod tidy` regularly. Pin major versions in `go.mod`. Review changelogs for security patches. + +--- + +## 18. Zero Values and Nullability + +### Embrace the Zero Value + +Go's zero values (`""`, `0`, `false`, `nil` for pointers/slices/maps) are part of the type system. Design types so that the zero value is useful: + +```go +// Good: zero value is a valid, empty state +type BatchResult struct { + Processed int + Failed int + Errors []error // nil = no errors +} + +// r := BatchResult{} is already valid, means "nothing processed, no errors" +``` + +### Pointer Fields Mean "Optional" + +Use pointer fields when the zero value is meaningful and you need to distinguish "unset" from "zero": + +```go +type UpdateRequest struct { + Name *string // nil = don't update, "" = set to empty + Workers *int // nil = don't update, 0 = valid value + Active *bool // nil = don't update, false = valid value +} +``` + +### Validate at Boundaries + +Same philosophy as the C# guide: eliminate nil/zero concerns at the edges. Inside the domain, types should carry only valid state: + +```go +// Boundary: validate and reject +func (h *Handler) HandleSync(req *http.Request) error { + var input SyncRequest + if err := json.NewDecoder(req.Body).Decode(&input); err != nil { + return fmt.Errorf("invalid request body: %w", err) + } + if input.TenantID == "" { + return &ValidationError{Field: "tenant_id", Message: "required"} + } + // domain functions receive validated, non-zero data + return h.svc.Sync(req.Context(), TenantID(input.TenantID), CompanyID(input.CompanyID)) +} +``` + +### Empty Collections Over Nil in JSON + +When serializing for external consumers, initialize slices if `null` vs `[]` matters: + +```go +type Response struct { + Items []Item `json:"items"` +} + +// If Items might be nil, initialize before marshaling: +if resp.Items == nil { + resp.Items = []Item{} +} +``` + +--- + +## 19. AWS SDK Patterns + +### Use SDK v2 + +All new code uses `aws-sdk-go-v2`. Do not use v1 (`aws-sdk-go`). + +### SQS Long Polling + +```go +out, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ + QueueUrl: &queueURL, + WaitTimeSeconds: 20, + MaxNumberOfMessages: 10, +}) +``` + +### S3 Pagination + +Use the paginator helpers from SDK v2: + +```go +paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: &prefix, +}) + +for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return fmt.Errorf("listing objects: %w", err) + } + for _, obj := range page.Contents { + // process + } +} +``` + +### DynamoDB Batch Operations + +```go +// BatchWriteItem supports max 25 items +for _, batch := range Chunk(writeRequests, 25) { + _, err := client.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{ + tableName: batch, + }, + }) + if err != nil { + return fmt.Errorf("batch write: %w", err) + } +} +``` + +--- + +## 20. Patterns to Maintain + +### Prefer Standard Library Over Hand-Rolled + +Same principle as the C# guide: check whether Go's stdlib already provides the functionality before writing a utility. In particular: + +- `slices` and `maps` packages replace many hand-rolled loops (Go 1.21+) +- `slog` replaces custom logging for CLI tools (Go 1.21+); `zap` remains appropriate for services +- `errors.Join` replaces custom multi-error types (Go 1.20+) +- `sync.OnceValue` replaces lazy initialization patterns (Go 1.21+) +- `http.NewServeMux` pattern matching replaces many router libraries (Go 1.22+) + +### decimal for Money + +Go's `float64` has the same problems as C#'s `double`. Use `shopspring/decimal` or a similar arbitrary-precision library for monetary calculations. Never `float64` for money: + +```go +import "github.com/shopspring/decimal" + +rate := decimal.NewFromString("0.0425") +monthly := rate.Div(decimal.NewFromInt(12)) +``` + +### time.Time, Not int64 + +Represent timestamps as `time.Time`, not Unix epoch integers. Convert at boundaries (JSON serialization, database storage), not in domain logic. + +### Performance Behind Good Names + +Same as C#: a function named `GetExchangeRate` can use `unsafe.Pointer` arithmetic internally if profiling demands it. The caller sees a clean API. Optimize hot paths, not cold paths. Profile before optimizing. + +### Composition Over Inheritance + +Go doesn't have inheritance. Use embedding for shared structure, interfaces for shared behavior: + +```go +// Embedding: shared fields +type BaseJob struct { + TenantID TenantID + CompanyID CompanyID + CreatedAt time.Time +} + +type SyncJob struct { + BaseJob + Platform PlatformType + Priority int +} + +// Interface: shared behavior +type Processor interface { + Process(ctx context.Context, job Job) error +} +``` diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ca02d3f..a7f7505 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -31,7 +31,7 @@ func GetOAuthConfig() (*oauth2.Config, error) { if err != nil { return nil, err } - b, err := os.ReadFile(credPath) + b, err := os.ReadFile(credPath) //nolint:gosec // Path from user config directory if err != nil { return nil, fmt.Errorf("unable to read credentials file: %w", err) } @@ -77,7 +77,7 @@ func ExchangeAuthCode(ctx context.Context, config *oauth2.Config, code string) ( } func tokenFromFile(file string) (*oauth2.Token, error) { - f, err := os.Open(file) + f, err := os.Open(file) //nolint:gosec // Path from user config directory if err != nil { return nil, err } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 430cc36..0b07c0a 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -64,7 +64,7 @@ func New(ttlHours int) (*Cache, error) { func (c *Cache) GetDrives() ([]*CachedDrive, error) { path := filepath.Join(c.dir, DrivesFile) - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // Path constructed from known config directory if err != nil { if os.IsNotExist(err) { return nil, nil // Cache miss, not an error @@ -134,7 +134,7 @@ func (c *Cache) GetStatus() (*Status, error) { // Check drives cache drivesPath := filepath.Join(c.dir, DrivesFile) - data, err := os.ReadFile(drivesPath) + data, err := os.ReadFile(drivesPath) //nolint:gosec // Path constructed from known config directory if err == nil { var cache DriveCache if json.Unmarshal(data, &cache) == nil { diff --git a/internal/cmd/calendar/events.go b/internal/cmd/calendar/events.go index 718fcb9..7e0c6a7 100644 --- a/internal/cmd/calendar/events.go +++ b/internal/cmd/calendar/events.go @@ -32,7 +32,7 @@ Examples: gro cal events --from 2026-01-01 --to 2026-01-31 gro calendar events work@group.calendar.google.com --json`, Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { calID := calendarID if len(args) > 0 { calID = args[0] diff --git a/internal/cmd/calendar/get.go b/internal/cmd/calendar/get.go index 2963f88..9648180 100644 --- a/internal/cmd/calendar/get.go +++ b/internal/cmd/calendar/get.go @@ -26,7 +26,7 @@ Examples: gro cal get abc123xyz --json gro cal get abc123xyz --calendar work@group.calendar.google.com`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { eventID := args[0] client, err := newCalendarClient() diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index 5dedf90..c879eba 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -144,7 +144,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { func TestEventsCommand_Success(t *testing.T) { mock := &testutil.MockCalendarClient{ - ListEventsFunc: func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { + ListEventsFunc: func(calendarID, _, _ string, _ int64) ([]*calendar.Event, error) { assert.Equal(t, "primary", calendarID) return []*calendar.Event{testutil.SampleEvent("event1")}, nil }, @@ -166,7 +166,7 @@ func TestEventsCommand_Success(t *testing.T) { func TestEventsCommand_WithDateRange(t *testing.T) { var capturedTimeMin, capturedTimeMax string mock := &testutil.MockCalendarClient{ - ListEventsFunc: func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_, timeMin, timeMax string, _ int64) ([]*calendar.Event, error) { capturedTimeMin = timeMin capturedTimeMax = timeMax return []*calendar.Event{}, nil @@ -191,7 +191,7 @@ func TestEventsCommand_WithDateRange(t *testing.T) { func TestEventsCommand_JSONOutput(t *testing.T) { mock := &testutil.MockCalendarClient{ - ListEventsFunc: func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{testutil.SampleEvent("event1")}, nil }, } @@ -260,7 +260,7 @@ func TestGetCommand_Success(t *testing.T) { func TestGetCommand_JSONOutput(t *testing.T) { mock := &testutil.MockCalendarClient{ - GetEventFunc: func(calendarID, eventID string) (*calendar.Event, error) { + GetEventFunc: func(_, _ string) (*calendar.Event, error) { return testutil.SampleEvent("event123"), nil }, } @@ -283,7 +283,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { func TestGetCommand_NotFound(t *testing.T) { mock := &testutil.MockCalendarClient{ - GetEventFunc: func(calendarID, eventID string) (*calendar.Event, error) { + GetEventFunc: func(_, _ string) (*calendar.Event, error) { return nil, errors.New("event not found") }, } @@ -300,7 +300,7 @@ func TestGetCommand_NotFound(t *testing.T) { func TestTodayCommand_Success(t *testing.T) { mock := &testutil.MockCalendarClient{ - ListEventsFunc: func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{testutil.SampleEvent("today_event")}, nil }, } @@ -319,7 +319,7 @@ func TestTodayCommand_Success(t *testing.T) { func TestWeekCommand_Success(t *testing.T) { mock := &testutil.MockCalendarClient{ - ListEventsFunc: func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{ testutil.SampleEvent("week_event1"), testutil.SampleEvent("week_event2"), diff --git a/internal/cmd/calendar/list.go b/internal/cmd/calendar/list.go index f9459c4..fa4b3f8 100644 --- a/internal/cmd/calendar/list.go +++ b/internal/cmd/calendar/list.go @@ -22,7 +22,7 @@ Examples: gro calendar list gro cal list --json`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newCalendarClient() if err != nil { return fmt.Errorf("failed to create Calendar client: %w", err) diff --git a/internal/cmd/calendar/today.go b/internal/cmd/calendar/today.go index 6fd6390..cebe6da 100644 --- a/internal/cmd/calendar/today.go +++ b/internal/cmd/calendar/today.go @@ -25,7 +25,7 @@ Examples: gro cal today --json gro cal today --calendar work@group.calendar.google.com`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newCalendarClient() if err != nil { return fmt.Errorf("failed to create Calendar client: %w", err) diff --git a/internal/cmd/calendar/week.go b/internal/cmd/calendar/week.go index 20d93fb..730912a 100644 --- a/internal/cmd/calendar/week.go +++ b/internal/cmd/calendar/week.go @@ -25,7 +25,7 @@ Examples: gro cal week --json gro cal week --calendar work@group.calendar.google.com`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newCalendarClient() if err != nil { return fmt.Errorf("failed to create Calendar client: %w", err) diff --git a/internal/cmd/config/cache.go b/internal/cmd/config/cache.go index 114e5b4..7f19c80 100644 --- a/internal/cmd/config/cache.go +++ b/internal/cmd/config/cache.go @@ -39,7 +39,7 @@ func newCacheShowCommand() *cobra.Command { - Configured TTL - Cached data status (when last updated, expiration)`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { cfg, err := configpkg.LoadConfig() if err != nil { return fmt.Errorf("failed to load config: %w", err) @@ -92,7 +92,7 @@ func newCacheClearCommand() *cobra.Command { Short: "Clear all cached data", Long: `Remove all cached data. Cache will be repopulated on next use.`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { cfg, err := configpkg.LoadConfig() if err != nil { return fmt.Errorf("failed to load config: %w", err) @@ -126,7 +126,7 @@ Examples: gro config cache ttl 12 # Set TTL to 12 hours gro config cache ttl 48 # Set TTL to 48 hours`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { ttl, err := strconv.Atoi(args[0]) if err != nil || ttl <= 0 { return fmt.Errorf("invalid TTL value: must be a positive integer (hours)") diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index cacee14..a0369f3 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -65,7 +65,7 @@ The credentials.json file (OAuth client config) is not removed.`, } } -func runShow(cmd *cobra.Command, args []string) error { +func runShow(_ *cobra.Command, _ []string) error { // Check credentials file credPath, err := gmail.GetCredentialsPath() if err != nil { @@ -127,7 +127,7 @@ func runShow(cmd *cobra.Command, args []string) error { return nil } -func runTest(cmd *cobra.Command, args []string) error { +func runTest(_ *cobra.Command, _ []string) error { fmt.Println("Testing Gmail API connection...") fmt.Println() @@ -166,7 +166,7 @@ func runTest(cmd *cobra.Command, args []string) error { return nil } -func runClear(cmd *cobra.Command, args []string) error { +func runClear(_ *cobra.Command, _ []string) error { if !keychain.HasStoredToken() { fmt.Println("No OAuth token found to clear.") return nil diff --git a/internal/cmd/contacts/get.go b/internal/cmd/contacts/get.go index cef9ad2..f8578e8 100644 --- a/internal/cmd/contacts/get.go +++ b/internal/cmd/contacts/get.go @@ -23,7 +23,7 @@ Examples: gro contacts get people/c123456789 gro ppl get people/c123456789 --json`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { resourceName := args[0] client, err := newContactsClient() diff --git a/internal/cmd/contacts/groups.go b/internal/cmd/contacts/groups.go index 1cb73d6..42297af 100644 --- a/internal/cmd/contacts/groups.go +++ b/internal/cmd/contacts/groups.go @@ -26,7 +26,7 @@ Examples: gro contacts groups --max 50 gro ppl groups --json`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newContactsClient() if err != nil { return fmt.Errorf("failed to create Contacts client: %w", err) diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index 60be8cf..6453909 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -55,7 +55,7 @@ func withFailingClientFactory(f func()) { func TestListCommand_Success(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactsFunc: func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{ testutil.SamplePerson("people/c123"), @@ -81,7 +81,7 @@ func TestListCommand_Success(t *testing.T) { func TestListCommand_JSONOutput(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactsFunc: func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{ testutil.SamplePerson("people/c123"), @@ -108,7 +108,7 @@ func TestListCommand_JSONOutput(t *testing.T) { func TestListCommand_Empty(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactsFunc: func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{}, }, nil @@ -129,7 +129,7 @@ func TestListCommand_Empty(t *testing.T) { func TestListCommand_APIError(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactsFunc: func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return nil, errors.New("API error") }, } @@ -155,7 +155,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { func TestSearchCommand_Success(t *testing.T) { mock := &testutil.MockContactsClient{ - SearchContactsFunc: func(query string, pageSize int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(query string, _ int64) (*people.SearchResponse, error) { assert.Equal(t, "John", query) return &people.SearchResponse{ Results: []*people.SearchResult{ @@ -181,7 +181,7 @@ func TestSearchCommand_Success(t *testing.T) { func TestSearchCommand_JSONOutput(t *testing.T) { mock := &testutil.MockContactsClient{ - SearchContactsFunc: func(query string, pageSize int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { return &people.SearchResponse{ Results: []*people.SearchResult{ {Person: testutil.SamplePerson("people/c123")}, @@ -208,7 +208,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { func TestSearchCommand_NoResults(t *testing.T) { mock := &testutil.MockContactsClient{ - SearchContactsFunc: func(query string, pageSize int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { return &people.SearchResponse{ Results: []*people.SearchResult{}, }, nil @@ -230,7 +230,7 @@ func TestSearchCommand_NoResults(t *testing.T) { func TestSearchCommand_APIError(t *testing.T) { mock := &testutil.MockContactsClient{ - SearchContactsFunc: func(query string, pageSize int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { return nil, errors.New("API error") }, } @@ -270,7 +270,7 @@ func TestGetCommand_Success(t *testing.T) { func TestGetCommand_JSONOutput(t *testing.T) { mock := &testutil.MockContactsClient{ - GetContactFunc: func(resourceName string) (*people.Person, error) { + GetContactFunc: func(_ string) (*people.Person, error) { return testutil.SamplePerson("people/c123"), nil }, } @@ -293,7 +293,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { func TestGetCommand_NotFound(t *testing.T) { mock := &testutil.MockContactsClient{ - GetContactFunc: func(resourceName string) (*people.Person, error) { + GetContactFunc: func(_ string) (*people.Person, error) { return nil, errors.New("contact not found") }, } @@ -310,7 +310,7 @@ func TestGetCommand_NotFound(t *testing.T) { func TestGroupsCommand_Success(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactGroupsFunc: func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{ { @@ -346,7 +346,7 @@ func TestGroupsCommand_Success(t *testing.T) { func TestGroupsCommand_JSONOutput(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactGroupsFunc: func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{ { @@ -379,7 +379,7 @@ func TestGroupsCommand_JSONOutput(t *testing.T) { func TestGroupsCommand_Empty(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactGroupsFunc: func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{}, }, nil @@ -400,7 +400,7 @@ func TestGroupsCommand_Empty(t *testing.T) { func TestGroupsCommand_APIError(t *testing.T) { mock := &testutil.MockContactsClient{ - ListContactGroupsFunc: func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return nil, errors.New("API error") }, } diff --git a/internal/cmd/contacts/list.go b/internal/cmd/contacts/list.go index 1ae3f2d..711840f 100644 --- a/internal/cmd/contacts/list.go +++ b/internal/cmd/contacts/list.go @@ -26,7 +26,7 @@ Examples: gro contacts list --max 50 gro ppl list --json`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newContactsClient() if err != nil { return fmt.Errorf("failed to create Contacts client: %w", err) diff --git a/internal/cmd/contacts/search.go b/internal/cmd/contacts/search.go index 739172e..f8bd452 100644 --- a/internal/cmd/contacts/search.go +++ b/internal/cmd/contacts/search.go @@ -32,7 +32,7 @@ Examples: gro contacts search "+1-555" --max 20 gro ppl search "Acme" --json`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { query := args[0] client, err := newContactsClient() diff --git a/internal/cmd/drive/download.go b/internal/cmd/drive/download.go index 95139d5..bb884cf 100644 --- a/internal/cmd/drive/download.go +++ b/internal/cmd/drive/download.go @@ -41,7 +41,7 @@ Export formats: Presentations: pdf, pptx, odp Drawings: pdf, png, svg, jpg`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client, err := newDriveClient() if err != nil { return fmt.Errorf("failed to create Drive client: %w", err) diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index 17be98f..431b82f 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -31,7 +31,7 @@ Examples: gro drive drives --refresh # Force refresh from API gro drive drives --json # Output as JSON`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newDriveClient() if err != nil { return fmt.Errorf("failed to create Drive client: %w", err) diff --git a/internal/cmd/drive/drives_test.go b/internal/cmd/drive/drives_test.go index 6a2d2bc..21c90fe 100644 --- a/internal/cmd/drive/drives_test.go +++ b/internal/cmd/drive/drives_test.go @@ -139,7 +139,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("resolves drive name to ID via API", func(t *testing.T) { mock := &testutil.MockDriveClient{ - ListSharedDrivesFunc: func(pageSize int64) ([]*drive.SharedDrive, error) { + ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, {ID: "0ALfin456", Name: "Finance"}, @@ -155,7 +155,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("resolves drive name case-insensitively", func(t *testing.T) { mock := &testutil.MockDriveClient{ - ListSharedDrivesFunc: func(pageSize int64) ([]*drive.SharedDrive, error) { + ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, }, nil @@ -170,7 +170,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("returns error when drive name not found", func(t *testing.T) { mock := &testutil.MockDriveClient{ - ListSharedDrivesFunc: func(pageSize int64) ([]*drive.SharedDrive, error) { + ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, }, nil diff --git a/internal/cmd/drive/get.go b/internal/cmd/drive/get.go index 92c597e..ce962a7 100644 --- a/internal/cmd/drive/get.go +++ b/internal/cmd/drive/get.go @@ -22,7 +22,7 @@ Examples: gro drive get # Show file details gro drive get --json # Output as JSON`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client, err := newDriveClient() if err != nil { return fmt.Errorf("failed to create Drive client: %w", err) diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index b615e24..40fa79d 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -54,7 +54,7 @@ func withFailingClientFactory(f func()) { func TestListCommand_Success(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { assert.Contains(t, query, "'root' in parents") return testutil.SampleDriveFiles(2), nil }, @@ -75,7 +75,7 @@ func TestListCommand_Success(t *testing.T) { func TestListCommand_JSONOutput(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return testutil.SampleDriveFiles(1), nil }, } @@ -98,7 +98,7 @@ func TestListCommand_JSONOutput(t *testing.T) { func TestListCommand_Empty(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return []*driveapi.File{}, nil }, } @@ -117,7 +117,7 @@ func TestListCommand_Empty(t *testing.T) { func TestListCommand_WithFolder(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { assert.Contains(t, query, "'folder123' in parents") return testutil.SampleDriveFiles(1), nil }, @@ -138,7 +138,7 @@ func TestListCommand_WithFolder(t *testing.T) { func TestListCommand_WithTypeFilter(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { assert.Contains(t, query, "mimeType") return testutil.SampleDriveFiles(1), nil }, @@ -170,7 +170,7 @@ func TestListCommand_InvalidType(t *testing.T) { func TestListCommand_APIError(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return nil, errors.New("API error") }, } @@ -196,7 +196,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { func TestSearchCommand_Success(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { assert.Contains(t, query, "fullText contains 'report'") return testutil.SampleDriveFiles(2), nil }, @@ -218,7 +218,7 @@ func TestSearchCommand_Success(t *testing.T) { func TestSearchCommand_NameOnly(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { assert.Contains(t, query, "name contains 'budget'") return testutil.SampleDriveFiles(1), nil }, @@ -239,7 +239,7 @@ func TestSearchCommand_NameOnly(t *testing.T) { func TestSearchCommand_JSONOutput(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return testutil.SampleDriveFiles(1), nil }, } @@ -262,7 +262,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { func TestSearchCommand_NoResults(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return []*driveapi.File{}, nil }, } @@ -282,7 +282,7 @@ func TestSearchCommand_NoResults(t *testing.T) { func TestSearchCommand_APIError(t *testing.T) { mock := &testutil.MockDriveClient{ - ListFilesFunc: func(query string, pageSize int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return nil, errors.New("API error") }, } @@ -322,7 +322,7 @@ func TestGetCommand_Success(t *testing.T) { func TestGetCommand_JSONOutput(t *testing.T) { mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, } @@ -345,7 +345,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { func TestGetCommand_NotFound(t *testing.T) { mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return nil, errors.New("file not found") }, } @@ -368,7 +368,7 @@ func TestDownloadCommand_RegularFile(t *testing.T) { defer os.Chdir(origDir) mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, DownloadFileFunc: func(fileID string) ([]byte, error) { @@ -393,10 +393,10 @@ func TestDownloadCommand_RegularFile(t *testing.T) { func TestDownloadCommand_ToStdout(t *testing.T) { mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, - DownloadFileFunc: func(fileID string) ([]byte, error) { + DownloadFileFunc: func(_ string) ([]byte, error) { return []byte("test content"), nil }, } @@ -416,7 +416,7 @@ func TestDownloadCommand_ToStdout(t *testing.T) { func TestDownloadCommand_GoogleDocRequiresFormat(t *testing.T) { mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleGoogleDoc("doc123"), nil }, } @@ -439,7 +439,7 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { defer os.Chdir(origDir) mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleGoogleDoc("doc123"), nil }, ExportFileFunc: func(fileID, mimeType string) ([]byte, error) { @@ -465,7 +465,7 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, } @@ -482,10 +482,10 @@ func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { func TestDownloadCommand_APIError(t *testing.T) { mock := &testutil.MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, - DownloadFileFunc: func(fileID string) ([]byte, error) { + DownloadFileFunc: func(_ string) ([]byte, error) { return nil, errors.New("download failed") }, } diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index 554f43e..fa3366f 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -39,7 +39,7 @@ Examples: File types: document, spreadsheet, presentation, folder, pdf, image, video, audio`, Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { // Validate mutually exclusive flags if myDrive && driveFlag != "" { return fmt.Errorf("--my-drive and --drive are mutually exclusive") diff --git a/internal/cmd/drive/search.go b/internal/cmd/drive/search.go index 625a64c..ec99b9c 100644 --- a/internal/cmd/drive/search.go +++ b/internal/cmd/drive/search.go @@ -44,7 +44,7 @@ Examples: File types: document, spreadsheet, presentation, folder, pdf, image, video, audio`, Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { // Validate mutually exclusive flags if myDrive && driveFlag != "" { return fmt.Errorf("--my-drive and --drive are mutually exclusive") diff --git a/internal/cmd/drive/tree.go b/internal/cmd/drive/tree.go index c92fc1e..097244c 100644 --- a/internal/cmd/drive/tree.go +++ b/internal/cmd/drive/tree.go @@ -42,7 +42,7 @@ Examples: gro drive tree --files # Include files, not just folders gro drive tree --json # Output as JSON`, Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { // Validate mutually exclusive flags if myDrive && driveFlag != "" { return fmt.Errorf("--my-drive and --drive are mutually exclusive") diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index d05524e..09f3aef 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -3,6 +3,7 @@ package initcmd import ( "bufio" "context" + "errors" "fmt" "net/http" "net/url" @@ -45,7 +46,7 @@ Prerequisites: return cmd } -func runInit(cmd *cobra.Command, args []string) error { +func runInit(_ *cobra.Command, _ []string) error { // Step 1: Check for credentials.json credPath, err := gmail.GetCredentialsPath() if err != nil { @@ -259,23 +260,7 @@ func isAuthError(err error) bool { } // errorAs is a wrapper for errors.As to make testing easier -var errorAs = func(err error, target interface{}) bool { - switch t := target.(type) { - case **googleapi.Error: - for e := err; e != nil; { - if apiErr, ok := e.(*googleapi.Error); ok { - *t = apiErr - return true - } - if unwrapper, ok := e.(interface{ Unwrap() error }); ok { - e = unwrapper.Unwrap() - } else { - break - } - } - } - return false -} +var errorAs = errors.As // promptReauth asks the user if they want to re-authenticate func promptReauth() bool { diff --git a/internal/cmd/mail/attachments_download.go b/internal/cmd/mail/attachments_download.go index bea9b12..acfc232 100644 --- a/internal/cmd/mail/attachments_download.go +++ b/internal/cmd/mail/attachments_download.go @@ -38,7 +38,7 @@ Examples: gro mail attachments download 18abc123def456 --all --output ~/Downloads gro mail attachments download 18abc123def456 --filename archive.zip --extract`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { if filename == "" && !all { return fmt.Errorf("must specify --filename or --all") } diff --git a/internal/cmd/mail/attachments_list.go b/internal/cmd/mail/attachments_list.go index 30bc76a..35f2cbe 100644 --- a/internal/cmd/mail/attachments_list.go +++ b/internal/cmd/mail/attachments_list.go @@ -22,7 +22,7 @@ Examples: gro mail attachments list 18abc123def456 gro mail attachments list 18abc123def456 --json`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { return fmt.Errorf("failed to create Gmail client: %w", err) diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index 2f23986..588e06a 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -80,7 +80,7 @@ func TestSearchCommand_Success(t *testing.T) { func TestSearchCommand_JSONOutput(t *testing.T) { mock := &testutil.MockGmailClient{ - SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return testutil.SampleMessages(1), 0, nil }, } @@ -105,7 +105,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { func TestSearchCommand_NoResults(t *testing.T) { mock := &testutil.MockGmailClient{ - SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return []*gmailapi.Message{}, 0, nil }, } @@ -125,7 +125,7 @@ func TestSearchCommand_NoResults(t *testing.T) { func TestSearchCommand_APIError(t *testing.T) { mock := &testutil.MockGmailClient{ - SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return nil, 0, errors.New("API quota exceeded") }, } @@ -153,7 +153,7 @@ func TestSearchCommand_ClientCreationError(t *testing.T) { func TestSearchCommand_SkippedMessages(t *testing.T) { mock := &testutil.MockGmailClient{ - SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return testutil.SampleMessages(2), 3, nil // 3 messages skipped }, } @@ -197,7 +197,7 @@ func TestReadCommand_Success(t *testing.T) { func TestReadCommand_JSONOutput(t *testing.T) { mock := &testutil.MockGmailClient{ - GetMessageFunc: func(messageID string, includeBody bool) (*gmailapi.Message, error) { + GetMessageFunc: func(_ string, _ bool) (*gmailapi.Message, error) { return testutil.SampleMessage("msg123"), nil }, } @@ -220,7 +220,7 @@ func TestReadCommand_JSONOutput(t *testing.T) { func TestReadCommand_NotFound(t *testing.T) { mock := &testutil.MockGmailClient{ - GetMessageFunc: func(messageID string, includeBody bool) (*gmailapi.Message, error) { + GetMessageFunc: func(_ string, _ bool) (*gmailapi.Message, error) { return nil, errors.New("message not found") }, } @@ -261,7 +261,7 @@ func TestThreadCommand_Success(t *testing.T) { func TestThreadCommand_JSONOutput(t *testing.T) { mock := &testutil.MockGmailClient{ - GetThreadFunc: func(id string) ([]*gmailapi.Message, error) { + GetThreadFunc: func(_ string) ([]*gmailapi.Message, error) { return testutil.SampleMessages(2), nil }, } @@ -357,7 +357,7 @@ func TestLabelsCommand_Empty(t *testing.T) { func TestListAttachmentsCommand_Success(t *testing.T) { mock := &testutil.MockGmailClient{ - GetAttachmentsFunc: func(messageID string) ([]*gmailapi.Attachment, error) { + GetAttachmentsFunc: func(_ string) ([]*gmailapi.Attachment, error) { return []*gmailapi.Attachment{ testutil.SampleAttachment("report.pdf"), testutil.SampleAttachment("data.xlsx"), @@ -382,7 +382,7 @@ func TestListAttachmentsCommand_Success(t *testing.T) { func TestListAttachmentsCommand_NoAttachments(t *testing.T) { mock := &testutil.MockGmailClient{ - GetAttachmentsFunc: func(messageID string) ([]*gmailapi.Attachment, error) { + GetAttachmentsFunc: func(_ string) ([]*gmailapi.Attachment, error) { return []*gmailapi.Attachment{}, nil }, } diff --git a/internal/cmd/mail/labels.go b/internal/cmd/mail/labels.go index f2e1839..c017ae5 100644 --- a/internal/cmd/mail/labels.go +++ b/internal/cmd/mail/labels.go @@ -34,7 +34,7 @@ Examples: gro mail labels gro mail labels --json`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { client, err := newGmailClient() if err != nil { return fmt.Errorf("failed to create Gmail client: %w", err) diff --git a/internal/cmd/mail/read.go b/internal/cmd/mail/read.go index 362241d..38eab9a 100644 --- a/internal/cmd/mail/read.go +++ b/internal/cmd/mail/read.go @@ -20,7 +20,7 @@ Examples: gro mail read 18abc123def456 gro mail read 18abc123def456 --json`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { return fmt.Errorf("failed to create Gmail client: %w", err) diff --git a/internal/cmd/mail/search.go b/internal/cmd/mail/search.go index b0ac845..bea45c8 100644 --- a/internal/cmd/mail/search.go +++ b/internal/cmd/mail/search.go @@ -25,7 +25,7 @@ Examples: For more query operators, see: https://support.google.com/mail/answer/7190`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { return fmt.Errorf("failed to create Gmail client: %w", err) diff --git a/internal/cmd/mail/thread.go b/internal/cmd/mail/thread.go index 2dad55e..7c4e411 100644 --- a/internal/cmd/mail/thread.go +++ b/internal/cmd/mail/thread.go @@ -23,7 +23,7 @@ Examples: gro mail thread 18abc123def456 gro mail thread 18abc123def456 --json`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { return fmt.Errorf("failed to create Gmail client: %w", err) diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index f2636d4..b3a5e0e 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -31,7 +31,7 @@ To get started, run: This will guide you through OAuth setup for Google API access.`, Version: version.Version, - PersistentPreRun: func(cmd *cobra.Command, args []string) { + PersistentPreRun: func(_ *cobra.Command, _ []string) { log.Verbose = verbose }, } diff --git a/internal/config/config.go b/internal/config/config.go index 6d30e8e..58c3d22 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -110,7 +110,7 @@ func LoadConfig() (*Config, error) { return nil, err } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // Path from user config directory if err != nil { if os.IsNotExist(err) { // Return default config diff --git a/internal/errors/errors.go b/internal/errors/errors.go index de8cfc0..726e836 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -2,7 +2,10 @@ // and system errors, allowing commands to provide appropriate guidance. package errors -import "fmt" +import ( + "errors" + "fmt" +) // UserError represents an error caused by invalid user input or action. // These errors are actionable - the user can fix them. @@ -49,7 +52,8 @@ func NewSystemError(message string, cause error, retryable bool) SystemError { // IsRetryable returns true if the error is a retryable SystemError. func IsRetryable(err error) bool { - if sysErr, ok := err.(SystemError); ok { + var sysErr SystemError + if errors.As(err, &sysErr) { return sysErr.Retryable } return false diff --git a/internal/gmail/messages.go b/internal/gmail/messages.go index a948a4b..2a9c964 100644 --- a/internal/gmail/messages.go +++ b/internal/gmail/messages.go @@ -160,7 +160,7 @@ func parseMessage(msg *gmail.Message, includeBody bool, resolver LabelResolver) } // extractLabelsAndCategories separates label IDs into user labels and Gmail categories -func extractLabelsAndCategories(labelIds []string, resolver LabelResolver) ([]string, []string) { +func extractLabelsAndCategories(labelIDs []string, resolver LabelResolver) ([]string, []string) { var labels, categories []string // System labels to exclude from display @@ -170,7 +170,7 @@ func extractLabelsAndCategories(labelIds []string, resolver LabelResolver) ([]st "CHAT": true, "CATEGORY_PERSONAL": true, } - for _, labelID := range labelIds { + for _, labelID := range labelIDs { // Check if it's a category if strings.HasPrefix(labelID, "CATEGORY_") { // Convert CATEGORY_UPDATES -> updates diff --git a/internal/gmail/messages_test.go b/internal/gmail/messages_test.go index 8f6e952..3537510 100644 --- a/internal/gmail/messages_test.go +++ b/internal/gmail/messages_test.go @@ -544,37 +544,37 @@ func TestParseMessageWithAttachments(t *testing.T) { func TestExtractLabelsAndCategories(t *testing.T) { t.Run("separates user labels from categories", func(t *testing.T) { - labelIds := []string{"Label_1", "CATEGORY_UPDATES", "Label_2", "CATEGORY_SOCIAL"} + labelIDs := []string{"Label_1", "CATEGORY_UPDATES", "Label_2", "CATEGORY_SOCIAL"} resolver := func(id string) string { return id } - labels, categories := extractLabelsAndCategories(labelIds, resolver) + labels, categories := extractLabelsAndCategories(labelIDs, resolver) assert.ElementsMatch(t, []string{"Label_1", "Label_2"}, labels) assert.ElementsMatch(t, []string{"updates", "social"}, categories) }) t.Run("filters out system labels", func(t *testing.T) { - labelIds := []string{"INBOX", "Label_1", "UNREAD", "STARRED", "IMPORTANT"} + labelIDs := []string{"INBOX", "Label_1", "UNREAD", "STARRED", "IMPORTANT"} resolver := func(id string) string { return id } - labels, categories := extractLabelsAndCategories(labelIds, resolver) + labels, categories := extractLabelsAndCategories(labelIDs, resolver) assert.Equal(t, []string{"Label_1"}, labels) assert.Empty(t, categories) }) t.Run("filters out CATEGORY_PERSONAL", func(t *testing.T) { - labelIds := []string{"CATEGORY_PERSONAL", "CATEGORY_UPDATES"} + labelIDs := []string{"CATEGORY_PERSONAL", "CATEGORY_UPDATES"} resolver := func(id string) string { return id } - labels, categories := extractLabelsAndCategories(labelIds, resolver) + labels, categories := extractLabelsAndCategories(labelIDs, resolver) assert.Empty(t, labels) assert.Equal(t, []string{"updates"}, categories) }) t.Run("uses resolver to translate label IDs", func(t *testing.T) { - labelIds := []string{"Label_123", "Label_456"} + labelIDs := []string{"Label_123", "Label_456"} resolver := func(id string) string { if id == "Label_123" { return "Work" @@ -585,16 +585,16 @@ func TestExtractLabelsAndCategories(t *testing.T) { return id } - labels, categories := extractLabelsAndCategories(labelIds, resolver) + labels, categories := extractLabelsAndCategories(labelIDs, resolver) assert.ElementsMatch(t, []string{"Work", "Personal"}, labels) assert.Empty(t, categories) }) t.Run("handles nil resolver", func(t *testing.T) { - labelIds := []string{"Label_1", "CATEGORY_SOCIAL"} + labelIDs := []string{"Label_1", "CATEGORY_SOCIAL"} - labels, categories := extractLabelsAndCategories(labelIds, nil) + labels, categories := extractLabelsAndCategories(labelIDs, nil) assert.Equal(t, []string{"Label_1"}, labels) assert.Equal(t, []string{"social"}, categories) diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 0e4589e..30c4d03 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -16,12 +16,13 @@ import ( const ( serviceName = config.DirName - tokenKey = "oauth_token" + tokenKey = "oauth_token" //nolint:gosec // Not a credential; key name for keychain lookup ) // StorageBackend represents where tokens are stored type StorageBackend string +// StorageBackend constants define where OAuth tokens are persisted. const ( BackendKeychain StorageBackend = "Keychain" // macOS Keychain BackendSecretTool StorageBackend = "secret-tool" // Linux libsecret @@ -83,7 +84,7 @@ func MigrateFromFile(tokenFilePath string) error { } // Read token from file - f, err := os.Open(tokenFilePath) + f, err := os.Open(tokenFilePath) //nolint:gosec // Path from user config directory if err != nil { return fmt.Errorf("failed to open token file: %w", err) } @@ -123,7 +124,7 @@ func secureDelete(path string) error { } // Overwrite with zeros - f, err := os.OpenFile(path, os.O_WRONLY, 0) + f, err := os.OpenFile(path, os.O_WRONLY, 0) //nolint:gosec // Path from user config directory if err != nil { // If we can't open for writing, try to delete anyway return os.Remove(path) @@ -145,7 +146,7 @@ func getFromConfigFile() (*oauth2.Token, error) { return nil, err } - f, err := os.Open(path) + f, err := os.Open(path) //nolint:gosec // Path from user config directory if err != nil { if os.IsNotExist(err) { return nil, ErrTokenNotFound @@ -175,7 +176,7 @@ func setInConfigFile(token *oauth2.Token) error { } // Write token with restricted permissions - f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, config.TokenPerm) + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, config.TokenPerm) //nolint:gosec // Path from user config directory if err != nil { return fmt.Errorf("failed to create token file: %w", err) } diff --git a/internal/zip/extract.go b/internal/zip/extract.go index cb48215..974c145 100644 --- a/internal/zip/extract.go +++ b/internal/zip/extract.go @@ -79,14 +79,14 @@ func validateZip(r *zip.Reader, opts Options) error { var totalSize uint64 for _, f := range r.File { // Check for zip bomb (compression ratio attack) - if f.UncompressedSize64 > uint64(opts.MaxFileSize) { + if f.UncompressedSize64 > uint64(opts.MaxFileSize) { //nolint:gosec // MaxFileSize is always positive return fmt.Errorf("file %s exceeds max size: %d bytes", f.Name, f.UncompressedSize64) } totalSize += f.UncompressedSize64 } - if totalSize > uint64(opts.MaxTotalSize) { + if totalSize > uint64(opts.MaxTotalSize) { //nolint:gosec // MaxTotalSize is always positive return fmt.Errorf("total extracted size exceeds limit: %d bytes (max %d)", totalSize, opts.MaxTotalSize) } diff --git a/internal/zip/extract_test.go b/internal/zip/extract_test.go index 57914d6..6ebfbf8 100644 --- a/internal/zip/extract_test.go +++ b/internal/zip/extract_test.go @@ -252,7 +252,7 @@ type mockFS struct { failAfterN int // fail MkdirAll after N calls (0 = fail immediately) } -func (m *mockFS) MkdirAll(path string, perm os.FileMode) error { +func (m *mockFS) MkdirAll(_ string, _ os.FileMode) error { m.mkdirCalls++ if m.failAfterN > 0 && m.mkdirCalls <= m.failAfterN { return nil @@ -295,15 +295,15 @@ type mockFSWithErrorWriter struct { writer *errorWriter } -func (m *mockFSWithErrorWriter) MkdirAll(path string, perm os.FileMode) error { +func (m *mockFSWithErrorWriter) MkdirAll(_ string, _ os.FileMode) error { return nil } -func (m *mockFSWithErrorWriter) OpenFile(name string, flag int, perm os.FileMode) (io.WriteCloser, error) { +func (m *mockFSWithErrorWriter) OpenFile(_ string, _ int, _ os.FileMode) (io.WriteCloser, error) { return m.writer, nil } -func (m *mockFSWithErrorWriter) Remove(name string) error { +func (m *mockFSWithErrorWriter) Remove(_ string) error { return nil } diff --git a/internal/zip/fs.go b/internal/zip/fs.go index 25646b9..c2fb497 100644 --- a/internal/zip/fs.go +++ b/internal/zip/fs.go @@ -20,7 +20,7 @@ func (osFS) MkdirAll(path string, perm os.FileMode) error { } func (osFS) OpenFile(name string, flag int, perm os.FileMode) (io.WriteCloser, error) { - return os.OpenFile(name, flag, perm) + return os.OpenFile(name, flag, perm) //nolint:gosec // Path validated by caller in extractFile } func (osFS) Remove(name string) error { From b43fccf38919a214ab3830aed03fbb4a44e27aec Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:00:20 -0500 Subject: [PATCH 02/13] test: add stdlib-based test assertion helpers Create internal/testutil/assert.go with generic test helpers that replace testify assertions: Equal, NoError, Error, ErrorIs, Contains, NotContains, Len, Nil, NotNil, True, False, Empty, NotEmpty, Greater, GreaterOrEqual, Less. All helpers use t.Helper() for accurate failure line reporting and accept testing.TB for flexibility. Includes comprehensive tests. --- internal/testutil/assert.go | 135 ++++++++++++ internal/testutil/assert_test.go | 349 +++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 internal/testutil/assert.go create mode 100644 internal/testutil/assert_test.go diff --git a/internal/testutil/assert.go b/internal/testutil/assert.go new file mode 100644 index 0000000..aa66b18 --- /dev/null +++ b/internal/testutil/assert.go @@ -0,0 +1,135 @@ +package testutil + +import ( + "errors" + "strings" + "testing" +) + +// Equal checks that got equals want using comparable constraint. +func Equal[T comparable](t testing.TB, got, want T) { + t.Helper() + if got != want { + t.Errorf("got %v, want %v", got, want) + } +} + +// NoError fails the test immediately if err is not nil. +func NoError(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// Error checks that err is not nil. +func Error(t testing.TB, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// ErrorIs checks that err matches target using errors.Is. +func ErrorIs(t testing.TB, err, target error) { + t.Helper() + if !errors.Is(err, target) { + t.Errorf("got error %v, want error matching %v", err, target) + } +} + +// Contains checks that s contains substr. +func Contains(t testing.TB, s, substr string) { + t.Helper() + if !strings.Contains(s, substr) { + t.Errorf("expected %q to contain %q", s, substr) + } +} + +// NotContains checks that s does not contain substr. +func NotContains(t testing.TB, s, substr string) { + t.Helper() + if strings.Contains(s, substr) { + t.Errorf("expected %q to not contain %q", s, substr) + } +} + +// Len checks that the slice has the expected length. +func Len[T any](t testing.TB, slice []T, want int) { + t.Helper() + if len(slice) != want { + t.Errorf("got length %d, want %d", len(slice), want) + } +} + +// Nil checks that val is nil. +func Nil(t testing.TB, val any) { + t.Helper() + if val != nil { + t.Errorf("got %v, want nil", val) + } +} + +// NotNil fails the test immediately if val is nil. +func NotNil(t testing.TB, val any) { + t.Helper() + if val == nil { + t.Fatal("got nil, want non-nil") + } +} + +// True checks that condition is true. +func True(t testing.TB, condition bool) { + t.Helper() + if !condition { + t.Error("got false, want true") + } +} + +// False checks that condition is false. +func False(t testing.TB, condition bool) { + t.Helper() + if condition { + t.Error("got true, want false") + } +} + +// Empty checks that s is the empty string. +func Empty(t testing.TB, s string) { + t.Helper() + if s != "" { + t.Errorf("got %q, want empty string", s) + } +} + +// NotEmpty checks that s is not the empty string. +func NotEmpty(t testing.TB, s string) { + t.Helper() + if s == "" { + t.Error("got empty string, want non-empty") + } +} + +// Greater checks that a > b. +func Greater(t testing.TB, a, b int) { + t.Helper() + if a <= b { + t.Errorf("got %d, want greater than %d", a, b) + } +} + +// GreaterOrEqual checks that a >= b. +func GreaterOrEqual(t testing.TB, a, b int) { + t.Helper() + if a < b { + t.Errorf("got %d, want >= %d", a, b) + } +} + +// Less checks that a < b. +func Less(t testing.TB, a, b int) { + t.Helper() + if a >= b { + t.Errorf("got %d, want less than %d", a, b) + } +} diff --git a/internal/testutil/assert_test.go b/internal/testutil/assert_test.go new file mode 100644 index 0000000..7a04cd8 --- /dev/null +++ b/internal/testutil/assert_test.go @@ -0,0 +1,349 @@ +package testutil + +import ( + "errors" + "testing" +) + +// mockT captures test failures without stopping the outer test. +type mockT struct { + testing.TB + failed bool + message string +} + +func (m *mockT) Helper() {} +func (m *mockT) Errorf(format string, a ...any) { m.failed = true } +func (m *mockT) Error(a ...any) { m.failed = true } +func (m *mockT) Fatalf(format string, a ...any) { m.failed = true } +func (m *mockT) Fatal(a ...any) { m.failed = true } + +func TestEqual(t *testing.T) { + t.Run("passes on equal values", func(t *testing.T) { + mt := &mockT{} + Equal(mt, 42, 42) + if mt.failed { + t.Error("Equal should not fail for equal values") + } + }) + + t.Run("fails on unequal values", func(t *testing.T) { + mt := &mockT{} + Equal(mt, 1, 2) + if !mt.failed { + t.Error("Equal should fail for unequal values") + } + }) + + t.Run("works with strings", func(t *testing.T) { + mt := &mockT{} + Equal(mt, "hello", "hello") + if mt.failed { + t.Error("Equal should not fail for equal strings") + } + }) +} + +func TestNoError(t *testing.T) { + t.Run("passes on nil error", func(t *testing.T) { + mt := &mockT{} + NoError(mt, nil) + if mt.failed { + t.Error("NoError should not fail for nil error") + } + }) + + t.Run("fails on non-nil error", func(t *testing.T) { + mt := &mockT{} + NoError(mt, errors.New("boom")) + if !mt.failed { + t.Error("NoError should fail for non-nil error") + } + }) +} + +func TestError(t *testing.T) { + t.Run("passes on non-nil error", func(t *testing.T) { + mt := &mockT{} + Error(mt, errors.New("boom")) + if mt.failed { + t.Error("Error should not fail for non-nil error") + } + }) + + t.Run("fails on nil error", func(t *testing.T) { + mt := &mockT{} + Error(mt, nil) + if !mt.failed { + t.Error("Error should fail for nil error") + } + }) +} + +func TestErrorIs(t *testing.T) { + sentinel := errors.New("sentinel") + + t.Run("passes when errors match", func(t *testing.T) { + mt := &mockT{} + ErrorIs(mt, sentinel, sentinel) + if mt.failed { + t.Error("ErrorIs should not fail for matching errors") + } + }) + + t.Run("fails when errors don't match", func(t *testing.T) { + mt := &mockT{} + ErrorIs(mt, errors.New("other"), sentinel) + if !mt.failed { + t.Error("ErrorIs should fail for non-matching errors") + } + }) +} + +func TestContains(t *testing.T) { + t.Run("passes when string contains substr", func(t *testing.T) { + mt := &mockT{} + Contains(mt, "hello world", "world") + if mt.failed { + t.Error("Contains should not fail when substr is present") + } + }) + + t.Run("fails when string doesn't contain substr", func(t *testing.T) { + mt := &mockT{} + Contains(mt, "hello world", "xyz") + if !mt.failed { + t.Error("Contains should fail when substr is absent") + } + }) +} + +func TestNotContains(t *testing.T) { + t.Run("passes when string doesn't contain substr", func(t *testing.T) { + mt := &mockT{} + NotContains(mt, "hello world", "xyz") + if mt.failed { + t.Error("NotContains should not fail when substr is absent") + } + }) + + t.Run("fails when string contains substr", func(t *testing.T) { + mt := &mockT{} + NotContains(mt, "hello world", "world") + if !mt.failed { + t.Error("NotContains should fail when substr is present") + } + }) +} + +func TestLen(t *testing.T) { + t.Run("passes on correct length", func(t *testing.T) { + mt := &mockT{} + Len(mt, []int{1, 2, 3}, 3) + if mt.failed { + t.Error("Len should not fail for correct length") + } + }) + + t.Run("fails on wrong length", func(t *testing.T) { + mt := &mockT{} + Len(mt, []int{1, 2}, 3) + if !mt.failed { + t.Error("Len should fail for wrong length") + } + }) + + t.Run("works with empty slice", func(t *testing.T) { + mt := &mockT{} + Len(mt, []string{}, 0) + if mt.failed { + t.Error("Len should not fail for empty slice with want 0") + } + }) +} + +func TestNil(t *testing.T) { + t.Run("passes on nil", func(t *testing.T) { + mt := &mockT{} + Nil(mt, nil) + if mt.failed { + t.Error("Nil should not fail for nil") + } + }) + + t.Run("fails on non-nil", func(t *testing.T) { + mt := &mockT{} + Nil(mt, "something") + if !mt.failed { + t.Error("Nil should fail for non-nil") + } + }) +} + +func TestNotNil(t *testing.T) { + t.Run("passes on non-nil", func(t *testing.T) { + mt := &mockT{} + NotNil(mt, "something") + if mt.failed { + t.Error("NotNil should not fail for non-nil") + } + }) + + t.Run("fails on nil", func(t *testing.T) { + mt := &mockT{} + NotNil(mt, nil) + if !mt.failed { + t.Error("NotNil should fail for nil") + } + }) +} + +func TestTrue(t *testing.T) { + t.Run("passes on true", func(t *testing.T) { + mt := &mockT{} + True(mt, true) + if mt.failed { + t.Error("True should not fail for true") + } + }) + + t.Run("fails on false", func(t *testing.T) { + mt := &mockT{} + True(mt, false) + if !mt.failed { + t.Error("True should fail for false") + } + }) +} + +func TestFalse(t *testing.T) { + t.Run("passes on false", func(t *testing.T) { + mt := &mockT{} + False(mt, false) + if mt.failed { + t.Error("False should not fail for false") + } + }) + + t.Run("fails on true", func(t *testing.T) { + mt := &mockT{} + False(mt, true) + if !mt.failed { + t.Error("False should fail for true") + } + }) +} + +func TestEmpty(t *testing.T) { + t.Run("passes on empty string", func(t *testing.T) { + mt := &mockT{} + Empty(mt, "") + if mt.failed { + t.Error("Empty should not fail for empty string") + } + }) + + t.Run("fails on non-empty string", func(t *testing.T) { + mt := &mockT{} + Empty(mt, "hello") + if !mt.failed { + t.Error("Empty should fail for non-empty string") + } + }) +} + +func TestNotEmpty(t *testing.T) { + t.Run("passes on non-empty string", func(t *testing.T) { + mt := &mockT{} + NotEmpty(mt, "hello") + if mt.failed { + t.Error("NotEmpty should not fail for non-empty string") + } + }) + + t.Run("fails on empty string", func(t *testing.T) { + mt := &mockT{} + NotEmpty(mt, "") + if !mt.failed { + t.Error("NotEmpty should fail for empty string") + } + }) +} + +func TestGreater(t *testing.T) { + t.Run("passes when a > b", func(t *testing.T) { + mt := &mockT{} + Greater(mt, 5, 3) + if mt.failed { + t.Error("Greater should not fail when a > b") + } + }) + + t.Run("fails when a == b", func(t *testing.T) { + mt := &mockT{} + Greater(mt, 3, 3) + if !mt.failed { + t.Error("Greater should fail when a == b") + } + }) + + t.Run("fails when a < b", func(t *testing.T) { + mt := &mockT{} + Greater(mt, 2, 3) + if !mt.failed { + t.Error("Greater should fail when a < b") + } + }) +} + +func TestGreaterOrEqual(t *testing.T) { + t.Run("passes when a > b", func(t *testing.T) { + mt := &mockT{} + GreaterOrEqual(mt, 5, 3) + if mt.failed { + t.Error("GreaterOrEqual should not fail when a > b") + } + }) + + t.Run("passes when a == b", func(t *testing.T) { + mt := &mockT{} + GreaterOrEqual(mt, 3, 3) + if mt.failed { + t.Error("GreaterOrEqual should not fail when a == b") + } + }) + + t.Run("fails when a < b", func(t *testing.T) { + mt := &mockT{} + GreaterOrEqual(mt, 2, 3) + if !mt.failed { + t.Error("GreaterOrEqual should fail when a < b") + } + }) +} + +func TestLess(t *testing.T) { + t.Run("passes when a < b", func(t *testing.T) { + mt := &mockT{} + Less(mt, 2, 5) + if mt.failed { + t.Error("Less should not fail when a < b") + } + }) + + t.Run("fails when a == b", func(t *testing.T) { + mt := &mockT{} + Less(mt, 3, 3) + if !mt.failed { + t.Error("Less should fail when a == b") + } + }) + + t.Run("fails when a > b", func(t *testing.T) { + mt := &mockT{} + Less(mt, 5, 3) + if !mt.failed { + t.Error("Less should fail when a > b") + } + }) +} From a6301ba0e42f7be0c04cfbcbec7ecf46c8c929f3 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:20:02 -0500 Subject: [PATCH 03/13] refactor: remove testify dependency, use stdlib test assertions Migrate all 44 test files from testify/assert and testify/require to either the internal testutil package or raw stdlib assertions. Packages that would create import cycles (auth, config, keychain, log, gmail, calendar, contacts, drive) use stdlib directly. Adds SliceContains and LenSlice helpers to testutil. Fixes Nil helper to correctly handle nil slices/pointers boxed into interface values using reflection. Removes github.com/stretchr/testify and all transitive dependencies (go-spew, go-difflib, yaml.v3) from go.mod. --- go.mod | 4 - go.sum | 8 - internal/auth/auth_test.go | 113 +++-- internal/cache/cache_test.go | 109 +++-- internal/calendar/client_test.go | 6 +- internal/calendar/events_test.go | 158 +++++-- internal/cmd/calendar/calendar_test.go | 120 +++--- internal/cmd/calendar/dates_test.go | 65 ++- internal/cmd/calendar/handlers_test.go | 88 ++-- internal/cmd/calendar/output_test.go | 34 +- internal/cmd/config/config_test.go | 106 ++--- internal/cmd/contacts/contacts_test.go | 90 ++-- internal/cmd/contacts/handlers_test.go | 96 +++-- internal/cmd/contacts/output_test.go | 18 +- internal/cmd/drive/download_test.go | 32 +- internal/cmd/drive/drives_test.go | 74 ++-- internal/cmd/drive/get_test.go | 61 ++- internal/cmd/drive/handlers_test.go | 133 +++--- internal/cmd/drive/list_test.go | 98 ++--- internal/cmd/drive/search_test.go | 110 ++--- internal/cmd/drive/tree_test.go | 113 +++-- internal/cmd/initcmd/init_test.go | 22 +- .../cmd/mail/attachments_download_test.go | 17 +- internal/cmd/mail/attachments_test.go | 32 +- internal/cmd/mail/handlers_test.go | 112 +++-- internal/cmd/mail/labels_test.go | 44 +- internal/cmd/mail/mail_test.go | 18 +- internal/cmd/mail/output_test.go | 18 +- internal/cmd/mail/read_test.go | 22 +- internal/cmd/mail/sanitize_test.go | 8 +- internal/cmd/mail/search_test.go | 28 +- internal/cmd/mail/thread_test.go | 24 +- internal/cmd/root/root_test.go | 24 +- internal/config/config_test.go | 196 ++++++--- internal/contacts/client_test.go | 6 +- internal/contacts/contacts_test.go | 193 ++++++--- internal/drive/files_test.go | 165 ++++++-- internal/errors/errors_test.go | 20 +- internal/format/format_test.go | 6 +- internal/gmail/attachments_test.go | 33 +- internal/gmail/client_test.go | 83 +++- internal/gmail/messages_test.go | 385 ++++++++++++++---- internal/keychain/keychain_test.go | 379 ++++++++++++----- internal/log/log_test.go | 37 +- internal/output/output_test.go | 15 +- internal/testutil/assert.go | 35 +- internal/zip/extract_test.go | 113 +++-- 47 files changed, 2270 insertions(+), 1401 deletions(-) diff --git a/go.mod b/go.mod index 183f17c..cec1e5c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.24.0 require ( github.com/spf13/cobra v1.8.0 - github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 google.golang.org/api v0.262.0 ) @@ -14,7 +13,6 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -23,7 +21,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -37,5 +34,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9b5fa78..9e1eeba 100644 --- a/go.sum +++ b/go.sum @@ -30,14 +30,8 @@ github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5 github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= @@ -86,7 +80,5 @@ google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpW google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 85ae931..1e599bb 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,13 +1,11 @@ package auth import ( + "strings" "os" "path/filepath" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/open-cli-collective/google-readonly/internal/config" ) @@ -18,12 +16,18 @@ func TestDeprecatedWrappers(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) authDir, err := GetConfigDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } configDir, err := config.GetConfigDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, configDir, authDir) + if authDir != configDir { + t.Errorf("got %v, want %v", authDir, configDir) + } }) t.Run("GetCredentialsPath delegates to config package", func(t *testing.T) { @@ -31,12 +35,18 @@ func TestDeprecatedWrappers(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) authPath, err := GetCredentialsPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } configPath, err := config.GetCredentialsPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, configPath, authPath) + if authPath != configPath { + t.Errorf("got %v, want %v", authPath, configPath) + } }) t.Run("GetTokenPath delegates to config package", func(t *testing.T) { @@ -44,39 +54,66 @@ func TestDeprecatedWrappers(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) authPath, err := GetTokenPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } configPath, err := config.GetTokenPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, configPath, authPath) + if authPath != configPath { + t.Errorf("got %v, want %v", authPath, configPath) + } }) t.Run("ShortenPath delegates to config package", func(t *testing.T) { home, err := os.UserHomeDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } testPath := filepath.Join(home, ".config", "test") authResult := ShortenPath(testPath) configResult := config.ShortenPath(testPath) - assert.Equal(t, configResult, authResult) + if authResult != configResult { + t.Errorf("got %v, want %v", authResult, configResult) + } }) t.Run("Constants match config package", func(t *testing.T) { - assert.Equal(t, config.DirName, ConfigDirName) - assert.Equal(t, config.CredentialsFile, CredentialsFile) - assert.Equal(t, config.TokenFile, TokenFile) + if ConfigDirName != config.DirName { + t.Errorf("got %v, want %v", ConfigDirName, config.DirName) + } + if CredentialsFile != config.CredentialsFile { + t.Errorf("got %v, want %v", CredentialsFile, config.CredentialsFile) + } + if TokenFile != config.TokenFile { + t.Errorf("got %v, want %v", TokenFile, config.TokenFile) + } }) } func TestAllScopes(t *testing.T) { - assert.Len(t, AllScopes, 4) - assert.Contains(t, AllScopes, "https://www.googleapis.com/auth/gmail.readonly") - assert.Contains(t, AllScopes, "https://www.googleapis.com/auth/calendar.readonly") - assert.Contains(t, AllScopes, "https://www.googleapis.com/auth/contacts.readonly") - assert.Contains(t, AllScopes, "https://www.googleapis.com/auth/drive.readonly") + if len(AllScopes) != 4 { + t.Errorf("got length %d, want %d", len(AllScopes), 4) + } + scopeSet := strings.Join(AllScopes, " ") + if !strings.Contains(scopeSet, "https://www.googleapis.com/auth/gmail.readonly") { + t.Errorf("expected AllScopes to contain %q", "https://www.googleapis.com/auth/gmail.readonly") + } + if !strings.Contains(scopeSet, "https://www.googleapis.com/auth/calendar.readonly") { + t.Errorf("expected AllScopes to contain %q", "https://www.googleapis.com/auth/calendar.readonly") + } + if !strings.Contains(scopeSet, "https://www.googleapis.com/auth/contacts.readonly") { + t.Errorf("expected AllScopes to contain %q", "https://www.googleapis.com/auth/contacts.readonly") + } + if !strings.Contains(scopeSet, "https://www.googleapis.com/auth/drive.readonly") { + t.Errorf("expected AllScopes to contain %q", "https://www.googleapis.com/auth/drive.readonly") + } } func TestTokenFromFile(t *testing.T) { @@ -91,18 +128,30 @@ func TestTokenFromFile(t *testing.T) { "expiry": "2024-01-01T00:00:00Z" }` err := os.WriteFile(tokenPath, []byte(tokenData), 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } token, err := tokenFromFile(tokenPath) - require.NoError(t, err) - assert.Equal(t, "test-access-token", token.AccessToken) - assert.Equal(t, "Bearer", token.TokenType) - assert.Equal(t, "test-refresh-token", token.RefreshToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != "test-access-token" { + t.Errorf("got %v, want %v", token.AccessToken, "test-access-token") + } + if token.TokenType != "Bearer" { + t.Errorf("got %v, want %v", token.TokenType, "Bearer") + } + if token.RefreshToken != "test-refresh-token" { + t.Errorf("got %v, want %v", token.RefreshToken, "test-refresh-token") + } }) t.Run("returns error for non-existent file", func(t *testing.T) { _, err := tokenFromFile("/nonexistent/token.json") - assert.Error(t, err) + if err == nil { + t.Fatal("expected error, got nil") + } }) t.Run("returns error for invalid JSON", func(t *testing.T) { @@ -110,9 +159,13 @@ func TestTokenFromFile(t *testing.T) { tokenPath := filepath.Join(tmpDir, "token.json") err := os.WriteFile(tokenPath, []byte("not valid json"), 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } _, err = tokenFromFile(tokenPath) - assert.Error(t, err) + if err == nil { + t.Fatal("expected error, got nil") + } }) } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 96212e3..ee442b7 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -7,45 +7,44 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestNew(t *testing.T) { t.Run("creates cache with default TTL", func(t *testing.T) { c, err := New(0) - require.NoError(t, err) - assert.NotNil(t, c) - assert.Equal(t, DefaultTTLHours, c.ttlHours) + testutil.NoError(t, err) + testutil.NotNil(t, c) + testutil.Equal(t, c.ttlHours, DefaultTTLHours) defer c.Clear() }) t.Run("creates cache with custom TTL", func(t *testing.T) { c, err := New(12) - require.NoError(t, err) - assert.Equal(t, 12, c.ttlHours) + testutil.NoError(t, err) + testutil.Equal(t, c.ttlHours, 12) defer c.Clear() }) t.Run("creates cache directory", func(t *testing.T) { c, err := New(24) - require.NoError(t, err) + testutil.NoError(t, err) defer c.Clear() _, err = os.Stat(c.dir) - assert.NoError(t, err) + testutil.NoError(t, err) }) } func TestCache_GetSetDrives(t *testing.T) { c, err := New(24) - require.NoError(t, err) + testutil.NoError(t, err) defer c.Clear() t.Run("returns nil for missing cache", func(t *testing.T) { drives, err := c.GetDrives() - assert.NoError(t, err) - assert.Nil(t, drives) + testutil.NoError(t, err) + testutil.Nil(t, drives) }) t.Run("stores and retrieves drives", func(t *testing.T) { @@ -55,21 +54,21 @@ func TestCache_GetSetDrives(t *testing.T) { } err := c.SetDrives(input) - require.NoError(t, err) + testutil.NoError(t, err) drives, err := c.GetDrives() - require.NoError(t, err) - require.Len(t, drives, 2) - assert.Equal(t, "drive1", drives[0].ID) - assert.Equal(t, "Engineering", drives[0].Name) - assert.Equal(t, "drive2", drives[1].ID) - assert.Equal(t, "Marketing", drives[1].Name) + testutil.NoError(t, err) + testutil.Len(t, drives, 2) + testutil.Equal(t, drives[0].ID, "drive1") + testutil.Equal(t, drives[0].Name, "Engineering") + testutil.Equal(t, drives[1].ID, "drive2") + testutil.Equal(t, drives[1].Name, "Marketing") }) } func TestCache_Expiration(t *testing.T) { c, err := New(1) // 1 hour TTL - require.NoError(t, err) + testutil.NoError(t, err) defer c.Clear() t.Run("returns nil for expired cache", func(t *testing.T) { @@ -83,15 +82,15 @@ func TestCache_Expiration(t *testing.T) { } data, err := json.Marshal(expiredCache) - require.NoError(t, err) + testutil.NoError(t, err) path := filepath.Join(c.dir, DrivesFile) err = os.WriteFile(path, data, 0600) - require.NoError(t, err) + testutil.NoError(t, err) drives, err := c.GetDrives() - assert.NoError(t, err) - assert.Nil(t, drives, "expired cache should return nil") + testutil.NoError(t, err) + testutil.Nil(t, drives) }) t.Run("returns drives for valid cache", func(t *testing.T) { @@ -105,68 +104,68 @@ func TestCache_Expiration(t *testing.T) { } data, err := json.Marshal(freshCache) - require.NoError(t, err) + testutil.NoError(t, err) path := filepath.Join(c.dir, DrivesFile) err = os.WriteFile(path, data, 0600) - require.NoError(t, err) + testutil.NoError(t, err) drives, err := c.GetDrives() - assert.NoError(t, err) - require.Len(t, drives, 1) - assert.Equal(t, "drive1", drives[0].ID) + testutil.NoError(t, err) + testutil.Len(t, drives, 1) + testutil.Equal(t, drives[0].ID, "drive1") }) } func TestCache_CorruptedCache(t *testing.T) { c, err := New(24) - require.NoError(t, err) + testutil.NoError(t, err) defer c.Clear() t.Run("returns nil for corrupted JSON", func(t *testing.T) { path := filepath.Join(c.dir, DrivesFile) err := os.WriteFile(path, []byte("not valid json"), 0600) - require.NoError(t, err) + testutil.NoError(t, err) drives, err := c.GetDrives() - assert.NoError(t, err) - assert.Nil(t, drives, "corrupted cache should return nil") + testutil.NoError(t, err) + testutil.Nil(t, drives) }) } func TestCache_Clear(t *testing.T) { c, err := New(24) - require.NoError(t, err) + testutil.NoError(t, err) // Add some data err = c.SetDrives([]*CachedDrive{{ID: "test", Name: "Test"}}) - require.NoError(t, err) + testutil.NoError(t, err) // Verify file exists path := filepath.Join(c.dir, DrivesFile) _, err = os.Stat(path) - require.NoError(t, err) + testutil.NoError(t, err) // Clear cache err = c.Clear() - require.NoError(t, err) + testutil.NoError(t, err) // Verify directory is gone _, err = os.Stat(c.dir) - assert.True(t, os.IsNotExist(err)) + testutil.True(t, os.IsNotExist(err)) } func TestCache_GetStatus(t *testing.T) { c, err := New(24) - require.NoError(t, err) + testutil.NoError(t, err) defer c.Clear() t.Run("returns status with no cache", func(t *testing.T) { status, err := c.GetStatus() - require.NoError(t, err) - assert.Equal(t, c.dir, status.Dir) - assert.Equal(t, 24, status.TTLHours) - assert.Nil(t, status.DrivesCache) + testutil.NoError(t, err) + testutil.Equal(t, status.Dir, c.dir) + testutil.Equal(t, status.TTLHours, 24) + testutil.Nil(t, status.DrivesCache) }) t.Run("returns status with drives cache", func(t *testing.T) { @@ -174,14 +173,14 @@ func TestCache_GetStatus(t *testing.T) { {ID: "drive1", Name: "Test1"}, {ID: "drive2", Name: "Test2"}, }) - require.NoError(t, err) + testutil.NoError(t, err) status, err := c.GetStatus() - require.NoError(t, err) - require.NotNil(t, status.DrivesCache) - assert.Equal(t, 2, status.DrivesCache.Count) - assert.False(t, status.DrivesCache.IsStale) - assert.True(t, status.DrivesCache.ExpiresAt.After(time.Now())) + testutil.NoError(t, err) + testutil.NotNil(t, status.DrivesCache) + testutil.Equal(t, status.DrivesCache.Count, 2) + testutil.False(t, status.DrivesCache.IsStale) + testutil.True(t, status.DrivesCache.ExpiresAt.After(time.Now())) }) t.Run("marks stale cache as stale", func(t *testing.T) { @@ -196,17 +195,17 @@ func TestCache_GetStatus(t *testing.T) { os.WriteFile(path, data, 0600) status, err := c.GetStatus() - require.NoError(t, err) - require.NotNil(t, status.DrivesCache) - assert.True(t, status.DrivesCache.IsStale) + testutil.NoError(t, err) + testutil.NotNil(t, status.DrivesCache) + testutil.True(t, status.DrivesCache.IsStale) }) } func TestCache_GetDir(t *testing.T) { c, err := New(24) - require.NoError(t, err) + testutil.NoError(t, err) defer c.Clear() - assert.NotEmpty(t, c.GetDir()) - assert.Contains(t, c.GetDir(), "cache") + testutil.NotEmpty(t, c.GetDir()) + testutil.Contains(t, c.GetDir(), "cache") } diff --git a/internal/calendar/client_test.go b/internal/calendar/client_test.go index e056b5d..c9acb59 100644 --- a/internal/calendar/client_test.go +++ b/internal/calendar/client_test.go @@ -2,13 +2,13 @@ package calendar import ( "testing" - - "github.com/stretchr/testify/assert" ) func TestClientStructure(t *testing.T) { t.Run("Client has private service field", func(t *testing.T) { client := &Client{} - assert.Nil(t, client.service) + if client.service != nil { + t.Errorf("got %v, want nil", client.service) + } }) } diff --git a/internal/calendar/events_test.go b/internal/calendar/events_test.go index 0b41c0b..ffca145 100644 --- a/internal/calendar/events_test.go +++ b/internal/calendar/events_test.go @@ -1,9 +1,9 @@ package calendar import ( + "strings" "testing" - "github.com/stretchr/testify/assert" "google.golang.org/api/calendar/v3" ) @@ -26,12 +26,24 @@ func TestParseEvent(t *testing.T) { event := ParseEvent(apiEvent) - assert.Equal(t, "event123", event.ID) - assert.Equal(t, "Team Meeting", event.Summary) - assert.Equal(t, "Weekly sync", event.Description) - assert.Equal(t, "Conference Room A", event.Location) - assert.Equal(t, "confirmed", event.Status) - assert.False(t, event.AllDay) + if got := event.ID; got != "event123" { + t.Errorf("got %v, want %v", got, "event123") + } + if got := event.Summary; got != "Team Meeting" { + t.Errorf("got %v, want %v", got, "Team Meeting") + } + if got := event.Description; got != "Weekly sync" { + t.Errorf("got %v, want %v", got, "Weekly sync") + } + if got := event.Location; got != "Conference Room A" { + t.Errorf("got %v, want %v", got, "Conference Room A") + } + if got := event.Status; got != "confirmed" { + t.Errorf("got %v, want %v", got, "confirmed") + } + if event.AllDay { + t.Error("got true, want false") + } }) t.Run("parses all-day event", func(t *testing.T) { @@ -48,9 +60,15 @@ func TestParseEvent(t *testing.T) { event := ParseEvent(apiEvent) - assert.Equal(t, "allday123", event.ID) - assert.True(t, event.AllDay) - assert.Equal(t, "2026-01-01", event.Start.Date) + if got := event.ID; got != "allday123" { + t.Errorf("got %v, want %v", got, "allday123") + } + if !event.AllDay { + t.Error("got false, want true") + } + if got := event.Start.Date; got != "2026-01-01" { + t.Errorf("got %v, want %v", got, "2026-01-01") + } }) t.Run("parses event with organizer", func(t *testing.T) { @@ -72,9 +90,15 @@ func TestParseEvent(t *testing.T) { event := ParseEvent(apiEvent) - assert.NotNil(t, event.Organizer) - assert.Equal(t, "boss@example.com", event.Organizer.Email) - assert.Equal(t, "The Boss", event.Organizer.DisplayName) + if event.Organizer == nil { + t.Fatal("expected non-nil, got nil") + } + if got := event.Organizer.Email; got != "boss@example.com" { + t.Errorf("got %v, want %v", got, "boss@example.com") + } + if got := event.Organizer.DisplayName; got != "The Boss" { + t.Errorf("got %v, want %v", got, "The Boss") + } }) t.Run("parses event with attendees", func(t *testing.T) { @@ -104,11 +128,21 @@ func TestParseEvent(t *testing.T) { event := ParseEvent(apiEvent) - assert.Len(t, event.Attendees, 2) - assert.Equal(t, "alice@example.com", event.Attendees[0].Email) - assert.Equal(t, "accepted", event.Attendees[0].Status) - assert.Equal(t, "bob@example.com", event.Attendees[1].Email) - assert.True(t, event.Attendees[1].Optional) + if len(event.Attendees) != 2 { + t.Errorf("got length %d, want %d", len(event.Attendees), 2) + } + if got := event.Attendees[0].Email; got != "alice@example.com" { + t.Errorf("got %v, want %v", got, "alice@example.com") + } + if got := event.Attendees[0].Status; got != "accepted" { + t.Errorf("got %v, want %v", got, "accepted") + } + if got := event.Attendees[1].Email; got != "bob@example.com" { + t.Errorf("got %v, want %v", got, "bob@example.com") + } + if !event.Attendees[1].Optional { + t.Error("got false, want true") + } }) t.Run("handles event with hangout link", func(t *testing.T) { @@ -126,7 +160,9 @@ func TestParseEvent(t *testing.T) { event := ParseEvent(apiEvent) - assert.Equal(t, "https://meet.google.com/abc-defg-hij", event.HangoutLink) + if got := event.HangoutLink; got != "https://meet.google.com/abc-defg-hij" { + t.Errorf("got %v, want %v", got, "https://meet.google.com/abc-defg-hij") + } }) } @@ -143,12 +179,24 @@ func TestParseCalendar(t *testing.T) { cal := ParseCalendar(apiCal) - assert.Equal(t, "primary", cal.ID) - assert.Equal(t, "My Calendar", cal.Summary) - assert.Equal(t, "Personal calendar", cal.Description) - assert.True(t, cal.Primary) - assert.Equal(t, "owner", cal.AccessRole) - assert.Equal(t, "America/New_York", cal.TimeZone) + if got := cal.ID; got != "primary" { + t.Errorf("got %v, want %v", got, "primary") + } + if got := cal.Summary; got != "My Calendar" { + t.Errorf("got %v, want %v", got, "My Calendar") + } + if got := cal.Description; got != "Personal calendar" { + t.Errorf("got %v, want %v", got, "Personal calendar") + } + if !cal.Primary { + t.Error("got false, want true") + } + if got := cal.AccessRole; got != "owner" { + t.Errorf("got %v, want %v", got, "owner") + } + if got := cal.TimeZone; got != "America/New_York" { + t.Errorf("got %v, want %v", got, "America/New_York") + } }) t.Run("parses shared calendar", func(t *testing.T) { @@ -161,8 +209,12 @@ func TestParseCalendar(t *testing.T) { cal := ParseCalendar(apiCal) - assert.False(t, cal.Primary) - assert.Equal(t, "reader", cal.AccessRole) + if cal.Primary { + t.Error("got true, want false") + } + if got := cal.AccessRole; got != "reader" { + t.Errorf("got %v, want %v", got, "reader") + } }) } @@ -175,10 +227,18 @@ func TestEventGetStartTime(t *testing.T) { } start, err := event.GetStartTime() - assert.NoError(t, err) - assert.Equal(t, 2026, start.Year()) - assert.Equal(t, 1, int(start.Month())) - assert.Equal(t, 24, start.Day()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := start.Year(); got != 2026 { + t.Errorf("got %v, want %v", got, 2026) + } + if got := int(start.Month()); got != 1 { + t.Errorf("got %v, want %v", got, 1) + } + if got := start.Day(); got != 24 { + t.Errorf("got %v, want %v", got, 24) + } }) t.Run("parses date for all-day event", func(t *testing.T) { @@ -190,16 +250,24 @@ func TestEventGetStartTime(t *testing.T) { } start, err := event.GetStartTime() - assert.NoError(t, err) - assert.Equal(t, 24, start.Day()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := start.Day(); got != 24 { + t.Errorf("got %v, want %v", got, 24) + } }) t.Run("handles nil start", func(t *testing.T) { event := &Event{} start, err := event.GetStartTime() - assert.NoError(t, err) - assert.True(t, start.IsZero()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !start.IsZero() { + t.Error("got false, want true") + } }) } @@ -215,9 +283,15 @@ func TestEventFormatTimeRange(t *testing.T) { } result := event.FormatTimeRange() - assert.Contains(t, result, "Jan 24, 2026") - assert.Contains(t, result, "10:00") - assert.Contains(t, result, "11:00") + if !strings.Contains(result, "Jan 24, 2026") { + t.Errorf("expected %q to contain %q", result, "Jan 24, 2026") + } + if !strings.Contains(result, "10:00") { + t.Errorf("expected %q to contain %q", result, "10:00") + } + if !strings.Contains(result, "11:00") { + t.Errorf("expected %q to contain %q", result, "11:00") + } }) t.Run("formats all-day event", func(t *testing.T) { @@ -232,7 +306,11 @@ func TestEventFormatTimeRange(t *testing.T) { } result := event.FormatTimeRange() - assert.Contains(t, result, "Jan 24, 2026") - assert.Contains(t, result, "all day") + if !strings.Contains(result, "Jan 24, 2026") { + t.Errorf("expected %q to contain %q", result, "Jan 24, 2026") + } + if !strings.Contains(result, "all day") { + t.Errorf("expected %q to contain %q", result, "all day") + } }) } diff --git a/internal/cmd/calendar/calendar_test.go b/internal/cmd/calendar/calendar_test.go index 3a39182..c2b21d4 100644 --- a/internal/cmd/calendar/calendar_test.go +++ b/internal/cmd/calendar/calendar_test.go @@ -3,43 +3,43 @@ package calendar import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestCalendarCommand(t *testing.T) { cmd := NewCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "calendar", cmd.Use) + testutil.Equal(t, cmd.Use, "calendar") }) t.Run("has cal alias", func(t *testing.T) { - assert.Contains(t, cmd.Aliases, "cal") + testutil.SliceContains(t, cmd.Aliases, "cal") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "Calendar") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "Calendar") }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "events") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "events") }) t.Run("has subcommands", func(t *testing.T) { subcommands := cmd.Commands() - assert.GreaterOrEqual(t, len(subcommands), 5) + testutil.GreaterOrEqual(t, len(subcommands), 5) var names []string for _, sub := range subcommands { names = append(names, sub.Name()) } - assert.Contains(t, names, "list") - assert.Contains(t, names, "events") - assert.Contains(t, names, "get") - assert.Contains(t, names, "today") - assert.Contains(t, names, "week") + testutil.SliceContains(t, names, "list") + testutil.SliceContains(t, names, "events") + testutil.SliceContains(t, names, "get") + testutil.SliceContains(t, names, "today") + testutil.SliceContains(t, names, "week") }) } @@ -47,27 +47,27 @@ func TestListCommand(t *testing.T) { cmd := newListCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "list", cmd.Use) + testutil.Equal(t, cmd.Use, "list") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "calendar") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "calendar") }) } @@ -75,48 +75,48 @@ func TestEventsCommand(t *testing.T) { cmd := newEventsCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "events [calendar-id]", cmd.Use) + testutil.Equal(t, cmd.Use, "events [calendar-id]") }) t.Run("accepts optional calendar id argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"calendar-id"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"calendar-id", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) - assert.Equal(t, "10", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "10") }) t.Run("has from flag", func(t *testing.T) { flag := cmd.Flags().Lookup("from") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has to flag", func(t *testing.T) { flag := cmd.Flags().Lookup("to") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has calendar flag", func(t *testing.T) { flag := cmd.Flags().Lookup("calendar") - assert.NotNil(t, flag) - assert.Equal(t, "c", flag.Shorthand) - assert.Equal(t, "primary", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "c") + testutil.Equal(t, flag.DefValue, "primary") }) } @@ -124,31 +124,31 @@ func TestGetCommand(t *testing.T) { cmd := newGetCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "get ", cmd.Use) + testutil.Equal(t, cmd.Use, "get ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"event-id"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"event-id", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) t.Run("has calendar flag", func(t *testing.T) { flag := cmd.Flags().Lookup("calendar") - assert.NotNil(t, flag) - assert.Equal(t, "c", flag.Shorthand) - assert.Equal(t, "primary", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "c") + testutil.Equal(t, flag.DefValue, "primary") }) } @@ -156,31 +156,31 @@ func TestTodayCommand(t *testing.T) { cmd := newTodayCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "today", cmd.Use) + testutil.Equal(t, cmd.Use, "today") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) t.Run("has calendar flag", func(t *testing.T) { flag := cmd.Flags().Lookup("calendar") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "today") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "today") }) } @@ -188,30 +188,30 @@ func TestWeekCommand(t *testing.T) { cmd := newWeekCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "week", cmd.Use) + testutil.Equal(t, cmd.Use, "week") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) t.Run("has calendar flag", func(t *testing.T) { flag := cmd.Flags().Lookup("calendar") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "week") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "week") }) } diff --git a/internal/cmd/calendar/dates_test.go b/internal/cmd/calendar/dates_test.go index 286aa1b..fdb5dc5 100644 --- a/internal/cmd/calendar/dates_test.go +++ b/internal/cmd/calendar/dates_test.go @@ -4,8 +4,7 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestParseDate(t *testing.T) { @@ -81,13 +80,13 @@ func TestParseDate(t *testing.T) { result, err := parseDate(tt.input) if tt.wantErr { - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid date format") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "invalid date format") } else { - require.NoError(t, err) - assert.Equal(t, tt.want.Year(), result.Year()) - assert.Equal(t, tt.want.Month(), result.Month()) - assert.Equal(t, tt.want.Day(), result.Day()) + testutil.NoError(t, err) + testutil.Equal(t, result.Year(), tt.want.Year()) + testutil.Equal(t, result.Month(), tt.want.Month()) + testutil.Equal(t, result.Day(), tt.want.Day()) } }) } @@ -124,7 +123,7 @@ func TestEndOfDay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := endOfDay(tt.input) - assert.Equal(t, tt.want, result) + testutil.Equal(t, result, tt.want) }) } } @@ -204,24 +203,24 @@ func TestWeekBounds(t *testing.T) { t.Run(tt.name, func(t *testing.T) { start, end := weekBounds(tt.input) - assert.Equal(t, tt.wantStart, start, "start mismatch") - assert.Equal(t, tt.wantEnd, end, "end mismatch") + testutil.Equal(t, start, tt.wantStart) + testutil.Equal(t, end, tt.wantEnd) // Verify start is Monday - assert.Equal(t, time.Monday, start.Weekday(), "start should be Monday") + testutil.Equal(t, start.Weekday(), time.Monday) // Verify end is Sunday - assert.Equal(t, time.Sunday, end.Weekday(), "end should be Sunday") + testutil.Equal(t, end.Weekday(), time.Sunday) // Verify start is at 00:00:00 - assert.Equal(t, 0, start.Hour()) - assert.Equal(t, 0, start.Minute()) - assert.Equal(t, 0, start.Second()) + testutil.Equal(t, start.Hour(), 0) + testutil.Equal(t, start.Minute(), 0) + testutil.Equal(t, start.Second(), 0) // Verify end is at 23:59:59 - assert.Equal(t, 23, end.Hour()) - assert.Equal(t, 59, end.Minute()) - assert.Equal(t, 59, end.Second()) + testutil.Equal(t, end.Hour(), 23) + testutil.Equal(t, end.Minute(), 59) + testutil.Equal(t, end.Second(), 59) }) } } @@ -243,15 +242,15 @@ func TestWeekBoundsSundayEdgeCase(t *testing.T) { start, end := weekBounds(sunday) // The Sunday should be included in the week - assert.Equal(t, sunday.Year(), end.Year()) - assert.Equal(t, sunday.Month(), end.Month()) - assert.Equal(t, sunday.Day(), end.Day()) + testutil.Equal(t, end.Year(), sunday.Year()) + testutil.Equal(t, end.Month(), sunday.Month()) + testutil.Equal(t, end.Day(), sunday.Day()) // The Monday should be 6 days before the Sunday expectedMonday := sunday.AddDate(0, 0, -6) - assert.Equal(t, expectedMonday.Year(), start.Year()) - assert.Equal(t, expectedMonday.Month(), start.Month()) - assert.Equal(t, expectedMonday.Day(), start.Day()) + testutil.Equal(t, start.Year(), expectedMonday.Year()) + testutil.Equal(t, start.Month(), expectedMonday.Month()) + testutil.Equal(t, start.Day(), expectedMonday.Day()) }) } } @@ -307,17 +306,17 @@ func TestTodayBounds(t *testing.T) { t.Run(tt.name, func(t *testing.T) { start, end := todayBounds(tt.input) - assert.Equal(t, tt.wantStart, start) - assert.Equal(t, tt.wantEnd, end) + testutil.Equal(t, start, tt.wantStart) + testutil.Equal(t, end, tt.wantEnd) // Verify same day - assert.Equal(t, tt.input.Year(), start.Year()) - assert.Equal(t, tt.input.Month(), start.Month()) - assert.Equal(t, tt.input.Day(), start.Day()) + testutil.Equal(t, start.Year(), tt.input.Year()) + testutil.Equal(t, start.Month(), tt.input.Month()) + testutil.Equal(t, start.Day(), tt.input.Day()) - assert.Equal(t, tt.input.Year(), end.Year()) - assert.Equal(t, tt.input.Month(), end.Month()) - assert.Equal(t, tt.input.Day(), end.Day()) + testutil.Equal(t, end.Year(), tt.input.Year()) + testutil.Equal(t, end.Month(), tt.input.Month()) + testutil.Equal(t, end.Day(), tt.input.Day()) }) } } diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index c879eba..2f2ccac 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -8,8 +8,6 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "google.golang.org/api/calendar/v3" calendarapi "github.com/open-cli-collective/google-readonly/internal/calendar" @@ -21,7 +19,7 @@ func captureOutput(t *testing.T, f func()) string { t.Helper() old := os.Stdout r, w, err := os.Pipe() - require.NoError(t, err) + testutil.NoError(t, err) os.Stdout = w f() @@ -65,12 +63,12 @@ func TestListCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "primary@example.com") - assert.Contains(t, output, "(primary)") - assert.Contains(t, output, "work@example.com") + testutil.Contains(t, output, "primary@example.com") + testutil.Contains(t, output, "(primary)") + testutil.Contains(t, output, "work@example.com") }) } @@ -87,13 +85,13 @@ func TestListCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var calendars []*calendarapi.CalendarInfo err := json.Unmarshal([]byte(output), &calendars) - assert.NoError(t, err) - assert.Len(t, calendars, 2) + testutil.NoError(t, err) + testutil.Len(t, calendars, 2) }) } @@ -109,10 +107,10 @@ func TestListCommand_Empty(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No calendars found") + testutil.Contains(t, output, "No calendars found") }) } @@ -127,8 +125,8 @@ func TestListCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to list calendars") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to list calendars") }) } @@ -137,15 +135,15 @@ func TestListCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create Calendar client") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to create Calendar client") }) } func TestEventsCommand_Success(t *testing.T) { mock := &testutil.MockCalendarClient{ ListEventsFunc: func(calendarID, _, _ string, _ int64) ([]*calendar.Event, error) { - assert.Equal(t, "primary", calendarID) + testutil.Equal(t, calendarID, "primary") return []*calendar.Event{testutil.SampleEvent("event1")}, nil }, } @@ -156,10 +154,10 @@ func TestEventsCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "Test Meeting") + testutil.Contains(t, output, "Test Meeting") }) } @@ -179,13 +177,13 @@ func TestEventsCommand_WithDateRange(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) // Verify dates were parsed and passed - assert.Contains(t, capturedTimeMin, "2024-01-01") - assert.Contains(t, capturedTimeMax, "2024-01-31") - assert.Contains(t, output, "No events") + testutil.Contains(t, capturedTimeMin, "2024-01-01") + testutil.Contains(t, capturedTimeMax, "2024-01-31") + testutil.Contains(t, output, "No events") }) } @@ -202,13 +200,13 @@ func TestEventsCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var events []*calendarapi.Event err := json.Unmarshal([]byte(output), &events) - assert.NoError(t, err) - assert.Len(t, events, 1) + testutil.NoError(t, err) + testutil.Len(t, events, 1) }) } @@ -218,8 +216,8 @@ func TestEventsCommand_InvalidFromDate(t *testing.T) { withMockClient(&testutil.MockCalendarClient{}, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid --from date") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "invalid --from date") }) } @@ -229,16 +227,16 @@ func TestEventsCommand_InvalidToDate(t *testing.T) { withMockClient(&testutil.MockCalendarClient{}, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid --to date") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "invalid --to date") }) } func TestGetCommand_Success(t *testing.T) { mock := &testutil.MockCalendarClient{ GetEventFunc: func(calendarID, eventID string) (*calendar.Event, error) { - assert.Equal(t, "primary", calendarID) - assert.Equal(t, "event123", eventID) + testutil.Equal(t, calendarID, "primary") + testutil.Equal(t, eventID, "event123") return testutil.SampleEvent("event123"), nil }, } @@ -249,12 +247,12 @@ func TestGetCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "event123") - assert.Contains(t, output, "Test Meeting") - assert.Contains(t, output, "Conference Room A") + testutil.Contains(t, output, "event123") + testutil.Contains(t, output, "Test Meeting") + testutil.Contains(t, output, "Conference Room A") }) } @@ -271,13 +269,13 @@ func TestGetCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var event calendarapi.Event err := json.Unmarshal([]byte(output), &event) - assert.NoError(t, err) - assert.Equal(t, "event123", event.ID) + testutil.NoError(t, err) + testutil.Equal(t, event.ID, "event123") }) } @@ -293,8 +291,8 @@ func TestGetCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get event") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to get event") }) } @@ -310,10 +308,10 @@ func TestTodayCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "Test Meeting") + testutil.Contains(t, output, "Test Meeting") }) } @@ -332,10 +330,10 @@ func TestWeekCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) // Should show events - assert.Contains(t, output, "Test Meeting") + testutil.Contains(t, output, "Test Meeting") }) } diff --git a/internal/cmd/calendar/output_test.go b/internal/cmd/calendar/output_test.go index da83c03..417f3ce 100644 --- a/internal/cmd/calendar/output_test.go +++ b/internal/cmd/calendar/output_test.go @@ -8,10 +8,8 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/open-cli-collective/google-readonly/internal/calendar" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestPrintJSON(t *testing.T) { @@ -61,7 +59,7 @@ func TestPrintJSON(t *testing.T) { os.Stdout = w err := printJSON(tt.data) - require.NoError(t, err) + testutil.NoError(t, err) w.Close() os.Stdout = oldStdout @@ -70,15 +68,15 @@ func TestPrintJSON(t *testing.T) { io.Copy(&buf, r) output := buf.String() - assert.NotEmpty(t, output) + testutil.NotEmpty(t, output) // Verify it's valid JSON var parsed any err = json.Unmarshal([]byte(output), &parsed) - assert.NoError(t, err, "output should be valid JSON") + testutil.NoError(t, err) if tt.wantJSON != "" { - assert.Equal(t, tt.wantJSON, output) + testutil.Equal(t, output, tt.wantJSON) } }) } @@ -241,10 +239,10 @@ func TestPrintEvent(t *testing.T) { output := buf.String() for _, want := range tt.wantContains { - assert.Contains(t, output, want) + testutil.Contains(t, output, want) } for _, notWant := range tt.wantNotContains { - assert.NotContains(t, output, notWant) + testutil.NotContains(t, output, notWant) } }) } @@ -317,7 +315,7 @@ func TestPrintEventSummary(t *testing.T) { output := buf.String() for _, want := range tt.wantContains { - assert.Contains(t, output, want) + testutil.Contains(t, output, want) } }) } @@ -397,22 +395,22 @@ func TestPrintCalendar(t *testing.T) { output := buf.String() for _, want := range tt.wantContains { - assert.Contains(t, output, want) + testutil.Contains(t, output, want) } // Check that non-primary calendars don't have "(primary)" if !tt.cal.Primary { - assert.NotContains(t, output, "(primary)") + testutil.NotContains(t, output, "(primary)") } // Check that empty descriptions aren't printed if tt.cal.Description == "" { - assert.NotContains(t, output, "Description:") + testutil.NotContains(t, output, "Description:") } // Check that empty timezones aren't printed if tt.cal.TimeZone == "" { - assert.NotContains(t, output, "Timezone:") + testutil.NotContains(t, output, "Timezone:") } }) } @@ -440,8 +438,8 @@ func TestPrintCalendarNoPrimary(t *testing.T) { output := buf.String() // Should have the ID without "(primary)" - assert.Contains(t, output, "ID: other@google.com") - assert.NotContains(t, output, "(primary)") + testutil.Contains(t, output, "ID: other@google.com") + testutil.NotContains(t, output, "(primary)") } func TestPrintAttendeeWithoutStatus(t *testing.T) { @@ -472,8 +470,8 @@ func TestPrintAttendeeWithoutStatus(t *testing.T) { lines := strings.Split(output, "\n") for _, line := range lines { if strings.Contains(line, "Alice") { - assert.NotContains(t, line, "()") - assert.Contains(t, line, "Alice ") + testutil.NotContains(t, line, "()") + testutil.Contains(t, line, "Alice ") } } } diff --git a/internal/cmd/config/config_test.go b/internal/cmd/config/config_test.go index b9e9503..76a2670 100644 --- a/internal/cmd/config/config_test.go +++ b/internal/cmd/config/config_test.go @@ -3,32 +3,32 @@ package config import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestConfigCommand(t *testing.T) { cmd := NewCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "config", cmd.Use) + testutil.Equal(t, cmd.Use, "config") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has subcommands", func(t *testing.T) { subcommands := cmd.Commands() - assert.GreaterOrEqual(t, len(subcommands), 4) + testutil.GreaterOrEqual(t, len(subcommands), 4) var names []string for _, sub := range subcommands { names = append(names, sub.Name()) } - assert.Contains(t, names, "show") - assert.Contains(t, names, "test") - assert.Contains(t, names, "clear") - assert.Contains(t, names, "cache") + testutil.SliceContains(t, names, "show") + testutil.SliceContains(t, names, "test") + testutil.SliceContains(t, names, "clear") + testutil.SliceContains(t, names, "cache") }) } @@ -36,23 +36,23 @@ func TestConfigShowCommand(t *testing.T) { cmd := newShowCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "show", cmd.Use) + testutil.Equal(t, cmd.Use, "show") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) + testutil.NotEmpty(t, cmd.Long) }) } @@ -60,23 +60,23 @@ func TestConfigTestCommand(t *testing.T) { cmd := newTestCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "test", cmd.Use) + testutil.Equal(t, cmd.Use, "test") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) + testutil.NotEmpty(t, cmd.Long) }) } @@ -84,24 +84,24 @@ func TestConfigClearCommand(t *testing.T) { cmd := newClearCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "clear", cmd.Use) + testutil.Equal(t, cmd.Use, "clear") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "token") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "token") }) } @@ -109,29 +109,29 @@ func TestCacheCommand(t *testing.T) { cmd := newCacheCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "cache", cmd.Use) + testutil.Equal(t, cmd.Use, "cache") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "cache") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "cache") }) t.Run("has subcommands", func(t *testing.T) { subcommands := cmd.Commands() - assert.Equal(t, 3, len(subcommands)) + testutil.Equal(t, len(subcommands), 3) var names []string for _, sub := range subcommands { names = append(names, sub.Name()) } - assert.Contains(t, names, "show") - assert.Contains(t, names, "clear") - assert.Contains(t, names, "ttl") + testutil.SliceContains(t, names, "show") + testutil.SliceContains(t, names, "clear") + testutil.SliceContains(t, names, "ttl") }) } @@ -139,31 +139,31 @@ func TestCacheShowCommand(t *testing.T) { cmd := newCacheShowCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "show", cmd.Use) + testutil.Equal(t, cmd.Use, "show") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "cache") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "cache") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) } @@ -171,23 +171,23 @@ func TestCacheClearCommand(t *testing.T) { cmd := newCacheClearCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "clear", cmd.Use) + testutil.Equal(t, cmd.Use, "clear") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) + testutil.NotEmpty(t, cmd.Long) }) } @@ -195,26 +195,26 @@ func TestCacheTTLCommand(t *testing.T) { cmd := newCacheTTLCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "ttl ", cmd.Use) + testutil.Equal(t, cmd.Use, "ttl ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"24"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"24", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "TTL") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "TTL") }) } diff --git a/internal/cmd/contacts/contacts_test.go b/internal/cmd/contacts/contacts_test.go index 0047fcf..40de2e2 100644 --- a/internal/cmd/contacts/contacts_test.go +++ b/internal/cmd/contacts/contacts_test.go @@ -3,41 +3,41 @@ package contacts import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestContactsCommand(t *testing.T) { cmd := NewCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "contacts", cmd.Use) + testutil.Equal(t, cmd.Use, "contacts") }) t.Run("has ppl alias", func(t *testing.T) { - assert.Contains(t, cmd.Aliases, "ppl") + testutil.SliceContains(t, cmd.Aliases, "ppl") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "read-only") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "read-only") }) t.Run("has subcommands", func(t *testing.T) { subcommands := cmd.Commands() - assert.GreaterOrEqual(t, len(subcommands), 4) + testutil.GreaterOrEqual(t, len(subcommands), 4) var names []string for _, sub := range subcommands { names = append(names, sub.Name()) } - assert.Contains(t, names, "list") - assert.Contains(t, names, "search") - assert.Contains(t, names, "get") - assert.Contains(t, names, "groups") + testutil.SliceContains(t, names, "list") + testutil.SliceContains(t, names, "search") + testutil.SliceContains(t, names, "get") + testutil.SliceContains(t, names, "groups") }) } @@ -45,35 +45,35 @@ func TestListCommand(t *testing.T) { cmd := newListCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "list", cmd.Use) + testutil.Equal(t, cmd.Use, "list") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("rejects arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) - assert.Equal(t, "10", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "10") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) } @@ -81,38 +81,38 @@ func TestSearchCommand(t *testing.T) { cmd := newSearchCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "search ", cmd.Use) + testutil.Equal(t, cmd.Use, "search ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{"query"}) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("rejects no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("rejects multiple arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{"query1", "query2"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) } @@ -120,27 +120,27 @@ func TestGetCommand(t *testing.T) { cmd := newGetCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "get ", cmd.Use) + testutil.Equal(t, cmd.Use, "get ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{"people/c123"}) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("rejects no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) } @@ -148,33 +148,33 @@ func TestGroupsCommand(t *testing.T) { cmd := newGroupsCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "groups", cmd.Use) + testutil.Equal(t, cmd.Use, "groups") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("rejects arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) - assert.Equal(t, "30", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "30") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) } diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index 6453909..d379d37 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -8,8 +8,6 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "google.golang.org/api/people/v1" contactsapi "github.com/open-cli-collective/google-readonly/internal/contacts" @@ -21,7 +19,7 @@ func captureOutput(t *testing.T, f func()) string { t.Helper() old := os.Stdout r, w, err := os.Pipe() - require.NoError(t, err) + testutil.NoError(t, err) os.Stdout = w f() @@ -70,12 +68,12 @@ func TestListCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "people/c123") - assert.Contains(t, output, "John Doe") - assert.Contains(t, output, "2 contact(s)") + testutil.Contains(t, output, "people/c123") + testutil.Contains(t, output, "John Doe") + testutil.Contains(t, output, "2 contact(s)") }) } @@ -96,13 +94,13 @@ func TestListCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var contacts []*contactsapi.Contact err := json.Unmarshal([]byte(output), &contacts) - assert.NoError(t, err) - assert.Len(t, contacts, 1) + testutil.NoError(t, err) + testutil.Len(t, contacts, 1) }) } @@ -120,10 +118,10 @@ func TestListCommand_Empty(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No contacts found") + testutil.Contains(t, output, "No contacts found") }) } @@ -138,8 +136,8 @@ func TestListCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to list contacts") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to list contacts") }) } @@ -148,15 +146,15 @@ func TestListCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create Contacts client") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to create Contacts client") }) } func TestSearchCommand_Success(t *testing.T) { mock := &testutil.MockContactsClient{ SearchContactsFunc: func(query string, _ int64) (*people.SearchResponse, error) { - assert.Equal(t, "John", query) + testutil.Equal(t, query, "John") return &people.SearchResponse{ Results: []*people.SearchResult{ {Person: testutil.SamplePerson("people/c123")}, @@ -171,11 +169,11 @@ func TestSearchCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "John Doe") - assert.Contains(t, output, "1 contact(s)") + testutil.Contains(t, output, "John Doe") + testutil.Contains(t, output, "1 contact(s)") }) } @@ -196,13 +194,13 @@ func TestSearchCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var contacts []*contactsapi.Contact err := json.Unmarshal([]byte(output), &contacts) - assert.NoError(t, err) - assert.Len(t, contacts, 1) + testutil.NoError(t, err) + testutil.Len(t, contacts, 1) }) } @@ -221,10 +219,10 @@ func TestSearchCommand_NoResults(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No contacts found") + testutil.Contains(t, output, "No contacts found") }) } @@ -240,15 +238,15 @@ func TestSearchCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to search contacts") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to search contacts") }) } func TestGetCommand_Success(t *testing.T) { mock := &testutil.MockContactsClient{ GetContactFunc: func(resourceName string) (*people.Person, error) { - assert.Equal(t, "people/c123", resourceName) + testutil.Equal(t, resourceName, "people/c123") return testutil.SamplePerson("people/c123"), nil }, } @@ -259,12 +257,12 @@ func TestGetCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "people/c123") - assert.Contains(t, output, "John Doe") - assert.Contains(t, output, "john@example.com") + testutil.Contains(t, output, "people/c123") + testutil.Contains(t, output, "John Doe") + testutil.Contains(t, output, "john@example.com") }) } @@ -281,13 +279,13 @@ func TestGetCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var contact contactsapi.Contact err := json.Unmarshal([]byte(output), &contact) - assert.NoError(t, err) - assert.Equal(t, "people/c123", contact.ResourceName) + testutil.NoError(t, err) + testutil.Equal(t, contact.ResourceName, "people/c123") }) } @@ -303,8 +301,8 @@ func TestGetCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get contact") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to get contact") }) } @@ -335,12 +333,12 @@ func TestGroupsCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "Friends") - assert.Contains(t, output, "Family") - assert.Contains(t, output, "2 contact group(s)") + testutil.Contains(t, output, "Friends") + testutil.Contains(t, output, "Family") + testutil.Contains(t, output, "2 contact group(s)") }) } @@ -366,14 +364,14 @@ func TestGroupsCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var groups []*contactsapi.ContactGroup err := json.Unmarshal([]byte(output), &groups) - assert.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, "Friends", groups[0].Name) + testutil.NoError(t, err) + testutil.Len(t, groups, 1) + testutil.Equal(t, groups[0].Name, "Friends") }) } @@ -391,10 +389,10 @@ func TestGroupsCommand_Empty(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No contact groups found") + testutil.Contains(t, output, "No contact groups found") }) } @@ -409,7 +407,7 @@ func TestGroupsCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to list contact groups") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to list contact groups") }) } diff --git a/internal/cmd/contacts/output_test.go b/internal/cmd/contacts/output_test.go index 1c691b8..2b3f995 100644 --- a/internal/cmd/contacts/output_test.go +++ b/internal/cmd/contacts/output_test.go @@ -7,10 +7,8 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/open-cli-collective/google-readonly/internal/contacts" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestPrintJSON(t *testing.T) { @@ -50,7 +48,7 @@ func TestPrintJSON(t *testing.T) { os.Stdout = w err := printJSON(tt.data) - require.NoError(t, err) + testutil.NoError(t, err) w.Close() os.Stdout = oldStdout @@ -59,12 +57,12 @@ func TestPrintJSON(t *testing.T) { io.Copy(&buf, r) output := buf.String() - assert.NotEmpty(t, output) + testutil.NotEmpty(t, output) // Verify it's valid JSON var parsed any err = json.Unmarshal([]byte(output), &parsed) - assert.NoError(t, err, "output should be valid JSON") + testutil.NoError(t, err) }) } } @@ -239,10 +237,10 @@ func TestPrintContact(t *testing.T) { output := buf.String() for _, want := range tt.wantContains { - assert.Contains(t, output, want) + testutil.Contains(t, output, want) } for _, notWant := range tt.wantNotContains { - assert.NotContains(t, output, notWant) + testutil.NotContains(t, output, notWant) } }) } @@ -303,7 +301,7 @@ func TestPrintContactSummary(t *testing.T) { output := buf.String() for _, want := range tt.wantContains { - assert.Contains(t, output, want) + testutil.Contains(t, output, want) } }) } @@ -377,7 +375,7 @@ func TestPrintContactGroup(t *testing.T) { output := buf.String() for _, want := range tt.wantContains { - assert.Contains(t, output, want) + testutil.Contains(t, output, want) } }) } diff --git a/internal/cmd/drive/download_test.go b/internal/cmd/drive/download_test.go index 551e337..69583e5 100644 --- a/internal/cmd/drive/download_test.go +++ b/internal/cmd/drive/download_test.go @@ -3,68 +3,68 @@ package drive import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestDownloadCommand(t *testing.T) { cmd := newDownloadCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "download ", cmd.Use) + testutil.Equal(t, cmd.Use, "download ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"file-id"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"file-id", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has output flag", func(t *testing.T) { flag := cmd.Flags().Lookup("output") - assert.NotNil(t, flag) - assert.Equal(t, "o", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "o") }) t.Run("has format flag", func(t *testing.T) { flag := cmd.Flags().Lookup("format") - assert.NotNil(t, flag) - assert.Equal(t, "f", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "f") }) t.Run("has stdout flag", func(t *testing.T) { flag := cmd.Flags().Lookup("stdout") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has short description", func(t *testing.T) { - assert.Contains(t, cmd.Short, "Download") + testutil.Contains(t, cmd.Short, "Download") }) } func TestDetermineOutputPath(t *testing.T) { t.Run("uses user-specified output path", func(t *testing.T) { result := determineOutputPath("original.doc", "pdf", "/custom/path.pdf") - assert.Equal(t, "/custom/path.pdf", result) + testutil.Equal(t, result, "/custom/path.pdf") }) t.Run("uses original name when no format or output", func(t *testing.T) { result := determineOutputPath("document.pdf", "", "") - assert.Equal(t, "document.pdf", result) + testutil.Equal(t, result, "document.pdf") }) t.Run("replaces extension when format specified", func(t *testing.T) { result := determineOutputPath("Report", "pdf", "") - assert.Equal(t, "Report.pdf", result) + testutil.Equal(t, result, "Report.pdf") }) t.Run("replaces existing extension when format specified", func(t *testing.T) { result := determineOutputPath("Report.gdoc", "docx", "") - assert.Equal(t, "Report.docx", result) + testutil.Equal(t, result, "Report.docx") }) t.Run("handles various export formats", func(t *testing.T) { @@ -86,7 +86,7 @@ func TestDetermineOutputPath(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { result := determineOutputPath(tt.name, tt.format, "") - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } }) diff --git a/internal/cmd/drive/drives_test.go b/internal/cmd/drive/drives_test.go index 21c90fe..87881c9 100644 --- a/internal/cmd/drive/drives_test.go +++ b/internal/cmd/drive/drives_test.go @@ -3,8 +3,6 @@ package drive import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/open-cli-collective/google-readonly/internal/drive" "github.com/open-cli-collective/google-readonly/internal/testutil" ) @@ -13,37 +11,37 @@ func TestDrivesCommand(t *testing.T) { cmd := newDrivesCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "drives", cmd.Use) + testutil.Equal(t, cmd.Use, "drives") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has refresh flag", func(t *testing.T) { flag := cmd.Flags().Lookup("refresh") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.Contains(t, cmd.Short, "shared drives") + testutil.Contains(t, cmd.Short, "shared drives") }) t.Run("has long description", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Shared Drives") - assert.Contains(t, cmd.Long, "cache") + testutil.Contains(t, cmd.Long, "Shared Drives") + testutil.Contains(t, cmd.Long, "cache") }) } @@ -98,7 +96,7 @@ func TestLooksLikeDriveID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := looksLikeDriveID(tt.input) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } @@ -109,10 +107,10 @@ func TestResolveDriveScope(t *testing.T) { scope, err := resolveDriveScope(mock, true, "") - assert.NoError(t, err) - assert.True(t, scope.MyDriveOnly) - assert.False(t, scope.AllDrives) - assert.Empty(t, scope.DriveID) + testutil.NoError(t, err) + testutil.True(t, scope.MyDriveOnly) + testutil.False(t, scope.AllDrives) + testutil.Empty(t, scope.DriveID) }) t.Run("returns AllDrives when no flags provided", func(t *testing.T) { @@ -120,10 +118,10 @@ func TestResolveDriveScope(t *testing.T) { scope, err := resolveDriveScope(mock, false, "") - assert.NoError(t, err) - assert.True(t, scope.AllDrives) - assert.False(t, scope.MyDriveOnly) - assert.Empty(t, scope.DriveID) + testutil.NoError(t, err) + testutil.True(t, scope.AllDrives) + testutil.False(t, scope.MyDriveOnly) + testutil.Empty(t, scope.DriveID) }) t.Run("returns DriveID directly when input looks like ID", func(t *testing.T) { @@ -131,10 +129,10 @@ func TestResolveDriveScope(t *testing.T) { scope, err := resolveDriveScope(mock, false, "0ALengineering123456") - assert.NoError(t, err) - assert.Equal(t, "0ALengineering123456", scope.DriveID) - assert.False(t, scope.AllDrives) - assert.False(t, scope.MyDriveOnly) + testutil.NoError(t, err) + testutil.Equal(t, scope.DriveID, "0ALengineering123456") + testutil.False(t, scope.AllDrives) + testutil.False(t, scope.MyDriveOnly) }) t.Run("resolves drive name to ID via API", func(t *testing.T) { @@ -149,8 +147,8 @@ func TestResolveDriveScope(t *testing.T) { scope, err := resolveDriveScope(mock, false, "Engineering") - assert.NoError(t, err) - assert.Equal(t, "0ALeng123", scope.DriveID) + testutil.NoError(t, err) + testutil.Equal(t, scope.DriveID, "0ALeng123") }) t.Run("resolves drive name case-insensitively", func(t *testing.T) { @@ -164,8 +162,8 @@ func TestResolveDriveScope(t *testing.T) { scope, err := resolveDriveScope(mock, false, "ENGINEERING") - assert.NoError(t, err) - assert.Equal(t, "0ALeng123", scope.DriveID) + testutil.NoError(t, err) + testutil.Equal(t, scope.DriveID, "0ALeng123") }) t.Run("returns error when drive name not found", func(t *testing.T) { @@ -179,8 +177,8 @@ func TestResolveDriveScope(t *testing.T) { _, err := resolveDriveScope(mock, false, "NonExistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "shared drive not found") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "shared drive not found") }) } @@ -191,8 +189,8 @@ func TestSearchCommand_MutualExclusivity(t *testing.T) { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mutually exclusive") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "mutually exclusive") }) } @@ -203,8 +201,8 @@ func TestListCommand_MutualExclusivity(t *testing.T) { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mutually exclusive") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "mutually exclusive") }) } @@ -215,7 +213,7 @@ func TestTreeCommand_MutualExclusivity(t *testing.T) { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "mutually exclusive") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "mutually exclusive") }) } diff --git a/internal/cmd/drive/get_test.go b/internal/cmd/drive/get_test.go index 9177481..0bcc25c 100644 --- a/internal/cmd/drive/get_test.go +++ b/internal/cmd/drive/get_test.go @@ -7,38 +7,37 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/open-cli-collective/google-readonly/internal/drive" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestGetCommand(t *testing.T) { cmd := newGetCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "get ", cmd.Use) + testutil.Equal(t, cmd.Use, "get ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"file-id"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"file-id", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.Contains(t, cmd.Short, "Get") + testutil.Contains(t, cmd.Short, "Get") }) } @@ -77,17 +76,17 @@ func TestPrintFileDetails(t *testing.T) { printFileDetails(f) }) - assert.Contains(t, output, "File Details") - assert.Contains(t, output, "ID: abc123") - assert.Contains(t, output, "Name: Test Document") - assert.Contains(t, output, "Type: Document") - assert.Contains(t, output, "Size: -") - assert.Contains(t, output, "Created: 2024-01-10 09:30:00") - assert.Contains(t, output, "Modified: 2024-01-15 14:22:00") - assert.Contains(t, output, "Owner: owner@example.com") - assert.Contains(t, output, "Shared: Yes") - assert.Contains(t, output, "Web Link: https://docs.google.com/document/d/abc123/edit") - assert.Contains(t, output, "Parent: parent123") + testutil.Contains(t, output, "File Details") + testutil.Contains(t, output, "ID: abc123") + testutil.Contains(t, output, "Name: Test Document") + testutil.Contains(t, output, "Type: Document") + testutil.Contains(t, output, "Size: -") + testutil.Contains(t, output, "Created: 2024-01-10 09:30:00") + testutil.Contains(t, output, "Modified: 2024-01-15 14:22:00") + testutil.Contains(t, output, "Owner: owner@example.com") + testutil.Contains(t, output, "Shared: Yes") + testutil.Contains(t, output, "Web Link: https://docs.google.com/document/d/abc123/edit") + testutil.Contains(t, output, "Parent: parent123") }) t.Run("prints size for regular files", func(t *testing.T) { @@ -102,7 +101,7 @@ func TestPrintFileDetails(t *testing.T) { printFileDetails(f) }) - assert.Contains(t, output, "Size: 1.5 MB") + testutil.Contains(t, output, "Size: 1.5 MB") }) t.Run("handles unshared file", func(t *testing.T) { @@ -116,7 +115,7 @@ func TestPrintFileDetails(t *testing.T) { printFileDetails(f) }) - assert.Contains(t, output, "Shared: No") + testutil.Contains(t, output, "Shared: No") }) t.Run("handles multiple owners", func(t *testing.T) { @@ -130,7 +129,7 @@ func TestPrintFileDetails(t *testing.T) { printFileDetails(f) }) - assert.Contains(t, output, "Owner: owner1@example.com, owner2@example.com") + testutil.Contains(t, output, "Owner: owner1@example.com, owner2@example.com") }) t.Run("omits missing fields gracefully", func(t *testing.T) { @@ -143,13 +142,13 @@ func TestPrintFileDetails(t *testing.T) { printFileDetails(f) }) - assert.Contains(t, output, "ID: minimal123") - assert.Contains(t, output, "Name: minimal.txt") + testutil.Contains(t, output, "ID: minimal123") + testutil.Contains(t, output, "Name: minimal.txt") // Should not contain empty values or crash - assert.NotContains(t, output, "Created:") - assert.NotContains(t, output, "Modified:") - assert.NotContains(t, output, "Owner:") - assert.NotContains(t, output, "Web Link:") - assert.NotContains(t, output, "Parent:") + testutil.NotContains(t, output, "Created:") + testutil.NotContains(t, output, "Modified:") + testutil.NotContains(t, output, "Owner:") + testutil.NotContains(t, output, "Web Link:") + testutil.NotContains(t, output, "Parent:") }) } diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index 40fa79d..a229996 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -8,9 +8,6 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - driveapi "github.com/open-cli-collective/google-readonly/internal/drive" "github.com/open-cli-collective/google-readonly/internal/testutil" ) @@ -20,7 +17,7 @@ func captureOutput(t *testing.T, f func()) string { t.Helper() old := os.Stdout r, w, err := os.Pipe() - require.NoError(t, err) + testutil.NoError(t, err) os.Stdout = w f() @@ -55,7 +52,7 @@ func withFailingClientFactory(f func()) { func TestListCommand_Success(t *testing.T) { mock := &testutil.MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { - assert.Contains(t, query, "'root' in parents") + testutil.Contains(t, query, "'root' in parents") return testutil.SampleDriveFiles(2), nil }, } @@ -65,11 +62,11 @@ func TestListCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "file_a") - assert.Contains(t, output, "test-document.pdf") + testutil.Contains(t, output, "file_a") + testutil.Contains(t, output, "test-document.pdf") }) } @@ -86,13 +83,13 @@ func TestListCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var files []*driveapi.File err := json.Unmarshal([]byte(output), &files) - assert.NoError(t, err) - assert.Len(t, files, 1) + testutil.NoError(t, err) + testutil.Len(t, files, 1) }) } @@ -108,17 +105,17 @@ func TestListCommand_Empty(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No files found") + testutil.Contains(t, output, "No files found") }) } func TestListCommand_WithFolder(t *testing.T) { mock := &testutil.MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { - assert.Contains(t, query, "'folder123' in parents") + testutil.Contains(t, query, "'folder123' in parents") return testutil.SampleDriveFiles(1), nil }, } @@ -129,17 +126,17 @@ func TestListCommand_WithFolder(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "file_a") + testutil.Contains(t, output, "file_a") }) } func TestListCommand_WithTypeFilter(t *testing.T) { mock := &testutil.MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { - assert.Contains(t, query, "mimeType") + testutil.Contains(t, query, "mimeType") return testutil.SampleDriveFiles(1), nil }, } @@ -150,10 +147,10 @@ func TestListCommand_WithTypeFilter(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "file_a") + testutil.Contains(t, output, "file_a") }) } @@ -163,8 +160,8 @@ func TestListCommand_InvalidType(t *testing.T) { withMockClient(&testutil.MockDriveClient{}, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown file type") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "unknown file type") }) } @@ -179,8 +176,8 @@ func TestListCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to list files") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to list files") }) } @@ -189,15 +186,15 @@ func TestListCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create Drive client") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to create Drive client") }) } func TestSearchCommand_Success(t *testing.T) { mock := &testutil.MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { - assert.Contains(t, query, "fullText contains 'report'") + testutil.Contains(t, query, "fullText contains 'report'") return testutil.SampleDriveFiles(2), nil }, } @@ -208,18 +205,18 @@ func TestSearchCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "file_a") - assert.Contains(t, output, "2 file(s)") + testutil.Contains(t, output, "file_a") + testutil.Contains(t, output, "2 file(s)") }) } func TestSearchCommand_NameOnly(t *testing.T) { mock := &testutil.MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { - assert.Contains(t, query, "name contains 'budget'") + testutil.Contains(t, query, "name contains 'budget'") return testutil.SampleDriveFiles(1), nil }, } @@ -230,10 +227,10 @@ func TestSearchCommand_NameOnly(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "file_a") + testutil.Contains(t, output, "file_a") }) } @@ -250,13 +247,13 @@ func TestSearchCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var files []*driveapi.File err := json.Unmarshal([]byte(output), &files) - assert.NoError(t, err) - assert.Len(t, files, 1) + testutil.NoError(t, err) + testutil.Len(t, files, 1) }) } @@ -273,10 +270,10 @@ func TestSearchCommand_NoResults(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No files found") + testutil.Contains(t, output, "No files found") }) } @@ -292,15 +289,15 @@ func TestSearchCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to search files") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to search files") }) } func TestGetCommand_Success(t *testing.T) { mock := &testutil.MockDriveClient{ GetFileFunc: func(fileID string) (*driveapi.File, error) { - assert.Equal(t, "file123", fileID) + testutil.Equal(t, fileID, "file123") return testutil.SampleDriveFile("file123"), nil }, } @@ -311,12 +308,12 @@ func TestGetCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "file123") - assert.Contains(t, output, "test-document.pdf") - assert.Contains(t, output, "owner@example.com") + testutil.Contains(t, output, "file123") + testutil.Contains(t, output, "test-document.pdf") + testutil.Contains(t, output, "owner@example.com") }) } @@ -333,13 +330,13 @@ func TestGetCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var file driveapi.File err := json.Unmarshal([]byte(output), &file) - assert.NoError(t, err) - assert.Equal(t, "file123", file.ID) + testutil.NoError(t, err) + testutil.Equal(t, file.ID, "file123") }) } @@ -355,8 +352,8 @@ func TestGetCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get file") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to get file") }) } @@ -372,7 +369,7 @@ func TestDownloadCommand_RegularFile(t *testing.T) { return testutil.SampleDriveFile("file123"), nil }, DownloadFileFunc: func(fileID string) ([]byte, error) { - assert.Equal(t, "file123", fileID) + testutil.Equal(t, fileID, "file123") return []byte("test content"), nil }, } @@ -383,11 +380,11 @@ func TestDownloadCommand_RegularFile(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "Downloading") - assert.Contains(t, output, "Saved to") + testutil.Contains(t, output, "Downloading") + testutil.Contains(t, output, "Saved to") }) } @@ -407,10 +404,10 @@ func TestDownloadCommand_ToStdout(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Equal(t, "test content", output) + testutil.Equal(t, output, "test content") }) } @@ -426,8 +423,8 @@ func TestDownloadCommand_GoogleDocRequiresFormat(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "requires --format flag") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "requires --format flag") }) } @@ -443,8 +440,8 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { return testutil.SampleGoogleDoc("doc123"), nil }, ExportFileFunc: func(fileID, mimeType string) ([]byte, error) { - assert.Equal(t, "doc123", fileID) - assert.Contains(t, mimeType, "pdf") + testutil.Equal(t, fileID, "doc123") + testutil.Contains(t, mimeType, "pdf") return []byte("pdf content"), nil }, } @@ -455,11 +452,11 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "Exporting") - assert.Contains(t, output, "Saved to") + testutil.Contains(t, output, "Exporting") + testutil.Contains(t, output, "Saved to") }) } @@ -475,8 +472,8 @@ func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "--format flag is only for Google Workspace files") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "--format flag is only for Google Workspace files") }) } @@ -495,8 +492,8 @@ func TestDownloadCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to download file") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to download file") }) } @@ -506,7 +503,7 @@ func TestDownloadCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create Drive client") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to create Drive client") }) } diff --git a/internal/cmd/drive/list_test.go b/internal/cmd/drive/list_test.go index 1a9c76e..162cba1 100644 --- a/internal/cmd/drive/list_test.go +++ b/internal/cmd/drive/list_test.go @@ -3,139 +3,139 @@ package drive import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestListCommand(t *testing.T) { cmd := newListCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "list [folder-id]", cmd.Use) + testutil.Equal(t, cmd.Use, "list [folder-id]") }) t.Run("accepts zero or one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"folder-id"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"folder-id", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) - assert.Equal(t, "25", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "25") }) t.Run("has type flag", func(t *testing.T) { flag := cmd.Flags().Lookup("type") - assert.NotNil(t, flag) - assert.Equal(t, "t", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "t") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.Contains(t, cmd.Short, "List") + testutil.Contains(t, cmd.Short, "List") }) } func TestBuildListQuery(t *testing.T) { t.Run("builds query for root folder", func(t *testing.T) { query, err := buildListQuery("", "") - assert.NoError(t, err) - assert.Contains(t, query, "trashed = false") - assert.Contains(t, query, "'root' in parents") + testutil.NoError(t, err) + testutil.Contains(t, query, "trashed = false") + testutil.Contains(t, query, "'root' in parents") }) t.Run("builds query for specific folder", func(t *testing.T) { query, err := buildListQuery("folder123", "") - assert.NoError(t, err) - assert.Contains(t, query, "'folder123' in parents") - assert.NotContains(t, query, "'root' in parents") + testutil.NoError(t, err) + testutil.Contains(t, query, "'folder123' in parents") + testutil.NotContains(t, query, "'root' in parents") }) t.Run("adds type filter for document", func(t *testing.T) { query, err := buildListQuery("", "document") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType = 'application/vnd.google-apps.document'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType = 'application/vnd.google-apps.document'") }) t.Run("adds type filter for spreadsheet", func(t *testing.T) { query, err := buildListQuery("", "spreadsheet") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType = 'application/vnd.google-apps.spreadsheet'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType = 'application/vnd.google-apps.spreadsheet'") }) t.Run("adds type filter for presentation", func(t *testing.T) { query, err := buildListQuery("", "presentation") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType = 'application/vnd.google-apps.presentation'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType = 'application/vnd.google-apps.presentation'") }) t.Run("adds type filter for folder", func(t *testing.T) { query, err := buildListQuery("", "folder") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType = 'application/vnd.google-apps.folder'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType = 'application/vnd.google-apps.folder'") }) t.Run("adds type filter for pdf", func(t *testing.T) { query, err := buildListQuery("", "pdf") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType = 'application/pdf'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType = 'application/pdf'") }) t.Run("adds type filter for image", func(t *testing.T) { query, err := buildListQuery("", "image") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType contains 'image/'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType contains 'image/'") }) t.Run("adds type filter for video", func(t *testing.T) { query, err := buildListQuery("", "video") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType contains 'video/'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType contains 'video/'") }) t.Run("adds type filter for audio", func(t *testing.T) { query, err := buildListQuery("", "audio") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType contains 'audio/'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType contains 'audio/'") }) t.Run("returns error for unknown type", func(t *testing.T) { _, err := buildListQuery("", "unknown") - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown file type") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "unknown file type") }) t.Run("accepts type aliases", func(t *testing.T) { query, err := buildListQuery("", "doc") - assert.NoError(t, err) - assert.Contains(t, query, "application/vnd.google-apps.document") + testutil.NoError(t, err) + testutil.Contains(t, query, "application/vnd.google-apps.document") query, err = buildListQuery("", "sheet") - assert.NoError(t, err) - assert.Contains(t, query, "application/vnd.google-apps.spreadsheet") + testutil.NoError(t, err) + testutil.Contains(t, query, "application/vnd.google-apps.spreadsheet") query, err = buildListQuery("", "slides") - assert.NoError(t, err) - assert.Contains(t, query, "application/vnd.google-apps.presentation") + testutil.NoError(t, err) + testutil.Contains(t, query, "application/vnd.google-apps.presentation") }) t.Run("is case insensitive for type", func(t *testing.T) { query, err := buildListQuery("", "DOCUMENT") - assert.NoError(t, err) - assert.Contains(t, query, "application/vnd.google-apps.document") + testutil.NoError(t, err) + testutil.Contains(t, query, "application/vnd.google-apps.document") }) } @@ -163,10 +163,10 @@ func TestGetMimeTypeFilter(t *testing.T) { t.Run(tt.fileType, func(t *testing.T) { result, err := getMimeTypeFilter(tt.fileType) if tt.hasError { - assert.Error(t, err) + testutil.Error(t, err) } else { - assert.NoError(t, err) - assert.Equal(t, tt.expected, result) + testutil.NoError(t, err) + testutil.Equal(t, result, tt.expected) } }) } diff --git a/internal/cmd/drive/search_test.go b/internal/cmd/drive/search_test.go index a7f8a30..a9b82f0 100644 --- a/internal/cmd/drive/search_test.go +++ b/internal/cmd/drive/search_test.go @@ -3,173 +3,173 @@ package drive import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestSearchCommand(t *testing.T) { cmd := newSearchCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "search [query]", cmd.Use) + testutil.Equal(t, cmd.Use, "search [query]") }) t.Run("accepts zero or one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"query"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"query1", "query2"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) - assert.Equal(t, "25", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "25") }) t.Run("has name flag", func(t *testing.T) { flag := cmd.Flags().Lookup("name") - assert.NotNil(t, flag) - assert.Equal(t, "n", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "n") }) t.Run("has type flag", func(t *testing.T) { flag := cmd.Flags().Lookup("type") - assert.NotNil(t, flag) - assert.Equal(t, "t", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "t") }) t.Run("has owner flag", func(t *testing.T) { flag := cmd.Flags().Lookup("owner") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has modified-after flag", func(t *testing.T) { flag := cmd.Flags().Lookup("modified-after") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has modified-before flag", func(t *testing.T) { flag := cmd.Flags().Lookup("modified-before") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has in-folder flag", func(t *testing.T) { flag := cmd.Flags().Lookup("in-folder") - assert.NotNil(t, flag) + testutil.NotNil(t, flag) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) t.Run("has short description", func(t *testing.T) { - assert.Contains(t, cmd.Short, "Search") + testutil.Contains(t, cmd.Short, "Search") }) } func TestBuildSearchQuery(t *testing.T) { t.Run("builds full-text search query", func(t *testing.T) { query, err := buildSearchQuery("quarterly report", false, "", "", "", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "trashed = false") - assert.Contains(t, query, "fullText contains 'quarterly report'") + testutil.NoError(t, err) + testutil.Contains(t, query, "trashed = false") + testutil.Contains(t, query, "fullText contains 'quarterly report'") }) t.Run("builds name-only search query", func(t *testing.T) { query, err := buildSearchQuery("budget", true, "", "", "", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "name contains 'budget'") - assert.NotContains(t, query, "fullText") + testutil.NoError(t, err) + testutil.Contains(t, query, "name contains 'budget'") + testutil.NotContains(t, query, "fullText") }) t.Run("adds type filter", func(t *testing.T) { query, err := buildSearchQuery("test", false, "document", "", "", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "mimeType = 'application/vnd.google-apps.document'") + testutil.NoError(t, err) + testutil.Contains(t, query, "mimeType = 'application/vnd.google-apps.document'") }) t.Run("returns error for invalid type", func(t *testing.T) { _, err := buildSearchQuery("test", false, "invalid", "", "", "", "") - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown file type") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "unknown file type") }) t.Run("adds owner filter with 'me'", func(t *testing.T) { query, err := buildSearchQuery("", false, "", "me", "", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "'me' in owners") + testutil.NoError(t, err) + testutil.Contains(t, query, "'me' in owners") }) t.Run("adds owner filter with email", func(t *testing.T) { query, err := buildSearchQuery("", false, "", "john@example.com", "", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "'john@example.com' in owners") + testutil.NoError(t, err) + testutil.Contains(t, query, "'john@example.com' in owners") }) t.Run("adds modified-after filter", func(t *testing.T) { query, err := buildSearchQuery("", false, "", "", "2024-01-01", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "modifiedTime > '2024-01-01T00:00:00'") + testutil.NoError(t, err) + testutil.Contains(t, query, "modifiedTime > '2024-01-01T00:00:00'") }) t.Run("adds modified-before filter", func(t *testing.T) { query, err := buildSearchQuery("", false, "", "", "", "2024-12-31", "") - assert.NoError(t, err) - assert.Contains(t, query, "modifiedTime < '2024-12-31T23:59:59'") + testutil.NoError(t, err) + testutil.Contains(t, query, "modifiedTime < '2024-12-31T23:59:59'") }) t.Run("adds folder scope", func(t *testing.T) { query, err := buildSearchQuery("", false, "", "", "", "", "folder123") - assert.NoError(t, err) - assert.Contains(t, query, "'folder123' in parents") + testutil.NoError(t, err) + testutil.Contains(t, query, "'folder123' in parents") }) t.Run("combines multiple filters", func(t *testing.T) { query, err := buildSearchQuery("report", false, "document", "me", "2024-01-01", "", "folder123") - assert.NoError(t, err) - assert.Contains(t, query, "trashed = false") - assert.Contains(t, query, "fullText contains 'report'") - assert.Contains(t, query, "mimeType = 'application/vnd.google-apps.document'") - assert.Contains(t, query, "'me' in owners") - assert.Contains(t, query, "modifiedTime > '2024-01-01T00:00:00'") - assert.Contains(t, query, "'folder123' in parents") + testutil.NoError(t, err) + testutil.Contains(t, query, "trashed = false") + testutil.Contains(t, query, "fullText contains 'report'") + testutil.Contains(t, query, "mimeType = 'application/vnd.google-apps.document'") + testutil.Contains(t, query, "'me' in owners") + testutil.Contains(t, query, "modifiedTime > '2024-01-01T00:00:00'") + testutil.Contains(t, query, "'folder123' in parents") }) t.Run("builds query with no search term", func(t *testing.T) { query, err := buildSearchQuery("", false, "document", "", "", "", "") - assert.NoError(t, err) - assert.Contains(t, query, "trashed = false") - assert.Contains(t, query, "mimeType") - assert.NotContains(t, query, "fullText") - assert.NotContains(t, query, "name contains") + testutil.NoError(t, err) + testutil.Contains(t, query, "trashed = false") + testutil.Contains(t, query, "mimeType") + testutil.NotContains(t, query, "fullText") + testutil.NotContains(t, query, "name contains") }) } func TestEscapeQueryString(t *testing.T) { t.Run("escapes single quotes", func(t *testing.T) { result := escapeQueryString("it's a test") - assert.Equal(t, "it\\'s a test", result) + testutil.Equal(t, result, "it\\'s a test") }) t.Run("handles string without quotes", func(t *testing.T) { result := escapeQueryString("simple query") - assert.Equal(t, "simple query", result) + testutil.Equal(t, result, "simple query") }) t.Run("handles multiple quotes", func(t *testing.T) { result := escapeQueryString("don't won't can't") - assert.Equal(t, "don\\'t won\\'t can\\'t", result) + testutil.Equal(t, result, "don\\'t won\\'t can\\'t") }) t.Run("handles empty string", func(t *testing.T) { result := escapeQueryString("") - assert.Equal(t, "", result) + testutil.Equal(t, result, "") }) } diff --git a/internal/cmd/drive/tree_test.go b/internal/cmd/drive/tree_test.go index 40c0673..58ed42c 100644 --- a/internal/cmd/drive/tree_test.go +++ b/internal/cmd/drive/tree_test.go @@ -8,51 +8,50 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/open-cli-collective/google-readonly/internal/drive" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestTreeCommand(t *testing.T) { cmd := newTreeCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "tree [folder-id]", cmd.Use) + testutil.Equal(t, cmd.Use, "tree [folder-id]") }) t.Run("accepts zero or one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"folder-id"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"folder-id", "extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has depth flag", func(t *testing.T) { flag := cmd.Flags().Lookup("depth") - assert.NotNil(t, flag) - assert.Equal(t, "d", flag.Shorthand) - assert.Equal(t, "2", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "d") + testutil.Equal(t, flag.DefValue, "2") }) t.Run("has files flag", func(t *testing.T) { flag := cmd.Flags().Lookup("files") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.Contains(t, cmd.Short, "folder structure") + testutil.Contains(t, cmd.Short, "folder structure") }) } @@ -84,7 +83,7 @@ func TestPrintTree(t *testing.T) { printTree(node, "", true) }) - assert.Equal(t, "My Drive\n", output) + testutil.Equal(t, output, "My Drive\n") }) t.Run("prints tree with children", func(t *testing.T) { @@ -102,9 +101,9 @@ func TestPrintTree(t *testing.T) { printTree(node, "", true) }) - assert.Contains(t, output, "My Drive") - assert.Contains(t, output, "├── Documents") - assert.Contains(t, output, "└── Photos") + testutil.Contains(t, output, "My Drive") + testutil.Contains(t, output, "├── Documents") + testutil.Contains(t, output, "└── Photos") }) t.Run("prints nested tree", func(t *testing.T) { @@ -130,11 +129,11 @@ func TestPrintTree(t *testing.T) { printTree(node, "", true) }) - assert.Contains(t, output, "My Drive") - assert.Contains(t, output, "├── Projects") - assert.Contains(t, output, "│ ├── Project A") - assert.Contains(t, output, "│ └── Project B") - assert.Contains(t, output, "└── Documents") + testutil.Contains(t, output, "My Drive") + testutil.Contains(t, output, "├── Projects") + testutil.Contains(t, output, "│ ├── Project A") + testutil.Contains(t, output, "│ └── Project B") + testutil.Contains(t, output, "└── Documents") }) t.Run("prints deeply nested tree", func(t *testing.T) { @@ -165,10 +164,10 @@ func TestPrintTree(t *testing.T) { printTree(node, "", true) }) - assert.Contains(t, output, "Root") - assert.Contains(t, output, "└── Level1") - assert.Contains(t, output, " └── Level2") - assert.Contains(t, output, " └── Level3") + testutil.Contains(t, output, "Root") + testutil.Contains(t, output, "└── Level1") + testutil.Contains(t, output, " └── Level2") + testutil.Contains(t, output, " └── Level3") }) t.Run("handles empty children", func(t *testing.T) { @@ -183,7 +182,7 @@ func TestPrintTree(t *testing.T) { printTree(node, "", true) }) - assert.Equal(t, "Empty Folder\n", output) + testutil.Equal(t, output, "Empty Folder\n") }) } @@ -198,10 +197,10 @@ func TestTreeNode(t *testing.T) { }, } - assert.Equal(t, "abc123", node.ID) - assert.Equal(t, "Test", node.Name) - assert.Equal(t, "Folder", node.Type) - assert.Len(t, node.Children, 1) + testutil.Equal(t, node.ID, "abc123") + testutil.Equal(t, node.Name, "Test") + testutil.Equal(t, node.Type, "Folder") + testutil.Len(t, node.Children, 1) }) t.Run("handles nil children", func(t *testing.T) { @@ -212,7 +211,7 @@ func TestTreeNode(t *testing.T) { Children: nil, } - assert.Nil(t, node.Children) + testutil.Nil(t, node.Children) }) } @@ -279,11 +278,11 @@ func TestBuildTree(t *testing.T) { tree, err := buildTree(mock, "root", 1, false) - assert.NoError(t, err) - assert.Equal(t, "root", tree.ID) - assert.Equal(t, "My Drive", tree.Name) - assert.Equal(t, "Folder", tree.Type) - assert.Len(t, tree.Children, 2) + testutil.NoError(t, err) + testutil.Equal(t, tree.ID, "root") + testutil.Equal(t, tree.Name, "My Drive") + testutil.Equal(t, tree.Type, "Folder") + testutil.Len(t, tree.Children, 2) }) t.Run("builds tree for specific folder", func(t *testing.T) { @@ -299,11 +298,11 @@ func TestBuildTree(t *testing.T) { tree, err := buildTree(mock, "folder123", 1, true) - assert.NoError(t, err) - assert.Equal(t, "folder123", tree.ID) - assert.Equal(t, "My Folder", tree.Name) - assert.Len(t, tree.Children, 1) - assert.Equal(t, "Notes.txt", tree.Children[0].Name) + testutil.NoError(t, err) + testutil.Equal(t, tree.ID, "folder123") + testutil.Equal(t, tree.Name, "My Folder") + testutil.Len(t, tree.Children, 1) + testutil.Equal(t, tree.Children[0].Name, "Notes.txt") }) t.Run("respects depth limit", func(t *testing.T) { @@ -320,11 +319,11 @@ func TestBuildTree(t *testing.T) { // With depth 1, should not recurse into Level1 tree, err := buildTree(mock, "root", 1, false) - assert.NoError(t, err) - assert.Len(t, tree.Children, 1) - assert.Equal(t, "Level1", tree.Children[0].Name) + testutil.NoError(t, err) + testutil.Len(t, tree.Children, 1) + testutil.Equal(t, tree.Children[0].Name, "Level1") // Children of Level1 should be empty due to depth limit - assert.Empty(t, tree.Children[0].Children) + testutil.Len(t, tree.Children[0].Children, 0) }) t.Run("returns node with no children at depth 0", func(t *testing.T) { @@ -335,9 +334,9 @@ func TestBuildTree(t *testing.T) { tree, err := buildTree(mock, "root", 0, false) - assert.NoError(t, err) - assert.Equal(t, "My Drive", tree.Name) - assert.Nil(t, tree.Children) + testutil.NoError(t, err) + testutil.Equal(t, tree.Name, "My Drive") + testutil.Nil(t, tree.Children) }) t.Run("includes files when includeFiles is true", func(t *testing.T) { @@ -350,8 +349,8 @@ func TestBuildTree(t *testing.T) { tree, err := buildTree(mock, "root", 1, true) - assert.NoError(t, err) - assert.Len(t, tree.Children, 2) + testutil.NoError(t, err) + testutil.Len(t, tree.Children, 2) }) t.Run("sorts folders before files", func(t *testing.T) { @@ -364,10 +363,10 @@ func TestBuildTree(t *testing.T) { tree, err := buildTree(mock, "root", 1, true) - assert.NoError(t, err) - assert.Len(t, tree.Children, 2) + testutil.NoError(t, err) + testutil.Len(t, tree.Children, 2) // Folder should come first despite alphabetical order - assert.Equal(t, "zzz-folder", tree.Children[0].Name) - assert.Equal(t, "aaa.txt", tree.Children[1].Name) + testutil.Equal(t, tree.Children[0].Name, "zzz-folder") + testutil.Equal(t, tree.Children[1].Name, "aaa.txt") }) } diff --git a/internal/cmd/initcmd/init_test.go b/internal/cmd/initcmd/init_test.go index f6a9f9b..0d5332d 100644 --- a/internal/cmd/initcmd/init_test.go +++ b/internal/cmd/initcmd/init_test.go @@ -5,38 +5,38 @@ import ( "net/http" "testing" - "github.com/stretchr/testify/assert" "google.golang.org/api/googleapi" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestInitCommand(t *testing.T) { cmd := NewCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "init", cmd.Use) + testutil.Equal(t, cmd.Use, "init") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has no-verify flag", func(t *testing.T) { flag := cmd.Flags().Lookup("no-verify") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "OAuth") + testutil.NotEmpty(t, cmd.Long) + testutil.Contains(t, cmd.Long, "OAuth") }) } @@ -111,7 +111,7 @@ func TestExtractAuthCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := extractAuthCode(tt.input) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } @@ -177,7 +177,7 @@ func TestIsAuthError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isAuthError(tt.err) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } diff --git a/internal/cmd/mail/attachments_download_test.go b/internal/cmd/mail/attachments_download_test.go index 46afaee..6f67c48 100644 --- a/internal/cmd/mail/attachments_download_test.go +++ b/internal/cmd/mail/attachments_download_test.go @@ -4,8 +4,7 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestSafeOutputPath(t *testing.T) { @@ -84,14 +83,14 @@ func TestSafeOutputPath(t *testing.T) { result, err := safeOutputPath(destDir, tt.filename) if tt.expectError { - assert.Error(t, err) + testutil.Error(t, err) if tt.errorMsg != "" { - assert.Contains(t, err.Error(), tt.errorMsg) + testutil.Contains(t, err.Error(), tt.errorMsg) } } else { - require.NoError(t, err) + testutil.NoError(t, err) // Verify the result is within destDir - assert.True(t, filepath.IsAbs(result) || result == filepath.Join(destDir, filepath.Clean(tt.filename))) + testutil.True(t, filepath.IsAbs(result) || result == filepath.Join(destDir, filepath.Clean(tt.filename))) } }) } @@ -110,11 +109,11 @@ func TestSafeOutputPath_StaysWithinDestDir(t *testing.T) { for _, filename := range validCases { t.Run(filename, func(t *testing.T) { result, err := safeOutputPath(destDir, filename) - require.NoError(t, err) + testutil.NoError(t, err) // Result must start with destDir - assert.True(t, len(result) >= len(destDir)) - assert.Equal(t, destDir, result[:len(destDir)]) + testutil.True(t, len(result) >= len(destDir)) + testutil.Equal(t, result[:len(destDir)], destDir) }) } } diff --git a/internal/cmd/mail/attachments_test.go b/internal/cmd/mail/attachments_test.go index 99cc497..e498fe1 100644 --- a/internal/cmd/mail/attachments_test.go +++ b/internal/cmd/mail/attachments_test.go @@ -3,7 +3,7 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestIsZipFile(t *testing.T) { @@ -28,7 +28,7 @@ func TestIsZipFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isZipFile(tt.filename, tt.mimeType) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } @@ -39,19 +39,19 @@ func TestAttachmentsCommand(t *testing.T) { cmd := newAttachmentsCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "attachments", cmd.Use) + testutil.Equal(t, cmd.Use, "attachments") }) t.Run("has subcommands", func(t *testing.T) { subcommands := cmd.Commands() - assert.GreaterOrEqual(t, len(subcommands), 2) + testutil.GreaterOrEqual(t, len(subcommands), 2) var names []string for _, cmd := range subcommands { names = append(names, cmd.Name()) } - assert.Contains(t, names, "list") - assert.Contains(t, names, "download") + testutil.SliceContains(t, names, "list") + testutil.SliceContains(t, names, "download") }) } @@ -59,21 +59,21 @@ func TestListAttachmentsCommand(t *testing.T) { cmd := newListAttachmentsCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "list ", cmd.Use) + testutil.Equal(t, cmd.Use, "list ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"msg123"}) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") }) } @@ -81,15 +81,15 @@ func TestDownloadAttachmentsCommand(t *testing.T) { cmd := newDownloadAttachmentsCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "download ", cmd.Use) + testutil.Equal(t, cmd.Use, "download ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"msg123"}) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("has required flags", func(t *testing.T) { @@ -105,8 +105,8 @@ func TestDownloadAttachmentsCommand(t *testing.T) { for _, f := range flags { flag := cmd.Flags().Lookup(f.name) - assert.NotNil(t, flag, "flag %s should exist", f.name) - assert.Equal(t, f.shorthand, flag.Shorthand, "flag %s should have shorthand %s", f.name, f.shorthand) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, f.shorthand) } }) } diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index 588e06a..ecda257 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -8,8 +8,6 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "google.golang.org/api/gmail/v1" gmailapi "github.com/open-cli-collective/google-readonly/internal/gmail" @@ -21,7 +19,7 @@ func captureOutput(t *testing.T, f func()) string { t.Helper() old := os.Stdout r, w, err := os.Pipe() - require.NoError(t, err) + testutil.NoError(t, err) os.Stdout = w f() @@ -56,8 +54,8 @@ func withFailingClientFactory(f func()) { func TestSearchCommand_Success(t *testing.T) { mock := &testutil.MockGmailClient{ SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { - assert.Equal(t, "is:unread", query) - assert.Equal(t, int64(10), maxResults) + testutil.Equal(t, query, "is:unread") + testutil.Equal(t, maxResults, int64(10)) return testutil.SampleMessages(2), 0, nil }, } @@ -68,13 +66,13 @@ func TestSearchCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) // Verify output contains expected message data - assert.Contains(t, output, "ID: msg_a") - assert.Contains(t, output, "ID: msg_b") - assert.Contains(t, output, "Test Subject") + testutil.Contains(t, output, "ID: msg_a") + testutil.Contains(t, output, "ID: msg_b") + testutil.Contains(t, output, "Test Subject") }) } @@ -91,15 +89,15 @@ func TestSearchCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) // Verify JSON output is valid var messages []*gmailapi.Message err := json.Unmarshal([]byte(output), &messages) - assert.NoError(t, err) - assert.Len(t, messages, 1) - assert.Equal(t, "msg_a", messages[0].ID) + testutil.NoError(t, err) + testutil.Len(t, messages, 1) + testutil.Equal(t, messages[0].ID, "msg_a") }) } @@ -116,10 +114,10 @@ func TestSearchCommand_NoResults(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No messages found") + testutil.Contains(t, output, "No messages found") }) } @@ -135,8 +133,8 @@ func TestSearchCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to search messages") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to search messages") }) } @@ -146,8 +144,8 @@ func TestSearchCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create Gmail client") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to create Gmail client") }) } @@ -164,18 +162,18 @@ func TestSearchCommand_SkippedMessages(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "3 message(s) could not be retrieved") + testutil.Contains(t, output, "3 message(s) could not be retrieved") }) } func TestReadCommand_Success(t *testing.T) { mock := &testutil.MockGmailClient{ GetMessageFunc: func(messageID string, includeBody bool) (*gmailapi.Message, error) { - assert.Equal(t, "msg123", messageID) - assert.True(t, includeBody) + testutil.Equal(t, messageID, "msg123") + testutil.True(t, includeBody) return testutil.SampleMessage("msg123"), nil }, } @@ -186,12 +184,12 @@ func TestReadCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "ID: msg123") - assert.Contains(t, output, "Test Subject") - assert.Contains(t, output, "--- Body ---") + testutil.Contains(t, output, "ID: msg123") + testutil.Contains(t, output, "Test Subject") + testutil.Contains(t, output, "--- Body ---") }) } @@ -208,13 +206,13 @@ func TestReadCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var msg gmailapi.Message err := json.Unmarshal([]byte(output), &msg) - assert.NoError(t, err) - assert.Equal(t, "msg123", msg.ID) + testutil.NoError(t, err) + testutil.Equal(t, msg.ID, "msg123") }) } @@ -230,15 +228,15 @@ func TestReadCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read message") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to read message") }) } func TestThreadCommand_Success(t *testing.T) { mock := &testutil.MockGmailClient{ GetThreadFunc: func(id string) ([]*gmailapi.Message, error) { - assert.Equal(t, "thread123", id) + testutil.Equal(t, id, "thread123") return testutil.SampleMessages(3), nil }, } @@ -249,13 +247,13 @@ func TestThreadCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "Thread contains 3 message(s)") - assert.Contains(t, output, "Message 1 of 3") - assert.Contains(t, output, "Message 2 of 3") - assert.Contains(t, output, "Message 3 of 3") + testutil.Contains(t, output, "Thread contains 3 message(s)") + testutil.Contains(t, output, "Message 1 of 3") + testutil.Contains(t, output, "Message 2 of 3") + testutil.Contains(t, output, "Message 3 of 3") }) } @@ -272,13 +270,13 @@ func TestThreadCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var messages []*gmailapi.Message err := json.Unmarshal([]byte(output), &messages) - assert.NoError(t, err) - assert.Len(t, messages, 2) + testutil.NoError(t, err) + testutil.Len(t, messages, 2) }) } @@ -297,13 +295,13 @@ func TestLabelsCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "NAME") - assert.Contains(t, output, "TYPE") - assert.Contains(t, output, "Work") - assert.Contains(t, output, "user") + testutil.Contains(t, output, "NAME") + testutil.Contains(t, output, "TYPE") + testutil.Contains(t, output, "Work") + testutil.Contains(t, output, "user") }) } @@ -323,13 +321,13 @@ func TestLabelsCommand_JSONOutput(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) var labels []Label err := json.Unmarshal([]byte(output), &labels) - assert.NoError(t, err) - assert.Greater(t, len(labels), 0) + testutil.NoError(t, err) + testutil.Greater(t, len(labels), 0) }) } @@ -348,10 +346,10 @@ func TestLabelsCommand_Empty(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No labels found") + testutil.Contains(t, output, "No labels found") }) } @@ -371,12 +369,12 @@ func TestListAttachmentsCommand_Success(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "2 attachment(s)") - assert.Contains(t, output, "report.pdf") - assert.Contains(t, output, "data.xlsx") + testutil.Contains(t, output, "2 attachment(s)") + testutil.Contains(t, output, "report.pdf") + testutil.Contains(t, output, "data.xlsx") }) } @@ -393,9 +391,9 @@ func TestListAttachmentsCommand_NoAttachments(t *testing.T) { withMockClient(mock, func() { output := captureOutput(t, func() { err := cmd.Execute() - assert.NoError(t, err) + testutil.NoError(t, err) }) - assert.Contains(t, output, "No attachments found") + testutil.Contains(t, output, "No attachments found") }) } diff --git a/internal/cmd/mail/labels_test.go b/internal/cmd/mail/labels_test.go index 6fe9af7..abc52c6 100644 --- a/internal/cmd/mail/labels_test.go +++ b/internal/cmd/mail/labels_test.go @@ -3,90 +3,90 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" gmailapi "google.golang.org/api/gmail/v1" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestLabelsCommand(t *testing.T) { cmd := newLabelsCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "labels", cmd.Use) + testutil.Equal(t, cmd.Use, "labels") }) t.Run("requires no arguments", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"extra"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "label") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "label") }) } func TestGetLabelType(t *testing.T) { t.Run("returns category for CATEGORY_ prefix", func(t *testing.T) { label := &gmailapi.Label{Id: "CATEGORY_UPDATES", Type: "system"} - assert.Equal(t, "category", getLabelType(label)) + testutil.Equal(t, getLabelType(label), "category") }) t.Run("returns category for all category types", func(t *testing.T) { categories := []string{"CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_FORUMS", "CATEGORY_PERSONAL"} for _, id := range categories { label := &gmailapi.Label{Id: id, Type: "system"} - assert.Equal(t, "category", getLabelType(label), "expected category for %s", id) + testutil.Equal(t, getLabelType(label), "category") } }) t.Run("returns system for system type", func(t *testing.T) { label := &gmailapi.Label{Id: "INBOX", Type: "system"} - assert.Equal(t, "system", getLabelType(label)) + testutil.Equal(t, getLabelType(label), "system") }) t.Run("returns user for user type", func(t *testing.T) { label := &gmailapi.Label{Id: "Label_123", Type: "user"} - assert.Equal(t, "user", getLabelType(label)) + testutil.Equal(t, getLabelType(label), "user") }) t.Run("returns user for empty type", func(t *testing.T) { label := &gmailapi.Label{Id: "Label_456", Type: ""} - assert.Equal(t, "user", getLabelType(label)) + testutil.Equal(t, getLabelType(label), "user") }) } func TestLabelTypePriority(t *testing.T) { t.Run("user has highest priority (lowest value)", func(t *testing.T) { - assert.Equal(t, 0, labelTypePriority("user")) + testutil.Equal(t, labelTypePriority("user"), 0) }) t.Run("category is second priority", func(t *testing.T) { - assert.Equal(t, 1, labelTypePriority("category")) + testutil.Equal(t, labelTypePriority("category"), 1) }) t.Run("system is third priority", func(t *testing.T) { - assert.Equal(t, 2, labelTypePriority("system")) + testutil.Equal(t, labelTypePriority("system"), 2) }) t.Run("unknown types have lowest priority", func(t *testing.T) { - assert.Equal(t, 3, labelTypePriority("unknown")) - assert.Equal(t, 3, labelTypePriority("")) + testutil.Equal(t, labelTypePriority("unknown"), 3) + testutil.Equal(t, labelTypePriority(""), 3) }) t.Run("priorities maintain correct sort order", func(t *testing.T) { - assert.Less(t, labelTypePriority("user"), labelTypePriority("category")) - assert.Less(t, labelTypePriority("category"), labelTypePriority("system")) - assert.Less(t, labelTypePriority("system"), labelTypePriority("unknown")) + testutil.Less(t, labelTypePriority("user"), labelTypePriority("category")) + testutil.Less(t, labelTypePriority("category"), labelTypePriority("system")) + testutil.Less(t, labelTypePriority("system"), labelTypePriority("unknown")) }) } diff --git a/internal/cmd/mail/mail_test.go b/internal/cmd/mail/mail_test.go index c2688b3..c4c35e7 100644 --- a/internal/cmd/mail/mail_test.go +++ b/internal/cmd/mail/mail_test.go @@ -3,32 +3,32 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestMailCommand(t *testing.T) { cmd := NewCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "mail", cmd.Use) + testutil.Equal(t, cmd.Use, "mail") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) + testutil.NotEmpty(t, cmd.Short) }) t.Run("has subcommands", func(t *testing.T) { subcommands := cmd.Commands() - assert.GreaterOrEqual(t, len(subcommands), 5) + testutil.GreaterOrEqual(t, len(subcommands), 5) var names []string for _, sub := range subcommands { names = append(names, sub.Name()) } - assert.Contains(t, names, "search") - assert.Contains(t, names, "read") - assert.Contains(t, names, "thread") - assert.Contains(t, names, "labels") - assert.Contains(t, names, "attachments") + testutil.SliceContains(t, names, "search") + testutil.SliceContains(t, names, "read") + testutil.SliceContains(t, names, "thread") + testutil.SliceContains(t, names, "labels") + testutil.SliceContains(t, names, "attachments") }) } diff --git a/internal/cmd/mail/output_test.go b/internal/cmd/mail/output_test.go index f059ec3..028321c 100644 --- a/internal/cmd/mail/output_test.go +++ b/internal/cmd/mail/output_test.go @@ -3,16 +3,16 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestMessagePrintOptions(t *testing.T) { t.Run("default options are all false", func(t *testing.T) { opts := MessagePrintOptions{} - assert.False(t, opts.IncludeThreadID) - assert.False(t, opts.IncludeTo) - assert.False(t, opts.IncludeSnippet) - assert.False(t, opts.IncludeBody) + testutil.False(t, opts.IncludeThreadID) + testutil.False(t, opts.IncludeTo) + testutil.False(t, opts.IncludeSnippet) + testutil.False(t, opts.IncludeBody) }) t.Run("options can be set individually", func(t *testing.T) { @@ -20,9 +20,9 @@ func TestMessagePrintOptions(t *testing.T) { IncludeThreadID: true, IncludeBody: true, } - assert.True(t, opts.IncludeThreadID) - assert.False(t, opts.IncludeTo) - assert.False(t, opts.IncludeSnippet) - assert.True(t, opts.IncludeBody) + testutil.True(t, opts.IncludeThreadID) + testutil.False(t, opts.IncludeTo) + testutil.False(t, opts.IncludeSnippet) + testutil.True(t, opts.IncludeBody) }) } diff --git a/internal/cmd/mail/read_test.go b/internal/cmd/mail/read_test.go index 8f741cf..27bb594 100644 --- a/internal/cmd/mail/read_test.go +++ b/internal/cmd/mail/read_test.go @@ -3,40 +3,40 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestReadCommand(t *testing.T) { cmd := newReadCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "read ", cmd.Use) + testutil.Equal(t, cmd.Use, "read ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"msg123"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"msg1", "msg2"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "message") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "message") }) t.Run("long description mentions message ID source", func(t *testing.T) { - assert.Contains(t, cmd.Long, "search") + testutil.Contains(t, cmd.Long, "search") }) } diff --git a/internal/cmd/mail/sanitize_test.go b/internal/cmd/mail/sanitize_test.go index e662ff0..074cf35 100644 --- a/internal/cmd/mail/sanitize_test.go +++ b/internal/cmd/mail/sanitize_test.go @@ -3,7 +3,7 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestSanitizeOutput(t *testing.T) { @@ -122,7 +122,7 @@ func TestSanitizeOutput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := SanitizeOutput(tt.input) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } @@ -183,7 +183,7 @@ func TestSanitizeFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := SanitizeFilename(tt.input) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } @@ -214,7 +214,7 @@ func TestSanitizeOutput_RealWorldExamples(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := SanitizeOutput(tt.input) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } diff --git a/internal/cmd/mail/search_test.go b/internal/cmd/mail/search_test.go index 15a1f52..a00526b 100644 --- a/internal/cmd/mail/search_test.go +++ b/internal/cmd/mail/search_test.go @@ -3,44 +3,44 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestSearchCommand(t *testing.T) { cmd := newSearchCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "search ", cmd.Use) + testutil.Equal(t, cmd.Use, "search ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"query"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"query1", "query2"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has max flag", func(t *testing.T) { flag := cmd.Flags().Lookup("max") - assert.NotNil(t, flag) - assert.Equal(t, "m", flag.Shorthand) - assert.Equal(t, "10", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "m") + testutil.Equal(t, flag.DefValue, "10") }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has examples in long description", func(t *testing.T) { - assert.Contains(t, cmd.Long, "from:") - assert.Contains(t, cmd.Long, "subject:") - assert.Contains(t, cmd.Long, "is:unread") + testutil.Contains(t, cmd.Long, "from:") + testutil.Contains(t, cmd.Long, "subject:") + testutil.Contains(t, cmd.Long, "is:unread") }) } diff --git a/internal/cmd/mail/thread_test.go b/internal/cmd/mail/thread_test.go index 47f9749..b608f0e 100644 --- a/internal/cmd/mail/thread_test.go +++ b/internal/cmd/mail/thread_test.go @@ -3,41 +3,41 @@ package mail import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestThreadCommand(t *testing.T) { cmd := newThreadCommand() t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "thread ", cmd.Use) + testutil.Equal(t, cmd.Use, "thread ") }) t.Run("requires exactly one argument", func(t *testing.T) { err := cmd.Args(cmd, []string{}) - assert.Error(t, err) + testutil.Error(t, err) err = cmd.Args(cmd, []string{"thread123"}) - assert.NoError(t, err) + testutil.NoError(t, err) err = cmd.Args(cmd, []string{"thread1", "thread2"}) - assert.Error(t, err) + testutil.Error(t, err) }) t.Run("has json flag", func(t *testing.T) { flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "j", flag.Shorthand) - assert.Equal(t, "false", flag.DefValue) + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "j") + testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "thread") + testutil.NotEmpty(t, cmd.Short) + testutil.Contains(t, cmd.Short, "thread") }) t.Run("long description explains thread ID", func(t *testing.T) { - assert.Contains(t, cmd.Long, "thread ID") - assert.Contains(t, cmd.Long, "message ID") + testutil.Contains(t, cmd.Long, "thread ID") + testutil.Contains(t, cmd.Long, "message ID") }) } diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index e051192..50f7d92 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -3,39 +3,39 @@ package root import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestRootCommand(t *testing.T) { t.Run("has correct use", func(t *testing.T) { - assert.Equal(t, "gro", rootCmd.Use) + testutil.Equal(t, rootCmd.Use, "gro") }) t.Run("has short description", func(t *testing.T) { - assert.NotEmpty(t, rootCmd.Short) + testutil.NotEmpty(t, rootCmd.Short) }) t.Run("has long description", func(t *testing.T) { - assert.NotEmpty(t, rootCmd.Long) - assert.Contains(t, rootCmd.Long, "read-only") + testutil.NotEmpty(t, rootCmd.Long) + testutil.Contains(t, rootCmd.Long, "read-only") }) t.Run("has version set", func(t *testing.T) { - assert.NotEmpty(t, rootCmd.Version) + testutil.NotEmpty(t, rootCmd.Version) }) t.Run("has subcommands", func(t *testing.T) { subcommands := rootCmd.Commands() - assert.GreaterOrEqual(t, len(subcommands), 5) + testutil.GreaterOrEqual(t, len(subcommands), 5) var names []string for _, sub := range subcommands { names = append(names, sub.Name()) } - assert.Contains(t, names, "init") - assert.Contains(t, names, "config") - assert.Contains(t, names, "mail") - assert.Contains(t, names, "calendar") - assert.Contains(t, names, "contacts") + testutil.SliceContains(t, names, "init") + testutil.SliceContains(t, names, "config") + testutil.SliceContains(t, names, "mail") + testutil.SliceContains(t, names, "calendar") + testutil.SliceContains(t, names, "contacts") }) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f602e51..46687fd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,11 +3,9 @@ package config import ( "os" "path/filepath" + "strings" "testing" "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestGetConfigDir(t *testing.T) { @@ -16,24 +14,36 @@ func TestGetConfigDir(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) dir, err := GetConfigDir() - require.NoError(t, err) - assert.Equal(t, filepath.Join(tmpDir, DirName), dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dir != filepath.Join(tmpDir, DirName) { + t.Errorf("got %v, want %v", dir, filepath.Join(tmpDir, DirName)) + } // Verify directory was created info, err := os.Stat(dir) - require.NoError(t, err) - assert.True(t, info.IsDir()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.IsDir() { + t.Error("got false, want true") + } }) t.Run("uses ~/.config if XDG_CONFIG_HOME not set", func(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "") dir, err := GetConfigDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } home, _ := os.UserHomeDir() expected := filepath.Join(home, ".config", DirName) - assert.Equal(t, expected, dir) + if dir != expected { + t.Errorf("got %v, want %v", dir, expected) + } }) t.Run("creates directory with correct permissions", func(t *testing.T) { @@ -41,11 +51,17 @@ func TestGetConfigDir(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) dir, err := GetConfigDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } info, err := os.Stat(dir) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0700), info.Mode().Perm()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Mode().Perm() != os.FileMode(0700) { + t.Errorf("got %v, want %v", info.Mode().Perm(), os.FileMode(0700)) + } }) } @@ -54,8 +70,12 @@ func TestGetCredentialsPath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) path, err := GetCredentialsPath() - require.NoError(t, err) - assert.Equal(t, filepath.Join(tmpDir, DirName, CredentialsFile), path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != filepath.Join(tmpDir, DirName, CredentialsFile) { + t.Errorf("got %v, want %v", path, filepath.Join(tmpDir, DirName, CredentialsFile)) + } } func TestGetTokenPath(t *testing.T) { @@ -63,13 +83,19 @@ func TestGetTokenPath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) path, err := GetTokenPath() - require.NoError(t, err) - assert.Equal(t, filepath.Join(tmpDir, DirName, TokenFile), path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != filepath.Join(tmpDir, DirName, TokenFile) { + t.Errorf("got %v, want %v", path, filepath.Join(tmpDir, DirName, TokenFile)) + } } func TestShortenPath(t *testing.T) { home, err := os.UserHomeDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } tests := []struct { name string @@ -106,17 +132,29 @@ func TestShortenPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ShortenPath(tt.input) - assert.Equal(t, tt.expected, result) + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } }) } } func TestConstants(t *testing.T) { - assert.Equal(t, "google-readonly", DirName) - assert.Equal(t, "credentials.json", CredentialsFile) - assert.Equal(t, "token.json", TokenFile) - assert.Equal(t, "config.json", ConfigFile) - assert.Equal(t, 24, DefaultCacheTTLHours) + if DirName != "google-readonly" { + t.Errorf("got %v, want %v", DirName, "google-readonly") + } + if CredentialsFile != "credentials.json" { + t.Errorf("got %v, want %v", CredentialsFile, "credentials.json") + } + if TokenFile != "token.json" { + t.Errorf("got %v, want %v", TokenFile, "token.json") + } + if ConfigFile != "config.json" { + t.Errorf("got %v, want %v", ConfigFile, "config.json") + } + if DefaultCacheTTLHours != 24 { + t.Errorf("got %v, want %v", DefaultCacheTTLHours, 24) + } } func TestGetConfigPath(t *testing.T) { @@ -124,8 +162,12 @@ func TestGetConfigPath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) path, err := GetConfigPath() - require.NoError(t, err) - assert.Equal(t, filepath.Join(tmpDir, DirName, ConfigFile), path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != filepath.Join(tmpDir, DirName, ConfigFile) { + t.Errorf("got %v, want %v", path, filepath.Join(tmpDir, DirName, ConfigFile)) + } } func TestLoadConfig(t *testing.T) { @@ -134,8 +176,12 @@ func TestLoadConfig(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) cfg, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, DefaultCacheTTLHours, cfg.CacheTTLHours) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.CacheTTLHours != DefaultCacheTTLHours { + t.Errorf("got %v, want %v", cfg.CacheTTLHours, DefaultCacheTTLHours) + } }) t.Run("loads config from file", func(t *testing.T) { @@ -144,14 +190,22 @@ func TestLoadConfig(t *testing.T) { // Create config directory and file configDir := filepath.Join(tmpDir, DirName) - require.NoError(t, os.MkdirAll(configDir, DirPerm)) + if err := os.MkdirAll(configDir, DirPerm); err != nil { + t.Fatalf("unexpected error: %v", err) + } configData := `{"cache_ttl_hours": 48}` - require.NoError(t, os.WriteFile(filepath.Join(configDir, ConfigFile), []byte(configData), TokenPerm)) + if err := os.WriteFile(filepath.Join(configDir, ConfigFile), []byte(configData), TokenPerm); err != nil { + t.Fatalf("unexpected error: %v", err) + } cfg, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, 48, cfg.CacheTTLHours) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.CacheTTLHours != 48 { + t.Errorf("got %v, want %v", cfg.CacheTTLHours, 48) + } }) t.Run("applies default for zero or negative TTL", func(t *testing.T) { @@ -159,14 +213,22 @@ func TestLoadConfig(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) configDir := filepath.Join(tmpDir, DirName) - require.NoError(t, os.MkdirAll(configDir, DirPerm)) + if err := os.MkdirAll(configDir, DirPerm); err != nil { + t.Fatalf("unexpected error: %v", err) + } configData := `{"cache_ttl_hours": 0}` - require.NoError(t, os.WriteFile(filepath.Join(configDir, ConfigFile), []byte(configData), TokenPerm)) + if err := os.WriteFile(filepath.Join(configDir, ConfigFile), []byte(configData), TokenPerm); err != nil { + t.Fatalf("unexpected error: %v", err) + } cfg, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, DefaultCacheTTLHours, cfg.CacheTTLHours) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.CacheTTLHours != DefaultCacheTTLHours { + t.Errorf("got %v, want %v", cfg.CacheTTLHours, DefaultCacheTTLHours) + } }) t.Run("returns error for invalid JSON", func(t *testing.T) { @@ -174,12 +236,18 @@ func TestLoadConfig(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) configDir := filepath.Join(tmpDir, DirName) - require.NoError(t, os.MkdirAll(configDir, DirPerm)) + if err := os.MkdirAll(configDir, DirPerm); err != nil { + t.Fatalf("unexpected error: %v", err) + } - require.NoError(t, os.WriteFile(filepath.Join(configDir, ConfigFile), []byte("not json"), TokenPerm)) + if err := os.WriteFile(filepath.Join(configDir, ConfigFile), []byte("not json"), TokenPerm); err != nil { + t.Fatalf("unexpected error: %v", err) + } _, err := LoadConfig() - assert.Error(t, err) + if err == nil { + t.Fatal("expected error, got nil") + } }) } @@ -190,13 +258,19 @@ func TestSaveConfig(t *testing.T) { cfg := &Config{CacheTTLHours: 12} err := SaveConfig(cfg) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify file was created path, _ := GetConfigPath() data, err := os.ReadFile(path) - require.NoError(t, err) - assert.Contains(t, string(data), `"cache_ttl_hours": 12`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(data), `"cache_ttl_hours": 12`) { + t.Errorf("expected %q to contain %q", string(data), `"cache_ttl_hours": 12`) + } }) t.Run("overwrites existing config", func(t *testing.T) { @@ -205,16 +279,24 @@ func TestSaveConfig(t *testing.T) { // Save initial config cfg1 := &Config{CacheTTLHours: 12} - require.NoError(t, SaveConfig(cfg1)) + if err := SaveConfig(cfg1); err != nil { + t.Fatalf("unexpected error: %v", err) + } // Save new config cfg2 := &Config{CacheTTLHours: 36} - require.NoError(t, SaveConfig(cfg2)) + if err := SaveConfig(cfg2); err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify new value loaded, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, 36, loaded.CacheTTLHours) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.CacheTTLHours != 36 { + t.Errorf("got %v, want %v", loaded.CacheTTLHours, 36) + } }) } @@ -224,10 +306,14 @@ func TestGetCacheTTL(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) cfg := &Config{CacheTTLHours: 12} - require.NoError(t, SaveConfig(cfg)) + if err := SaveConfig(cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } ttl := GetCacheTTL() - assert.Equal(t, 12*time.Hour, ttl) + if ttl != 12*time.Hour { + t.Errorf("got %v, want %v", ttl, 12*time.Hour) + } }) t.Run("returns default TTL when no config exists", func(t *testing.T) { @@ -235,7 +321,9 @@ func TestGetCacheTTL(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) ttl := GetCacheTTL() - assert.Equal(t, time.Duration(DefaultCacheTTLHours)*time.Hour, ttl) + if ttl != time.Duration(DefaultCacheTTLHours)*time.Hour { + t.Errorf("got %v, want %v", ttl, time.Duration(DefaultCacheTTLHours)*time.Hour) + } }) } @@ -245,10 +333,14 @@ func TestGetCacheTTLHours(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) cfg := &Config{CacheTTLHours: 48} - require.NoError(t, SaveConfig(cfg)) + if err := SaveConfig(cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } hours := GetCacheTTLHours() - assert.Equal(t, 48, hours) + if hours != 48 { + t.Errorf("got %v, want %v", hours, 48) + } }) t.Run("returns default TTL when no config exists", func(t *testing.T) { @@ -256,6 +348,8 @@ func TestGetCacheTTLHours(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) hours := GetCacheTTLHours() - assert.Equal(t, DefaultCacheTTLHours, hours) + if hours != DefaultCacheTTLHours { + t.Errorf("got %v, want %v", hours, DefaultCacheTTLHours) + } }) } diff --git a/internal/contacts/client_test.go b/internal/contacts/client_test.go index eb60418..edb4cef 100644 --- a/internal/contacts/client_test.go +++ b/internal/contacts/client_test.go @@ -2,13 +2,13 @@ package contacts import ( "testing" - - "github.com/stretchr/testify/assert" ) func TestClientStructure(t *testing.T) { t.Run("Client has private service field", func(t *testing.T) { client := &Client{} - assert.Nil(t, client.service) + if client.service != nil { + t.Errorf("got %v, want nil", client.service) + } }) } diff --git a/internal/contacts/contacts_test.go b/internal/contacts/contacts_test.go index 6d420cb..05cf8cd 100644 --- a/internal/contacts/contacts_test.go +++ b/internal/contacts/contacts_test.go @@ -3,7 +3,6 @@ package contacts import ( "testing" - "github.com/stretchr/testify/assert" "google.golang.org/api/people/v1" ) @@ -22,11 +21,21 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Equal(t, "people/c123", contact.ResourceName) - assert.Equal(t, "John Doe", contact.DisplayName) - assert.Len(t, contact.Names, 1) - assert.Equal(t, "John", contact.Names[0].GivenName) - assert.Equal(t, "Doe", contact.Names[0].FamilyName) + if contact.ResourceName != "people/c123" { + t.Errorf("got %v, want %v", contact.ResourceName, "people/c123") + } + if contact.DisplayName != "John Doe" { + t.Errorf("got %v, want %v", contact.DisplayName, "John Doe") + } + if len(contact.Names) != 1 { + t.Errorf("got length %d, want %d", len(contact.Names), 1) + } + if contact.Names[0].GivenName != "John" { + t.Errorf("got %v, want %v", contact.Names[0].GivenName, "John") + } + if contact.Names[0].FamilyName != "Doe" { + t.Errorf("got %v, want %v", contact.Names[0].FamilyName, "Doe") + } }) t.Run("parses contact with email", func(t *testing.T) { @@ -50,12 +59,24 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Len(t, contact.Emails, 2) - assert.Equal(t, "jane@example.com", contact.Emails[0].Value) - assert.Equal(t, "work", contact.Emails[0].Type) - assert.True(t, contact.Emails[0].Primary) - assert.Equal(t, "jane.personal@example.com", contact.Emails[1].Value) - assert.False(t, contact.Emails[1].Primary) + if len(contact.Emails) != 2 { + t.Errorf("got length %d, want %d", len(contact.Emails), 2) + } + if contact.Emails[0].Value != "jane@example.com" { + t.Errorf("got %v, want %v", contact.Emails[0].Value, "jane@example.com") + } + if contact.Emails[0].Type != "work" { + t.Errorf("got %v, want %v", contact.Emails[0].Type, "work") + } + if !contact.Emails[0].Primary { + t.Error("got false, want true") + } + if contact.Emails[1].Value != "jane.personal@example.com" { + t.Errorf("got %v, want %v", contact.Emails[1].Value, "jane.personal@example.com") + } + if contact.Emails[1].Primary { + t.Error("got true, want false") + } }) t.Run("parses contact with phone numbers", func(t *testing.T) { @@ -69,9 +90,15 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Len(t, contact.Phones, 2) - assert.Equal(t, "+1-555-123-4567", contact.Phones[0].Value) - assert.Equal(t, "mobile", contact.Phones[0].Type) + if len(contact.Phones) != 2 { + t.Errorf("got length %d, want %d", len(contact.Phones), 2) + } + if contact.Phones[0].Value != "+1-555-123-4567" { + t.Errorf("got %v, want %v", contact.Phones[0].Value, "+1-555-123-4567") + } + if contact.Phones[0].Type != "mobile" { + t.Errorf("got %v, want %v", contact.Phones[0].Type, "mobile") + } }) t.Run("parses contact with organization", func(t *testing.T) { @@ -88,10 +115,18 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Len(t, contact.Organizations, 1) - assert.Equal(t, "Acme Corp", contact.Organizations[0].Name) - assert.Equal(t, "Software Engineer", contact.Organizations[0].Title) - assert.Equal(t, "Engineering", contact.Organizations[0].Department) + if len(contact.Organizations) != 1 { + t.Errorf("got length %d, want %d", len(contact.Organizations), 1) + } + if contact.Organizations[0].Name != "Acme Corp" { + t.Errorf("got %v, want %v", contact.Organizations[0].Name, "Acme Corp") + } + if contact.Organizations[0].Title != "Software Engineer" { + t.Errorf("got %v, want %v", contact.Organizations[0].Title, "Software Engineer") + } + if contact.Organizations[0].Department != "Engineering" { + t.Errorf("got %v, want %v", contact.Organizations[0].Department, "Engineering") + } }) t.Run("parses contact with address", func(t *testing.T) { @@ -111,10 +146,18 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Len(t, contact.Addresses, 1) - assert.Equal(t, "home", contact.Addresses[0].Type) - assert.Equal(t, "San Francisco", contact.Addresses[0].City) - assert.Equal(t, "94102", contact.Addresses[0].PostalCode) + if len(contact.Addresses) != 1 { + t.Errorf("got length %d, want %d", len(contact.Addresses), 1) + } + if contact.Addresses[0].Type != "home" { + t.Errorf("got %v, want %v", contact.Addresses[0].Type, "home") + } + if contact.Addresses[0].City != "San Francisco" { + t.Errorf("got %v, want %v", contact.Addresses[0].City, "San Francisco") + } + if contact.Addresses[0].PostalCode != "94102" { + t.Errorf("got %v, want %v", contact.Addresses[0].PostalCode, "94102") + } }) t.Run("parses contact with URLs", func(t *testing.T) { @@ -128,8 +171,12 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Len(t, contact.URLs, 2) - assert.Equal(t, "https://linkedin.com/in/johndoe", contact.URLs[0].Value) + if len(contact.URLs) != 2 { + t.Errorf("got length %d, want %d", len(contact.URLs), 2) + } + if contact.URLs[0].Value != "https://linkedin.com/in/johndoe" { + t.Errorf("got %v, want %v", contact.URLs[0].Value, "https://linkedin.com/in/johndoe") + } }) t.Run("parses contact with biography", func(t *testing.T) { @@ -142,7 +189,9 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Equal(t, "A passionate software developer.", contact.Biography) + if contact.Biography != "A passionate software developer." { + t.Errorf("got %v, want %v", contact.Biography, "A passionate software developer.") + } }) t.Run("parses contact with birthday including year", func(t *testing.T) { @@ -155,7 +204,9 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Equal(t, "1990-06-15", contact.Birthday) + if contact.Birthday != "1990-06-15" { + t.Errorf("got %v, want %v", contact.Birthday, "1990-06-15") + } }) t.Run("parses contact with birthday month/day only", func(t *testing.T) { @@ -168,7 +219,9 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Equal(t, "12-25", contact.Birthday) + if contact.Birthday != "12-25" { + t.Errorf("got %v, want %v", contact.Birthday, "12-25") + } }) t.Run("parses contact with photo", func(t *testing.T) { @@ -181,12 +234,16 @@ func TestParseContact(t *testing.T) { contact := ParseContact(p) - assert.Equal(t, "https://example.com/photo.jpg", contact.PhotoURL) + if contact.PhotoURL != "https://example.com/photo.jpg" { + t.Errorf("got %v, want %v", contact.PhotoURL, "https://example.com/photo.jpg") + } }) t.Run("handles nil person", func(t *testing.T) { contact := ParseContact(nil) - assert.Nil(t, contact) + if contact != nil { + t.Errorf("got %v, want nil", contact) + } }) } @@ -201,15 +258,25 @@ func TestParseContactGroup(t *testing.T) { group := ParseContactGroup(g) - assert.Equal(t, "contactGroups/abc123", group.ResourceName) - assert.Equal(t, "Work", group.Name) - assert.Equal(t, "USER_CONTACT_GROUP", group.GroupType) - assert.Equal(t, int64(42), group.MemberCount) + if group.ResourceName != "contactGroups/abc123" { + t.Errorf("got %v, want %v", group.ResourceName, "contactGroups/abc123") + } + if group.Name != "Work" { + t.Errorf("got %v, want %v", group.Name, "Work") + } + if group.GroupType != "USER_CONTACT_GROUP" { + t.Errorf("got %v, want %v", group.GroupType, "USER_CONTACT_GROUP") + } + if group.MemberCount != int64(42) { + t.Errorf("got %v, want %v", group.MemberCount, int64(42)) + } }) t.Run("handles nil group", func(t *testing.T) { group := ParseContactGroup(nil) - assert.Nil(t, group) + if group != nil { + t.Errorf("got %v, want nil", group) + } }) } @@ -219,7 +286,9 @@ func TestContactGetDisplayName(t *testing.T) { ResourceName: "people/c1", DisplayName: "John Doe", } - assert.Equal(t, "John Doe", c.GetDisplayName()) + if c.GetDisplayName() != "John Doe" { + t.Errorf("got %v, want %v", c.GetDisplayName(), "John Doe") + } }) t.Run("falls back to names array", func(t *testing.T) { @@ -229,7 +298,9 @@ func TestContactGetDisplayName(t *testing.T) { {DisplayName: "Jane Smith"}, }, } - assert.Equal(t, "Jane Smith", c.GetDisplayName()) + if c.GetDisplayName() != "Jane Smith" { + t.Errorf("got %v, want %v", c.GetDisplayName(), "Jane Smith") + } }) t.Run("falls back to email", func(t *testing.T) { @@ -239,14 +310,18 @@ func TestContactGetDisplayName(t *testing.T) { {Value: "test@example.com"}, }, } - assert.Equal(t, "test@example.com", c.GetDisplayName()) + if c.GetDisplayName() != "test@example.com" { + t.Errorf("got %v, want %v", c.GetDisplayName(), "test@example.com") + } }) t.Run("falls back to resource name", func(t *testing.T) { c := &Contact{ ResourceName: "people/c4", } - assert.Equal(t, "people/c4", c.GetDisplayName()) + if c.GetDisplayName() != "people/c4" { + t.Errorf("got %v, want %v", c.GetDisplayName(), "people/c4") + } }) } @@ -258,7 +333,9 @@ func TestContactGetPrimaryEmail(t *testing.T) { {Value: "primary@example.com", Primary: true}, }, } - assert.Equal(t, "primary@example.com", c.GetPrimaryEmail()) + if c.GetPrimaryEmail() != "primary@example.com" { + t.Errorf("got %v, want %v", c.GetPrimaryEmail(), "primary@example.com") + } }) t.Run("returns first email when no primary", func(t *testing.T) { @@ -268,12 +345,16 @@ func TestContactGetPrimaryEmail(t *testing.T) { {Value: "second@example.com"}, }, } - assert.Equal(t, "first@example.com", c.GetPrimaryEmail()) + if c.GetPrimaryEmail() != "first@example.com" { + t.Errorf("got %v, want %v", c.GetPrimaryEmail(), "first@example.com") + } }) t.Run("returns empty string when no emails", func(t *testing.T) { c := &Contact{} - assert.Equal(t, "", c.GetPrimaryEmail()) + if c.GetPrimaryEmail() != "" { + t.Errorf("got %v, want %v", c.GetPrimaryEmail(), "") + } }) } @@ -285,12 +366,16 @@ func TestContactGetPrimaryPhone(t *testing.T) { {Value: "+1-555-987-6543"}, }, } - assert.Equal(t, "+1-555-123-4567", c.GetPrimaryPhone()) + if c.GetPrimaryPhone() != "+1-555-123-4567" { + t.Errorf("got %v, want %v", c.GetPrimaryPhone(), "+1-555-123-4567") + } }) t.Run("returns empty string when no phones", func(t *testing.T) { c := &Contact{} - assert.Equal(t, "", c.GetPrimaryPhone()) + if c.GetPrimaryPhone() != "" { + t.Errorf("got %v, want %v", c.GetPrimaryPhone(), "") + } }) } @@ -301,7 +386,9 @@ func TestContactGetOrganization(t *testing.T) { {Name: "Acme Corp", Title: "Engineer"}, }, } - assert.Equal(t, "Acme Corp", c.GetOrganization()) + if c.GetOrganization() != "Acme Corp" { + t.Errorf("got %v, want %v", c.GetOrganization(), "Acme Corp") + } }) t.Run("returns title when no name", func(t *testing.T) { @@ -310,12 +397,16 @@ func TestContactGetOrganization(t *testing.T) { {Title: "Freelance Developer"}, }, } - assert.Equal(t, "Freelance Developer", c.GetOrganization()) + if c.GetOrganization() != "Freelance Developer" { + t.Errorf("got %v, want %v", c.GetOrganization(), "Freelance Developer") + } }) t.Run("returns empty string when no organizations", func(t *testing.T) { c := &Contact{} - assert.Equal(t, "", c.GetOrganization()) + if c.GetOrganization() != "" { + t.Errorf("got %v, want %v", c.GetOrganization(), "") + } }) } @@ -335,7 +426,9 @@ func TestFormatDate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatDate(tt.year, tt.month, tt.day) - assert.Equal(t, tt.expect, result) + if result != tt.expect { + t.Errorf("got %v, want %v", result, tt.expect) + } }) } } @@ -355,7 +448,9 @@ func TestFormatMonthDay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatMonthDay(tt.month, tt.day) - assert.Equal(t, tt.expect, result) + if result != tt.expect { + t.Errorf("got %v, want %v", result, tt.expect) + } }) } } diff --git a/internal/drive/files_test.go b/internal/drive/files_test.go index 698c83f..9218d9f 100644 --- a/internal/drive/files_test.go +++ b/internal/drive/files_test.go @@ -1,9 +1,11 @@ package drive import ( + "reflect" + "slices" + "strings" "testing" - "github.com/stretchr/testify/assert" "google.golang.org/api/drive/v3" ) @@ -23,15 +25,33 @@ func TestParseFile(t *testing.T) { result := ParseFile(f) - assert.Equal(t, "123", result.ID) - assert.Equal(t, "test.txt", result.Name) - assert.Equal(t, "text/plain", result.MimeType) - assert.Equal(t, int64(1024), result.Size) - assert.Equal(t, 2024, result.CreatedTime.Year()) - assert.Equal(t, 2024, result.ModifiedTime.Year()) - assert.Equal(t, []string{"parent1"}, result.Parents) - assert.Equal(t, "https://drive.google.com/file/d/123", result.WebViewLink) - assert.True(t, result.Shared) + if result.ID != "123" { + t.Errorf("got %v, want %v", result.ID, "123") + } + if result.Name != "test.txt" { + t.Errorf("got %v, want %v", result.Name, "test.txt") + } + if result.MimeType != "text/plain" { + t.Errorf("got %v, want %v", result.MimeType, "text/plain") + } + if result.Size != int64(1024) { + t.Errorf("got %v, want %v", result.Size, int64(1024)) + } + if result.CreatedTime.Year() != 2024 { + t.Errorf("got %v, want %v", result.CreatedTime.Year(), 2024) + } + if result.ModifiedTime.Year() != 2024 { + t.Errorf("got %v, want %v", result.ModifiedTime.Year(), 2024) + } + if !reflect.DeepEqual(result.Parents, []string{"parent1"}) { + t.Errorf("got %v, want %v", result.Parents, []string{"parent1"}) + } + if result.WebViewLink != "https://drive.google.com/file/d/123" { + t.Errorf("got %v, want %v", result.WebViewLink, "https://drive.google.com/file/d/123") + } + if !result.Shared { + t.Error("got false, want true") + } }) t.Run("parses file with owners", func(t *testing.T) { @@ -47,7 +67,10 @@ func TestParseFile(t *testing.T) { result := ParseFile(f) - assert.Equal(t, []string{"owner1@example.com", "owner2@example.com"}, result.Owners) + expected := []string{"owner1@example.com", "owner2@example.com"} + if !reflect.DeepEqual(result.Owners, expected) { + t.Errorf("got %v, want %v", result.Owners, expected) + } }) t.Run("handles empty timestamps", func(t *testing.T) { @@ -61,8 +84,12 @@ func TestParseFile(t *testing.T) { result := ParseFile(f) - assert.True(t, result.CreatedTime.IsZero()) - assert.True(t, result.ModifiedTime.IsZero()) + if !result.CreatedTime.IsZero() { + t.Error("got false, want true") + } + if !result.ModifiedTime.IsZero() { + t.Error("got false, want true") + } }) t.Run("handles malformed timestamps", func(t *testing.T) { @@ -76,8 +103,12 @@ func TestParseFile(t *testing.T) { result := ParseFile(f) - assert.True(t, result.CreatedTime.IsZero()) - assert.True(t, result.ModifiedTime.IsZero()) + if !result.CreatedTime.IsZero() { + t.Error("got false, want true") + } + if !result.ModifiedTime.IsZero() { + t.Error("got false, want true") + } }) t.Run("handles nil owners", func(t *testing.T) { @@ -90,7 +121,9 @@ func TestParseFile(t *testing.T) { result := ParseFile(f) - assert.Nil(t, result.Owners) + if result.Owners != nil { + t.Errorf("got %v, want nil", result.Owners) + } }) t.Run("handles empty owners slice", func(t *testing.T) { @@ -103,7 +136,9 @@ func TestParseFile(t *testing.T) { result := ParseFile(f) - assert.Nil(t, result.Owners) + if result.Owners != nil { + t.Errorf("got %v, want nil", result.Owners) + } }) } @@ -142,7 +177,9 @@ func TestGetTypeName(t *testing.T) { for _, tt := range tests { t.Run(tt.mimeType, func(t *testing.T) { result := GetTypeName(tt.mimeType) - assert.Equal(t, tt.expected, result) + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } }) } } @@ -159,7 +196,9 @@ func TestIsGoogleWorkspaceFile(t *testing.T) { } for _, mimeType := range workspaceTypes { - assert.True(t, IsGoogleWorkspaceFile(mimeType), "expected true for %s", mimeType) + if !IsGoogleWorkspaceFile(mimeType) { + t.Errorf("got false, want true for %s", mimeType) + } } }) @@ -175,7 +214,9 @@ func TestIsGoogleWorkspaceFile(t *testing.T) { } for _, mimeType := range nonWorkspaceTypes { - assert.False(t, IsGoogleWorkspaceFile(mimeType), "expected false for %s", mimeType) + if IsGoogleWorkspaceFile(mimeType) { + t.Errorf("got true, want false for %s", mimeType) + } } }) } @@ -196,8 +237,12 @@ func TestGetExportMimeType(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { result, err := GetExportMimeType(MimeTypeDocument, tt.format) - assert.NoError(t, err) - assert.Equal(t, tt.expected, result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } }) } }) @@ -215,61 +260,97 @@ func TestGetExportMimeType(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { result, err := GetExportMimeType(MimeTypeSpreadsheet, tt.format) - assert.NoError(t, err) - assert.Equal(t, tt.expected, result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } }) } }) t.Run("returns correct MIME type for Presentation exports", func(t *testing.T) { result, err := GetExportMimeType(MimeTypePresentation, "pptx") - assert.NoError(t, err) - assert.Equal(t, "application/vnd.openxmlformats-officedocument.presentationml.presentation", result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "application/vnd.openxmlformats-officedocument.presentationml.presentation" { + t.Errorf("got %v, want %v", result, "application/vnd.openxmlformats-officedocument.presentationml.presentation") + } }) t.Run("returns correct MIME type for Drawing exports", func(t *testing.T) { result, err := GetExportMimeType(MimeTypeDrawing, "png") - assert.NoError(t, err) - assert.Equal(t, "image/png", result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/png" { + t.Errorf("got %v, want %v", result, "image/png") + } }) t.Run("returns error for unsupported format", func(t *testing.T) { _, err := GetExportMimeType(MimeTypeDocument, "xyz") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not supported") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "not supported") { + t.Errorf("expected %q to contain %q", err.Error(), "not supported") + } }) t.Run("returns error for non-exportable file type", func(t *testing.T) { _, err := GetExportMimeType("application/pdf", "docx") - assert.Error(t, err) - assert.Contains(t, err.Error(), "does not support export") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "does not support export") { + t.Errorf("expected %q to contain %q", err.Error(), "does not support export") + } }) t.Run("returns error for format not matching file type", func(t *testing.T) { // csv is valid for spreadsheets but not documents _, err := GetExportMimeType(MimeTypeDocument, "csv") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not supported for Google Document") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "not supported for Google Document") { + t.Errorf("expected %q to contain %q", err.Error(), "not supported for Google Document") + } }) } func TestGetSupportedExportFormats(t *testing.T) { t.Run("returns formats for Document", func(t *testing.T) { formats := GetSupportedExportFormats(MimeTypeDocument) - assert.Contains(t, formats, "pdf") - assert.Contains(t, formats, "docx") - assert.Contains(t, formats, "txt") + if !slices.Contains(formats, "pdf") { + t.Errorf("expected formats to contain %q", "pdf") + } + if !slices.Contains(formats, "docx") { + t.Errorf("expected formats to contain %q", "docx") + } + if !slices.Contains(formats, "txt") { + t.Errorf("expected formats to contain %q", "txt") + } }) t.Run("returns formats for Spreadsheet", func(t *testing.T) { formats := GetSupportedExportFormats(MimeTypeSpreadsheet) - assert.Contains(t, formats, "xlsx") - assert.Contains(t, formats, "csv") + if !slices.Contains(formats, "xlsx") { + t.Errorf("expected formats to contain %q", "xlsx") + } + if !slices.Contains(formats, "csv") { + t.Errorf("expected formats to contain %q", "csv") + } }) t.Run("returns nil for non-exportable file", func(t *testing.T) { formats := GetSupportedExportFormats("application/pdf") - assert.Nil(t, formats) + if formats != nil { + t.Errorf("got %v, want nil", formats) + } }) } @@ -295,7 +376,9 @@ func TestGetFileExtension(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { result := GetFileExtension(tt.format) - assert.Equal(t, tt.expected, result) + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } }) } } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index ce1bae4..0157f4a 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestUserError(t *testing.T) { @@ -27,14 +27,14 @@ func TestUserError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.err.Error()) + testutil.Equal(t, tt.err.Error(), tt.expected) }) } } func TestNewUserError(t *testing.T) { err := NewUserError("invalid value: %d", 42) - assert.Equal(t, "invalid value: 42", err.Error()) + testutil.Equal(t, err.Error(), "invalid value: 42") } func TestSystemError(t *testing.T) { @@ -64,7 +64,7 @@ func TestSystemError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.err.Error()) + testutil.Equal(t, tt.err.Error(), tt.expected) }) } } @@ -76,17 +76,17 @@ func TestSystemErrorUnwrap(t *testing.T) { Cause: cause, } - assert.Equal(t, cause, err.Unwrap()) - assert.True(t, errors.Is(err, cause)) + testutil.Equal(t, err.Unwrap(), cause) + testutil.True(t, errors.Is(err, cause)) } func TestNewSystemError(t *testing.T) { cause := errors.New("network timeout") err := NewSystemError("API call failed", cause, true) - assert.Equal(t, "API call failed", err.Message) - assert.Equal(t, cause, err.Cause) - assert.True(t, err.Retryable) + testutil.Equal(t, err.Message, "API call failed") + testutil.Equal(t, err.Cause, cause) + testutil.True(t, err.Retryable) } func TestIsRetryable(t *testing.T) { @@ -119,7 +119,7 @@ func TestIsRetryable(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, IsRetryable(tt.err)) + testutil.Equal(t, IsRetryable(tt.err), tt.expected) }) } } diff --git a/internal/format/format_test.go b/internal/format/format_test.go index 7add5bf..6c126ca 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -3,7 +3,7 @@ package format import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestTruncate(t *testing.T) { @@ -24,7 +24,7 @@ func TestTruncate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := Truncate(tt.input, tt.maxLen) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } @@ -48,7 +48,7 @@ func TestSize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := Size(tt.bytes) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, result, tt.expected) }) } } diff --git a/internal/gmail/attachments_test.go b/internal/gmail/attachments_test.go index efb95a1..eaa7c18 100644 --- a/internal/gmail/attachments_test.go +++ b/internal/gmail/attachments_test.go @@ -3,7 +3,6 @@ package gmail import ( "testing" - "github.com/stretchr/testify/assert" "google.golang.org/api/gmail/v1" ) @@ -13,7 +12,9 @@ func TestFindPart(t *testing.T) { MimeType: "text/plain", } result := findPart(payload, "") - assert.Equal(t, payload, result) + if result != payload { + t.Errorf("got %v, want %v", result, payload) + } }) t.Run("finds part at index 0", func(t *testing.T) { @@ -23,7 +24,9 @@ func TestFindPart(t *testing.T) { Parts: []*gmail.MessagePart{child}, } result := findPart(payload, "0") - assert.Equal(t, child, result) + if result != child { + t.Errorf("got %v, want %v", result, child) + } }) t.Run("finds nested part", func(t *testing.T) { @@ -41,7 +44,9 @@ func TestFindPart(t *testing.T) { }, } result := findPart(payload, "0.1") - assert.Equal(t, deepChild, result) + if result != deepChild { + t.Errorf("got %v, want %v", result, deepChild) + } }) t.Run("returns nil for invalid index", func(t *testing.T) { @@ -50,7 +55,9 @@ func TestFindPart(t *testing.T) { Parts: []*gmail.MessagePart{{MimeType: "text/plain"}}, } result := findPart(payload, "5") - assert.Nil(t, result) + if result != nil { + t.Errorf("got %v, want nil", result) + } }) t.Run("returns nil for negative index", func(t *testing.T) { @@ -59,7 +66,9 @@ func TestFindPart(t *testing.T) { Parts: []*gmail.MessagePart{{MimeType: "text/plain"}}, } result := findPart(payload, "-1") - assert.Nil(t, result) + if result != nil { + t.Errorf("got %v, want nil", result) + } }) t.Run("returns nil for non-numeric path", func(t *testing.T) { @@ -68,7 +77,9 @@ func TestFindPart(t *testing.T) { Parts: []*gmail.MessagePart{{MimeType: "text/plain"}}, } result := findPart(payload, "abc") - assert.Nil(t, result) + if result != nil { + t.Errorf("got %v, want nil", result) + } }) t.Run("returns nil for out of bounds nested path", func(t *testing.T) { @@ -82,7 +93,9 @@ func TestFindPart(t *testing.T) { }, } result := findPart(payload, "0.5") - assert.Nil(t, result) + if result != nil { + t.Errorf("got %v, want nil", result) + } }) t.Run("handles deeply nested path", func(t *testing.T) { @@ -101,6 +114,8 @@ func TestFindPart(t *testing.T) { }, } result := findPart(payload, "0.0.0") - assert.Equal(t, deepest, result) + if result != deepest { + t.Errorf("got %v, want %v", result, deepest) + } }) } diff --git a/internal/gmail/client_test.go b/internal/gmail/client_test.go index 7cf77d7..49b061e 100644 --- a/internal/gmail/client_test.go +++ b/internal/gmail/client_test.go @@ -5,8 +5,6 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" gmailapi "google.golang.org/api/gmail/v1" "github.com/open-cli-collective/google-readonly/internal/auth" @@ -22,8 +20,12 @@ func TestGetLabelName(t *testing.T) { labelsLoaded: true, } - assert.Equal(t, "Work", client.GetLabelName("Label_123")) - assert.Equal(t, "Personal", client.GetLabelName("Label_456")) + if got := client.GetLabelName("Label_123"); got != "Work" { + t.Errorf("got %v, want %v", got, "Work") + } + if got := client.GetLabelName("Label_456"); got != "Personal" { + t.Errorf("got %v, want %v", got, "Personal") + } }) t.Run("returns ID for uncached label", func(t *testing.T) { @@ -32,7 +34,9 @@ func TestGetLabelName(t *testing.T) { labelsLoaded: true, } - assert.Equal(t, "Unknown_Label", client.GetLabelName("Unknown_Label")) + if got := client.GetLabelName("Unknown_Label"); got != "Unknown_Label" { + t.Errorf("got %v, want %v", got, "Unknown_Label") + } }) t.Run("returns ID when labels not loaded", func(t *testing.T) { @@ -41,7 +45,9 @@ func TestGetLabelName(t *testing.T) { labelsLoaded: false, } - assert.Equal(t, "Label_123", client.GetLabelName("Label_123")) + if got := client.GetLabelName("Label_123"); got != "Label_123" { + t.Errorf("got %v, want %v", got, "Label_123") + } }) } @@ -53,7 +59,9 @@ func TestGetLabels(t *testing.T) { } result := client.GetLabels() - assert.Nil(t, result) + if result != nil { + t.Errorf("got %v, want nil", result) + } }) t.Run("returns all cached labels", func(t *testing.T) { @@ -69,9 +77,24 @@ func TestGetLabels(t *testing.T) { } result := client.GetLabels() - assert.Len(t, result, 2) - assert.Contains(t, result, label1) - assert.Contains(t, result, label2) + if len(result) != 2 { + t.Errorf("got length %d, want %d", len(result), 2) + } + found1, found2 := false, false + for _, l := range result { + if l == label1 { + found1 = true + } + if l == label2 { + found2 = true + } + } + if !found1 { + t.Errorf("expected result to contain label1 (Work)") + } + if !found2 { + t.Errorf("expected result to contain label2 (Personal)") + } }) t.Run("returns empty slice for empty cache", func(t *testing.T) { @@ -81,8 +104,12 @@ func TestGetLabels(t *testing.T) { } result := client.GetLabels() - assert.NotNil(t, result) - assert.Empty(t, result) + if result == nil { + t.Fatal("expected non-nil, got nil") + } + if len(result) != 0 { + t.Errorf("got length %d, want 0", len(result)) + } }) } @@ -93,12 +120,18 @@ func TestDeprecatedWrappers(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) gmailDir, err := GetConfigDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } authDir, err := auth.GetConfigDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, authDir, gmailDir) + if gmailDir != authDir { + t.Errorf("got %v, want %v", gmailDir, authDir) + } }) t.Run("GetCredentialsPath delegates to auth package", func(t *testing.T) { @@ -106,23 +139,33 @@ func TestDeprecatedWrappers(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) gmailPath, err := GetCredentialsPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } authPath, err := auth.GetCredentialsPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, authPath, gmailPath) + if gmailPath != authPath { + t.Errorf("got %v, want %v", gmailPath, authPath) + } }) t.Run("ShortenPath delegates to auth package", func(t *testing.T) { home, err := os.UserHomeDir() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } testPath := filepath.Join(home, ".config", "test") gmailResult := ShortenPath(testPath) authResult := auth.ShortenPath(testPath) - assert.Equal(t, authResult, gmailResult) + if gmailResult != authResult { + t.Errorf("got %v, want %v", gmailResult, authResult) + } }) } diff --git a/internal/gmail/messages_test.go b/internal/gmail/messages_test.go index 3537510..38f8edb 100644 --- a/internal/gmail/messages_test.go +++ b/internal/gmail/messages_test.go @@ -2,9 +2,9 @@ package gmail import ( "encoding/base64" + "sort" "testing" - "github.com/stretchr/testify/assert" "google.golang.org/api/gmail/v1" ) @@ -26,13 +26,27 @@ func TestParseMessage(t *testing.T) { result := parseMessage(msg, false, nil) - assert.Equal(t, "msg123", result.ID) - assert.Equal(t, "thread456", result.ThreadID) - assert.Equal(t, "Test Subject", result.Subject) - assert.Equal(t, "alice@example.com", result.From) - assert.Equal(t, "bob@example.com", result.To) - assert.Equal(t, "Mon, 1 Jan 2024 12:00:00 +0000", result.Date) - assert.Equal(t, "This is a test...", result.Snippet) + if result.ID != "msg123" { + t.Errorf("got %v, want %v", result.ID, "msg123") + } + if result.ThreadID != "thread456" { + t.Errorf("got %v, want %v", result.ThreadID, "thread456") + } + if result.Subject != "Test Subject" { + t.Errorf("got %v, want %v", result.Subject, "Test Subject") + } + if result.From != "alice@example.com" { + t.Errorf("got %v, want %v", result.From, "alice@example.com") + } + if result.To != "bob@example.com" { + t.Errorf("got %v, want %v", result.To, "bob@example.com") + } + if result.Date != "Mon, 1 Jan 2024 12:00:00 +0000" { + t.Errorf("got %v, want %v", result.Date, "Mon, 1 Jan 2024 12:00:00 +0000") + } + if result.Snippet != "This is a test..." { + t.Errorf("got %v, want %v", result.Snippet, "This is a test...") + } }) t.Run("extracts thread ID", func(t *testing.T) { @@ -46,8 +60,12 @@ func TestParseMessage(t *testing.T) { result := parseMessage(msg, false, nil) - assert.Equal(t, "msg123", result.ID) - assert.Equal(t, "thread789", result.ThreadID) + if result.ID != "msg123" { + t.Errorf("got %v, want %v", result.ID, "msg123") + } + if result.ThreadID != "thread789" { + t.Errorf("got %v, want %v", result.ThreadID, "thread789") + } }) t.Run("handles nil payload", func(t *testing.T) { @@ -61,12 +79,22 @@ func TestParseMessage(t *testing.T) { result := parseMessage(msg, true, nil) // Should not panic, basic fields populated - assert.Equal(t, "msg123", result.ID) - assert.Equal(t, "thread456", result.ThreadID) - assert.Equal(t, "Preview text", result.Snippet) + if result.ID != "msg123" { + t.Errorf("got %v, want %v", result.ID, "msg123") + } + if result.ThreadID != "thread456" { + t.Errorf("got %v, want %v", result.ThreadID, "thread456") + } + if result.Snippet != "Preview text" { + t.Errorf("got %v, want %v", result.Snippet, "Preview text") + } // Headers won't be extracted - assert.Empty(t, result.Subject) - assert.Empty(t, result.Body) + if result.Subject != "" { + t.Errorf("got %q, want empty", result.Subject) + } + if result.Body != "" { + t.Errorf("got %q, want empty", result.Body) + } }) t.Run("handles case-insensitive headers", func(t *testing.T) { @@ -83,9 +111,15 @@ func TestParseMessage(t *testing.T) { result := parseMessage(msg, false, nil) - assert.Equal(t, "Upper Case", result.Subject) - assert.Equal(t, "lower@example.com", result.From) - assert.Equal(t, "mixed@example.com", result.To) + if result.Subject != "Upper Case" { + t.Errorf("got %v, want %v", result.Subject, "Upper Case") + } + if result.From != "lower@example.com" { + t.Errorf("got %v, want %v", result.From, "lower@example.com") + } + if result.To != "mixed@example.com" { + t.Errorf("got %v, want %v", result.To, "mixed@example.com") + } }) t.Run("handles missing headers gracefully", func(t *testing.T) { @@ -98,11 +132,21 @@ func TestParseMessage(t *testing.T) { result := parseMessage(msg, false, nil) - assert.Equal(t, "msg123", result.ID) - assert.Empty(t, result.Subject) - assert.Empty(t, result.From) - assert.Empty(t, result.To) - assert.Empty(t, result.Date) + if result.ID != "msg123" { + t.Errorf("got %v, want %v", result.ID, "msg123") + } + if result.Subject != "" { + t.Errorf("got %q, want empty", result.Subject) + } + if result.From != "" { + t.Errorf("got %q, want empty", result.From) + } + if result.To != "" { + t.Errorf("got %q, want empty", result.To) + } + if result.Date != "" { + t.Errorf("got %q, want empty", result.Date) + } }) } @@ -119,7 +163,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Equal(t, bodyText, result) + if result != bodyText { + t.Errorf("got %v, want %v", result, bodyText) + } }) t.Run("extracts plain text from multipart message", func(t *testing.T) { @@ -145,7 +191,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Equal(t, bodyText, result) + if result != bodyText { + t.Errorf("got %v, want %v", result, bodyText) + } }) t.Run("falls back to HTML if no plain text", func(t *testing.T) { @@ -160,7 +208,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Equal(t, htmlContent, result) + if result != htmlContent { + t.Errorf("got %v, want %v", result, htmlContent) + } }) t.Run("handles nested multipart", func(t *testing.T) { @@ -185,7 +235,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Equal(t, bodyText, result) + if result != bodyText { + t.Errorf("got %v, want %v", result, bodyText) + } }) t.Run("returns empty string for empty body", func(t *testing.T) { @@ -195,7 +247,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Empty(t, result) + if result != "" { + t.Errorf("got %q, want empty", result) + } }) t.Run("returns empty string for nil body", func(t *testing.T) { @@ -204,7 +258,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Empty(t, result) + if result != "" { + t.Errorf("got %q, want empty", result) + } }) t.Run("handles invalid base64 gracefully", func(t *testing.T) { @@ -216,7 +272,9 @@ func TestExtractBody(t *testing.T) { } result := extractBody(payload) - assert.Empty(t, result) + if result != "" { + t.Errorf("got %q, want empty", result) + } }) } @@ -233,14 +291,30 @@ func TestMessageStruct(t *testing.T) { Body: "Full body content", } - assert.Equal(t, "test-id", msg.ID) - assert.Equal(t, "thread-id", msg.ThreadID) - assert.Equal(t, "Test Subject", msg.Subject) - assert.Equal(t, "from@example.com", msg.From) - assert.Equal(t, "to@example.com", msg.To) - assert.Equal(t, "2024-01-01", msg.Date) - assert.Equal(t, "Preview...", msg.Snippet) - assert.Equal(t, "Full body content", msg.Body) + if msg.ID != "test-id" { + t.Errorf("got %v, want %v", msg.ID, "test-id") + } + if msg.ThreadID != "thread-id" { + t.Errorf("got %v, want %v", msg.ThreadID, "thread-id") + } + if msg.Subject != "Test Subject" { + t.Errorf("got %v, want %v", msg.Subject, "Test Subject") + } + if msg.From != "from@example.com" { + t.Errorf("got %v, want %v", msg.From, "from@example.com") + } + if msg.To != "to@example.com" { + t.Errorf("got %v, want %v", msg.To, "to@example.com") + } + if msg.Date != "2024-01-01" { + t.Errorf("got %v, want %v", msg.Date, "2024-01-01") + } + if msg.Snippet != "Preview..." { + t.Errorf("got %v, want %v", msg.Snippet, "Preview...") + } + if msg.Body != "Full body content" { + t.Errorf("got %v, want %v", msg.Body, "Full body content") + } }) } @@ -263,7 +337,9 @@ func TestParseMessageWithBody(t *testing.T) { } result := parseMessage(msg, true, nil) - assert.Equal(t, bodyText, result.Body) + if result.Body != bodyText { + t.Errorf("got %v, want %v", result.Body, bodyText) + } }) t.Run("excludes body when not requested", func(t *testing.T) { @@ -284,7 +360,9 @@ func TestParseMessageWithBody(t *testing.T) { } result := parseMessage(msg, false, nil) - assert.Empty(t, result.Body) + if result.Body != "" { + t.Errorf("got %q, want empty", result.Body) + } }) } @@ -309,12 +387,24 @@ func TestExtractAttachments(t *testing.T) { } attachments := extractAttachments(payload, "") - assert.Len(t, attachments, 1) - assert.Equal(t, "report.pdf", attachments[0].Filename) - assert.Equal(t, "application/pdf", attachments[0].MimeType) - assert.Equal(t, int64(12345), attachments[0].Size) - assert.Equal(t, "att123", attachments[0].AttachmentID) - assert.Equal(t, "1", attachments[0].PartID) + if len(attachments) != 1 { + t.Errorf("got length %d, want %d", len(attachments), 1) + } + if attachments[0].Filename != "report.pdf" { + t.Errorf("got %v, want %v", attachments[0].Filename, "report.pdf") + } + if attachments[0].MimeType != "application/pdf" { + t.Errorf("got %v, want %v", attachments[0].MimeType, "application/pdf") + } + if attachments[0].Size != int64(12345) { + t.Errorf("got %v, want %v", attachments[0].Size, int64(12345)) + } + if attachments[0].AttachmentID != "att123" { + t.Errorf("got %v, want %v", attachments[0].AttachmentID, "att123") + } + if attachments[0].PartID != "1" { + t.Errorf("got %v, want %v", attachments[0].PartID, "1") + } }) t.Run("detects attachment by Content-Disposition header", func(t *testing.T) { @@ -333,9 +423,15 @@ func TestExtractAttachments(t *testing.T) { } attachments := extractAttachments(payload, "") - assert.Len(t, attachments, 1) - assert.Equal(t, "data.csv", attachments[0].Filename) - assert.False(t, attachments[0].IsInline) + if len(attachments) != 1 { + t.Errorf("got length %d, want %d", len(attachments), 1) + } + if attachments[0].Filename != "data.csv" { + t.Errorf("got %v, want %v", attachments[0].Filename, "data.csv") + } + if attachments[0].IsInline { + t.Error("got true, want false") + } }) t.Run("detects inline attachment", func(t *testing.T) { @@ -354,9 +450,15 @@ func TestExtractAttachments(t *testing.T) { } attachments := extractAttachments(payload, "") - assert.Len(t, attachments, 1) - assert.Equal(t, "image.png", attachments[0].Filename) - assert.True(t, attachments[0].IsInline) + if len(attachments) != 1 { + t.Errorf("got length %d, want %d", len(attachments), 1) + } + if attachments[0].Filename != "image.png" { + t.Errorf("got %v, want %v", attachments[0].Filename, "image.png") + } + if !attachments[0].IsInline { + t.Error("got false, want true") + } }) t.Run("handles nested multipart with multiple attachments", func(t *testing.T) { @@ -384,11 +486,21 @@ func TestExtractAttachments(t *testing.T) { } attachments := extractAttachments(payload, "") - assert.Len(t, attachments, 2) - assert.Equal(t, "doc1.pdf", attachments[0].Filename) - assert.Equal(t, "1", attachments[0].PartID) - assert.Equal(t, "doc2.pdf", attachments[1].Filename) - assert.Equal(t, "2", attachments[1].PartID) + if len(attachments) != 2 { + t.Errorf("got length %d, want %d", len(attachments), 2) + } + if attachments[0].Filename != "doc1.pdf" { + t.Errorf("got %v, want %v", attachments[0].Filename, "doc1.pdf") + } + if attachments[0].PartID != "1" { + t.Errorf("got %v, want %v", attachments[0].PartID, "1") + } + if attachments[1].Filename != "doc2.pdf" { + t.Errorf("got %v, want %v", attachments[1].Filename, "doc2.pdf") + } + if attachments[1].PartID != "2" { + t.Errorf("got %v, want %v", attachments[1].PartID, "2") + } }) t.Run("handles message with no attachments", func(t *testing.T) { @@ -398,7 +510,9 @@ func TestExtractAttachments(t *testing.T) { } attachments := extractAttachments(payload, "") - assert.Empty(t, attachments) + if len(attachments) != 0 { + t.Errorf("got length %d, want 0", len(attachments)) + } }) t.Run("generates correct part paths for deeply nested", func(t *testing.T) { @@ -425,16 +539,24 @@ func TestExtractAttachments(t *testing.T) { } attachments := extractAttachments(payload, "") - assert.Len(t, attachments, 1) - assert.Equal(t, "nested.png", attachments[0].Filename) - assert.Equal(t, "0.1", attachments[0].PartID) + if len(attachments) != 1 { + t.Errorf("got length %d, want %d", len(attachments), 1) + } + if attachments[0].Filename != "nested.png" { + t.Errorf("got %v, want %v", attachments[0].Filename, "nested.png") + } + if attachments[0].PartID != "0.1" { + t.Errorf("got %v, want %v", attachments[0].PartID, "0.1") + } }) } func TestIsAttachment(t *testing.T) { t.Run("returns true for part with filename", func(t *testing.T) { part := &gmail.MessagePart{Filename: "test.pdf"} - assert.True(t, isAttachment(part)) + if !isAttachment(part) { + t.Error("got false, want true") + } }) t.Run("returns true for Content-Disposition attachment", func(t *testing.T) { @@ -443,7 +565,9 @@ func TestIsAttachment(t *testing.T) { {Name: "Content-Disposition", Value: "attachment; filename=\"test.pdf\""}, }, } - assert.True(t, isAttachment(part)) + if !isAttachment(part) { + t.Error("got false, want true") + } }) t.Run("returns false for plain text part", func(t *testing.T) { @@ -451,7 +575,9 @@ func TestIsAttachment(t *testing.T) { MimeType: "text/plain", Body: &gmail.MessagePartBody{Data: "text"}, } - assert.False(t, isAttachment(part)) + if isAttachment(part) { + t.Error("got true, want false") + } }) t.Run("handles case-insensitive Content-Disposition", func(t *testing.T) { @@ -460,7 +586,9 @@ func TestIsAttachment(t *testing.T) { {Name: "CONTENT-DISPOSITION", Value: "ATTACHMENT"}, }, } - assert.True(t, isAttachment(part)) + if !isAttachment(part) { + t.Error("got false, want true") + } }) } @@ -472,7 +600,9 @@ func TestIsInlineAttachment(t *testing.T) { {Name: "Content-Disposition", Value: "inline; filename=\"image.png\""}, }, } - assert.True(t, isInlineAttachment(part)) + if !isInlineAttachment(part) { + t.Error("got false, want true") + } }) t.Run("returns false for attachment disposition", func(t *testing.T) { @@ -482,12 +612,16 @@ func TestIsInlineAttachment(t *testing.T) { {Name: "Content-Disposition", Value: "attachment; filename=\"doc.pdf\""}, }, } - assert.False(t, isInlineAttachment(part)) + if isInlineAttachment(part) { + t.Error("got true, want false") + } }) t.Run("returns false for no disposition header", func(t *testing.T) { part := &gmail.MessagePart{Filename: "file.txt"} - assert.False(t, isInlineAttachment(part)) + if isInlineAttachment(part) { + t.Error("got true, want false") + } }) } @@ -517,9 +651,15 @@ func TestParseMessageWithAttachments(t *testing.T) { } result := parseMessage(msg, true, nil) - assert.Equal(t, "body text", result.Body) - assert.Len(t, result.Attachments, 1) - assert.Equal(t, "attachment.pdf", result.Attachments[0].Filename) + if result.Body != "body text" { + t.Errorf("got %v, want %v", result.Body, "body text") + } + if len(result.Attachments) != 1 { + t.Errorf("got length %d, want %d", len(result.Attachments), 1) + } + if result.Attachments[0].Filename != "attachment.pdf" { + t.Errorf("got %v, want %v", result.Attachments[0].Filename, "attachment.pdf") + } }) t.Run("does not extract attachments when body not requested", func(t *testing.T) { @@ -538,7 +678,9 @@ func TestParseMessageWithAttachments(t *testing.T) { } result := parseMessage(msg, false, nil) - assert.Empty(t, result.Attachments) + if len(result.Attachments) != 0 { + t.Errorf("got length %d, want 0", len(result.Attachments)) + } }) } @@ -549,8 +691,30 @@ func TestExtractLabelsAndCategories(t *testing.T) { labels, categories := extractLabelsAndCategories(labelIDs, resolver) - assert.ElementsMatch(t, []string{"Label_1", "Label_2"}, labels) - assert.ElementsMatch(t, []string{"updates", "social"}, categories) + sort.Strings(labels) + sort.Strings(categories) + expectedLabels := []string{"Label_1", "Label_2"} + expectedCategories := []string{"social", "updates"} + sort.Strings(expectedLabels) + sort.Strings(expectedCategories) + + if len(labels) != len(expectedLabels) { + t.Fatalf("got labels length %d, want %d", len(labels), len(expectedLabels)) + } + for i := range labels { + if labels[i] != expectedLabels[i] { + t.Errorf("labels[%d]: got %v, want %v", i, labels[i], expectedLabels[i]) + } + } + + if len(categories) != len(expectedCategories) { + t.Fatalf("got categories length %d, want %d", len(categories), len(expectedCategories)) + } + for i := range categories { + if categories[i] != expectedCategories[i] { + t.Errorf("categories[%d]: got %v, want %v", i, categories[i], expectedCategories[i]) + } + } }) t.Run("filters out system labels", func(t *testing.T) { @@ -559,8 +723,12 @@ func TestExtractLabelsAndCategories(t *testing.T) { labels, categories := extractLabelsAndCategories(labelIDs, resolver) - assert.Equal(t, []string{"Label_1"}, labels) - assert.Empty(t, categories) + if len(labels) != 1 || labels[0] != "Label_1" { + t.Errorf("got labels %v, want %v", labels, []string{"Label_1"}) + } + if len(categories) != 0 { + t.Errorf("got categories length %d, want 0", len(categories)) + } }) t.Run("filters out CATEGORY_PERSONAL", func(t *testing.T) { @@ -569,8 +737,12 @@ func TestExtractLabelsAndCategories(t *testing.T) { labels, categories := extractLabelsAndCategories(labelIDs, resolver) - assert.Empty(t, labels) - assert.Equal(t, []string{"updates"}, categories) + if len(labels) != 0 { + t.Errorf("got labels length %d, want 0", len(labels)) + } + if len(categories) != 1 || categories[0] != "updates" { + t.Errorf("got categories %v, want %v", categories, []string{"updates"}) + } }) t.Run("uses resolver to translate label IDs", func(t *testing.T) { @@ -587,8 +759,21 @@ func TestExtractLabelsAndCategories(t *testing.T) { labels, categories := extractLabelsAndCategories(labelIDs, resolver) - assert.ElementsMatch(t, []string{"Work", "Personal"}, labels) - assert.Empty(t, categories) + sort.Strings(labels) + expectedLabels := []string{"Personal", "Work"} + sort.Strings(expectedLabels) + + if len(labels) != len(expectedLabels) { + t.Fatalf("got labels length %d, want %d", len(labels), len(expectedLabels)) + } + for i := range labels { + if labels[i] != expectedLabels[i] { + t.Errorf("labels[%d]: got %v, want %v", i, labels[i], expectedLabels[i]) + } + } + if len(categories) != 0 { + t.Errorf("got categories length %d, want 0", len(categories)) + } }) t.Run("handles nil resolver", func(t *testing.T) { @@ -596,22 +781,34 @@ func TestExtractLabelsAndCategories(t *testing.T) { labels, categories := extractLabelsAndCategories(labelIDs, nil) - assert.Equal(t, []string{"Label_1"}, labels) - assert.Equal(t, []string{"social"}, categories) + if len(labels) != 1 || labels[0] != "Label_1" { + t.Errorf("got labels %v, want %v", labels, []string{"Label_1"}) + } + if len(categories) != 1 || categories[0] != "social" { + t.Errorf("got categories %v, want %v", categories, []string{"social"}) + } }) t.Run("handles empty label IDs", func(t *testing.T) { labels, categories := extractLabelsAndCategories([]string{}, nil) - assert.Empty(t, labels) - assert.Empty(t, categories) + if len(labels) != 0 { + t.Errorf("got labels length %d, want 0", len(labels)) + } + if len(categories) != 0 { + t.Errorf("got categories length %d, want 0", len(categories)) + } }) t.Run("handles nil label IDs", func(t *testing.T) { labels, categories := extractLabelsAndCategories(nil, nil) - assert.Empty(t, labels) - assert.Empty(t, categories) + if len(labels) != 0 { + t.Errorf("got labels length %d, want 0", len(labels)) + } + if len(categories) != 0 { + t.Errorf("got categories length %d, want 0", len(categories)) + } }) } @@ -635,8 +832,12 @@ func TestParseMessageWithLabels(t *testing.T) { result := parseMessage(msg, false, resolver) - assert.Equal(t, []string{"Work"}, result.Labels) - assert.Equal(t, []string{"updates"}, result.Categories) + if len(result.Labels) != 1 || result.Labels[0] != "Work" { + t.Errorf("got labels %v, want %v", result.Labels, []string{"Work"}) + } + if len(result.Categories) != 1 || result.Categories[0] != "updates" { + t.Errorf("got categories %v, want %v", result.Categories, []string{"updates"}) + } }) t.Run("handles message with no labels", func(t *testing.T) { @@ -650,7 +851,11 @@ func TestParseMessageWithLabels(t *testing.T) { result := parseMessage(msg, false, nil) - assert.Empty(t, result.Labels) - assert.Empty(t, result.Categories) + if len(result.Labels) != 0 { + t.Errorf("got labels length %d, want 0", len(result.Labels)) + } + if len(result.Categories) != 0 { + t.Errorf("got categories length %d, want 0", len(result.Categories)) + } }) } diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 6adcb30..0e6ed01 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -2,13 +2,14 @@ package keychain import ( "encoding/json" + "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/oauth2" "github.com/open-cli-collective/google-readonly/internal/config" @@ -33,17 +34,29 @@ func TestConfigFile_TokenRoundTrip(t *testing.T) { // Store token err := setInConfigFile(token) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Retrieve token retrieved, err := getFromConfigFile() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, token.AccessToken, retrieved.AccessToken) - assert.Equal(t, token.RefreshToken, retrieved.RefreshToken) - assert.Equal(t, token.TokenType, retrieved.TokenType) + if retrieved.AccessToken != token.AccessToken { + t.Errorf("got %v, want %v", retrieved.AccessToken, token.AccessToken) + } + if retrieved.RefreshToken != token.RefreshToken { + t.Errorf("got %v, want %v", retrieved.RefreshToken, token.RefreshToken) + } + if retrieved.TokenType != token.TokenType { + t.Errorf("got %v, want %v", retrieved.TokenType, token.TokenType) + } // Compare times with tolerance for JSON marshaling - assert.WithinDuration(t, token.Expiry, retrieved.Expiry, time.Second) + if diff := token.Expiry.Sub(retrieved.Expiry); diff < -time.Second || diff > time.Second { + t.Errorf("times differ by %v, max allowed %v", diff, time.Second) + } } func TestConfigFile_Permissions(t *testing.T) { @@ -61,15 +74,21 @@ func TestConfigFile_Permissions(t *testing.T) { } err := setInConfigFile(token) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Check file permissions path := filepath.Join(tmpDir, serviceName, config.TokenFile) info, err := os.Stat(path) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify 0600 permissions (read/write for owner only) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) + if info.Mode().Perm() != os.FileMode(0600) { + t.Errorf("got %v, want %v", info.Mode().Perm(), os.FileMode(0600)) + } } func TestConfigFile_DirectoryPermissions(t *testing.T) { @@ -87,15 +106,21 @@ func TestConfigFile_DirectoryPermissions(t *testing.T) { } err := setInConfigFile(token) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Check directory permissions dir := filepath.Join(tmpDir, serviceName) info, err := os.Stat(dir) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify 0700 permissions (read/write/execute for owner only) - assert.Equal(t, os.FileMode(0700), info.Mode().Perm()) + if info.Mode().Perm() != os.FileMode(0700) { + t.Errorf("got %v, want %v", info.Mode().Perm(), os.FileMode(0700)) + } } func TestConfigFile_NotFound(t *testing.T) { @@ -108,7 +133,9 @@ func TestConfigFile_NotFound(t *testing.T) { defer os.Setenv("XDG_CONFIG_HOME", originalXDG) _, err := getFromConfigFile() - assert.ErrorIs(t, err, ErrTokenNotFound) + if !errors.Is(err, ErrTokenNotFound) { + t.Errorf("got %v, want %v", err, ErrTokenNotFound) + } } func TestConfigFile_InvalidJSON(t *testing.T) { @@ -123,16 +150,24 @@ func TestConfigFile_InvalidJSON(t *testing.T) { // Create config directory configDir := filepath.Join(tmpDir, serviceName) err := os.MkdirAll(configDir, 0700) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Write invalid JSON path := filepath.Join(configDir, config.TokenFile) err = os.WriteFile(path, []byte("invalid json"), 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } _, err = getFromConfigFile() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse token file") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to parse token file") { + t.Errorf("expected %q to contain %q", err.Error(), "failed to parse token file") + } } func TestConfigFile_Overwrite(t *testing.T) { @@ -150,7 +185,9 @@ func TestConfigFile_Overwrite(t *testing.T) { TokenType: "Bearer", } err := setInConfigFile(token1) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Store second token token2 := &oauth2.Token{ @@ -158,12 +195,18 @@ func TestConfigFile_Overwrite(t *testing.T) { TokenType: "Bearer", } err = setInConfigFile(token2) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Retrieve should return second token retrieved, err := getFromConfigFile() - require.NoError(t, err) - assert.Equal(t, "second-token", retrieved.AccessToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if retrieved.AccessToken != "second-token" { + t.Errorf("got %v, want %v", retrieved.AccessToken, "second-token") + } } func TestConfigFile_Delete(t *testing.T) { @@ -181,15 +224,21 @@ func TestConfigFile_Delete(t *testing.T) { TokenType: "Bearer", } err := setInConfigFile(token) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Delete token err = deleteFromConfigFile() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Should be gone _, err = getFromConfigFile() - assert.ErrorIs(t, err, ErrTokenNotFound) + if !errors.Is(err, ErrTokenNotFound) { + t.Errorf("got %v, want %v", err, ErrTokenNotFound) + } } func TestConfigFile_DeleteNonExistent(t *testing.T) { @@ -203,13 +252,17 @@ func TestConfigFile_DeleteNonExistent(t *testing.T) { // Delete should not error on non-existent file err := deleteFromConfigFile() - assert.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } } func TestMigrateFromFile_NoFile(t *testing.T) { // Migration should succeed (no-op) when file doesn't exist err := MigrateFromFile("/nonexistent/path/token.json") - assert.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } } func TestMigrateFromFile_InvalidJSON(t *testing.T) { @@ -232,7 +285,9 @@ func TestMigrateFromFile_InvalidJSON(t *testing.T) { // Create temp file with invalid JSON tokenPath := filepath.Join(tmpDir, "token.json") err := os.WriteFile(tokenPath, []byte("invalid json"), 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // If secure storage has a token (e.g., from real keychain), migration is skipped // In that case, we test the direct file parsing instead @@ -241,8 +296,12 @@ func TestMigrateFromFile_InvalidJSON(t *testing.T) { } err = MigrateFromFile(tokenPath) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse token file") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to parse token file") { + t.Errorf("expected %q to contain %q", err.Error(), "failed to parse token file") + } } func TestMigrateFromFile_Success(t *testing.T) { @@ -273,30 +332,44 @@ func TestMigrateFromFile_Success(t *testing.T) { } tokenPath := filepath.Join(tmpDir, "token.json") data, err := json.Marshal(token) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } err = os.WriteFile(tokenPath, data, 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Run migration err = MigrateFromFile(tokenPath) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify token was stored (uses GetToken to check all backends) retrieved, err := GetToken() - require.NoError(t, err) - assert.Equal(t, "migrated-token", retrieved.AccessToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if retrieved.AccessToken != "migrated-token" { + t.Errorf("got %v, want %v", retrieved.AccessToken, "migrated-token") + } // Clean up: delete the token we just stored defer DeleteToken() // Verify original file was securely deleted (not renamed to backup) _, err = os.Stat(tokenPath) - assert.True(t, os.IsNotExist(err), "original token file should be deleted") + if !os.IsNotExist(err) { + t.Error("original token file should be deleted") + } // Verify no backup file was created (secure delete, not rename) backupPath := tokenPath + ".backup" _, err = os.Stat(backupPath) - assert.True(t, os.IsNotExist(err), "backup file should not exist (secure delete)") + if !os.IsNotExist(err) { + t.Error("backup file should not exist (secure delete)") + } } func TestHasStoredToken_ConfigFile(t *testing.T) { @@ -314,7 +387,9 @@ func TestHasStoredToken_ConfigFile(t *testing.T) { // Should return error when no token file _, err := getFromConfigFile() - assert.ErrorIs(t, err, ErrTokenNotFound) + if !errors.Is(err, ErrTokenNotFound) { + t.Errorf("got %v, want %v", err, ErrTokenNotFound) + } // Store a token in config file token := &oauth2.Token{ @@ -322,24 +397,40 @@ func TestHasStoredToken_ConfigFile(t *testing.T) { TokenType: "Bearer", } err = setInConfigFile(token) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Should successfully retrieve from config file retrieved, err := getFromConfigFile() - require.NoError(t, err) - assert.Equal(t, "test-token", retrieved.AccessToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if retrieved.AccessToken != "test-token" { + t.Errorf("got %v, want %v", retrieved.AccessToken, "test-token") + } } func TestGetStorageBackend(t *testing.T) { // Just verify it returns a valid backend backend := GetStorageBackend() - assert.Contains(t, []StorageBackend{BackendKeychain, BackendSecretTool, BackendFile}, backend) + validBackends := []StorageBackend{BackendKeychain, BackendSecretTool, BackendFile} + found := false + for _, v := range validBackends { + if v == backend { + found = true + break + } + } + if !found { + t.Errorf("got %v, want one of %v", backend, validBackends) + } } func TestIsSecureStorage(t *testing.T) { // This will vary by platform - just verify it returns a bool - result := IsSecureStorage() - assert.IsType(t, true, result) + // Go enforces the type at compile time, so no runtime check needed + _ = IsSecureStorage() } func TestTokenFilePath(t *testing.T) { @@ -348,17 +439,25 @@ func TestTokenFilePath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) path, err := tokenFilePath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } configPath, err := config.GetTokenPath() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - assert.Equal(t, configPath, path) + if path != configPath { + t.Errorf("got %v, want %v", path, configPath) + } } func TestServiceNameConstant(t *testing.T) { // Verify serviceName matches config.DirName - assert.Equal(t, config.DirName, serviceName) + if serviceName != config.DirName { + t.Errorf("got %v, want %v", serviceName, config.DirName) + } } func TestSecureDelete(t *testing.T) { @@ -369,25 +468,35 @@ func TestSecureDelete(t *testing.T) { // Create file with sensitive data sensitiveData := []byte("super secret token data") err := os.WriteFile(path, sensitiveData, 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify file exists _, err = os.Stat(path) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Secure delete err = secureDelete(path) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Verify file is gone _, err = os.Stat(path) - assert.True(t, os.IsNotExist(err)) + if !os.IsNotExist(err) { + t.Error("got false, want true") + } }) t.Run("handles non-existent file", func(t *testing.T) { // Should not error on non-existent file err := secureDelete("/nonexistent/path/file.txt") - assert.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } }) t.Run("overwrites file content before deletion", func(t *testing.T) { @@ -397,50 +506,70 @@ func TestSecureDelete(t *testing.T) { // Create file with known content sensitiveData := []byte("secret123456") err := os.WriteFile(path, sensitiveData, 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Get file size before deletion info, err := os.Stat(path) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } originalSize := info.Size() // Create a copy to verify overwrite behavior // We'll use a custom path that we keep open to observe the overwrite copyPath := filepath.Join(tmpDir, "observe.txt") err = os.WriteFile(copyPath, sensitiveData, 0600) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Open the file to observe content after overwrite but before unlink // This simulates what forensic tools would see f, err := os.OpenFile(copyPath, os.O_RDWR, 0) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Overwrite with zeros (simulating what secureDelete does) zeros := make([]byte, originalSize) _, err = f.Write(zeros) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } _ = f.Sync() // Read back - should be all zeros _, err = f.Seek(0, 0) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } content := make([]byte, originalSize) _, err = f.Read(content) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } f.Close() // Verify content is all zeros for i, b := range content { - assert.Equal(t, byte(0), b, "byte %d should be zero", i) + if b != byte(0) { + t.Errorf("byte %d: got %v, want %v", i, b, byte(0)) + } } // Now test actual secureDelete err = secureDelete(path) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // File should be gone _, err = os.Stat(path) - assert.True(t, os.IsNotExist(err)) + if !os.IsNotExist(err) { + t.Error("got false, want true") + } }) } @@ -476,12 +605,20 @@ func TestPersistentTokenSource_NoChange(t *testing.T) { // Call Token() token, err := pts.Token() - require.NoError(t, err) - assert.Equal(t, "initial-token", token.AccessToken) - assert.Equal(t, 1, mock.calls) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != "initial-token" { + t.Errorf("got %v, want %v", token.AccessToken, "initial-token") + } + if mock.calls != 1 { + t.Errorf("got %v, want %v", mock.calls, 1) + } // current should remain the same (same pointer) - assert.Same(t, initialToken, pts.current) + if pts.current != initialToken { + t.Errorf("expected same pointer, got different") + } } func TestPersistentTokenSource_RefreshUpdatesCurrent(t *testing.T) { @@ -512,13 +649,23 @@ func TestPersistentTokenSource_RefreshUpdatesCurrent(t *testing.T) { // Call Token() - should detect change and update current token, err := pts.Token() - require.NoError(t, err) - assert.Equal(t, "refreshed-token", token.AccessToken) - assert.Equal(t, 1, mock.calls) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != "refreshed-token" { + t.Errorf("got %v, want %v", token.AccessToken, "refreshed-token") + } + if mock.calls != 1 { + t.Errorf("got %v, want %v", mock.calls, 1) + } // Verify current was updated to the refreshed token - assert.Equal(t, "refreshed-token", pts.current.AccessToken) - assert.Equal(t, "new-refresh-token", pts.current.RefreshToken) + if pts.current.AccessToken != "refreshed-token" { + t.Errorf("got %v, want %v", pts.current.AccessToken, "refreshed-token") + } + if pts.current.RefreshToken != "new-refresh-token" { + t.Errorf("got %v, want %v", pts.current.RefreshToken, "new-refresh-token") + } } func TestPersistentTokenSource_NilCurrentUpdatesCurrent(t *testing.T) { @@ -541,19 +688,27 @@ func TestPersistentTokenSource_NilCurrentUpdatesCurrent(t *testing.T) { // Call Token() - should detect as change (nil -> token) and update current token, err := pts.Token() - require.NoError(t, err) - assert.Equal(t, "new-token", token.AccessToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != "new-token" { + t.Errorf("got %v, want %v", token.AccessToken, "new-token") + } // Verify current was set - require.NotNil(t, pts.current) - assert.Equal(t, "new-token", pts.current.AccessToken) + if pts.current == nil { + t.Fatal("expected non-nil, got nil") + } + if pts.current.AccessToken != "new-token" { + t.Errorf("got %v, want %v", pts.current.AccessToken, "new-token") + } } func TestPersistentTokenSource_BaseError(t *testing.T) { // Mock returns an error mock := &mockTokenSource{ token: nil, - err: assert.AnError, + err: fmt.Errorf("mock error"), } initialToken := &oauth2.Token{ @@ -569,12 +724,20 @@ func TestPersistentTokenSource_BaseError(t *testing.T) { // Call Token() - should propagate error token, err := pts.Token() - assert.Error(t, err) - assert.Nil(t, token) - assert.Equal(t, 1, mock.calls) + if err == nil { + t.Fatal("expected error, got nil") + } + if token != nil { + t.Errorf("got %v, want nil", token) + } + if mock.calls != 1 { + t.Errorf("got %v, want %v", mock.calls, 1) + } // current should remain unchanged on error - assert.Equal(t, "initial-token", pts.current.AccessToken) + if pts.current.AccessToken != "initial-token" { + t.Errorf("got %v, want %v", pts.current.AccessToken, "initial-token") + } } func TestPersistentTokenSource_MultipleCalls_NoChange(t *testing.T) { @@ -598,15 +761,23 @@ func TestPersistentTokenSource_MultipleCalls_NoChange(t *testing.T) { // Multiple calls should all succeed for i := 0; i < 3; i++ { token, err := pts.Token() - require.NoError(t, err) - assert.Equal(t, "stable-token", token.AccessToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != "stable-token" { + t.Errorf("got %v, want %v", token.AccessToken, "stable-token") + } } // Verify mock was called 3 times - assert.Equal(t, 3, mock.calls) + if mock.calls != 3 { + t.Errorf("got %v, want %v", mock.calls, 3) + } // current should still be the same - assert.Same(t, stableToken, pts.current) + if pts.current != stableToken { + t.Errorf("expected same pointer, got different") + } } func TestPersistentTokenSource_ChangeDetection(t *testing.T) { @@ -627,24 +798,38 @@ func TestPersistentTokenSource_ChangeDetection(t *testing.T) { // First call: nil -> token1 (change detected) _, err := pts.Token() - require.NoError(t, err) - assert.Equal(t, "token-1", pts.current.AccessToken) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pts.current.AccessToken != "token-1" { + t.Errorf("got %v, want %v", pts.current.AccessToken, "token-1") + } originalCurrent := pts.current // Second call: token1 -> token2 (change detected) mock.token = token2 _, err = pts.Token() - require.NoError(t, err) - assert.Equal(t, "token-2", pts.current.AccessToken) - assert.NotSame(t, originalCurrent, pts.current) // current was updated + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pts.current.AccessToken != "token-2" { + t.Errorf("got %v, want %v", pts.current.AccessToken, "token-2") + } + if pts.current == originalCurrent { + t.Errorf("expected different pointers, got same") + } // Third call: token2 -> token3 (same AccessToken, no change) secondCurrent := pts.current mock.token = token3 _, err = pts.Token() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // current should not have changed since AccessToken is the same - assert.Same(t, secondCurrent, pts.current) + if pts.current != secondCurrent { + t.Errorf("expected same pointer, got different") + } } func TestPersistentTokenSource_ReturnsCorrectToken(t *testing.T) { @@ -660,8 +845,12 @@ func TestPersistentTokenSource_ReturnsCorrectToken(t *testing.T) { } token, err := pts.Token() - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Should return the token from base, not current - assert.Equal(t, "from-base", token.AccessToken) + if token.AccessToken != "from-base" { + t.Errorf("got %v, want %v", token.AccessToken, "from-base") + } } diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 8804071..c9d7991 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" ) func TestDebug_WhenVerboseTrue(t *testing.T) { @@ -29,8 +28,12 @@ func TestDebug_WhenVerboseTrue(t *testing.T) { buf.ReadFrom(r) output := buf.String() - assert.Contains(t, output, "[DEBUG]") - assert.Contains(t, output, "test message 42") + if !strings.Contains(output, "[DEBUG]") { + t.Errorf("expected %q to contain %q", output, "[DEBUG]") + } + if !strings.Contains(output, "test message 42") { + t.Errorf("expected %q to contain %q", output, "test message 42") + } } func TestDebug_WhenVerboseFalse(t *testing.T) { @@ -53,7 +56,9 @@ func TestDebug_WhenVerboseFalse(t *testing.T) { buf.ReadFrom(r) output := buf.String() - assert.Empty(t, output) + if output != "" { + t.Errorf("got %q, want empty string", output) + } } func TestInfo(t *testing.T) { @@ -70,8 +75,12 @@ func TestInfo(t *testing.T) { buf.ReadFrom(r) output := buf.String() - assert.Equal(t, "info message test\n", output) - assert.False(t, strings.Contains(output, "[INFO]")) // No prefix for info + if output != "info message test\n" { + t.Errorf("got %v, want %v", output, "info message test\n") + } + if strings.Contains(output, "[INFO]") { + t.Error("got true, want false") + } // No prefix for info } func TestWarn(t *testing.T) { @@ -88,8 +97,12 @@ func TestWarn(t *testing.T) { buf.ReadFrom(r) output := buf.String() - assert.Contains(t, output, "[WARN]") - assert.Contains(t, output, "warning: something") + if !strings.Contains(output, "[WARN]") { + t.Errorf("expected %q to contain %q", output, "[WARN]") + } + if !strings.Contains(output, "warning: something") { + t.Errorf("expected %q to contain %q", output, "warning: something") + } } func TestError(t *testing.T) { @@ -106,6 +119,10 @@ func TestError(t *testing.T) { buf.ReadFrom(r) output := buf.String() - assert.Contains(t, output, "[ERROR]") - assert.Contains(t, output, "error occurred: failure") + if !strings.Contains(output, "[ERROR]") { + t.Errorf("expected %q to contain %q", output, "[ERROR]") + } + if !strings.Contains(output, "error occurred: failure") { + t.Errorf("expected %q to contain %q", output, "error occurred: failure") + } } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index eb662a8..3494185 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -5,8 +5,7 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestJSON(t *testing.T) { @@ -41,8 +40,8 @@ func TestJSON(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var buf bytes.Buffer err := JSON(&buf, tt.data) - require.NoError(t, err) - assert.Equal(t, tt.expected, buf.String()) + testutil.NoError(t, err) + testutil.Equal(t, buf.String(), tt.expected) }) } } @@ -58,12 +57,12 @@ func TestJSON_indentation(t *testing.T) { var buf bytes.Buffer err := JSON(&buf, data) - require.NoError(t, err) + testutil.NoError(t, err) // Check that indentation uses 2 spaces lines := strings.Split(buf.String(), "\n") - assert.True(t, strings.HasPrefix(lines[1], " "), "expected 2-space indentation") - assert.True(t, strings.HasPrefix(lines[2], " "), "expected 4-space indentation for nested") + testutil.True(t, strings.HasPrefix(lines[1], " ")) + testutil.True(t, strings.HasPrefix(lines[2], " ")) } func TestJSON_error(t *testing.T) { @@ -72,5 +71,5 @@ func TestJSON_error(t *testing.T) { var buf bytes.Buffer err := JSON(&buf, data) - assert.Error(t, err) + testutil.Error(t, err) } diff --git a/internal/testutil/assert.go b/internal/testutil/assert.go index aa66b18..4d5365e 100644 --- a/internal/testutil/assert.go +++ b/internal/testutil/assert.go @@ -2,6 +2,7 @@ package testutil import ( "errors" + "reflect" "strings" "testing" ) @@ -63,11 +64,21 @@ func Len[T any](t testing.TB, slice []T, want int) { } // Nil checks that val is nil. +// Uses reflection to handle nil slices, maps, pointers, channels, and functions +// that appear non-nil when boxed into an interface. func Nil(t testing.TB, val any) { t.Helper() - if val != nil { - t.Errorf("got %v, want nil", val) + if val == nil { + return + } + v := reflect.ValueOf(val) + switch v.Kind() { + case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func, reflect.Interface: + if v.IsNil() { + return + } } + t.Errorf("got %v, want nil", val) } // NotNil fails the test immediately if val is nil. @@ -133,3 +144,23 @@ func Less(t testing.TB, a, b int) { t.Errorf("got %d, want less than %d", a, b) } } + +// SliceContains checks that the slice contains the target value. +func SliceContains[T comparable](t testing.TB, slice []T, target T) { + t.Helper() + for _, v := range slice { + if v == target { + return + } + } + t.Errorf("slice %v does not contain %v", slice, target) +} + +// LenSlice checks that an arbitrary slice has the expected length. +// Use this when Len's type parameter cannot be inferred. +func LenSlice(t testing.TB, length, want int) { + t.Helper() + if length != want { + t.Errorf("got length %d, want %d", length, want) + } +} diff --git a/internal/zip/extract_test.go b/internal/zip/extract_test.go index 6ebfbf8..bfdfb99 100644 --- a/internal/zip/extract_test.go +++ b/internal/zip/extract_test.go @@ -8,25 +8,24 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func createTestZip(t *testing.T, files map[string][]byte) string { t.Helper() tmpFile, err := os.CreateTemp("", "test-*.zip") - require.NoError(t, err) + testutil.NoError(t, err) defer tmpFile.Close() w := zip.NewWriter(tmpFile) for name, content := range files { f, err := w.Create(name) - require.NoError(t, err) + testutil.NoError(t, err) _, err = f.Write(content) - require.NoError(t, err) + testutil.NoError(t, err) } - require.NoError(t, w.Close()) + testutil.NoError(t, w.Close()) return tmpFile.Name() } @@ -41,15 +40,15 @@ func TestExtract(t *testing.T) { destDir := t.TempDir() err := Extract(zipPath, destDir, DefaultOptions()) - require.NoError(t, err) + testutil.NoError(t, err) content1, err := os.ReadFile(filepath.Join(destDir, "file1.txt")) - require.NoError(t, err) - assert.Equal(t, "content 1", string(content1)) + testutil.NoError(t, err) + testutil.Equal(t, string(content1), "content 1") content2, err := os.ReadFile(filepath.Join(destDir, "file2.txt")) - require.NoError(t, err) - assert.Equal(t, "content 2", string(content2)) + testutil.NoError(t, err) + testutil.Equal(t, string(content2), "content 2") }) t.Run("extracts nested directories", func(t *testing.T) { @@ -61,11 +60,11 @@ func TestExtract(t *testing.T) { destDir := t.TempDir() err := Extract(zipPath, destDir, DefaultOptions()) - require.NoError(t, err) + testutil.NoError(t, err) content, err := os.ReadFile(filepath.Join(destDir, "dir1", "dir2", "file2.txt")) - require.NoError(t, err) - assert.Equal(t, "nested 2", string(content)) + testutil.NoError(t, err) + testutil.Equal(t, string(content), "nested 2") }) t.Run("creates destination directory if not exists", func(t *testing.T) { @@ -76,23 +75,23 @@ func TestExtract(t *testing.T) { destDir := filepath.Join(t.TempDir(), "new", "nested", "dir") err := Extract(zipPath, destDir, DefaultOptions()) - require.NoError(t, err) + testutil.NoError(t, err) _, err = os.Stat(filepath.Join(destDir, "test.txt")) - assert.NoError(t, err) + testutil.NoError(t, err) }) t.Run("rejects invalid zip file", func(t *testing.T) { tmpFile, err := os.CreateTemp("", "invalid-*.zip") - require.NoError(t, err) + testutil.NoError(t, err) tmpFile.WriteString("not a zip file") tmpFile.Close() defer os.Remove(tmpFile.Name()) destDir := t.TempDir() err = Extract(tmpFile.Name(), destDir, DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to open zip") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to open zip") }) } @@ -100,7 +99,7 @@ func TestExtractSecurityPathTraversal(t *testing.T) { t.Run("rejects path with leading ..", func(t *testing.T) { // Create a malicious zip with path traversal tmpFile, err := os.CreateTemp("", "malicious-*.zip") - require.NoError(t, err) + testutil.NoError(t, err) defer os.Remove(tmpFile.Name()) w := zip.NewWriter(tmpFile) @@ -110,20 +109,20 @@ func TestExtractSecurityPathTraversal(t *testing.T) { Method: zip.Store, } f, err := w.CreateHeader(header) - require.NoError(t, err) + testutil.NoError(t, err) f.Write([]byte("malicious")) w.Close() tmpFile.Close() destDir := t.TempDir() err = Extract(tmpFile.Name(), destDir, DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid file path") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "invalid file path") }) t.Run("rejects absolute paths", func(t *testing.T) { tmpFile, err := os.CreateTemp("", "malicious-*.zip") - require.NoError(t, err) + testutil.NoError(t, err) defer os.Remove(tmpFile.Name()) w := zip.NewWriter(tmpFile) @@ -132,14 +131,14 @@ func TestExtractSecurityPathTraversal(t *testing.T) { Method: zip.Store, } f, err := w.CreateHeader(header) - require.NoError(t, err) + testutil.NoError(t, err) f.Write([]byte("malicious")) w.Close() tmpFile.Close() destDir := t.TempDir() err = Extract(tmpFile.Name(), destDir, DefaultOptions()) - assert.Error(t, err) + testutil.Error(t, err) }) } @@ -160,8 +159,8 @@ func TestExtractSecurityLimits(t *testing.T) { MaxDepth: MaxDepth, } err := Extract(zipPath, destDir, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "too many files") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "too many files") }) t.Run("rejects file exceeding max size", func(t *testing.T) { @@ -178,8 +177,8 @@ func TestExtractSecurityLimits(t *testing.T) { MaxDepth: MaxDepth, } err := Extract(zipPath, destDir, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "exceeds max size") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "exceeds max size") }) t.Run("rejects total size exceeding limit", func(t *testing.T) { @@ -197,8 +196,8 @@ func TestExtractSecurityLimits(t *testing.T) { MaxDepth: MaxDepth, } err := Extract(zipPath, destDir, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "exceeds limit") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "exceeds limit") }) t.Run("rejects path too deep", func(t *testing.T) { @@ -215,17 +214,17 @@ func TestExtractSecurityLimits(t *testing.T) { MaxDepth: 3, // Less than actual depth } err := Extract(zipPath, destDir, opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "too deep") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "too deep") }) } func TestDefaultOptions(t *testing.T) { opts := DefaultOptions() - assert.Equal(t, int64(MaxFileSize), opts.MaxFileSize) - assert.Equal(t, int64(MaxTotalSize), opts.MaxTotalSize) - assert.Equal(t, MaxFiles, opts.MaxFiles) - assert.Equal(t, MaxDepth, opts.MaxDepth) + testutil.Equal(t, opts.MaxFileSize, int64(MaxFileSize)) + testutil.Equal(t, opts.MaxTotalSize, int64(MaxTotalSize)) + testutil.Equal(t, opts.MaxFiles, MaxFiles) + testutil.Equal(t, opts.MaxDepth, MaxDepth) } func TestValidateZip(t *testing.T) { @@ -236,11 +235,11 @@ func TestValidateZip(t *testing.T) { defer os.Remove(zipPath) r, err := zip.OpenReader(zipPath) - require.NoError(t, err) + testutil.NoError(t, err) defer r.Close() err = validateZip(&r.Reader, DefaultOptions()) - assert.NoError(t, err) + testutil.NoError(t, err) }) } @@ -323,8 +322,8 @@ func TestExtractFileSystemErrors(t *testing.T) { defer os.Remove(zipPath) err := Extract(zipPath, "/tmp/test-dest", DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create destination") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "failed to create destination") }) t.Run("returns error when MkdirAll fails for parent directory", func(t *testing.T) { @@ -340,8 +339,8 @@ func TestExtractFileSystemErrors(t *testing.T) { destDir := t.TempDir() err := Extract(zipPath, destDir, DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "disk full") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "disk full") }) t.Run("returns error when OpenFile fails", func(t *testing.T) { @@ -356,8 +355,8 @@ func TestExtractFileSystemErrors(t *testing.T) { destDir := t.TempDir() err := Extract(zipPath, destDir, DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "too many open files") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "too many open files") }) t.Run("returns error when io.Copy fails", func(t *testing.T) { @@ -375,8 +374,8 @@ func TestExtractFileSystemErrors(t *testing.T) { destDir := t.TempDir() err := Extract(zipPath, destDir, DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "write error") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "write error") }) } @@ -388,7 +387,7 @@ func TestExtractDirectoryEntry(t *testing.T) { t.Run("extracts directory entries", func(t *testing.T) { // Create zip with explicit directory entry tmpFile, err := os.CreateTemp("", "dir-test-*.zip") - require.NoError(t, err) + testutil.NoError(t, err) defer os.Remove(tmpFile.Name()) w := zip.NewWriter(tmpFile) @@ -399,18 +398,18 @@ func TestExtractDirectoryEntry(t *testing.T) { } header.SetMode(os.ModeDir | 0755) _, err = w.CreateHeader(header) - require.NoError(t, err) + testutil.NoError(t, err) w.Close() tmpFile.Close() destDir := t.TempDir() err = Extract(tmpFile.Name(), destDir, DefaultOptions()) - require.NoError(t, err) + testutil.NoError(t, err) // Verify directory was created info, err := os.Stat(filepath.Join(destDir, "mydir")) - require.NoError(t, err) - assert.True(t, info.IsDir()) + testutil.NoError(t, err) + testutil.True(t, info.IsDir()) }) t.Run("returns error when MkdirAll fails for directory entry", func(t *testing.T) { @@ -421,18 +420,18 @@ func TestExtractDirectoryEntry(t *testing.T) { // Create zip with explicit directory entry tmpFile, err := os.CreateTemp("", "dir-test-*.zip") - require.NoError(t, err) + testutil.NoError(t, err) defer os.Remove(tmpFile.Name()) w := zip.NewWriter(tmpFile) _, err = w.Create("mydir/") - require.NoError(t, err) + testutil.NoError(t, err) w.Close() tmpFile.Close() destDir := t.TempDir() err = Extract(tmpFile.Name(), destDir, DefaultOptions()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot create directory") + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "cannot create directory") }) } From d385568fcac7fed26a410e44afa7b9c5252fc79b Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:21:02 -0500 Subject: [PATCH 04/13] refactor: use present participle in error messages Replace all "failed to X" error prefixes with present participle form ("Xing") across 40 source and test files (149 replacements). This follows Go error string conventions where errors describe what was happening when the failure occurred, not that it failed. --- internal/calendar/client.go | 6 +++--- internal/cmd/calendar/events.go | 2 +- internal/cmd/calendar/get.go | 4 ++-- internal/cmd/calendar/handlers_test.go | 6 +++--- internal/cmd/calendar/list.go | 4 ++-- internal/cmd/calendar/today.go | 2 +- internal/cmd/calendar/week.go | 2 +- internal/cmd/config/cache.go | 16 ++++++++-------- internal/cmd/config/config.go | 8 ++++---- internal/cmd/contacts/get.go | 4 ++-- internal/cmd/contacts/groups.go | 4 ++-- internal/cmd/contacts/handlers_test.go | 10 +++++----- internal/cmd/contacts/list.go | 4 ++-- internal/cmd/contacts/search.go | 4 ++-- internal/cmd/drive/download.go | 14 +++++++------- internal/cmd/drive/drives.go | 12 ++++++------ internal/cmd/drive/get.go | 4 ++-- internal/cmd/drive/handlers_test.go | 12 ++++++------ internal/cmd/drive/list.go | 8 ++++---- internal/cmd/drive/search.go | 8 ++++---- internal/cmd/drive/tree.go | 10 +++++----- internal/cmd/initcmd/init.go | 18 +++++++++--------- internal/cmd/mail/attachments_download.go | 8 ++++---- internal/cmd/mail/attachments_list.go | 4 ++-- internal/cmd/mail/handlers_test.go | 6 +++--- internal/cmd/mail/labels.go | 4 ++-- internal/cmd/mail/read.go | 4 ++-- internal/cmd/mail/search.go | 4 ++-- internal/cmd/mail/thread.go | 4 ++-- internal/contacts/client.go | 8 ++++---- internal/drive/client.go | 16 ++++++++-------- internal/gmail/attachments.go | 10 +++++----- internal/gmail/client.go | 4 ++-- internal/gmail/messages.go | 8 ++++---- internal/keychain/keychain.go | 18 +++++++++--------- internal/keychain/keychain_darwin.go | 10 +++++----- internal/keychain/keychain_linux.go | 10 +++++----- internal/keychain/keychain_test.go | 8 ++++---- internal/zip/extract.go | 6 +++--- internal/zip/extract_test.go | 4 ++-- 40 files changed, 149 insertions(+), 149 deletions(-) diff --git a/internal/calendar/client.go b/internal/calendar/client.go index 05d9583..ba9a5e0 100644 --- a/internal/calendar/client.go +++ b/internal/calendar/client.go @@ -36,7 +36,7 @@ func NewClient(ctx context.Context) (*Client, error) { func (c *Client) ListCalendars() ([]*calendar.CalendarListEntry, error) { resp, err := c.service.CalendarList.List().Do() if err != nil { - return nil, fmt.Errorf("failed to list calendars: %w", err) + return nil, fmt.Errorf("listing calendars: %w", err) } return resp.Items, nil } @@ -59,7 +59,7 @@ func (c *Client) ListEvents(calendarID string, timeMin, timeMax string, maxResul resp, err := call.Do() if err != nil { - return nil, fmt.Errorf("failed to list events: %w", err) + return nil, fmt.Errorf("listing events: %w", err) } return resp.Items, nil } @@ -68,7 +68,7 @@ func (c *Client) ListEvents(calendarID string, timeMin, timeMax string, maxResul func (c *Client) GetEvent(calendarID, eventID string) (*calendar.Event, error) { event, err := c.service.Events.Get(calendarID, eventID).Do() if err != nil { - return nil, fmt.Errorf("failed to get event: %w", err) + return nil, fmt.Errorf("getting event: %w", err) } return event, nil } diff --git a/internal/cmd/calendar/events.go b/internal/cmd/calendar/events.go index 7e0c6a7..c2b0c98 100644 --- a/internal/cmd/calendar/events.go +++ b/internal/cmd/calendar/events.go @@ -40,7 +40,7 @@ Examples: client, err := newCalendarClient() if err != nil { - return fmt.Errorf("failed to create Calendar client: %w", err) + return fmt.Errorf("creating Calendar client: %w", err) } // Parse date range diff --git a/internal/cmd/calendar/get.go b/internal/cmd/calendar/get.go index 9648180..54ef26c 100644 --- a/internal/cmd/calendar/get.go +++ b/internal/cmd/calendar/get.go @@ -31,12 +31,12 @@ Examples: client, err := newCalendarClient() if err != nil { - return fmt.Errorf("failed to create Calendar client: %w", err) + return fmt.Errorf("creating Calendar client: %w", err) } event, err := client.GetEvent(calendarID, eventID) if err != nil { - return fmt.Errorf("failed to get event: %w", err) + return fmt.Errorf("getting event: %w", err) } parsedEvent := calendar.ParseEvent(event) diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index 2f2ccac..e6c8d22 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -126,7 +126,7 @@ func TestListCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to list calendars") + testutil.Contains(t, err.Error(), "listing calendars") }) } @@ -136,7 +136,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to create Calendar client") + testutil.Contains(t, err.Error(), "creating Calendar client") }) } @@ -292,7 +292,7 @@ func TestGetCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to get event") + testutil.Contains(t, err.Error(), "getting event") }) } diff --git a/internal/cmd/calendar/list.go b/internal/cmd/calendar/list.go index fa4b3f8..96c37e9 100644 --- a/internal/cmd/calendar/list.go +++ b/internal/cmd/calendar/list.go @@ -25,12 +25,12 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newCalendarClient() if err != nil { - return fmt.Errorf("failed to create Calendar client: %w", err) + return fmt.Errorf("creating Calendar client: %w", err) } calendars, err := client.ListCalendars() if err != nil { - return fmt.Errorf("failed to list calendars: %w", err) + return fmt.Errorf("listing calendars: %w", err) } if len(calendars) == 0 { diff --git a/internal/cmd/calendar/today.go b/internal/cmd/calendar/today.go index cebe6da..c7468a6 100644 --- a/internal/cmd/calendar/today.go +++ b/internal/cmd/calendar/today.go @@ -28,7 +28,7 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newCalendarClient() if err != nil { - return fmt.Errorf("failed to create Calendar client: %w", err) + return fmt.Errorf("creating Calendar client: %w", err) } now := time.Now() diff --git a/internal/cmd/calendar/week.go b/internal/cmd/calendar/week.go index 730912a..5fa051f 100644 --- a/internal/cmd/calendar/week.go +++ b/internal/cmd/calendar/week.go @@ -28,7 +28,7 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newCalendarClient() if err != nil { - return fmt.Errorf("failed to create Calendar client: %w", err) + return fmt.Errorf("creating Calendar client: %w", err) } now := time.Now() diff --git a/internal/cmd/config/cache.go b/internal/cmd/config/cache.go index 7f19c80..716a35c 100644 --- a/internal/cmd/config/cache.go +++ b/internal/cmd/config/cache.go @@ -42,17 +42,17 @@ func newCacheShowCommand() *cobra.Command { RunE: func(_ *cobra.Command, _ []string) error { cfg, err := configpkg.LoadConfig() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("loading config: %w", err) } c, err := cache.New(cfg.CacheTTLHours) if err != nil { - return fmt.Errorf("failed to initialize cache: %w", err) + return fmt.Errorf("initializing cache: %w", err) } status, err := c.GetStatus() if err != nil { - return fmt.Errorf("failed to get cache status: %w", err) + return fmt.Errorf("getting cache status: %w", err) } if jsonOutput { @@ -95,16 +95,16 @@ func newCacheClearCommand() *cobra.Command { RunE: func(_ *cobra.Command, _ []string) error { cfg, err := configpkg.LoadConfig() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("loading config: %w", err) } c, err := cache.New(cfg.CacheTTLHours) if err != nil { - return fmt.Errorf("failed to initialize cache: %w", err) + return fmt.Errorf("initializing cache: %w", err) } if err := c.Clear(); err != nil { - return fmt.Errorf("failed to clear cache: %w", err) + return fmt.Errorf("clearing cache: %w", err) } fmt.Println("Cache cleared.") @@ -134,13 +134,13 @@ Examples: cfg, err := configpkg.LoadConfig() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("loading config: %w", err) } cfg.CacheTTLHours = ttl if err := configpkg.SaveConfig(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) + return fmt.Errorf("saving config: %w", err) } fmt.Printf("Cache TTL set to %d hours.\n", ttl) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index a0369f3..1190761 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -69,7 +69,7 @@ func runShow(_ *cobra.Command, _ []string) error { // Check credentials file credPath, err := gmail.GetCredentialsPath() if err != nil { - return fmt.Errorf("failed to get credentials path: %w", err) + return fmt.Errorf("getting credentials path: %w", err) } credStatus := "OK" @@ -147,7 +147,7 @@ func runTest(_ *cobra.Command, _ []string) error { fmt.Println() fmt.Println("Token may be expired or revoked.") fmt.Println("Run 'gro config clear' then 'gro init' to re-authenticate.") - return fmt.Errorf("failed to create client: %w", err) + return fmt.Errorf("creating client: %w", err) } fmt.Println(" Token valid: OK") @@ -155,7 +155,7 @@ func runTest(_ *cobra.Command, _ []string) error { profile, err := client.GetProfile() if err != nil { fmt.Println(" Gmail API: FAILED") - return fmt.Errorf("failed to access Gmail API: %w", err) + return fmt.Errorf("accessing Gmail API: %w", err) } fmt.Println(" Gmail API: OK") fmt.Printf(" Messages: %d total\n", profile.MessagesTotal) @@ -175,7 +175,7 @@ func runClear(_ *cobra.Command, _ []string) error { backend := keychain.GetStorageBackend() if err := keychain.DeleteToken(); err != nil { - return fmt.Errorf("failed to clear token: %w", err) + return fmt.Errorf("clearing token: %w", err) } fmt.Printf("Cleared OAuth token from %s.\n", backend) diff --git a/internal/cmd/contacts/get.go b/internal/cmd/contacts/get.go index f8578e8..ecc1481 100644 --- a/internal/cmd/contacts/get.go +++ b/internal/cmd/contacts/get.go @@ -28,12 +28,12 @@ Examples: client, err := newContactsClient() if err != nil { - return fmt.Errorf("failed to create Contacts client: %w", err) + return fmt.Errorf("creating Contacts client: %w", err) } person, err := client.GetContact(resourceName) if err != nil { - return fmt.Errorf("failed to get contact: %w", err) + return fmt.Errorf("getting contact: %w", err) } contact := contacts.ParseContact(person) diff --git a/internal/cmd/contacts/groups.go b/internal/cmd/contacts/groups.go index 42297af..9ce61a7 100644 --- a/internal/cmd/contacts/groups.go +++ b/internal/cmd/contacts/groups.go @@ -29,12 +29,12 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newContactsClient() if err != nil { - return fmt.Errorf("failed to create Contacts client: %w", err) + return fmt.Errorf("creating Contacts client: %w", err) } resp, err := client.ListContactGroups("", maxResults) if err != nil { - return fmt.Errorf("failed to list contact groups: %w", err) + return fmt.Errorf("listing contact groups: %w", err) } if len(resp.ContactGroups) == 0 { diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index d379d37..f6495d6 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -137,7 +137,7 @@ func TestListCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to list contacts") + testutil.Contains(t, err.Error(), "listing contacts") }) } @@ -147,7 +147,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to create Contacts client") + testutil.Contains(t, err.Error(), "creating Contacts client") }) } @@ -239,7 +239,7 @@ func TestSearchCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to search contacts") + testutil.Contains(t, err.Error(), "searching contacts") }) } @@ -302,7 +302,7 @@ func TestGetCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to get contact") + testutil.Contains(t, err.Error(), "getting contact") }) } @@ -408,6 +408,6 @@ func TestGroupsCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to list contact groups") + testutil.Contains(t, err.Error(), "listing contact groups") }) } diff --git a/internal/cmd/contacts/list.go b/internal/cmd/contacts/list.go index 711840f..123fadd 100644 --- a/internal/cmd/contacts/list.go +++ b/internal/cmd/contacts/list.go @@ -29,12 +29,12 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newContactsClient() if err != nil { - return fmt.Errorf("failed to create Contacts client: %w", err) + return fmt.Errorf("creating Contacts client: %w", err) } resp, err := client.ListContacts("", maxResults) if err != nil { - return fmt.Errorf("failed to list contacts: %w", err) + return fmt.Errorf("listing contacts: %w", err) } if len(resp.Connections) == 0 { diff --git a/internal/cmd/contacts/search.go b/internal/cmd/contacts/search.go index f8bd452..8fe9045 100644 --- a/internal/cmd/contacts/search.go +++ b/internal/cmd/contacts/search.go @@ -37,12 +37,12 @@ Examples: client, err := newContactsClient() if err != nil { - return fmt.Errorf("failed to create Contacts client: %w", err) + return fmt.Errorf("creating Contacts client: %w", err) } resp, err := client.SearchContacts(query, maxResults) if err != nil { - return fmt.Errorf("failed to search contacts: %w", err) + return fmt.Errorf("searching contacts: %w", err) } if len(resp.Results) == 0 { diff --git a/internal/cmd/drive/download.go b/internal/cmd/drive/download.go index bb884cf..50999b2 100644 --- a/internal/cmd/drive/download.go +++ b/internal/cmd/drive/download.go @@ -44,7 +44,7 @@ Export formats: RunE: func(_ *cobra.Command, args []string) error { client, err := newDriveClient() if err != nil { - return fmt.Errorf("failed to create Drive client: %w", err) + return fmt.Errorf("creating Drive client: %w", err) } fileID := args[0] @@ -52,7 +52,7 @@ Export formats: // Get file metadata first file, err := client.GetFile(fileID) if err != nil { - return fmt.Errorf("failed to get file info: %w", err) + return fmt.Errorf("getting file info: %w", err) } var data []byte @@ -67,7 +67,7 @@ Export formats: exportMime, err := drive.GetExportMimeType(file.MimeType, format) if err != nil { - return fmt.Errorf("failed to get export type: %w", err) + return fmt.Errorf("getting export type: %w", err) } if !stdout { @@ -77,7 +77,7 @@ Export formats: data, err = client.ExportFile(fileID, exportMime) if err != nil { - return fmt.Errorf("failed to export file: %w", err) + return fmt.Errorf("exporting file: %w", err) } } else { // Regular file - download directly @@ -92,7 +92,7 @@ Export formats: data, err = client.DownloadFile(fileID) if err != nil { - return fmt.Errorf("failed to download file: %w", err) + return fmt.Errorf("downloading file: %w", err) } } @@ -100,7 +100,7 @@ Export formats: if stdout { _, err = os.Stdout.Write(data) if err != nil { - return fmt.Errorf("failed to write to stdout: %w", err) + return fmt.Errorf("writing to stdout: %w", err) } return nil } @@ -108,7 +108,7 @@ Export formats: outputPath := determineOutputPath(file.Name, format, output) if err := os.WriteFile(outputPath, data, config.OutputFilePerm); err != nil { - return fmt.Errorf("failed to write file: %w", err) + return fmt.Errorf("writing file: %w", err) } fmt.Printf("Size: %s\n", formatpkg.Size(int64(len(data)))) diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index 431b82f..b0c0ed0 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -34,14 +34,14 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newDriveClient() if err != nil { - return fmt.Errorf("failed to create Drive client: %w", err) + return fmt.Errorf("creating Drive client: %w", err) } // Initialize cache ttl := config.GetCacheTTLHours() c, err := cache.New(ttl) if err != nil { - return fmt.Errorf("failed to initialize cache: %w", err) + return fmt.Errorf("initializing cache: %w", err) } var drives []*drive.SharedDrive @@ -50,7 +50,7 @@ Examples: if !refresh { cached, err := c.GetDrives() if err != nil { - return fmt.Errorf("failed to read cache: %w", err) + return fmt.Errorf("reading cache: %w", err) } if cached != nil { // Convert from cache type to drive type @@ -68,7 +68,7 @@ Examples: if drives == nil { drives, err = client.ListSharedDrives(100) if err != nil { - return fmt.Errorf("failed to list shared drives: %w", err) + return fmt.Errorf("listing shared drives: %w", err) } // Update cache @@ -139,7 +139,7 @@ func resolveDriveScope(client drive.DriveClientInterface, myDrive bool, driveFla ttl := config.GetCacheTTLHours() c, err := cache.New(ttl) if err != nil { - return drive.DriveScope{}, fmt.Errorf("failed to initialize cache: %w", err) + return drive.DriveScope{}, fmt.Errorf("initializing cache: %w", err) } cached, _ := c.GetDrives() @@ -147,7 +147,7 @@ func resolveDriveScope(client drive.DriveClientInterface, myDrive bool, driveFla // Cache miss - fetch from API drives, err := client.ListSharedDrives(100) if err != nil { - return drive.DriveScope{}, fmt.Errorf("failed to list shared drives: %w", err) + return drive.DriveScope{}, fmt.Errorf("listing shared drives: %w", err) } // Update cache diff --git a/internal/cmd/drive/get.go b/internal/cmd/drive/get.go index ce962a7..58ae05c 100644 --- a/internal/cmd/drive/get.go +++ b/internal/cmd/drive/get.go @@ -25,13 +25,13 @@ Examples: RunE: func(_ *cobra.Command, args []string) error { client, err := newDriveClient() if err != nil { - return fmt.Errorf("failed to create Drive client: %w", err) + return fmt.Errorf("creating Drive client: %w", err) } fileID := args[0] file, err := client.GetFile(fileID) if err != nil { - return fmt.Errorf("failed to get file %s: %w", fileID, err) + return fmt.Errorf("getting file %s: %w", fileID, err) } if jsonOutput { diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index a229996..eb6429b 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -177,7 +177,7 @@ func TestListCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to list files") + testutil.Contains(t, err.Error(), "listing files") }) } @@ -187,7 +187,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to create Drive client") + testutil.Contains(t, err.Error(), "creating Drive client") }) } @@ -290,7 +290,7 @@ func TestSearchCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to search files") + testutil.Contains(t, err.Error(), "searching files") }) } @@ -353,7 +353,7 @@ func TestGetCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to get file") + testutil.Contains(t, err.Error(), "getting file") }) } @@ -493,7 +493,7 @@ func TestDownloadCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to download file") + testutil.Contains(t, err.Error(), "downloading file") }) } @@ -504,6 +504,6 @@ func TestDownloadCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to create Drive client") + testutil.Contains(t, err.Error(), "creating Drive client") }) } diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index fa3366f..acd470f 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -47,7 +47,7 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi client, err := newDriveClient() if err != nil { - return fmt.Errorf("failed to create Drive client: %w", err) + return fmt.Errorf("creating Drive client: %w", err) } folderID := "" @@ -58,17 +58,17 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi // Resolve drive scope for listing scope, err := resolveDriveScopeForList(client, myDrive, driveFlag, folderID) if err != nil { - return fmt.Errorf("failed to resolve drive scope: %w", err) + return fmt.Errorf("resolving drive scope: %w", err) } query, err := buildListQueryWithScope(folderID, fileType, scope) if err != nil { - return fmt.Errorf("failed to build query: %w", err) + return fmt.Errorf("building query: %w", err) } files, err := client.ListFilesWithScope(query, maxResults, scope) if err != nil { - return fmt.Errorf("failed to list files: %w", err) + return fmt.Errorf("listing files: %w", err) } if len(files) == 0 { diff --git a/internal/cmd/drive/search.go b/internal/cmd/drive/search.go index ec99b9c..4b7a498 100644 --- a/internal/cmd/drive/search.go +++ b/internal/cmd/drive/search.go @@ -52,7 +52,7 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi client, err := newDriveClient() if err != nil { - return fmt.Errorf("failed to create Drive client: %w", err) + return fmt.Errorf("creating Drive client: %w", err) } query := "" @@ -62,18 +62,18 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi searchQuery, err := buildSearchQuery(query, nameOnly, fileType, owner, modAfter, modBefore, inFolder) if err != nil { - return fmt.Errorf("failed to build search query: %w", err) + return fmt.Errorf("building search query: %w", err) } // Resolve drive scope scope, err := resolveDriveScope(client, myDrive, driveFlag) if err != nil { - return fmt.Errorf("failed to resolve drive scope: %w", err) + return fmt.Errorf("resolving drive scope: %w", err) } files, err := client.ListFilesWithScope(searchQuery, maxResults, scope) if err != nil { - return fmt.Errorf("failed to search files: %w", err) + return fmt.Errorf("searching files: %w", err) } if len(files) == 0 { diff --git a/internal/cmd/drive/tree.go b/internal/cmd/drive/tree.go index 097244c..fbfeefc 100644 --- a/internal/cmd/drive/tree.go +++ b/internal/cmd/drive/tree.go @@ -50,7 +50,7 @@ Examples: client, err := newDriveClient() if err != nil { - return fmt.Errorf("failed to create Drive client: %w", err) + return fmt.Errorf("creating Drive client: %w", err) } folderID := "root" @@ -63,7 +63,7 @@ Examples: // Resolve shared drive scope, err := resolveDriveScope(client, false, driveFlag) if err != nil { - return fmt.Errorf("failed to resolve drive: %w", err) + return fmt.Errorf("resolving drive: %w", err) } folderID = scope.DriveID rootName = driveFlag // Use the provided name @@ -72,7 +72,7 @@ Examples: // Build the tree tree, err := buildTreeWithScope(client, folderID, rootName, depth, files) if err != nil { - return fmt.Errorf("failed to build folder tree: %w", err) + return fmt.Errorf("building folder tree: %w", err) } if jsonOutput { @@ -113,7 +113,7 @@ func buildTreeWithScope(client drive.DriveClientInterface, folderID, rootName st } else { folder, err := client.GetFile(folderID) if err != nil { - return nil, fmt.Errorf("failed to get folder info: %w", err) + return nil, fmt.Errorf("getting folder info: %w", err) } folderName = folder.Name folderType = drive.GetTypeName(folder.MimeType) @@ -140,7 +140,7 @@ func buildTreeWithScope(client drive.DriveClientInterface, folderID, rootName st scope := drive.DriveScope{AllDrives: true} children, err := client.ListFilesWithScope(query, 100, scope) if err != nil { - return nil, fmt.Errorf("failed to list children: %w", err) + return nil, fmt.Errorf("listing children: %w", err) } // Sort children: folders first, then by name diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index 09f3aef..1a94229 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -50,7 +50,7 @@ func runInit(_ *cobra.Command, _ []string) error { // Step 1: Check for credentials.json credPath, err := gmail.GetCredentialsPath() if err != nil { - return fmt.Errorf("failed to get credentials path: %w", err) + return fmt.Errorf("getting credentials path: %w", err) } shortPath := gmail.ShortenPath(credPath) @@ -65,7 +65,7 @@ func runInit(_ *cobra.Command, _ []string) error { // Step 2: Load OAuth config config, err := gmail.GetOAuthConfig() if err != nil { - return fmt.Errorf("failed to load OAuth config: %w", err) + return fmt.Errorf("loading OAuth config: %w", err) } // Step 3: Check if already authenticated @@ -89,7 +89,7 @@ func runInit(_ *cobra.Command, _ []string) error { fmt.Println() fmt.Println("Clearing old token...") if delErr := keychain.DeleteToken(); delErr != nil { - return fmt.Errorf("failed to clear token: %w", delErr) + return fmt.Errorf("clearing token: %w", delErr) } // Fall through to OAuth flow below } else { @@ -129,7 +129,7 @@ func runInit(_ *cobra.Command, _ []string) error { reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { - return fmt.Errorf("failed to read input: %w", err) + return fmt.Errorf("reading input: %w", err) } code := extractAuthCode(input) @@ -144,12 +144,12 @@ func runInit(_ *cobra.Command, _ []string) error { ctx := context.Background() token, err := gmail.ExchangeAuthCode(ctx, config, code) if err != nil { - return fmt.Errorf("failed to exchange authorization code: %w", err) + return fmt.Errorf("exchanging authorization code: %w", err) } // Step 6: Save token if err := keychain.SetToken(token); err != nil { - return fmt.Errorf("failed to save token: %w", err) + return fmt.Errorf("saving token: %w", err) } fmt.Printf("Token saved to: %s\n", keychain.GetStorageBackend()) @@ -196,7 +196,7 @@ func verifyConnectivity() error { client, err := gmail.NewClient(context.Background()) if err != nil { fmt.Println(" OAuth token: FAILED") - return fmt.Errorf("failed to create client: %w", err) + return fmt.Errorf("creating client: %w", err) } fmt.Println(" OAuth token: OK") @@ -204,7 +204,7 @@ func verifyConnectivity() error { profile, err := client.GetProfile() if err != nil { fmt.Println(" Gmail API: FAILED") - return fmt.Errorf("failed to access Gmail API: %w", err) + return fmt.Errorf("accessing Gmail API: %w", err) } fmt.Println(" Gmail API: OK") fmt.Printf(" Messages: %d total\n", profile.MessagesTotal) @@ -319,6 +319,6 @@ func promptCacheTTL() { } if err := config.SaveConfig(cfg); err != nil { - fmt.Printf("Warning: failed to save config: %v\n", err) + fmt.Printf("Warning: saving config: %v\n", err) } } diff --git a/internal/cmd/mail/attachments_download.go b/internal/cmd/mail/attachments_download.go index acfc232..39bcb91 100644 --- a/internal/cmd/mail/attachments_download.go +++ b/internal/cmd/mail/attachments_download.go @@ -45,13 +45,13 @@ Examples: client, err := newGmailClient() if err != nil { - return fmt.Errorf("failed to create Gmail client: %w", err) + return fmt.Errorf("creating Gmail client: %w", err) } messageID := args[0] attachments, err := client.GetAttachments(messageID) if err != nil { - return fmt.Errorf("failed to get attachments: %w", err) + return fmt.Errorf("getting attachments: %w", err) } if len(attachments) == 0 { @@ -73,13 +73,13 @@ Examples: // Create output directory if needed if err := os.MkdirAll(outputDir, config.OutputDirPerm); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) + return fmt.Errorf("creating output directory: %w", err) } // Get absolute path of download directory for path validation absOutputDir, err := filepath.Abs(outputDir) if err != nil { - return fmt.Errorf("failed to resolve download directory: %w", err) + return fmt.Errorf("resolving download directory: %w", err) } // Download each attachment diff --git a/internal/cmd/mail/attachments_list.go b/internal/cmd/mail/attachments_list.go index 35f2cbe..7d04a99 100644 --- a/internal/cmd/mail/attachments_list.go +++ b/internal/cmd/mail/attachments_list.go @@ -25,12 +25,12 @@ Examples: RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { - return fmt.Errorf("failed to create Gmail client: %w", err) + return fmt.Errorf("creating Gmail client: %w", err) } attachments, err := client.GetAttachments(args[0]) if err != nil { - return fmt.Errorf("failed to get attachments: %w", err) + return fmt.Errorf("getting attachments: %w", err) } if len(attachments) == 0 { diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index ecda257..fdf6ef7 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -134,7 +134,7 @@ func TestSearchCommand_APIError(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to search messages") + testutil.Contains(t, err.Error(), "searching messages") }) } @@ -145,7 +145,7 @@ func TestSearchCommand_ClientCreationError(t *testing.T) { withFailingClientFactory(func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to create Gmail client") + testutil.Contains(t, err.Error(), "creating Gmail client") }) } @@ -229,7 +229,7 @@ func TestReadCommand_NotFound(t *testing.T) { withMockClient(mock, func() { err := cmd.Execute() testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to read message") + testutil.Contains(t, err.Error(), "reading message") }) } diff --git a/internal/cmd/mail/labels.go b/internal/cmd/mail/labels.go index c017ae5..e152db3 100644 --- a/internal/cmd/mail/labels.go +++ b/internal/cmd/mail/labels.go @@ -37,11 +37,11 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { client, err := newGmailClient() if err != nil { - return fmt.Errorf("failed to create Gmail client: %w", err) + return fmt.Errorf("creating Gmail client: %w", err) } if err := client.FetchLabels(); err != nil { - return fmt.Errorf("failed to fetch labels: %w", err) + return fmt.Errorf("fetching labels: %w", err) } gmailLabels := client.GetLabels() diff --git a/internal/cmd/mail/read.go b/internal/cmd/mail/read.go index 38eab9a..90ca478 100644 --- a/internal/cmd/mail/read.go +++ b/internal/cmd/mail/read.go @@ -23,12 +23,12 @@ Examples: RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { - return fmt.Errorf("failed to create Gmail client: %w", err) + return fmt.Errorf("creating Gmail client: %w", err) } msg, err := client.GetMessage(args[0], true) if err != nil { - return fmt.Errorf("failed to read message: %w", err) + return fmt.Errorf("reading message: %w", err) } if jsonOutput { diff --git a/internal/cmd/mail/search.go b/internal/cmd/mail/search.go index bea45c8..331721a 100644 --- a/internal/cmd/mail/search.go +++ b/internal/cmd/mail/search.go @@ -28,12 +28,12 @@ For more query operators, see: https://support.google.com/mail/answer/7190`, RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { - return fmt.Errorf("failed to create Gmail client: %w", err) + return fmt.Errorf("creating Gmail client: %w", err) } messages, skipped, err := client.SearchMessages(args[0], maxResults) if err != nil { - return fmt.Errorf("failed to search messages: %w", err) + return fmt.Errorf("searching messages: %w", err) } if len(messages) == 0 { diff --git a/internal/cmd/mail/thread.go b/internal/cmd/mail/thread.go index 7c4e411..5bf3af6 100644 --- a/internal/cmd/mail/thread.go +++ b/internal/cmd/mail/thread.go @@ -26,12 +26,12 @@ Examples: RunE: func(_ *cobra.Command, args []string) error { client, err := newGmailClient() if err != nil { - return fmt.Errorf("failed to create Gmail client: %w", err) + return fmt.Errorf("creating Gmail client: %w", err) } messages, err := client.GetThread(args[0]) if err != nil { - return fmt.Errorf("failed to get thread: %w", err) + return fmt.Errorf("getting thread: %w", err) } if len(messages) == 0 { diff --git a/internal/contacts/client.go b/internal/contacts/client.go index f7982e5..95aa3bb 100644 --- a/internal/contacts/client.go +++ b/internal/contacts/client.go @@ -45,7 +45,7 @@ func (c *Client) ListContacts(pageToken string, pageSize int64) (*people.ListCon resp, err := call.Do() if err != nil { - return nil, fmt.Errorf("failed to list contacts: %w", err) + return nil, fmt.Errorf("listing contacts: %w", err) } return resp, nil @@ -59,7 +59,7 @@ func (c *Client) SearchContacts(query string, pageSize int64) (*people.SearchRes PageSize(int64(pageSize)). Do() if err != nil { - return nil, fmt.Errorf("failed to search contacts: %w", err) + return nil, fmt.Errorf("searching contacts: %w", err) } return resp, nil @@ -71,7 +71,7 @@ func (c *Client) GetContact(resourceName string) (*people.Person, error) { PersonFields("names,emailAddresses,phoneNumbers,organizations,addresses,biographies,urls,birthdays,events,relations,photos,metadata"). Do() if err != nil { - return nil, fmt.Errorf("failed to get contact: %w", err) + return nil, fmt.Errorf("getting contact: %w", err) } return resp, nil @@ -89,7 +89,7 @@ func (c *Client) ListContactGroups(pageToken string, pageSize int64) (*people.Li resp, err := call.Do() if err != nil { - return nil, fmt.Errorf("failed to list contact groups: %w", err) + return nil, fmt.Errorf("listing contact groups: %w", err) } return resp, nil diff --git a/internal/drive/client.go b/internal/drive/client.go index 53a8871..7b3ebdd 100644 --- a/internal/drive/client.go +++ b/internal/drive/client.go @@ -51,7 +51,7 @@ func (c *Client) ListFiles(query string, pageSize int64) ([]*File, error) { resp, err := call.Do() if err != nil { - return nil, fmt.Errorf("failed to list files: %w", err) + return nil, fmt.Errorf("listing files: %w", err) } files := make([]*File, 0, len(resp.Files)) @@ -91,7 +91,7 @@ func (c *Client) ListFilesWithScope(query string, pageSize int64, scope DriveSco resp, err := call.Do() if err != nil { - return nil, fmt.Errorf("failed to list files: %w", err) + return nil, fmt.Errorf("listing files: %w", err) } files := make([]*File, 0, len(resp.Files)) @@ -108,7 +108,7 @@ func (c *Client) GetFile(fileID string) (*File, error) { SupportsAllDrives(true). Do() if err != nil { - return nil, fmt.Errorf("failed to get file: %w", err) + return nil, fmt.Errorf("getting file: %w", err) } return ParseFile(f), nil } @@ -119,13 +119,13 @@ func (c *Client) DownloadFile(fileID string) ([]byte, error) { SupportsAllDrives(true). Download() if err != nil { - return nil, fmt.Errorf("failed to download file: %w", err) + return nil, fmt.Errorf("downloading file: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read file content: %w", err) + return nil, fmt.Errorf("reading file content: %w", err) } return data, nil } @@ -134,13 +134,13 @@ func (c *Client) DownloadFile(fileID string) ([]byte, error) { func (c *Client) ExportFile(fileID string, mimeType string) ([]byte, error) { resp, err := c.service.Files.Export(fileID, mimeType).Download() if err != nil { - return nil, fmt.Errorf("failed to export file: %w", err) + return nil, fmt.Errorf("exporting file: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read exported content: %w", err) + return nil, fmt.Errorf("reading exported content: %w", err) } return data, nil } @@ -163,7 +163,7 @@ func (c *Client) ListSharedDrives(pageSize int64) ([]*SharedDrive, error) { resp, err := call.Do() if err != nil { - return nil, fmt.Errorf("failed to list shared drives: %w", err) + return nil, fmt.Errorf("listing shared drives: %w", err) } for _, d := range resp.Drives { diff --git a/internal/gmail/attachments.go b/internal/gmail/attachments.go index a6375f9..c664c1b 100644 --- a/internal/gmail/attachments.go +++ b/internal/gmail/attachments.go @@ -13,7 +13,7 @@ import ( func (c *Client) GetAttachments(messageID string) ([]*Attachment, error) { msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format("full").Do() if err != nil { - return nil, fmt.Errorf("failed to get message: %w", err) + return nil, fmt.Errorf("getting message: %w", err) } return extractAttachments(msg.Payload, ""), nil @@ -23,12 +23,12 @@ func (c *Client) GetAttachments(messageID string) ([]*Attachment, error) { func (c *Client) DownloadAttachment(messageID string, attachmentID string) ([]byte, error) { att, err := c.service.Users.Messages.Attachments.Get(c.userID, messageID, attachmentID).Do() if err != nil { - return nil, fmt.Errorf("failed to download attachment: %w", err) + return nil, fmt.Errorf("downloading attachment: %w", err) } data, err := base64.URLEncoding.DecodeString(att.Data) if err != nil { - return nil, fmt.Errorf("failed to decode attachment data: %w", err) + return nil, fmt.Errorf("decoding attachment data: %w", err) } return data, nil @@ -38,7 +38,7 @@ func (c *Client) DownloadAttachment(messageID string, attachmentID string) ([]by func (c *Client) DownloadInlineAttachment(messageID string, partID string) ([]byte, error) { msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format("full").Do() if err != nil { - return nil, fmt.Errorf("failed to get message: %w", err) + return nil, fmt.Errorf("getting message: %w", err) } part := findPart(msg.Payload, partID) @@ -52,7 +52,7 @@ func (c *Client) DownloadInlineAttachment(messageID string, partID string) ([]by data, err := base64.URLEncoding.DecodeString(part.Body.Data) if err != nil { - return nil, fmt.Errorf("failed to decode inline attachment: %w", err) + return nil, fmt.Errorf("decoding inline attachment: %w", err) } return data, nil diff --git a/internal/gmail/client.go b/internal/gmail/client.go index b59dd85..e5333e4 100644 --- a/internal/gmail/client.go +++ b/internal/gmail/client.go @@ -60,7 +60,7 @@ func (c *Client) FetchLabels() error { resp, err := c.service.Users.Labels.List(c.userID).Do() if err != nil { - return fmt.Errorf("failed to fetch labels: %w", err) + return fmt.Errorf("fetching labels: %w", err) } c.labels = make(map[string]*gmail.Label) @@ -102,7 +102,7 @@ func (c *Client) GetLabels() []*gmail.Label { func (c *Client) GetProfile() (*Profile, error) { profile, err := c.service.Users.GetProfile(c.userID).Do() if err != nil { - return nil, fmt.Errorf("failed to get profile: %w", err) + return nil, fmt.Errorf("getting profile: %w", err) } return &Profile{ EmailAddress: profile.EmailAddress, diff --git a/internal/gmail/messages.go b/internal/gmail/messages.go index 2a9c964..91907f0 100644 --- a/internal/gmail/messages.go +++ b/internal/gmail/messages.go @@ -45,7 +45,7 @@ func (c *Client) SearchMessages(query string, maxResults int64) ([]*Message, int resp, err := call.Do() if err != nil { - return nil, 0, fmt.Errorf("failed to search messages: %w", err) + return nil, 0, fmt.Errorf("searching messages: %w", err) } var messages []*Message @@ -81,7 +81,7 @@ func (c *Client) GetMessage(messageID string, includeBody bool) (*Message, error msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format(format).Do() if err != nil { - return nil, fmt.Errorf("failed to get message: %w", err) + return nil, fmt.Errorf("getting message: %w", err) } return parseMessage(msg, includeBody, c.GetLabelName), nil @@ -102,12 +102,12 @@ func (c *Client) GetThread(id string) ([]*Message, error) { msg, msgErr := c.service.Users.Messages.Get(c.userID, id).Format("minimal").Do() if msgErr != nil { // Return the original thread error if message lookup also fails - return nil, fmt.Errorf("failed to get thread: %w", err) + return nil, fmt.Errorf("getting thread: %w", err) } // Use the thread ID from the message thread, err = c.service.Users.Threads.Get(c.userID, msg.ThreadId).Format("full").Do() if err != nil { - return nil, fmt.Errorf("failed to get thread: %w", err) + return nil, fmt.Errorf("getting thread: %w", err) } } diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 30c4d03..ca92f5d 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -86,18 +86,18 @@ func MigrateFromFile(tokenFilePath string) error { // Read token from file f, err := os.Open(tokenFilePath) //nolint:gosec // Path from user config directory if err != nil { - return fmt.Errorf("failed to open token file: %w", err) + return fmt.Errorf("opening token file: %w", err) } defer f.Close() var token oauth2.Token if err := json.NewDecoder(f).Decode(&token); err != nil { - return fmt.Errorf("failed to parse token file: %w", err) + return fmt.Errorf("parsing token file: %w", err) } // Store in secure storage if err := SetToken(&token); err != nil { - return fmt.Errorf("failed to store token in secure storage: %w", err) + return fmt.Errorf("storing token in secure storage: %w", err) } // Securely delete old token file (overwrite with zeros before removal) @@ -151,13 +151,13 @@ func getFromConfigFile() (*oauth2.Token, error) { if os.IsNotExist(err) { return nil, ErrTokenNotFound } - return nil, fmt.Errorf("failed to open token file: %w", err) + return nil, fmt.Errorf("opening token file: %w", err) } defer f.Close() var token oauth2.Token if err := json.NewDecoder(f).Decode(&token); err != nil { - return nil, fmt.Errorf("failed to parse token file: %w", err) + return nil, fmt.Errorf("parsing token file: %w", err) } return &token, nil @@ -172,18 +172,18 @@ func setInConfigFile(token *oauth2.Token) error { // Ensure directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, config.DirPerm); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) + return fmt.Errorf("creating config directory: %w", err) } // Write token with restricted permissions f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, config.TokenPerm) //nolint:gosec // Path from user config directory if err != nil { - return fmt.Errorf("failed to create token file: %w", err) + return fmt.Errorf("creating token file: %w", err) } defer f.Close() if err := json.NewEncoder(f).Encode(token); err != nil { - return fmt.Errorf("failed to write token: %w", err) + return fmt.Errorf("writing token: %w", err) } return nil @@ -196,7 +196,7 @@ func deleteFromConfigFile() error { } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete token file: %w", err) + return fmt.Errorf("deleting token file: %w", err) } return nil diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 4e5a035..33497a0 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -75,12 +75,12 @@ func getFromKeychain() (*oauth2.Token, error) { output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("failed to read from keychain: %w", err) + return nil, fmt.Errorf("reading from keychain: %w", err) } var token oauth2.Token if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &token); err != nil { - return nil, fmt.Errorf("failed to parse token from keychain: %w", err) + return nil, fmt.Errorf("parsing token from keychain: %w", err) } return &token, nil @@ -89,7 +89,7 @@ func getFromKeychain() (*oauth2.Token, error) { func setInKeychain(token *oauth2.Token) error { data, err := json.Marshal(token) if err != nil { - return fmt.Errorf("failed to serialize token: %w", err) + return fmt.Errorf("serializing token: %w", err) } // Delete existing entry (ignore error if not exists) @@ -106,7 +106,7 @@ func setInKeychain(token *oauth2.Token) error { cmd.Stdin = strings.NewReader(stdinCmd) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to store in keychain: %w", err) + return fmt.Errorf("storing in keychain: %w", err) } return nil @@ -118,7 +118,7 @@ func deleteFromKeychain() error { "-a", tokenKey) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to delete from keychain: %w", err) + return fmt.Errorf("deleting from keychain: %w", err) } return nil diff --git a/internal/keychain/keychain_linux.go b/internal/keychain/keychain_linux.go index 31d3755..109d8d8 100644 --- a/internal/keychain/keychain_linux.go +++ b/internal/keychain/keychain_linux.go @@ -92,12 +92,12 @@ func getFromSecretTool() (*oauth2.Token, error) { output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("failed to read from secret-tool: %w", err) + return nil, fmt.Errorf("reading from secret-tool: %w", err) } var token oauth2.Token if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &token); err != nil { - return nil, fmt.Errorf("failed to parse token from secret-tool: %w", err) + return nil, fmt.Errorf("parsing token from secret-tool: %w", err) } return &token, nil @@ -106,7 +106,7 @@ func getFromSecretTool() (*oauth2.Token, error) { func setInSecretTool(token *oauth2.Token) error { data, err := json.Marshal(token) if err != nil { - return fmt.Errorf("failed to serialize token: %w", err) + return fmt.Errorf("serializing token: %w", err) } // Delete existing entry (ignore error if not exists) @@ -119,7 +119,7 @@ func setInSecretTool(token *oauth2.Token) error { cmd.Stdin = strings.NewReader(string(data)) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to store in secret-tool: %w", err) + return fmt.Errorf("storing in secret-tool: %w", err) } return nil @@ -131,7 +131,7 @@ func deleteFromSecretTool() error { "account", tokenKey) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to delete from secret-tool: %w", err) + return fmt.Errorf("deleting from secret-tool: %w", err) } return nil diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 0e6ed01..1e9a3d6 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -165,8 +165,8 @@ func TestConfigFile_InvalidJSON(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "failed to parse token file") { - t.Errorf("expected %q to contain %q", err.Error(), "failed to parse token file") + if !strings.Contains(err.Error(), "parsing token file") { + t.Errorf("expected %q to contain %q", err.Error(), "parsing token file") } } @@ -299,8 +299,8 @@ func TestMigrateFromFile_InvalidJSON(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "failed to parse token file") { - t.Errorf("expected %q to contain %q", err.Error(), "failed to parse token file") + if !strings.Contains(err.Error(), "parsing token file") { + t.Errorf("expected %q to contain %q", err.Error(), "parsing token file") } } diff --git a/internal/zip/extract.go b/internal/zip/extract.go index 974c145..e77a340 100644 --- a/internal/zip/extract.go +++ b/internal/zip/extract.go @@ -42,7 +42,7 @@ func DefaultOptions() Options { func Extract(zipPath, destDir string, opts Options) error { r, err := zip.OpenReader(zipPath) if err != nil { - return fmt.Errorf("failed to open zip: %w", err) + return fmt.Errorf("opening zip: %w", err) } defer r.Close() @@ -54,10 +54,10 @@ func Extract(zipPath, destDir string, opts Options) error { // Create destination directory destDir, err = filepath.Abs(destDir) if err != nil { - return fmt.Errorf("failed to resolve destination path: %w", err) + return fmt.Errorf("resolving destination path: %w", err) } if err := fs.MkdirAll(destDir, 0755); err != nil { - return fmt.Errorf("failed to create destination: %w", err) + return fmt.Errorf("creating destination: %w", err) } var totalSize int64 diff --git a/internal/zip/extract_test.go b/internal/zip/extract_test.go index bfdfb99..bbb1bd1 100644 --- a/internal/zip/extract_test.go +++ b/internal/zip/extract_test.go @@ -91,7 +91,7 @@ func TestExtract(t *testing.T) { destDir := t.TempDir() err = Extract(tmpFile.Name(), destDir, DefaultOptions()) testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to open zip") + testutil.Contains(t, err.Error(), "opening zip") }) } @@ -323,7 +323,7 @@ func TestExtractFileSystemErrors(t *testing.T) { err := Extract(zipPath, "/tmp/test-dest", DefaultOptions()) testutil.Error(t, err) - testutil.Contains(t, err.Error(), "failed to create destination") + testutil.Contains(t, err.Error(), "creating destination") }) t.Run("returns error when MkdirAll fails for parent directory", func(t *testing.T) { From 9d0279c4a526896e27741a77a4e1074e60735425 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:30:17 -0500 Subject: [PATCH 05/13] refactor: relocate interfaces from implementation to consumer packages Move interface definitions to the packages that consume them per Go best practices. Each cmd/ package now owns its interface (MailClient, CalendarClient, ContactsClient, DriveClient) and its mock. This removes the coupling between implementation and consumer packages and eliminates the centralized testutil/mocks.go file. --- internal/auth/auth_test.go | 90 ++++----- internal/calendar/interfaces.go | 21 -- internal/cmd/calendar/events_helper.go | 2 +- internal/cmd/calendar/handlers_test.go | 34 ++-- internal/cmd/calendar/mock_test.go | 36 ++++ internal/cmd/calendar/output.go | 13 +- internal/cmd/contacts/handlers_test.go | 36 ++-- internal/cmd/contacts/mock_test.go | 44 +++++ internal/cmd/contacts/output.go | 14 +- internal/cmd/drive/drives.go | 2 +- internal/cmd/drive/drives_test.go | 12 +- internal/cmd/drive/handlers_test.go | 48 ++--- internal/cmd/drive/list.go | 2 +- internal/cmd/drive/mock_test.go | 64 ++++++ internal/cmd/drive/output.go | 14 +- internal/cmd/drive/tree.go | 4 +- internal/cmd/drive/tree_test.go | 2 +- internal/cmd/initcmd/init_test.go | 2 +- internal/cmd/mail/attachments_download.go | 2 +- internal/cmd/mail/handlers_test.go | 36 ++-- internal/cmd/mail/labels_test.go | 2 +- internal/cmd/mail/mock_test.go | 95 +++++++++ internal/cmd/mail/output.go | 20 +- internal/contacts/interfaces.go | 24 --- internal/drive/interfaces.go | 26 --- internal/gmail/client.go | 7 + internal/gmail/interfaces.go | 49 ----- internal/log/log_test.go | 1 - internal/testutil/assert_test.go | 2 +- internal/testutil/mocks.go | 231 ---------------------- 30 files changed, 437 insertions(+), 498 deletions(-) delete mode 100644 internal/calendar/interfaces.go create mode 100644 internal/cmd/calendar/mock_test.go create mode 100644 internal/cmd/contacts/mock_test.go create mode 100644 internal/cmd/drive/mock_test.go create mode 100644 internal/cmd/mail/mock_test.go delete mode 100644 internal/contacts/interfaces.go delete mode 100644 internal/drive/interfaces.go delete mode 100644 internal/gmail/interfaces.go delete mode 100644 internal/testutil/mocks.go diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 1e599bb..1618832 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,9 +1,9 @@ package auth import ( - "strings" "os" "path/filepath" + "strings" "testing" "github.com/open-cli-collective/google-readonly/internal/config" @@ -17,17 +17,17 @@ func TestDeprecatedWrappers(t *testing.T) { authDir, err := GetConfigDir() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } configDir, err := config.GetConfigDir() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } if authDir != configDir { - t.Errorf("got %v, want %v", authDir, configDir) - } + t.Errorf("got %v, want %v", authDir, configDir) + } }) t.Run("GetCredentialsPath delegates to config package", func(t *testing.T) { @@ -36,17 +36,17 @@ func TestDeprecatedWrappers(t *testing.T) { authPath, err := GetCredentialsPath() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } configPath, err := config.GetCredentialsPath() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } if authPath != configPath { - t.Errorf("got %v, want %v", authPath, configPath) - } + t.Errorf("got %v, want %v", authPath, configPath) + } }) t.Run("GetTokenPath delegates to config package", func(t *testing.T) { @@ -55,24 +55,24 @@ func TestDeprecatedWrappers(t *testing.T) { authPath, err := GetTokenPath() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } configPath, err := config.GetTokenPath() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } if authPath != configPath { - t.Errorf("got %v, want %v", authPath, configPath) - } + t.Errorf("got %v, want %v", authPath, configPath) + } }) t.Run("ShortenPath delegates to config package", func(t *testing.T) { home, err := os.UserHomeDir() if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } testPath := filepath.Join(home, ".config", "test") @@ -80,20 +80,20 @@ func TestDeprecatedWrappers(t *testing.T) { configResult := config.ShortenPath(testPath) if authResult != configResult { - t.Errorf("got %v, want %v", authResult, configResult) - } + t.Errorf("got %v, want %v", authResult, configResult) + } }) t.Run("Constants match config package", func(t *testing.T) { if ConfigDirName != config.DirName { - t.Errorf("got %v, want %v", ConfigDirName, config.DirName) - } + t.Errorf("got %v, want %v", ConfigDirName, config.DirName) + } if CredentialsFile != config.CredentialsFile { - t.Errorf("got %v, want %v", CredentialsFile, config.CredentialsFile) - } + t.Errorf("got %v, want %v", CredentialsFile, config.CredentialsFile) + } if TokenFile != config.TokenFile { - t.Errorf("got %v, want %v", TokenFile, config.TokenFile) - } + t.Errorf("got %v, want %v", TokenFile, config.TokenFile) + } }) } @@ -129,29 +129,29 @@ func TestTokenFromFile(t *testing.T) { }` err := os.WriteFile(tokenPath, []byte(tokenData), 0600) if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } token, err := tokenFromFile(tokenPath) if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } if token.AccessToken != "test-access-token" { - t.Errorf("got %v, want %v", token.AccessToken, "test-access-token") - } + t.Errorf("got %v, want %v", token.AccessToken, "test-access-token") + } if token.TokenType != "Bearer" { - t.Errorf("got %v, want %v", token.TokenType, "Bearer") - } + t.Errorf("got %v, want %v", token.TokenType, "Bearer") + } if token.RefreshToken != "test-refresh-token" { - t.Errorf("got %v, want %v", token.RefreshToken, "test-refresh-token") - } + t.Errorf("got %v, want %v", token.RefreshToken, "test-refresh-token") + } }) t.Run("returns error for non-existent file", func(t *testing.T) { _, err := tokenFromFile("/nonexistent/token.json") if err == nil { - t.Fatal("expected error, got nil") - } + t.Fatal("expected error, got nil") + } }) t.Run("returns error for invalid JSON", func(t *testing.T) { @@ -160,12 +160,12 @@ func TestTokenFromFile(t *testing.T) { err := os.WriteFile(tokenPath, []byte("not valid json"), 0600) if err != nil { - t.Fatalf("unexpected error: %v", err) - } + t.Fatalf("unexpected error: %v", err) + } _, err = tokenFromFile(tokenPath) if err == nil { - t.Fatal("expected error, got nil") - } + t.Fatal("expected error, got nil") + } }) } diff --git a/internal/calendar/interfaces.go b/internal/calendar/interfaces.go deleted file mode 100644 index f57d02e..0000000 --- a/internal/calendar/interfaces.go +++ /dev/null @@ -1,21 +0,0 @@ -package calendar - -import ( - "google.golang.org/api/calendar/v3" -) - -// CalendarClientInterface defines the interface for Calendar client operations. -// This enables unit testing through mock implementations. -type CalendarClientInterface interface { - // ListCalendars returns all calendars the user has access to - ListCalendars() ([]*calendar.CalendarListEntry, error) - - // ListEvents returns events from the specified calendar within the given time range - ListEvents(calendarID string, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) - - // GetEvent retrieves a single event by ID - GetEvent(calendarID, eventID string) (*calendar.Event, error) -} - -// Verify that Client implements CalendarClientInterface -var _ CalendarClientInterface = (*Client)(nil) diff --git a/internal/cmd/calendar/events_helper.go b/internal/cmd/calendar/events_helper.go index 84e710d..d3d7109 100644 --- a/internal/cmd/calendar/events_helper.go +++ b/internal/cmd/calendar/events_helper.go @@ -19,7 +19,7 @@ type EventListOptions struct { // listAndPrintEvents fetches events and prints them according to the options. // This is a shared helper used by today, week, and events commands. -func listAndPrintEvents(client calendar.CalendarClientInterface, opts EventListOptions) error { +func listAndPrintEvents(client CalendarClient, opts EventListOptions) error { events, err := client.ListEvents(opts.CalendarID, opts.TimeMin, opts.TimeMax, opts.MaxResults) if err != nil { return err diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index e6c8d22..df86f5a 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -32,9 +32,9 @@ func captureOutput(t *testing.T, f func()) string { } // withMockClient sets up a mock client factory for tests -func withMockClient(mock calendarapi.CalendarClientInterface, f func()) { +func withMockClient(mock CalendarClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (calendarapi.CalendarClientInterface, error) { + ClientFactory = func() (CalendarClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -44,7 +44,7 @@ func withMockClient(mock calendarapi.CalendarClientInterface, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (calendarapi.CalendarClientInterface, error) { + ClientFactory = func() (CalendarClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() @@ -52,7 +52,7 @@ func withFailingClientFactory(f func()) { } func TestListCommand_Success(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { return testutil.SampleCalendars(), nil }, @@ -73,7 +73,7 @@ func TestListCommand_Success(t *testing.T) { } func TestListCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { return testutil.SampleCalendars(), nil }, @@ -96,7 +96,7 @@ func TestListCommand_JSONOutput(t *testing.T) { } func TestListCommand_Empty(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { return []*calendar.CalendarListEntry{}, nil }, @@ -115,7 +115,7 @@ func TestListCommand_Empty(t *testing.T) { } func TestListCommand_APIError(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { return nil, errors.New("API error") }, @@ -141,7 +141,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { } func TestEventsCommand_Success(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListEventsFunc: func(calendarID, _, _ string, _ int64) ([]*calendar.Event, error) { testutil.Equal(t, calendarID, "primary") return []*calendar.Event{testutil.SampleEvent("event1")}, nil @@ -163,7 +163,7 @@ func TestEventsCommand_Success(t *testing.T) { func TestEventsCommand_WithDateRange(t *testing.T) { var capturedTimeMin, capturedTimeMax string - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListEventsFunc: func(_, timeMin, timeMax string, _ int64) ([]*calendar.Event, error) { capturedTimeMin = timeMin capturedTimeMax = timeMax @@ -188,7 +188,7 @@ func TestEventsCommand_WithDateRange(t *testing.T) { } func TestEventsCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{testutil.SampleEvent("event1")}, nil }, @@ -214,7 +214,7 @@ func TestEventsCommand_InvalidFromDate(t *testing.T) { cmd := newEventsCommand() cmd.SetArgs([]string{"--from", "invalid-date"}) - withMockClient(&testutil.MockCalendarClient{}, func() { + withMockClient(&MockCalendarClient{}, func() { err := cmd.Execute() testutil.Error(t, err) testutil.Contains(t, err.Error(), "invalid --from date") @@ -225,7 +225,7 @@ func TestEventsCommand_InvalidToDate(t *testing.T) { cmd := newEventsCommand() cmd.SetArgs([]string{"--to", "invalid-date"}) - withMockClient(&testutil.MockCalendarClient{}, func() { + withMockClient(&MockCalendarClient{}, func() { err := cmd.Execute() testutil.Error(t, err) testutil.Contains(t, err.Error(), "invalid --to date") @@ -233,7 +233,7 @@ func TestEventsCommand_InvalidToDate(t *testing.T) { } func TestGetCommand_Success(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ GetEventFunc: func(calendarID, eventID string) (*calendar.Event, error) { testutil.Equal(t, calendarID, "primary") testutil.Equal(t, eventID, "event123") @@ -257,7 +257,7 @@ func TestGetCommand_Success(t *testing.T) { } func TestGetCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ GetEventFunc: func(_, _ string) (*calendar.Event, error) { return testutil.SampleEvent("event123"), nil }, @@ -280,7 +280,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { } func TestGetCommand_NotFound(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ GetEventFunc: func(_, _ string) (*calendar.Event, error) { return nil, errors.New("event not found") }, @@ -297,7 +297,7 @@ func TestGetCommand_NotFound(t *testing.T) { } func TestTodayCommand_Success(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{testutil.SampleEvent("today_event")}, nil }, @@ -316,7 +316,7 @@ func TestTodayCommand_Success(t *testing.T) { } func TestWeekCommand_Success(t *testing.T) { - mock := &testutil.MockCalendarClient{ + mock := &MockCalendarClient{ ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{ testutil.SampleEvent("week_event1"), diff --git a/internal/cmd/calendar/mock_test.go b/internal/cmd/calendar/mock_test.go new file mode 100644 index 0000000..71df4cc --- /dev/null +++ b/internal/cmd/calendar/mock_test.go @@ -0,0 +1,36 @@ +package calendar + +import ( + "google.golang.org/api/calendar/v3" +) + +// MockCalendarClient is a configurable mock for CalendarClient. +type MockCalendarClient struct { + ListCalendarsFunc func() ([]*calendar.CalendarListEntry, error) + ListEventsFunc func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) + GetEventFunc func(calendarID, eventID string) (*calendar.Event, error) +} + +// Verify MockCalendarClient implements CalendarClient +var _ CalendarClient = (*MockCalendarClient)(nil) + +func (m *MockCalendarClient) ListCalendars() ([]*calendar.CalendarListEntry, error) { + if m.ListCalendarsFunc != nil { + return m.ListCalendarsFunc() + } + return nil, nil +} + +func (m *MockCalendarClient) ListEvents(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { + if m.ListEventsFunc != nil { + return m.ListEventsFunc(calendarID, timeMin, timeMax, maxResults) + } + return nil, nil +} + +func (m *MockCalendarClient) GetEvent(calendarID, eventID string) (*calendar.Event, error) { + if m.GetEventFunc != nil { + return m.GetEventFunc(calendarID, eventID) + } + return nil, nil +} diff --git a/internal/cmd/calendar/output.go b/internal/cmd/calendar/output.go index 2c494e6..e3ca1a0 100644 --- a/internal/cmd/calendar/output.go +++ b/internal/cmd/calendar/output.go @@ -4,18 +4,27 @@ import ( "context" "fmt" + calendarv3 "google.golang.org/api/calendar/v3" + "github.com/open-cli-collective/google-readonly/internal/calendar" "github.com/open-cli-collective/google-readonly/internal/output" ) +// CalendarClient defines the interface for Calendar client operations used by calendar commands. +type CalendarClient interface { + ListCalendars() ([]*calendarv3.CalendarListEntry, error) + ListEvents(calendarID string, timeMin, timeMax string, maxResults int64) ([]*calendarv3.Event, error) + GetEvent(calendarID, eventID string) (*calendarv3.Event, error) +} + // ClientFactory is the function used to create Calendar clients. // Override in tests to inject mocks. -var ClientFactory = func() (calendar.CalendarClientInterface, error) { +var ClientFactory = func() (CalendarClient, error) { return calendar.NewClient(context.Background()) } // newCalendarClient creates a new calendar client -func newCalendarClient() (calendar.CalendarClientInterface, error) { +func newCalendarClient() (CalendarClient, error) { return ClientFactory() } diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index f6495d6..b8a220d 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -32,9 +32,9 @@ func captureOutput(t *testing.T, f func()) string { } // withMockClient sets up a mock client factory for tests -func withMockClient(mock contactsapi.ContactsClientInterface, f func()) { +func withMockClient(mock ContactsClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (contactsapi.ContactsClientInterface, error) { + ClientFactory = func() (ContactsClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -44,7 +44,7 @@ func withMockClient(mock contactsapi.ContactsClientInterface, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (contactsapi.ContactsClientInterface, error) { + ClientFactory = func() (ContactsClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() @@ -52,7 +52,7 @@ func withFailingClientFactory(f func()) { } func TestListCommand_Success(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{ @@ -78,7 +78,7 @@ func TestListCommand_Success(t *testing.T) { } func TestListCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{ @@ -105,7 +105,7 @@ func TestListCommand_JSONOutput(t *testing.T) { } func TestListCommand_Empty(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{}, @@ -126,7 +126,7 @@ func TestListCommand_Empty(t *testing.T) { } func TestListCommand_APIError(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { return nil, errors.New("API error") }, @@ -152,7 +152,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { } func TestSearchCommand_Success(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ SearchContactsFunc: func(query string, _ int64) (*people.SearchResponse, error) { testutil.Equal(t, query, "John") return &people.SearchResponse{ @@ -178,7 +178,7 @@ func TestSearchCommand_Success(t *testing.T) { } func TestSearchCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { return &people.SearchResponse{ Results: []*people.SearchResult{ @@ -205,7 +205,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { } func TestSearchCommand_NoResults(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { return &people.SearchResponse{ Results: []*people.SearchResult{}, @@ -227,7 +227,7 @@ func TestSearchCommand_NoResults(t *testing.T) { } func TestSearchCommand_APIError(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { return nil, errors.New("API error") }, @@ -244,7 +244,7 @@ func TestSearchCommand_APIError(t *testing.T) { } func TestGetCommand_Success(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ GetContactFunc: func(resourceName string) (*people.Person, error) { testutil.Equal(t, resourceName, "people/c123") return testutil.SamplePerson("people/c123"), nil @@ -267,7 +267,7 @@ func TestGetCommand_Success(t *testing.T) { } func TestGetCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ GetContactFunc: func(_ string) (*people.Person, error) { return testutil.SamplePerson("people/c123"), nil }, @@ -290,7 +290,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { } func TestGetCommand_NotFound(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ GetContactFunc: func(_ string) (*people.Person, error) { return nil, errors.New("contact not found") }, @@ -307,7 +307,7 @@ func TestGetCommand_NotFound(t *testing.T) { } func TestGroupsCommand_Success(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{ @@ -343,7 +343,7 @@ func TestGroupsCommand_Success(t *testing.T) { } func TestGroupsCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{ @@ -376,7 +376,7 @@ func TestGroupsCommand_JSONOutput(t *testing.T) { } func TestGroupsCommand_Empty(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{}, @@ -397,7 +397,7 @@ func TestGroupsCommand_Empty(t *testing.T) { } func TestGroupsCommand_APIError(t *testing.T) { - mock := &testutil.MockContactsClient{ + mock := &MockContactsClient{ ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { return nil, errors.New("API error") }, diff --git a/internal/cmd/contacts/mock_test.go b/internal/cmd/contacts/mock_test.go new file mode 100644 index 0000000..6544df3 --- /dev/null +++ b/internal/cmd/contacts/mock_test.go @@ -0,0 +1,44 @@ +package contacts + +import ( + "google.golang.org/api/people/v1" +) + +// MockContactsClient is a configurable mock for ContactsClient. +type MockContactsClient struct { + ListContactsFunc func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) + SearchContactsFunc func(query string, pageSize int64) (*people.SearchResponse, error) + GetContactFunc func(resourceName string) (*people.Person, error) + ListContactGroupsFunc func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) +} + +// Verify MockContactsClient implements ContactsClient +var _ ContactsClient = (*MockContactsClient)(nil) + +func (m *MockContactsClient) ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { + if m.ListContactsFunc != nil { + return m.ListContactsFunc(pageToken, pageSize) + } + return nil, nil +} + +func (m *MockContactsClient) SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) { + if m.SearchContactsFunc != nil { + return m.SearchContactsFunc(query, pageSize) + } + return nil, nil +} + +func (m *MockContactsClient) GetContact(resourceName string) (*people.Person, error) { + if m.GetContactFunc != nil { + return m.GetContactFunc(resourceName) + } + return nil, nil +} + +func (m *MockContactsClient) ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { + if m.ListContactGroupsFunc != nil { + return m.ListContactGroupsFunc(pageToken, pageSize) + } + return nil, nil +} diff --git a/internal/cmd/contacts/output.go b/internal/cmd/contacts/output.go index a3651f4..15c2860 100644 --- a/internal/cmd/contacts/output.go +++ b/internal/cmd/contacts/output.go @@ -4,18 +4,28 @@ import ( "context" "fmt" + "google.golang.org/api/people/v1" + "github.com/open-cli-collective/google-readonly/internal/contacts" "github.com/open-cli-collective/google-readonly/internal/output" ) +// ContactsClient defines the interface for Contacts client operations used by contacts commands. +type ContactsClient interface { + ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) + SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) + GetContact(resourceName string) (*people.Person, error) + ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) +} + // ClientFactory is the function used to create Contacts clients. // Override in tests to inject mocks. -var ClientFactory = func() (contacts.ContactsClientInterface, error) { +var ClientFactory = func() (ContactsClient, error) { return contacts.NewClient(context.Background()) } // newContactsClient creates a new contacts client -func newContactsClient() (contacts.ContactsClientInterface, error) { +func newContactsClient() (ContactsClient, error) { return ClientFactory() } diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index b0c0ed0..9af7c2f 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -118,7 +118,7 @@ func printSharedDrives(drives []*drive.SharedDrive) { } // resolveDriveScope converts command flags to a DriveScope, resolving drive names via cache -func resolveDriveScope(client drive.DriveClientInterface, myDrive bool, driveFlag string) (drive.DriveScope, error) { +func resolveDriveScope(client DriveClient, myDrive bool, driveFlag string) (drive.DriveScope, error) { // --my-drive flag if myDrive { return drive.DriveScope{MyDriveOnly: true}, nil diff --git a/internal/cmd/drive/drives_test.go b/internal/cmd/drive/drives_test.go index 87881c9..a906f4b 100644 --- a/internal/cmd/drive/drives_test.go +++ b/internal/cmd/drive/drives_test.go @@ -103,7 +103,7 @@ func TestLooksLikeDriveID(t *testing.T) { func TestResolveDriveScope(t *testing.T) { t.Run("returns MyDriveOnly when myDrive flag is true", func(t *testing.T) { - mock := &testutil.MockDriveClient{} + mock := &MockDriveClient{} scope, err := resolveDriveScope(mock, true, "") @@ -114,7 +114,7 @@ func TestResolveDriveScope(t *testing.T) { }) t.Run("returns AllDrives when no flags provided", func(t *testing.T) { - mock := &testutil.MockDriveClient{} + mock := &MockDriveClient{} scope, err := resolveDriveScope(mock, false, "") @@ -125,7 +125,7 @@ func TestResolveDriveScope(t *testing.T) { }) t.Run("returns DriveID directly when input looks like ID", func(t *testing.T) { - mock := &testutil.MockDriveClient{} + mock := &MockDriveClient{} scope, err := resolveDriveScope(mock, false, "0ALengineering123456") @@ -136,7 +136,7 @@ func TestResolveDriveScope(t *testing.T) { }) t.Run("resolves drive name to ID via API", func(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, @@ -152,7 +152,7 @@ func TestResolveDriveScope(t *testing.T) { }) t.Run("resolves drive name case-insensitively", func(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, @@ -167,7 +167,7 @@ func TestResolveDriveScope(t *testing.T) { }) t.Run("returns error when drive name not found", func(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index eb6429b..6009143 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -30,9 +30,9 @@ func captureOutput(t *testing.T, f func()) string { } // withMockClient sets up a mock client factory for tests -func withMockClient(mock driveapi.DriveClientInterface, f func()) { +func withMockClient(mock DriveClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (driveapi.DriveClientInterface, error) { + ClientFactory = func() (DriveClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -42,7 +42,7 @@ func withMockClient(mock driveapi.DriveClientInterface, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (driveapi.DriveClientInterface, error) { + ClientFactory = func() (DriveClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() @@ -50,7 +50,7 @@ func withFailingClientFactory(f func()) { } func TestListCommand_Success(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "'root' in parents") return testutil.SampleDriveFiles(2), nil @@ -71,7 +71,7 @@ func TestListCommand_Success(t *testing.T) { } func TestListCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return testutil.SampleDriveFiles(1), nil }, @@ -94,7 +94,7 @@ func TestListCommand_JSONOutput(t *testing.T) { } func TestListCommand_Empty(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return []*driveapi.File{}, nil }, @@ -113,7 +113,7 @@ func TestListCommand_Empty(t *testing.T) { } func TestListCommand_WithFolder(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "'folder123' in parents") return testutil.SampleDriveFiles(1), nil @@ -134,7 +134,7 @@ func TestListCommand_WithFolder(t *testing.T) { } func TestListCommand_WithTypeFilter(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "mimeType") return testutil.SampleDriveFiles(1), nil @@ -158,7 +158,7 @@ func TestListCommand_InvalidType(t *testing.T) { cmd := newListCommand() cmd.SetArgs([]string{"--type", "invalid"}) - withMockClient(&testutil.MockDriveClient{}, func() { + withMockClient(&MockDriveClient{}, func() { err := cmd.Execute() testutil.Error(t, err) testutil.Contains(t, err.Error(), "unknown file type") @@ -166,7 +166,7 @@ func TestListCommand_InvalidType(t *testing.T) { } func TestListCommand_APIError(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return nil, errors.New("API error") }, @@ -192,7 +192,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { } func TestSearchCommand_Success(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "fullText contains 'report'") return testutil.SampleDriveFiles(2), nil @@ -214,7 +214,7 @@ func TestSearchCommand_Success(t *testing.T) { } func TestSearchCommand_NameOnly(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "name contains 'budget'") return testutil.SampleDriveFiles(1), nil @@ -235,7 +235,7 @@ func TestSearchCommand_NameOnly(t *testing.T) { } func TestSearchCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return testutil.SampleDriveFiles(1), nil }, @@ -258,7 +258,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { } func TestSearchCommand_NoResults(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return []*driveapi.File{}, nil }, @@ -278,7 +278,7 @@ func TestSearchCommand_NoResults(t *testing.T) { } func TestSearchCommand_APIError(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { return nil, errors.New("API error") }, @@ -295,7 +295,7 @@ func TestSearchCommand_APIError(t *testing.T) { } func TestGetCommand_Success(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(fileID string) (*driveapi.File, error) { testutil.Equal(t, fileID, "file123") return testutil.SampleDriveFile("file123"), nil @@ -318,7 +318,7 @@ func TestGetCommand_Success(t *testing.T) { } func TestGetCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, @@ -341,7 +341,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { } func TestGetCommand_NotFound(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return nil, errors.New("file not found") }, @@ -364,7 +364,7 @@ func TestDownloadCommand_RegularFile(t *testing.T) { os.Chdir(tmpDir) defer os.Chdir(origDir) - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, @@ -389,7 +389,7 @@ func TestDownloadCommand_RegularFile(t *testing.T) { } func TestDownloadCommand_ToStdout(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, @@ -412,7 +412,7 @@ func TestDownloadCommand_ToStdout(t *testing.T) { } func TestDownloadCommand_GoogleDocRequiresFormat(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleGoogleDoc("doc123"), nil }, @@ -435,7 +435,7 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { os.Chdir(tmpDir) defer os.Chdir(origDir) - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleGoogleDoc("doc123"), nil }, @@ -461,7 +461,7 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { } func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, @@ -478,7 +478,7 @@ func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { } func TestDownloadCommand_APIError(t *testing.T) { - mock := &testutil.MockDriveClient{ + mock := &MockDriveClient{ GetFileFunc: func(_ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index acd470f..607a0cf 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -141,7 +141,7 @@ func buildListQueryWithScope(folderID, fileType string, scope drive.DriveScope) // resolveDriveScopeForList resolves the scope for list operations // List has slightly different behavior - defaults to My Drive root if no flags -func resolveDriveScopeForList(client drive.DriveClientInterface, myDrive bool, driveFlag, folderID string) (drive.DriveScope, error) { +func resolveDriveScopeForList(client DriveClient, myDrive bool, driveFlag, folderID string) (drive.DriveScope, error) { // If a folder ID is provided, we need to support all drives to access it if folderID != "" && !myDrive && driveFlag == "" { return drive.DriveScope{AllDrives: true}, nil diff --git a/internal/cmd/drive/mock_test.go b/internal/cmd/drive/mock_test.go new file mode 100644 index 0000000..a66fa63 --- /dev/null +++ b/internal/cmd/drive/mock_test.go @@ -0,0 +1,64 @@ +package drive + +import ( + driveapi "github.com/open-cli-collective/google-readonly/internal/drive" +) + +// MockDriveClient is a configurable mock for DriveClient. +type MockDriveClient struct { + ListFilesFunc func(query string, pageSize int64) ([]*driveapi.File, error) + ListFilesWithScopeFunc func(query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) + GetFileFunc func(fileID string) (*driveapi.File, error) + DownloadFileFunc func(fileID string) ([]byte, error) + ExportFileFunc func(fileID, mimeType string) ([]byte, error) + ListSharedDrivesFunc func(pageSize int64) ([]*driveapi.SharedDrive, error) +} + +// Verify MockDriveClient implements DriveClient +var _ DriveClient = (*MockDriveClient)(nil) + +func (m *MockDriveClient) ListFiles(query string, pageSize int64) ([]*driveapi.File, error) { + if m.ListFilesFunc != nil { + return m.ListFilesFunc(query, pageSize) + } + return nil, nil +} + +func (m *MockDriveClient) ListFilesWithScope(query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) { + if m.ListFilesWithScopeFunc != nil { + return m.ListFilesWithScopeFunc(query, pageSize, scope) + } + // Fall back to ListFiles if no scope function defined + if m.ListFilesFunc != nil { + return m.ListFilesFunc(query, pageSize) + } + return nil, nil +} + +func (m *MockDriveClient) GetFile(fileID string) (*driveapi.File, error) { + if m.GetFileFunc != nil { + return m.GetFileFunc(fileID) + } + return nil, nil +} + +func (m *MockDriveClient) DownloadFile(fileID string) ([]byte, error) { + if m.DownloadFileFunc != nil { + return m.DownloadFileFunc(fileID) + } + return nil, nil +} + +func (m *MockDriveClient) ExportFile(fileID, mimeType string) ([]byte, error) { + if m.ExportFileFunc != nil { + return m.ExportFileFunc(fileID, mimeType) + } + return nil, nil +} + +func (m *MockDriveClient) ListSharedDrives(pageSize int64) ([]*driveapi.SharedDrive, error) { + if m.ListSharedDrivesFunc != nil { + return m.ListSharedDrivesFunc(pageSize) + } + return nil, nil +} diff --git a/internal/cmd/drive/output.go b/internal/cmd/drive/output.go index 4c7ceb4..c657619 100644 --- a/internal/cmd/drive/output.go +++ b/internal/cmd/drive/output.go @@ -7,14 +7,24 @@ import ( "github.com/open-cli-collective/google-readonly/internal/output" ) +// DriveClient defines the interface for Drive client operations used by drive commands. +type DriveClient interface { + ListFiles(query string, pageSize int64) ([]*drive.File, error) + ListFilesWithScope(query string, pageSize int64, scope drive.DriveScope) ([]*drive.File, error) + GetFile(fileID string) (*drive.File, error) + DownloadFile(fileID string) ([]byte, error) + ExportFile(fileID string, mimeType string) ([]byte, error) + ListSharedDrives(pageSize int64) ([]*drive.SharedDrive, error) +} + // ClientFactory is the function used to create Drive clients. // Override in tests to inject mocks. -var ClientFactory = func() (drive.DriveClientInterface, error) { +var ClientFactory = func() (DriveClient, error) { return drive.NewClient(context.Background()) } // newDriveClient creates and returns a new Drive client -func newDriveClient() (drive.DriveClientInterface, error) { +func newDriveClient() (DriveClient, error) { return ClientFactory() } diff --git a/internal/cmd/drive/tree.go b/internal/cmd/drive/tree.go index fbfeefc..294d6c8 100644 --- a/internal/cmd/drive/tree.go +++ b/internal/cmd/drive/tree.go @@ -94,12 +94,12 @@ Examples: } // buildTree recursively builds the folder tree structure -func buildTree(client drive.DriveClientInterface, folderID string, depth int, includeFiles bool) (*TreeNode, error) { +func buildTree(client DriveClient, folderID string, depth int, includeFiles bool) (*TreeNode, error) { return buildTreeWithScope(client, folderID, "", depth, includeFiles) } // buildTreeWithScope builds folder tree with optional root name override -func buildTreeWithScope(client drive.DriveClientInterface, folderID, rootName string, depth int, includeFiles bool) (*TreeNode, error) { +func buildTreeWithScope(client DriveClient, folderID, rootName string, depth int, includeFiles bool) (*TreeNode, error) { // Get folder info var folderName string var folderType string diff --git a/internal/cmd/drive/tree_test.go b/internal/cmd/drive/tree_test.go index 58ed42c..a7668f6 100644 --- a/internal/cmd/drive/tree_test.go +++ b/internal/cmd/drive/tree_test.go @@ -215,7 +215,7 @@ func TestTreeNode(t *testing.T) { }) } -// mockDriveClient implements drive.DriveClientInterface for testing +// mockDriveClient implements DriveClient for testing type mockDriveClient struct { files map[string]*drive.File // fileID -> File children map[string][]*drive.File // folderID -> children diff --git a/internal/cmd/initcmd/init_test.go b/internal/cmd/initcmd/init_test.go index 0d5332d..bd4a091 100644 --- a/internal/cmd/initcmd/init_test.go +++ b/internal/cmd/initcmd/init_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - "google.golang.org/api/googleapi" "github.com/open-cli-collective/google-readonly/internal/testutil" + "google.golang.org/api/googleapi" ) func TestInitCommand(t *testing.T) { diff --git a/internal/cmd/mail/attachments_download.go b/internal/cmd/mail/attachments_download.go index 39bcb91..010e2aa 100644 --- a/internal/cmd/mail/attachments_download.go +++ b/internal/cmd/mail/attachments_download.go @@ -135,7 +135,7 @@ Examples: return cmd } -func downloadAttachment(client gmail.GmailClientInterface, messageID string, att *gmail.Attachment) ([]byte, error) { +func downloadAttachment(client MailClient, messageID string, att *gmail.Attachment) ([]byte, error) { if att.AttachmentID != "" { return client.DownloadAttachment(messageID, att.AttachmentID) } diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index fdf6ef7..31c10f6 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -32,9 +32,9 @@ func captureOutput(t *testing.T, f func()) string { } // withMockClient sets up a mock client factory for tests -func withMockClient(mock gmailapi.GmailClientInterface, f func()) { +func withMockClient(mock MailClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (gmailapi.GmailClientInterface, error) { + ClientFactory = func() (MailClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -44,7 +44,7 @@ func withMockClient(mock gmailapi.GmailClientInterface, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (gmailapi.GmailClientInterface, error) { + ClientFactory = func() (MailClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() @@ -52,7 +52,7 @@ func withFailingClientFactory(f func()) { } func TestSearchCommand_Success(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { testutil.Equal(t, query, "is:unread") testutil.Equal(t, maxResults, int64(10)) @@ -77,7 +77,7 @@ func TestSearchCommand_Success(t *testing.T) { } func TestSearchCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return testutil.SampleMessages(1), 0, nil }, @@ -102,7 +102,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { } func TestSearchCommand_NoResults(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return []*gmailapi.Message{}, 0, nil }, @@ -122,7 +122,7 @@ func TestSearchCommand_NoResults(t *testing.T) { } func TestSearchCommand_APIError(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return nil, 0, errors.New("API quota exceeded") }, @@ -150,7 +150,7 @@ func TestSearchCommand_ClientCreationError(t *testing.T) { } func TestSearchCommand_SkippedMessages(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { return testutil.SampleMessages(2), 3, nil // 3 messages skipped }, @@ -170,7 +170,7 @@ func TestSearchCommand_SkippedMessages(t *testing.T) { } func TestReadCommand_Success(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetMessageFunc: func(messageID string, includeBody bool) (*gmailapi.Message, error) { testutil.Equal(t, messageID, "msg123") testutil.True(t, includeBody) @@ -194,7 +194,7 @@ func TestReadCommand_Success(t *testing.T) { } func TestReadCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetMessageFunc: func(_ string, _ bool) (*gmailapi.Message, error) { return testutil.SampleMessage("msg123"), nil }, @@ -217,7 +217,7 @@ func TestReadCommand_JSONOutput(t *testing.T) { } func TestReadCommand_NotFound(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetMessageFunc: func(_ string, _ bool) (*gmailapi.Message, error) { return nil, errors.New("message not found") }, @@ -234,7 +234,7 @@ func TestReadCommand_NotFound(t *testing.T) { } func TestThreadCommand_Success(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetThreadFunc: func(id string) ([]*gmailapi.Message, error) { testutil.Equal(t, id, "thread123") return testutil.SampleMessages(3), nil @@ -258,7 +258,7 @@ func TestThreadCommand_Success(t *testing.T) { } func TestThreadCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetThreadFunc: func(_ string) ([]*gmailapi.Message, error) { return testutil.SampleMessages(2), nil }, @@ -281,7 +281,7 @@ func TestThreadCommand_JSONOutput(t *testing.T) { } func TestLabelsCommand_Success(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ FetchLabelsFunc: func() error { return nil }, @@ -306,7 +306,7 @@ func TestLabelsCommand_Success(t *testing.T) { } func TestLabelsCommand_JSONOutput(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ FetchLabelsFunc: func() error { return nil }, @@ -332,7 +332,7 @@ func TestLabelsCommand_JSONOutput(t *testing.T) { } func TestLabelsCommand_Empty(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ FetchLabelsFunc: func() error { return nil }, @@ -354,7 +354,7 @@ func TestLabelsCommand_Empty(t *testing.T) { } func TestListAttachmentsCommand_Success(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetAttachmentsFunc: func(_ string) ([]*gmailapi.Attachment, error) { return []*gmailapi.Attachment{ testutil.SampleAttachment("report.pdf"), @@ -379,7 +379,7 @@ func TestListAttachmentsCommand_Success(t *testing.T) { } func TestListAttachmentsCommand_NoAttachments(t *testing.T) { - mock := &testutil.MockGmailClient{ + mock := &MockGmailClient{ GetAttachmentsFunc: func(_ string) ([]*gmailapi.Attachment, error) { return []*gmailapi.Attachment{}, nil }, diff --git a/internal/cmd/mail/labels_test.go b/internal/cmd/mail/labels_test.go index abc52c6..e84e235 100644 --- a/internal/cmd/mail/labels_test.go +++ b/internal/cmd/mail/labels_test.go @@ -3,8 +3,8 @@ package mail import ( "testing" - gmailapi "google.golang.org/api/gmail/v1" "github.com/open-cli-collective/google-readonly/internal/testutil" + gmailapi "google.golang.org/api/gmail/v1" ) func TestLabelsCommand(t *testing.T) { diff --git a/internal/cmd/mail/mock_test.go b/internal/cmd/mail/mock_test.go new file mode 100644 index 0000000..0bed671 --- /dev/null +++ b/internal/cmd/mail/mock_test.go @@ -0,0 +1,95 @@ +package mail + +import ( + "google.golang.org/api/gmail/v1" + + gmailapi "github.com/open-cli-collective/google-readonly/internal/gmail" +) + +// MockGmailClient is a configurable mock for MailClient. +// Set the function fields to control behavior in tests. +type MockGmailClient struct { + GetMessageFunc func(messageID string, includeBody bool) (*gmailapi.Message, error) + SearchMessagesFunc func(query string, maxResults int64) ([]*gmailapi.Message, int, error) + GetThreadFunc func(id string) ([]*gmailapi.Message, error) + FetchLabelsFunc func() error + GetLabelNameFunc func(labelID string) string + GetLabelsFunc func() []*gmail.Label + GetAttachmentsFunc func(messageID string) ([]*gmailapi.Attachment, error) + DownloadAttachmentFunc func(messageID, attachmentID string) ([]byte, error) + DownloadInlineAttachmentFunc func(messageID, partID string) ([]byte, error) + GetProfileFunc func() (*gmailapi.Profile, error) +} + +// Verify MockGmailClient implements MailClient +var _ MailClient = (*MockGmailClient)(nil) + +func (m *MockGmailClient) GetMessage(messageID string, includeBody bool) (*gmailapi.Message, error) { + if m.GetMessageFunc != nil { + return m.GetMessageFunc(messageID, includeBody) + } + return nil, nil +} + +func (m *MockGmailClient) SearchMessages(query string, maxResults int64) ([]*gmailapi.Message, int, error) { + if m.SearchMessagesFunc != nil { + return m.SearchMessagesFunc(query, maxResults) + } + return nil, 0, nil +} + +func (m *MockGmailClient) GetThread(id string) ([]*gmailapi.Message, error) { + if m.GetThreadFunc != nil { + return m.GetThreadFunc(id) + } + return nil, nil +} + +func (m *MockGmailClient) FetchLabels() error { + if m.FetchLabelsFunc != nil { + return m.FetchLabelsFunc() + } + return nil +} + +func (m *MockGmailClient) GetLabelName(labelID string) string { + if m.GetLabelNameFunc != nil { + return m.GetLabelNameFunc(labelID) + } + return labelID +} + +func (m *MockGmailClient) GetLabels() []*gmail.Label { + if m.GetLabelsFunc != nil { + return m.GetLabelsFunc() + } + return nil +} + +func (m *MockGmailClient) GetAttachments(messageID string) ([]*gmailapi.Attachment, error) { + if m.GetAttachmentsFunc != nil { + return m.GetAttachmentsFunc(messageID) + } + return nil, nil +} + +func (m *MockGmailClient) DownloadAttachment(messageID, attachmentID string) ([]byte, error) { + if m.DownloadAttachmentFunc != nil { + return m.DownloadAttachmentFunc(messageID, attachmentID) + } + return nil, nil +} + +func (m *MockGmailClient) DownloadInlineAttachment(messageID, partID string) ([]byte, error) { + if m.DownloadInlineAttachmentFunc != nil { + return m.DownloadInlineAttachmentFunc(messageID, partID) + } + return nil, nil +} + +func (m *MockGmailClient) GetProfile() (*gmailapi.Profile, error) { + if m.GetProfileFunc != nil { + return m.GetProfileFunc() + } + return nil, nil +} diff --git a/internal/cmd/mail/output.go b/internal/cmd/mail/output.go index 732b293..9f3fa10 100644 --- a/internal/cmd/mail/output.go +++ b/internal/cmd/mail/output.go @@ -5,18 +5,34 @@ import ( "fmt" "strings" + gmailv1 "google.golang.org/api/gmail/v1" + "github.com/open-cli-collective/google-readonly/internal/gmail" "github.com/open-cli-collective/google-readonly/internal/output" ) +// MailClient defines the interface for Gmail client operations used by mail commands. +type MailClient interface { + GetMessage(messageID string, includeBody bool) (*gmail.Message, error) + SearchMessages(query string, maxResults int64) ([]*gmail.Message, int, error) + GetThread(id string) ([]*gmail.Message, error) + FetchLabels() error + GetLabelName(labelID string) string + GetLabels() []*gmailv1.Label + GetAttachments(messageID string) ([]*gmail.Attachment, error) + DownloadAttachment(messageID string, attachmentID string) ([]byte, error) + DownloadInlineAttachment(messageID string, partID string) ([]byte, error) + GetProfile() (*gmail.Profile, error) +} + // ClientFactory is the function used to create Gmail clients. // Override in tests to inject mocks. -var ClientFactory = func() (gmail.GmailClientInterface, error) { +var ClientFactory = func() (MailClient, error) { return gmail.NewClient(context.Background()) } // newGmailClient creates and returns a new Gmail client -func newGmailClient() (gmail.GmailClientInterface, error) { +func newGmailClient() (MailClient, error) { return ClientFactory() } diff --git a/internal/contacts/interfaces.go b/internal/contacts/interfaces.go deleted file mode 100644 index 95ed9e3..0000000 --- a/internal/contacts/interfaces.go +++ /dev/null @@ -1,24 +0,0 @@ -package contacts - -import ( - "google.golang.org/api/people/v1" -) - -// ContactsClientInterface defines the interface for Contacts client operations. -// This enables unit testing through mock implementations. -type ContactsClientInterface interface { - // ListContacts retrieves contacts from the user's account - ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) - - // SearchContacts searches for contacts matching a query - SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) - - // GetContact retrieves a specific contact by resource name - GetContact(resourceName string) (*people.Person, error) - - // ListContactGroups retrieves all contact groups - ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) -} - -// Verify that Client implements ContactsClientInterface -var _ ContactsClientInterface = (*Client)(nil) diff --git a/internal/drive/interfaces.go b/internal/drive/interfaces.go deleted file mode 100644 index 8b55972..0000000 --- a/internal/drive/interfaces.go +++ /dev/null @@ -1,26 +0,0 @@ -package drive - -// DriveClientInterface defines the interface for Drive client operations. -// This enables unit testing through mock implementations. -type DriveClientInterface interface { - // ListFiles returns files matching the query (searches My Drive only for backwards compatibility) - ListFiles(query string, pageSize int64) ([]*File, error) - - // ListFilesWithScope returns files matching the query within the specified scope - ListFilesWithScope(query string, pageSize int64, scope DriveScope) ([]*File, error) - - // GetFile retrieves a single file by ID (supports all drives) - GetFile(fileID string) (*File, error) - - // DownloadFile downloads a regular (non-Google Workspace) file - DownloadFile(fileID string) ([]byte, error) - - // ExportFile exports a Google Workspace file to the specified MIME type - ExportFile(fileID string, mimeType string) ([]byte, error) - - // ListSharedDrives returns all shared drives accessible to the user - ListSharedDrives(pageSize int64) ([]*SharedDrive, error) -} - -// Verify that Client implements DriveClientInterface -var _ DriveClientInterface = (*Client)(nil) diff --git a/internal/gmail/client.go b/internal/gmail/client.go index e5333e4..8a7be2c 100644 --- a/internal/gmail/client.go +++ b/internal/gmail/client.go @@ -98,6 +98,13 @@ func (c *Client) GetLabels() []*gmail.Label { return labels } +// Profile represents a Gmail user profile. +type Profile struct { + EmailAddress string + MessagesTotal int64 + ThreadsTotal int64 +} + // GetProfile retrieves the authenticated user's profile func (c *Client) GetProfile() (*Profile, error) { profile, err := c.service.Users.GetProfile(c.userID).Do() diff --git a/internal/gmail/interfaces.go b/internal/gmail/interfaces.go deleted file mode 100644 index 3fea340..0000000 --- a/internal/gmail/interfaces.go +++ /dev/null @@ -1,49 +0,0 @@ -package gmail - -import ( - "google.golang.org/api/gmail/v1" -) - -// Profile represents a Gmail user profile. -type Profile struct { - EmailAddress string - MessagesTotal int64 - ThreadsTotal int64 -} - -// GmailClientInterface defines the interface for Gmail client operations. -// This enables unit testing through mock implementations. -type GmailClientInterface interface { - // GetMessage retrieves a single message by ID - GetMessage(messageID string, includeBody bool) (*Message, error) - - // SearchMessages searches for messages matching the query - SearchMessages(query string, maxResults int64) ([]*Message, int, error) - - // GetThread retrieves all messages in a thread - GetThread(id string) ([]*Message, error) - - // FetchLabels retrieves and caches all labels from the Gmail account - FetchLabels() error - - // GetLabelName resolves a label ID to its display name - GetLabelName(labelID string) string - - // GetLabels returns all cached labels - GetLabels() []*gmail.Label - - // GetAttachments retrieves attachment metadata for a message - GetAttachments(messageID string) ([]*Attachment, error) - - // DownloadAttachment downloads a single attachment by message ID and attachment ID - DownloadAttachment(messageID string, attachmentID string) ([]byte, error) - - // DownloadInlineAttachment downloads an attachment that has inline data - DownloadInlineAttachment(messageID string, partID string) ([]byte, error) - - // GetProfile retrieves the authenticated user's profile - GetProfile() (*Profile, error) -} - -// Verify that Client implements GmailClientInterface -var _ GmailClientInterface = (*Client)(nil) diff --git a/internal/log/log_test.go b/internal/log/log_test.go index c9d7991..bffd607 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -5,7 +5,6 @@ import ( "os" "strings" "testing" - ) func TestDebug_WhenVerboseTrue(t *testing.T) { diff --git a/internal/testutil/assert_test.go b/internal/testutil/assert_test.go index 7a04cd8..c8b2585 100644 --- a/internal/testutil/assert_test.go +++ b/internal/testutil/assert_test.go @@ -12,7 +12,7 @@ type mockT struct { message string } -func (m *mockT) Helper() {} +func (m *mockT) Helper() {} func (m *mockT) Errorf(format string, a ...any) { m.failed = true } func (m *mockT) Error(a ...any) { m.failed = true } func (m *mockT) Fatalf(format string, a ...any) { m.failed = true } diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go deleted file mode 100644 index 4a10737..0000000 --- a/internal/testutil/mocks.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package testutil provides test utilities including mock implementations -// of client interfaces for unit testing command handlers. -package testutil - -import ( - "google.golang.org/api/calendar/v3" - "google.golang.org/api/gmail/v1" - "google.golang.org/api/people/v1" - - calendarapi "github.com/open-cli-collective/google-readonly/internal/calendar" - contactsapi "github.com/open-cli-collective/google-readonly/internal/contacts" - driveapi "github.com/open-cli-collective/google-readonly/internal/drive" - gmailapi "github.com/open-cli-collective/google-readonly/internal/gmail" -) - -// MockGmailClient is a configurable mock for GmailClientInterface. -// Set the function fields to control behavior in tests. -type MockGmailClient struct { - GetMessageFunc func(messageID string, includeBody bool) (*gmailapi.Message, error) - SearchMessagesFunc func(query string, maxResults int64) ([]*gmailapi.Message, int, error) - GetThreadFunc func(id string) ([]*gmailapi.Message, error) - FetchLabelsFunc func() error - GetLabelNameFunc func(labelID string) string - GetLabelsFunc func() []*gmail.Label - GetAttachmentsFunc func(messageID string) ([]*gmailapi.Attachment, error) - DownloadAttachmentFunc func(messageID, attachmentID string) ([]byte, error) - DownloadInlineAttachmentFunc func(messageID, partID string) ([]byte, error) - GetProfileFunc func() (*gmailapi.Profile, error) -} - -// Verify MockGmailClient implements GmailClientInterface -var _ gmailapi.GmailClientInterface = (*MockGmailClient)(nil) - -func (m *MockGmailClient) GetMessage(messageID string, includeBody bool) (*gmailapi.Message, error) { - if m.GetMessageFunc != nil { - return m.GetMessageFunc(messageID, includeBody) - } - return nil, nil -} - -func (m *MockGmailClient) SearchMessages(query string, maxResults int64) ([]*gmailapi.Message, int, error) { - if m.SearchMessagesFunc != nil { - return m.SearchMessagesFunc(query, maxResults) - } - return nil, 0, nil -} - -func (m *MockGmailClient) GetThread(id string) ([]*gmailapi.Message, error) { - if m.GetThreadFunc != nil { - return m.GetThreadFunc(id) - } - return nil, nil -} - -func (m *MockGmailClient) FetchLabels() error { - if m.FetchLabelsFunc != nil { - return m.FetchLabelsFunc() - } - return nil -} - -func (m *MockGmailClient) GetLabelName(labelID string) string { - if m.GetLabelNameFunc != nil { - return m.GetLabelNameFunc(labelID) - } - return labelID -} - -func (m *MockGmailClient) GetLabels() []*gmail.Label { - if m.GetLabelsFunc != nil { - return m.GetLabelsFunc() - } - return nil -} - -func (m *MockGmailClient) GetAttachments(messageID string) ([]*gmailapi.Attachment, error) { - if m.GetAttachmentsFunc != nil { - return m.GetAttachmentsFunc(messageID) - } - return nil, nil -} - -func (m *MockGmailClient) DownloadAttachment(messageID, attachmentID string) ([]byte, error) { - if m.DownloadAttachmentFunc != nil { - return m.DownloadAttachmentFunc(messageID, attachmentID) - } - return nil, nil -} - -func (m *MockGmailClient) DownloadInlineAttachment(messageID, partID string) ([]byte, error) { - if m.DownloadInlineAttachmentFunc != nil { - return m.DownloadInlineAttachmentFunc(messageID, partID) - } - return nil, nil -} - -func (m *MockGmailClient) GetProfile() (*gmailapi.Profile, error) { - if m.GetProfileFunc != nil { - return m.GetProfileFunc() - } - return nil, nil -} - -// MockCalendarClient is a configurable mock for CalendarClientInterface. -type MockCalendarClient struct { - ListCalendarsFunc func() ([]*calendar.CalendarListEntry, error) - ListEventsFunc func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) - GetEventFunc func(calendarID, eventID string) (*calendar.Event, error) -} - -// Verify MockCalendarClient implements CalendarClientInterface -var _ calendarapi.CalendarClientInterface = (*MockCalendarClient)(nil) - -func (m *MockCalendarClient) ListCalendars() ([]*calendar.CalendarListEntry, error) { - if m.ListCalendarsFunc != nil { - return m.ListCalendarsFunc() - } - return nil, nil -} - -func (m *MockCalendarClient) ListEvents(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { - if m.ListEventsFunc != nil { - return m.ListEventsFunc(calendarID, timeMin, timeMax, maxResults) - } - return nil, nil -} - -func (m *MockCalendarClient) GetEvent(calendarID, eventID string) (*calendar.Event, error) { - if m.GetEventFunc != nil { - return m.GetEventFunc(calendarID, eventID) - } - return nil, nil -} - -// MockContactsClient is a configurable mock for ContactsClientInterface. -type MockContactsClient struct { - ListContactsFunc func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) - SearchContactsFunc func(query string, pageSize int64) (*people.SearchResponse, error) - GetContactFunc func(resourceName string) (*people.Person, error) - ListContactGroupsFunc func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) -} - -// Verify MockContactsClient implements ContactsClientInterface -var _ contactsapi.ContactsClientInterface = (*MockContactsClient)(nil) - -func (m *MockContactsClient) ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { - if m.ListContactsFunc != nil { - return m.ListContactsFunc(pageToken, pageSize) - } - return nil, nil -} - -func (m *MockContactsClient) SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) { - if m.SearchContactsFunc != nil { - return m.SearchContactsFunc(query, pageSize) - } - return nil, nil -} - -func (m *MockContactsClient) GetContact(resourceName string) (*people.Person, error) { - if m.GetContactFunc != nil { - return m.GetContactFunc(resourceName) - } - return nil, nil -} - -func (m *MockContactsClient) ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { - if m.ListContactGroupsFunc != nil { - return m.ListContactGroupsFunc(pageToken, pageSize) - } - return nil, nil -} - -// MockDriveClient is a configurable mock for DriveClientInterface. -type MockDriveClient struct { - ListFilesFunc func(query string, pageSize int64) ([]*driveapi.File, error) - ListFilesWithScopeFunc func(query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) - GetFileFunc func(fileID string) (*driveapi.File, error) - DownloadFileFunc func(fileID string) ([]byte, error) - ExportFileFunc func(fileID, mimeType string) ([]byte, error) - ListSharedDrivesFunc func(pageSize int64) ([]*driveapi.SharedDrive, error) -} - -// Verify MockDriveClient implements DriveClientInterface -var _ driveapi.DriveClientInterface = (*MockDriveClient)(nil) - -func (m *MockDriveClient) ListFiles(query string, pageSize int64) ([]*driveapi.File, error) { - if m.ListFilesFunc != nil { - return m.ListFilesFunc(query, pageSize) - } - return nil, nil -} - -func (m *MockDriveClient) ListFilesWithScope(query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) { - if m.ListFilesWithScopeFunc != nil { - return m.ListFilesWithScopeFunc(query, pageSize, scope) - } - // Fall back to ListFiles if no scope function defined - if m.ListFilesFunc != nil { - return m.ListFilesFunc(query, pageSize) - } - return nil, nil -} - -func (m *MockDriveClient) GetFile(fileID string) (*driveapi.File, error) { - if m.GetFileFunc != nil { - return m.GetFileFunc(fileID) - } - return nil, nil -} - -func (m *MockDriveClient) DownloadFile(fileID string) ([]byte, error) { - if m.DownloadFileFunc != nil { - return m.DownloadFileFunc(fileID) - } - return nil, nil -} - -func (m *MockDriveClient) ExportFile(fileID, mimeType string) ([]byte, error) { - if m.ExportFileFunc != nil { - return m.ExportFileFunc(fileID, mimeType) - } - return nil, nil -} - -func (m *MockDriveClient) ListSharedDrives(pageSize int64) ([]*driveapi.SharedDrive, error) { - if m.ListSharedDrivesFunc != nil { - return m.ListSharedDrivesFunc(pageSize) - } - return nil, nil -} From e26e33e9a2b4adaf7a6394f21768125180720b9a Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:32:46 -0500 Subject: [PATCH 06/13] refactor: propagate cmd.Context() instead of context.Background() All command handlers now extract context from the cobra command and pass it through to client constructors. This enables proper cancellation via signal handling. The ClientFactory signature now accepts context.Context. --- internal/cmd/calendar/events.go | 4 ++-- internal/cmd/calendar/get.go | 4 ++-- internal/cmd/calendar/handlers_test.go | 5 +++-- internal/cmd/calendar/list.go | 4 ++-- internal/cmd/calendar/output.go | 8 ++++---- internal/cmd/calendar/today.go | 4 ++-- internal/cmd/calendar/week.go | 4 ++-- internal/cmd/config/config.go | 9 ++++----- internal/cmd/contacts/get.go | 4 ++-- internal/cmd/contacts/groups.go | 4 ++-- internal/cmd/contacts/handlers_test.go | 5 +++-- internal/cmd/contacts/list.go | 4 ++-- internal/cmd/contacts/output.go | 8 ++++---- internal/cmd/contacts/search.go | 4 ++-- internal/cmd/drive/download.go | 4 ++-- internal/cmd/drive/drives.go | 4 ++-- internal/cmd/drive/get.go | 4 ++-- internal/cmd/drive/handlers_test.go | 5 +++-- internal/cmd/drive/list.go | 4 ++-- internal/cmd/drive/output.go | 8 ++++---- internal/cmd/drive/search.go | 4 ++-- internal/cmd/drive/tree.go | 4 ++-- internal/cmd/initcmd/init.go | 13 ++++++------- internal/cmd/mail/attachments_download.go | 4 ++-- internal/cmd/mail/attachments_list.go | 4 ++-- internal/cmd/mail/handlers_test.go | 5 +++-- internal/cmd/mail/labels.go | 4 ++-- internal/cmd/mail/output.go | 8 ++++---- internal/cmd/mail/read.go | 4 ++-- internal/cmd/mail/search.go | 4 ++-- internal/cmd/mail/thread.go | 4 ++-- 31 files changed, 80 insertions(+), 78 deletions(-) diff --git a/internal/cmd/calendar/events.go b/internal/cmd/calendar/events.go index c2b0c98..56e6d2f 100644 --- a/internal/cmd/calendar/events.go +++ b/internal/cmd/calendar/events.go @@ -32,13 +32,13 @@ Examples: gro cal events --from 2026-01-01 --to 2026-01-31 gro calendar events work@group.calendar.google.com --json`, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { calID := calendarID if len(args) > 0 { calID = args[0] } - client, err := newCalendarClient() + client, err := newCalendarClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Calendar client: %w", err) } diff --git a/internal/cmd/calendar/get.go b/internal/cmd/calendar/get.go index 54ef26c..6eeaa84 100644 --- a/internal/cmd/calendar/get.go +++ b/internal/cmd/calendar/get.go @@ -26,10 +26,10 @@ Examples: gro cal get abc123xyz --json gro cal get abc123xyz --calendar work@group.calendar.google.com`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { eventID := args[0] - client, err := newCalendarClient() + client, err := newCalendarClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Calendar client: %w", err) } diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index df86f5a..a1c9ca9 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -2,6 +2,7 @@ package calendar import ( "bytes" + "context" "encoding/json" "errors" "io" @@ -34,7 +35,7 @@ func captureOutput(t *testing.T, f func()) string { // withMockClient sets up a mock client factory for tests func withMockClient(mock CalendarClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (CalendarClient, error) { + ClientFactory = func(_ context.Context) (CalendarClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -44,7 +45,7 @@ func withMockClient(mock CalendarClient, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (CalendarClient, error) { + ClientFactory = func(_ context.Context) (CalendarClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() diff --git a/internal/cmd/calendar/list.go b/internal/cmd/calendar/list.go index 96c37e9..d3561f0 100644 --- a/internal/cmd/calendar/list.go +++ b/internal/cmd/calendar/list.go @@ -22,8 +22,8 @@ Examples: gro calendar list gro cal list --json`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newCalendarClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newCalendarClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Calendar client: %w", err) } diff --git a/internal/cmd/calendar/output.go b/internal/cmd/calendar/output.go index e3ca1a0..697b2c9 100644 --- a/internal/cmd/calendar/output.go +++ b/internal/cmd/calendar/output.go @@ -19,13 +19,13 @@ type CalendarClient interface { // ClientFactory is the function used to create Calendar clients. // Override in tests to inject mocks. -var ClientFactory = func() (CalendarClient, error) { - return calendar.NewClient(context.Background()) +var ClientFactory = func(ctx context.Context) (CalendarClient, error) { + return calendar.NewClient(ctx) } // newCalendarClient creates a new calendar client -func newCalendarClient() (CalendarClient, error) { - return ClientFactory() +func newCalendarClient(ctx context.Context) (CalendarClient, error) { + return ClientFactory(ctx) } // printJSON outputs data as indented JSON diff --git a/internal/cmd/calendar/today.go b/internal/cmd/calendar/today.go index c7468a6..34be3b1 100644 --- a/internal/cmd/calendar/today.go +++ b/internal/cmd/calendar/today.go @@ -25,8 +25,8 @@ Examples: gro cal today --json gro cal today --calendar work@group.calendar.google.com`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newCalendarClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newCalendarClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Calendar client: %w", err) } diff --git a/internal/cmd/calendar/week.go b/internal/cmd/calendar/week.go index 5fa051f..c403db2 100644 --- a/internal/cmd/calendar/week.go +++ b/internal/cmd/calendar/week.go @@ -25,8 +25,8 @@ Examples: gro cal week --json gro cal week --calendar work@group.calendar.google.com`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newCalendarClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newCalendarClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Calendar client: %w", err) } diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 1190761..56b046c 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -1,7 +1,6 @@ package config import ( - "context" "fmt" "os" "time" @@ -65,7 +64,7 @@ The credentials.json file (OAuth client config) is not removed.`, } } -func runShow(_ *cobra.Command, _ []string) error { +func runShow(cmd *cobra.Command, _ []string) error { // Check credentials file credPath, err := gmail.GetCredentialsPath() if err != nil { @@ -111,7 +110,7 @@ func runShow(_ *cobra.Command, _ []string) error { // Show email if we can get it without triggering auth if keychain.HasStoredToken() && credStatus == "OK" { - if client, err := gmail.NewClient(context.Background()); err == nil { + if client, err := gmail.NewClient(cmd.Context()); err == nil { if profile, err := client.GetProfile(); err == nil { fmt.Printf("Email: %s\n", profile.EmailAddress) } @@ -127,7 +126,7 @@ func runShow(_ *cobra.Command, _ []string) error { return nil } -func runTest(_ *cobra.Command, _ []string) error { +func runTest(cmd *cobra.Command, _ []string) error { fmt.Println("Testing Gmail API connection...") fmt.Println() @@ -141,7 +140,7 @@ func runTest(_ *cobra.Command, _ []string) error { fmt.Println(" OAuth token: Found") // Try to create client (tests token validity) - client, err := gmail.NewClient(context.Background()) + client, err := gmail.NewClient(cmd.Context()) if err != nil { fmt.Println(" Token valid: FAILED") fmt.Println() diff --git a/internal/cmd/contacts/get.go b/internal/cmd/contacts/get.go index ecc1481..70c75c6 100644 --- a/internal/cmd/contacts/get.go +++ b/internal/cmd/contacts/get.go @@ -23,10 +23,10 @@ Examples: gro contacts get people/c123456789 gro ppl get people/c123456789 --json`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { resourceName := args[0] - client, err := newContactsClient() + client, err := newContactsClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Contacts client: %w", err) } diff --git a/internal/cmd/contacts/groups.go b/internal/cmd/contacts/groups.go index 9ce61a7..cf370af 100644 --- a/internal/cmd/contacts/groups.go +++ b/internal/cmd/contacts/groups.go @@ -26,8 +26,8 @@ Examples: gro contacts groups --max 50 gro ppl groups --json`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newContactsClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newContactsClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Contacts client: %w", err) } diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index b8a220d..4d264a0 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -2,6 +2,7 @@ package contacts import ( "bytes" + "context" "encoding/json" "errors" "io" @@ -34,7 +35,7 @@ func captureOutput(t *testing.T, f func()) string { // withMockClient sets up a mock client factory for tests func withMockClient(mock ContactsClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (ContactsClient, error) { + ClientFactory = func(_ context.Context) (ContactsClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -44,7 +45,7 @@ func withMockClient(mock ContactsClient, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (ContactsClient, error) { + ClientFactory = func(_ context.Context) (ContactsClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() diff --git a/internal/cmd/contacts/list.go b/internal/cmd/contacts/list.go index 123fadd..03ac67a 100644 --- a/internal/cmd/contacts/list.go +++ b/internal/cmd/contacts/list.go @@ -26,8 +26,8 @@ Examples: gro contacts list --max 50 gro ppl list --json`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newContactsClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newContactsClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Contacts client: %w", err) } diff --git a/internal/cmd/contacts/output.go b/internal/cmd/contacts/output.go index 15c2860..0266a07 100644 --- a/internal/cmd/contacts/output.go +++ b/internal/cmd/contacts/output.go @@ -20,13 +20,13 @@ type ContactsClient interface { // ClientFactory is the function used to create Contacts clients. // Override in tests to inject mocks. -var ClientFactory = func() (ContactsClient, error) { - return contacts.NewClient(context.Background()) +var ClientFactory = func(ctx context.Context) (ContactsClient, error) { + return contacts.NewClient(ctx) } // newContactsClient creates a new contacts client -func newContactsClient() (ContactsClient, error) { - return ClientFactory() +func newContactsClient(ctx context.Context) (ContactsClient, error) { + return ClientFactory(ctx) } // printJSON outputs data as indented JSON diff --git a/internal/cmd/contacts/search.go b/internal/cmd/contacts/search.go index 8fe9045..d9d312b 100644 --- a/internal/cmd/contacts/search.go +++ b/internal/cmd/contacts/search.go @@ -32,10 +32,10 @@ Examples: gro contacts search "+1-555" --max 20 gro ppl search "Acme" --json`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { query := args[0] - client, err := newContactsClient() + client, err := newContactsClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Contacts client: %w", err) } diff --git a/internal/cmd/drive/download.go b/internal/cmd/drive/download.go index 50999b2..a33d18c 100644 --- a/internal/cmd/drive/download.go +++ b/internal/cmd/drive/download.go @@ -41,8 +41,8 @@ Export formats: Presentations: pdf, pptx, odp Drawings: pdf, png, svg, jpg`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - client, err := newDriveClient() + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newDriveClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Drive client: %w", err) } diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index 9af7c2f..838b078 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -31,8 +31,8 @@ Examples: gro drive drives --refresh # Force refresh from API gro drive drives --json # Output as JSON`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newDriveClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newDriveClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Drive client: %w", err) } diff --git a/internal/cmd/drive/get.go b/internal/cmd/drive/get.go index 58ae05c..b71b552 100644 --- a/internal/cmd/drive/get.go +++ b/internal/cmd/drive/get.go @@ -22,8 +22,8 @@ Examples: gro drive get # Show file details gro drive get --json # Output as JSON`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - client, err := newDriveClient() + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newDriveClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Drive client: %w", err) } diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index 6009143..95b28c9 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -2,6 +2,7 @@ package drive import ( "bytes" + "context" "encoding/json" "errors" "io" @@ -32,7 +33,7 @@ func captureOutput(t *testing.T, f func()) string { // withMockClient sets up a mock client factory for tests func withMockClient(mock DriveClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (DriveClient, error) { + ClientFactory = func(_ context.Context) (DriveClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -42,7 +43,7 @@ func withMockClient(mock DriveClient, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (DriveClient, error) { + ClientFactory = func(_ context.Context) (DriveClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index 607a0cf..5cad92a 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -39,13 +39,13 @@ Examples: File types: document, spreadsheet, presentation, folder, pdf, image, video, audio`, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { // Validate mutually exclusive flags if myDrive && driveFlag != "" { return fmt.Errorf("--my-drive and --drive are mutually exclusive") } - client, err := newDriveClient() + client, err := newDriveClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Drive client: %w", err) } diff --git a/internal/cmd/drive/output.go b/internal/cmd/drive/output.go index c657619..159cdf9 100644 --- a/internal/cmd/drive/output.go +++ b/internal/cmd/drive/output.go @@ -19,13 +19,13 @@ type DriveClient interface { // ClientFactory is the function used to create Drive clients. // Override in tests to inject mocks. -var ClientFactory = func() (DriveClient, error) { - return drive.NewClient(context.Background()) +var ClientFactory = func(ctx context.Context) (DriveClient, error) { + return drive.NewClient(ctx) } // newDriveClient creates and returns a new Drive client -func newDriveClient() (DriveClient, error) { - return ClientFactory() +func newDriveClient(ctx context.Context) (DriveClient, error) { + return ClientFactory(ctx) } // printJSON encodes data as indented JSON to stdout diff --git a/internal/cmd/drive/search.go b/internal/cmd/drive/search.go index 4b7a498..65e394b 100644 --- a/internal/cmd/drive/search.go +++ b/internal/cmd/drive/search.go @@ -44,13 +44,13 @@ Examples: File types: document, spreadsheet, presentation, folder, pdf, image, video, audio`, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { // Validate mutually exclusive flags if myDrive && driveFlag != "" { return fmt.Errorf("--my-drive and --drive are mutually exclusive") } - client, err := newDriveClient() + client, err := newDriveClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Drive client: %w", err) } diff --git a/internal/cmd/drive/tree.go b/internal/cmd/drive/tree.go index 294d6c8..b5575e4 100644 --- a/internal/cmd/drive/tree.go +++ b/internal/cmd/drive/tree.go @@ -42,13 +42,13 @@ Examples: gro drive tree --files # Include files, not just folders gro drive tree --json # Output as JSON`, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { // Validate mutually exclusive flags if myDrive && driveFlag != "" { return fmt.Errorf("--my-drive and --drive are mutually exclusive") } - client, err := newDriveClient() + client, err := newDriveClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Drive client: %w", err) } diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index 1a94229..09b821f 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -46,7 +46,7 @@ Prerequisites: return cmd } -func runInit(_ *cobra.Command, _ []string) error { +func runInit(cmd *cobra.Command, _ []string) error { // Step 1: Check for credentials.json credPath, err := gmail.GetCredentialsPath() if err != nil { @@ -74,7 +74,7 @@ func runInit(_ *cobra.Command, _ []string) error { fmt.Println() if !noVerify { - err := verifyConnectivity() + err := verifyConnectivity(cmd.Context()) if err == nil { promptCacheTTL() return nil @@ -141,8 +141,7 @@ func runInit(_ *cobra.Command, _ []string) error { fmt.Println() fmt.Println("Exchanging authorization code...") - ctx := context.Background() - token, err := gmail.ExchangeAuthCode(ctx, config, code) + token, err := gmail.ExchangeAuthCode(cmd.Context(), config, code) if err != nil { return fmt.Errorf("exchanging authorization code: %w", err) } @@ -156,7 +155,7 @@ func runInit(_ *cobra.Command, _ []string) error { // Step 7: Verify connectivity (unless --no-verify) if !noVerify { fmt.Println() - if err := verifyConnectivity(); err != nil { + if err := verifyConnectivity(cmd.Context()); err != nil { return err } promptCacheTTL() @@ -190,10 +189,10 @@ func extractAuthCode(input string) string { } // verifyConnectivity tests the Gmail API connection -func verifyConnectivity() error { +func verifyConnectivity(ctx context.Context) error { fmt.Println("Verifying Gmail API connection...") - client, err := gmail.NewClient(context.Background()) + client, err := gmail.NewClient(ctx) if err != nil { fmt.Println(" OAuth token: FAILED") return fmt.Errorf("creating client: %w", err) diff --git a/internal/cmd/mail/attachments_download.go b/internal/cmd/mail/attachments_download.go index 010e2aa..0a800c8 100644 --- a/internal/cmd/mail/attachments_download.go +++ b/internal/cmd/mail/attachments_download.go @@ -38,12 +38,12 @@ Examples: gro mail attachments download 18abc123def456 --all --output ~/Downloads gro mail attachments download 18abc123def456 --filename archive.zip --extract`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { if filename == "" && !all { return fmt.Errorf("must specify --filename or --all") } - client, err := newGmailClient() + client, err := newGmailClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Gmail client: %w", err) } diff --git a/internal/cmd/mail/attachments_list.go b/internal/cmd/mail/attachments_list.go index 7d04a99..eabcfe8 100644 --- a/internal/cmd/mail/attachments_list.go +++ b/internal/cmd/mail/attachments_list.go @@ -22,8 +22,8 @@ Examples: gro mail attachments list 18abc123def456 gro mail attachments list 18abc123def456 --json`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - client, err := newGmailClient() + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newGmailClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Gmail client: %w", err) } diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index 31c10f6..9855e55 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -2,6 +2,7 @@ package mail import ( "bytes" + "context" "encoding/json" "errors" "io" @@ -34,7 +35,7 @@ func captureOutput(t *testing.T, f func()) string { // withMockClient sets up a mock client factory for tests func withMockClient(mock MailClient, f func()) { originalFactory := ClientFactory - ClientFactory = func() (MailClient, error) { + ClientFactory = func(_ context.Context) (MailClient, error) { return mock, nil } defer func() { ClientFactory = originalFactory }() @@ -44,7 +45,7 @@ func withMockClient(mock MailClient, f func()) { // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { originalFactory := ClientFactory - ClientFactory = func() (MailClient, error) { + ClientFactory = func(_ context.Context) (MailClient, error) { return nil, errors.New("connection failed") } defer func() { ClientFactory = originalFactory }() diff --git a/internal/cmd/mail/labels.go b/internal/cmd/mail/labels.go index e152db3..c05b1f5 100644 --- a/internal/cmd/mail/labels.go +++ b/internal/cmd/mail/labels.go @@ -34,8 +34,8 @@ Examples: gro mail labels gro mail labels --json`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - client, err := newGmailClient() + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newGmailClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Gmail client: %w", err) } diff --git a/internal/cmd/mail/output.go b/internal/cmd/mail/output.go index 9f3fa10..c90ea7b 100644 --- a/internal/cmd/mail/output.go +++ b/internal/cmd/mail/output.go @@ -27,13 +27,13 @@ type MailClient interface { // ClientFactory is the function used to create Gmail clients. // Override in tests to inject mocks. -var ClientFactory = func() (MailClient, error) { - return gmail.NewClient(context.Background()) +var ClientFactory = func(ctx context.Context) (MailClient, error) { + return gmail.NewClient(ctx) } // newGmailClient creates and returns a new Gmail client -func newGmailClient() (MailClient, error) { - return ClientFactory() +func newGmailClient(ctx context.Context) (MailClient, error) { + return ClientFactory(ctx) } // printJSON encodes data as indented JSON to stdout diff --git a/internal/cmd/mail/read.go b/internal/cmd/mail/read.go index 90ca478..8cc7c0f 100644 --- a/internal/cmd/mail/read.go +++ b/internal/cmd/mail/read.go @@ -20,8 +20,8 @@ Examples: gro mail read 18abc123def456 gro mail read 18abc123def456 --json`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - client, err := newGmailClient() + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newGmailClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Gmail client: %w", err) } diff --git a/internal/cmd/mail/search.go b/internal/cmd/mail/search.go index 331721a..ca922a0 100644 --- a/internal/cmd/mail/search.go +++ b/internal/cmd/mail/search.go @@ -25,8 +25,8 @@ Examples: For more query operators, see: https://support.google.com/mail/answer/7190`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - client, err := newGmailClient() + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newGmailClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Gmail client: %w", err) } diff --git a/internal/cmd/mail/thread.go b/internal/cmd/mail/thread.go index 5bf3af6..572e67f 100644 --- a/internal/cmd/mail/thread.go +++ b/internal/cmd/mail/thread.go @@ -23,8 +23,8 @@ Examples: gro mail thread 18abc123def456 gro mail thread 18abc123def456 --json`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - client, err := newGmailClient() + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newGmailClient(cmd.Context()) if err != nil { return fmt.Errorf("creating Gmail client: %w", err) } From c8c08994f81e3bb57585a9e264768de1894caa25 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:33:14 -0500 Subject: [PATCH 07/13] feat: add signal handling with context propagation Main now creates a signal-aware context (SIGINT, SIGTERM) and passes it through ExecuteContext, enabling graceful cancellation of in-flight API calls when the user presses Ctrl+C. --- cmd/gro/main.go | 9 ++++++++- internal/cmd/root/root.go | 10 ++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/gro/main.go b/cmd/gro/main.go index 6ff498d..e4cb5eb 100644 --- a/cmd/gro/main.go +++ b/cmd/gro/main.go @@ -1,9 +1,16 @@ package main import ( + "context" + "os" + "os/signal" + "syscall" + "github.com/open-cli-collective/google-readonly/internal/cmd/root" ) func main() { - root.Execute() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + root.ExecuteContext(ctx) } diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index b3a5e0e..05be8ea 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -1,6 +1,7 @@ package root import ( + "context" "fmt" "os" @@ -36,9 +37,14 @@ This will guide you through OAuth setup for Google API access.`, }, } -// Execute runs the root command +// Execute runs the root command with a background context func Execute() { - if err := rootCmd.Execute(); err != nil { + ExecuteContext(context.Background()) +} + +// ExecuteContext runs the root command with the given context +func ExecuteContext(ctx context.Context) { + if err := rootCmd.ExecuteContext(ctx); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } From c6f8ec7a9189d7b2d5d00b2ecf7c338edcfc45b0 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:36:28 -0500 Subject: [PATCH 08/13] refactor: remove deprecated gmail wrapper functions Callers now use auth.X() directly instead of gmail.X() wrappers. Removes GetConfigDir, GetCredentialsPath, GetOAuthConfig, ExchangeAuthCode, GetAuthURL, and ShortenPath from the gmail package. --- internal/cmd/config/config.go | 3 +- internal/cmd/initcmd/init.go | 11 ++++--- internal/gmail/client.go | 37 --------------------- internal/gmail/client_test.go | 61 ----------------------------------- 4 files changed, 8 insertions(+), 104 deletions(-) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 56b046c..b51d107 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + "github.com/open-cli-collective/google-readonly/internal/auth" "github.com/open-cli-collective/google-readonly/internal/gmail" "github.com/open-cli-collective/google-readonly/internal/keychain" ) @@ -66,7 +67,7 @@ The credentials.json file (OAuth client config) is not removed.`, func runShow(cmd *cobra.Command, _ []string) error { // Check credentials file - credPath, err := gmail.GetCredentialsPath() + credPath, err := auth.GetCredentialsPath() if err != nil { return fmt.Errorf("getting credentials path: %w", err) } diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index 09b821f..a32a2c0 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "google.golang.org/api/googleapi" + "github.com/open-cli-collective/google-readonly/internal/auth" "github.com/open-cli-collective/google-readonly/internal/config" "github.com/open-cli-collective/google-readonly/internal/gmail" "github.com/open-cli-collective/google-readonly/internal/keychain" @@ -48,12 +49,12 @@ Prerequisites: func runInit(cmd *cobra.Command, _ []string) error { // Step 1: Check for credentials.json - credPath, err := gmail.GetCredentialsPath() + credPath, err := auth.GetCredentialsPath() if err != nil { return fmt.Errorf("getting credentials path: %w", err) } - shortPath := gmail.ShortenPath(credPath) + shortPath := auth.ShortenPath(credPath) if _, err := os.Stat(credPath); os.IsNotExist(err) { fmt.Println("Credentials file not found.") fmt.Println() @@ -63,7 +64,7 @@ func runInit(cmd *cobra.Command, _ []string) error { fmt.Printf("Credentials: %s\n", shortPath) // Step 2: Load OAuth config - config, err := gmail.GetOAuthConfig() + config, err := auth.GetOAuthConfig() if err != nil { return fmt.Errorf("loading OAuth config: %w", err) } @@ -112,7 +113,7 @@ func runInit(cmd *cobra.Command, _ []string) error { fmt.Println("Token: Not found - starting OAuth flow") fmt.Println() - authURL := gmail.GetAuthURL(config) + authURL := auth.GetAuthURL(config) fmt.Println("Open this URL in your browser:") fmt.Println() fmt.Println(authURL) @@ -141,7 +142,7 @@ func runInit(cmd *cobra.Command, _ []string) error { fmt.Println() fmt.Println("Exchanging authorization code...") - token, err := gmail.ExchangeAuthCode(cmd.Context(), config, code) + token, err := auth.ExchangeAuthCode(cmd.Context(), config, code) if err != nil { return fmt.Errorf("exchanging authorization code: %w", err) } diff --git a/internal/gmail/client.go b/internal/gmail/client.go index 8a7be2c..9da1730 100644 --- a/internal/gmail/client.go +++ b/internal/gmail/client.go @@ -5,7 +5,6 @@ import ( "fmt" "sync" - "golang.org/x/oauth2" "google.golang.org/api/gmail/v1" "google.golang.org/api/option" @@ -117,39 +116,3 @@ func (c *Client) GetProfile() (*Profile, error) { ThreadsTotal: profile.ThreadsTotal, }, nil } - -// GetConfigDir returns the configuration directory path -// Deprecated: Use auth.GetConfigDir() instead -func GetConfigDir() (string, error) { - return auth.GetConfigDir() -} - -// GetCredentialsPath returns the path to credentials.json -// Deprecated: Use auth.GetCredentialsPath() instead -func GetCredentialsPath() (string, error) { - return auth.GetCredentialsPath() -} - -// GetOAuthConfig loads OAuth config from credentials file -// Deprecated: Use auth.GetOAuthConfig() instead -func GetOAuthConfig() (*oauth2.Config, error) { - return auth.GetOAuthConfig() -} - -// ExchangeAuthCode exchanges an authorization code for a token -// Deprecated: Use auth.ExchangeAuthCode() instead -func ExchangeAuthCode(ctx context.Context, config *oauth2.Config, code string) (*oauth2.Token, error) { - return auth.ExchangeAuthCode(ctx, config, code) -} - -// GetAuthURL returns the OAuth authorization URL -// Deprecated: Use auth.GetAuthURL() instead -func GetAuthURL(config *oauth2.Config) string { - return auth.GetAuthURL(config) -} - -// ShortenPath replaces the home directory prefix with ~ for display purposes. -// Deprecated: Use auth.ShortenPath() instead -func ShortenPath(path string) string { - return auth.ShortenPath(path) -} diff --git a/internal/gmail/client_test.go b/internal/gmail/client_test.go index 49b061e..8af4eb4 100644 --- a/internal/gmail/client_test.go +++ b/internal/gmail/client_test.go @@ -1,13 +1,9 @@ package gmail import ( - "os" - "path/filepath" "testing" gmailapi "google.golang.org/api/gmail/v1" - - "github.com/open-cli-collective/google-readonly/internal/auth" ) func TestGetLabelName(t *testing.T) { @@ -112,60 +108,3 @@ func TestGetLabels(t *testing.T) { } }) } - -// TestDeprecatedWrappers verifies that the deprecated wrappers delegate correctly to the auth package -func TestDeprecatedWrappers(t *testing.T) { - t.Run("GetConfigDir delegates to auth package", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - gmailDir, err := GetConfigDir() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - authDir, err := auth.GetConfigDir() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if gmailDir != authDir { - t.Errorf("got %v, want %v", gmailDir, authDir) - } - }) - - t.Run("GetCredentialsPath delegates to auth package", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - gmailPath, err := GetCredentialsPath() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - authPath, err := auth.GetCredentialsPath() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if gmailPath != authPath { - t.Errorf("got %v, want %v", gmailPath, authPath) - } - }) - - t.Run("ShortenPath delegates to auth package", func(t *testing.T) { - home, err := os.UserHomeDir() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - testPath := filepath.Join(home, ".config", "test") - - gmailResult := ShortenPath(testPath) - authResult := auth.ShortenPath(testPath) - - if gmailResult != authResult { - t.Errorf("got %v, want %v", gmailResult, authResult) - } - }) -} From 81b96fe090e8bfee28ef5c6b0f328eba023aaa45 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:38:51 -0500 Subject: [PATCH 09/13] docs: add package comments and fix lint issues Add package-level doc comments to all 15 packages that were missing them. Fix lint issues: goimports ordering, unused parameters, exhaustive switch, and unused struct field. --- cmd/gro/main.go | 1 + internal/auth/auth.go | 1 + internal/calendar/client.go | 1 + internal/cmd/calendar/calendar.go | 1 + internal/cmd/config/config.go | 1 + internal/cmd/contacts/contacts.go | 1 + internal/cmd/drive/drive.go | 1 + internal/cmd/initcmd/init.go | 1 + internal/cmd/initcmd/init_test.go | 3 ++- internal/cmd/mail/labels_test.go | 3 ++- internal/cmd/mail/mail.go | 1 + internal/cmd/root/root.go | 1 + internal/contacts/client.go | 1 + internal/drive/client.go | 1 + internal/gmail/client.go | 1 + internal/keychain/keychain_test.go | 2 +- internal/testutil/assert.go | 3 ++- internal/testutil/assert_test.go | 13 ++++++------- internal/zip/extract.go | 1 + 19 files changed, 27 insertions(+), 11 deletions(-) diff --git a/cmd/gro/main.go b/cmd/gro/main.go index e4cb5eb..5e3ce7b 100644 --- a/cmd/gro/main.go +++ b/cmd/gro/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for the gro CLI. package main import ( diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a7f7505..7eae877 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,3 +1,4 @@ +// Package auth provides OAuth2 authentication and credential management for Google APIs. package auth import ( diff --git a/internal/calendar/client.go b/internal/calendar/client.go index ba9a5e0..27e430b 100644 --- a/internal/calendar/client.go +++ b/internal/calendar/client.go @@ -1,3 +1,4 @@ +// Package calendar provides a client for the Google Calendar API. package calendar import ( diff --git a/internal/cmd/calendar/calendar.go b/internal/cmd/calendar/calendar.go index 525ddc7..3b45873 100644 --- a/internal/cmd/calendar/calendar.go +++ b/internal/cmd/calendar/calendar.go @@ -1,3 +1,4 @@ +// Package calendar implements the gro calendar command and subcommands. package calendar import ( diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index b51d107..f2c8dd0 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -1,3 +1,4 @@ +// Package config implements the gro config command and subcommands. package config import ( diff --git a/internal/cmd/contacts/contacts.go b/internal/cmd/contacts/contacts.go index da06c00..0708aa0 100644 --- a/internal/cmd/contacts/contacts.go +++ b/internal/cmd/contacts/contacts.go @@ -1,3 +1,4 @@ +// Package contacts implements the gro contacts command and subcommands. package contacts import ( diff --git a/internal/cmd/drive/drive.go b/internal/cmd/drive/drive.go index 6d08766..9080df4 100644 --- a/internal/cmd/drive/drive.go +++ b/internal/cmd/drive/drive.go @@ -1,3 +1,4 @@ +// Package drive implements the gro drive command and subcommands. package drive import ( diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index a32a2c0..fe33a45 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -1,3 +1,4 @@ +// Package initcmd implements the gro init command for OAuth setup. package initcmd import ( diff --git a/internal/cmd/initcmd/init_test.go b/internal/cmd/initcmd/init_test.go index bd4a091..4e32b58 100644 --- a/internal/cmd/initcmd/init_test.go +++ b/internal/cmd/initcmd/init_test.go @@ -5,8 +5,9 @@ import ( "net/http" "testing" - "github.com/open-cli-collective/google-readonly/internal/testutil" "google.golang.org/api/googleapi" + + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestInitCommand(t *testing.T) { diff --git a/internal/cmd/mail/labels_test.go b/internal/cmd/mail/labels_test.go index e84e235..f3de5db 100644 --- a/internal/cmd/mail/labels_test.go +++ b/internal/cmd/mail/labels_test.go @@ -3,8 +3,9 @@ package mail import ( "testing" - "github.com/open-cli-collective/google-readonly/internal/testutil" gmailapi "google.golang.org/api/gmail/v1" + + "github.com/open-cli-collective/google-readonly/internal/testutil" ) func TestLabelsCommand(t *testing.T) { diff --git a/internal/cmd/mail/mail.go b/internal/cmd/mail/mail.go index 414ab79..ffccfda 100644 --- a/internal/cmd/mail/mail.go +++ b/internal/cmd/mail/mail.go @@ -1,3 +1,4 @@ +// Package mail implements the gro mail command and subcommands. package mail import ( diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 05be8ea..0dd06d9 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -1,3 +1,4 @@ +// Package root provides the top-level gro command and global flags. package root import ( diff --git a/internal/contacts/client.go b/internal/contacts/client.go index 95aa3bb..1870bc2 100644 --- a/internal/contacts/client.go +++ b/internal/contacts/client.go @@ -1,3 +1,4 @@ +// Package contacts provides a client for the Google People API. package contacts import ( diff --git a/internal/drive/client.go b/internal/drive/client.go index 7b3ebdd..8c21156 100644 --- a/internal/drive/client.go +++ b/internal/drive/client.go @@ -1,3 +1,4 @@ +// Package drive provides a client for the Google Drive API. package drive import ( diff --git a/internal/gmail/client.go b/internal/gmail/client.go index 9da1730..775b5d9 100644 --- a/internal/gmail/client.go +++ b/internal/gmail/client.go @@ -1,3 +1,4 @@ +// Package gmail provides a client for the Gmail API. package gmail import ( diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 1e9a3d6..a964bcc 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -427,7 +427,7 @@ func TestGetStorageBackend(t *testing.T) { } } -func TestIsSecureStorage(t *testing.T) { +func TestIsSecureStorage(_ *testing.T) { // This will vary by platform - just verify it returns a bool // Go enforces the type at compile time, so no runtime check needed _ = IsSecureStorage() diff --git a/internal/testutil/assert.go b/internal/testutil/assert.go index 4d5365e..24c3c44 100644 --- a/internal/testutil/assert.go +++ b/internal/testutil/assert.go @@ -1,3 +1,4 @@ +// Package testutil provides test assertion helpers and sample data fixtures. package testutil import ( @@ -72,7 +73,7 @@ func Nil(t testing.TB, val any) { return } v := reflect.ValueOf(val) - switch v.Kind() { + switch v.Kind() { //nolint:exhaustive // only nillable kinds are relevant case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func, reflect.Interface: if v.IsNil() { return diff --git a/internal/testutil/assert_test.go b/internal/testutil/assert_test.go index c8b2585..f726eee 100644 --- a/internal/testutil/assert_test.go +++ b/internal/testutil/assert_test.go @@ -8,15 +8,14 @@ import ( // mockT captures test failures without stopping the outer test. type mockT struct { testing.TB - failed bool - message string + failed bool } -func (m *mockT) Helper() {} -func (m *mockT) Errorf(format string, a ...any) { m.failed = true } -func (m *mockT) Error(a ...any) { m.failed = true } -func (m *mockT) Fatalf(format string, a ...any) { m.failed = true } -func (m *mockT) Fatal(a ...any) { m.failed = true } +func (m *mockT) Helper() {} +func (m *mockT) Errorf(_ string, _ ...any) { m.failed = true } +func (m *mockT) Error(_ ...any) { m.failed = true } +func (m *mockT) Fatalf(_ string, _ ...any) { m.failed = true } +func (m *mockT) Fatal(_ ...any) { m.failed = true } func TestEqual(t *testing.T) { t.Run("passes on equal values", func(t *testing.T) { diff --git a/internal/zip/extract.go b/internal/zip/extract.go index e77a340..618db6d 100644 --- a/internal/zip/extract.go +++ b/internal/zip/extract.go @@ -1,3 +1,4 @@ +// Package zip provides secure zip archive extraction with path traversal protection. package zip import ( From 0824de13e30de65aba0800a0e8beb9bd701636d9 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 14 Feb 2026 20:52:25 -0500 Subject: [PATCH 10/13] test: add t.Parallel() to safe test files Add parallel markers to 18 test files that don't use shared mutable state (ClientFactory, os.Stdout, os.Chdir). Subtests using t.Setenv correctly omit t.Parallel() since the two are incompatible. --- internal/auth/auth_test.go | 7 ++++ internal/calendar/client_test.go | 2 ++ internal/calendar/events_test.go | 16 +++++++++ internal/cmd/calendar/dates_test.go | 10 ++++++ internal/cmd/initcmd/init_test.go | 10 ++++++ internal/cmd/mail/output_test.go | 3 ++ internal/cmd/mail/sanitize_test.go | 6 ++++ internal/config/config_test.go | 3 ++ internal/contacts/client_test.go | 2 ++ internal/contacts/contacts_test.go | 35 +++++++++++++++++++ internal/drive/files_test.go | 28 +++++++++++++++ internal/errors/errors_test.go | 9 +++++ internal/format/format_test.go | 4 +++ internal/gmail/attachments_test.go | 9 +++++ internal/gmail/client_test.go | 8 +++++ internal/gmail/messages_test.go | 49 ++++++++++++++++++++++++++ internal/output/output_test.go | 4 +++ internal/testutil/assert_test.go | 53 +++++++++++++++++++++++++++++ 18 files changed, 258 insertions(+) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 1618832..c39722b 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -69,6 +69,7 @@ func TestDeprecatedWrappers(t *testing.T) { }) t.Run("ShortenPath delegates to config package", func(t *testing.T) { + t.Parallel() home, err := os.UserHomeDir() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -85,6 +86,7 @@ func TestDeprecatedWrappers(t *testing.T) { }) t.Run("Constants match config package", func(t *testing.T) { + t.Parallel() if ConfigDirName != config.DirName { t.Errorf("got %v, want %v", ConfigDirName, config.DirName) } @@ -98,6 +100,7 @@ func TestDeprecatedWrappers(t *testing.T) { } func TestAllScopes(t *testing.T) { + t.Parallel() if len(AllScopes) != 4 { t.Errorf("got length %d, want %d", len(AllScopes), 4) } @@ -117,7 +120,9 @@ func TestAllScopes(t *testing.T) { } func TestTokenFromFile(t *testing.T) { + t.Parallel() t.Run("reads valid token file", func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tokenPath := filepath.Join(tmpDir, "token.json") @@ -148,6 +153,7 @@ func TestTokenFromFile(t *testing.T) { }) t.Run("returns error for non-existent file", func(t *testing.T) { + t.Parallel() _, err := tokenFromFile("/nonexistent/token.json") if err == nil { t.Fatal("expected error, got nil") @@ -155,6 +161,7 @@ func TestTokenFromFile(t *testing.T) { }) t.Run("returns error for invalid JSON", func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tokenPath := filepath.Join(tmpDir, "token.json") diff --git a/internal/calendar/client_test.go b/internal/calendar/client_test.go index c9acb59..ea4a97f 100644 --- a/internal/calendar/client_test.go +++ b/internal/calendar/client_test.go @@ -5,7 +5,9 @@ import ( ) func TestClientStructure(t *testing.T) { + t.Parallel() t.Run("Client has private service field", func(t *testing.T) { + t.Parallel() client := &Client{} if client.service != nil { t.Errorf("got %v, want nil", client.service) diff --git a/internal/calendar/events_test.go b/internal/calendar/events_test.go index ffca145..ce27647 100644 --- a/internal/calendar/events_test.go +++ b/internal/calendar/events_test.go @@ -8,7 +8,9 @@ import ( ) func TestParseEvent(t *testing.T) { + t.Parallel() t.Run("parses basic event", func(t *testing.T) { + t.Parallel() apiEvent := &calendar.Event{ Id: "event123", Summary: "Team Meeting", @@ -47,6 +49,7 @@ func TestParseEvent(t *testing.T) { }) t.Run("parses all-day event", func(t *testing.T) { + t.Parallel() apiEvent := &calendar.Event{ Id: "allday123", Summary: "Company Holiday", @@ -72,6 +75,7 @@ func TestParseEvent(t *testing.T) { }) t.Run("parses event with organizer", func(t *testing.T) { + t.Parallel() apiEvent := &calendar.Event{ Id: "org123", Summary: "Project Review", @@ -102,6 +106,7 @@ func TestParseEvent(t *testing.T) { }) t.Run("parses event with attendees", func(t *testing.T) { + t.Parallel() apiEvent := &calendar.Event{ Id: "att123", Summary: "Team Standup", @@ -146,6 +151,7 @@ func TestParseEvent(t *testing.T) { }) t.Run("handles event with hangout link", func(t *testing.T) { + t.Parallel() apiEvent := &calendar.Event{ Id: "meet123", Summary: "Video Call", @@ -167,7 +173,9 @@ func TestParseEvent(t *testing.T) { } func TestParseCalendar(t *testing.T) { + t.Parallel() t.Run("parses calendar entry", func(t *testing.T) { + t.Parallel() apiCal := &calendar.CalendarListEntry{ Id: "primary", Summary: "My Calendar", @@ -200,6 +208,7 @@ func TestParseCalendar(t *testing.T) { }) t.Run("parses shared calendar", func(t *testing.T) { + t.Parallel() apiCal := &calendar.CalendarListEntry{ Id: "shared@group.calendar.google.com", Summary: "Team Calendar", @@ -219,7 +228,9 @@ func TestParseCalendar(t *testing.T) { } func TestEventGetStartTime(t *testing.T) { + t.Parallel() t.Run("parses datetime", func(t *testing.T) { + t.Parallel() event := &Event{ Start: &EventTime{ DateTime: "2026-01-24T10:00:00-05:00", @@ -242,6 +253,7 @@ func TestEventGetStartTime(t *testing.T) { }) t.Run("parses date for all-day event", func(t *testing.T) { + t.Parallel() event := &Event{ AllDay: true, Start: &EventTime{ @@ -259,6 +271,7 @@ func TestEventGetStartTime(t *testing.T) { }) t.Run("handles nil start", func(t *testing.T) { + t.Parallel() event := &Event{} start, err := event.GetStartTime() @@ -272,7 +285,9 @@ func TestEventGetStartTime(t *testing.T) { } func TestEventFormatTimeRange(t *testing.T) { + t.Parallel() t.Run("formats same-day event", func(t *testing.T) { + t.Parallel() event := &Event{ Start: &EventTime{ DateTime: "2026-01-24T10:00:00-05:00", @@ -295,6 +310,7 @@ func TestEventFormatTimeRange(t *testing.T) { }) t.Run("formats all-day event", func(t *testing.T) { + t.Parallel() event := &Event{ AllDay: true, Start: &EventTime{ diff --git a/internal/cmd/calendar/dates_test.go b/internal/cmd/calendar/dates_test.go index fdb5dc5..d23890d 100644 --- a/internal/cmd/calendar/dates_test.go +++ b/internal/cmd/calendar/dates_test.go @@ -8,6 +8,7 @@ import ( ) func TestParseDate(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -77,6 +78,7 @@ func TestParseDate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := parseDate(tt.input) if tt.wantErr { @@ -93,6 +95,7 @@ func TestParseDate(t *testing.T) { } func TestEndOfDay(t *testing.T) { + t.Parallel() tests := []struct { name string input time.Time @@ -122,6 +125,7 @@ func TestEndOfDay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := endOfDay(tt.input) testutil.Equal(t, result, tt.want) }) @@ -129,6 +133,7 @@ func TestEndOfDay(t *testing.T) { } func TestWeekBounds(t *testing.T) { + t.Parallel() loc := time.UTC tests := []struct { @@ -201,6 +206,7 @@ func TestWeekBounds(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() start, end := weekBounds(tt.input) testutil.Equal(t, start, tt.wantStart) @@ -226,6 +232,7 @@ func TestWeekBounds(t *testing.T) { } func TestWeekBoundsSundayEdgeCase(t *testing.T) { + t.Parallel() // Specific test for the Sunday edge case which requires special handling loc := time.UTC @@ -239,6 +246,7 @@ func TestWeekBoundsSundayEdgeCase(t *testing.T) { for _, sunday := range sundays { t.Run(sunday.Format("2006-01-02"), func(t *testing.T) { + t.Parallel() start, end := weekBounds(sunday) // The Sunday should be included in the week @@ -256,6 +264,7 @@ func TestWeekBoundsSundayEdgeCase(t *testing.T) { } func TestTodayBounds(t *testing.T) { + t.Parallel() loc := time.UTC tests := []struct { @@ -304,6 +313,7 @@ func TestTodayBounds(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() start, end := todayBounds(tt.input) testutil.Equal(t, start, tt.wantStart) diff --git a/internal/cmd/initcmd/init_test.go b/internal/cmd/initcmd/init_test.go index 4e32b58..2ab041e 100644 --- a/internal/cmd/initcmd/init_test.go +++ b/internal/cmd/initcmd/init_test.go @@ -11,13 +11,16 @@ import ( ) func TestInitCommand(t *testing.T) { + t.Parallel() cmd := NewCommand() t.Run("has correct use", func(t *testing.T) { + t.Parallel() testutil.Equal(t, cmd.Use, "init") }) t.Run("requires no arguments", func(t *testing.T) { + t.Parallel() err := cmd.Args(cmd, []string{}) testutil.NoError(t, err) @@ -26,22 +29,26 @@ func TestInitCommand(t *testing.T) { }) t.Run("has no-verify flag", func(t *testing.T) { + t.Parallel() flag := cmd.Flags().Lookup("no-verify") testutil.NotNil(t, flag) testutil.Equal(t, flag.DefValue, "false") }) t.Run("has short description", func(t *testing.T) { + t.Parallel() testutil.NotEmpty(t, cmd.Short) }) t.Run("has long description", func(t *testing.T) { + t.Parallel() testutil.NotEmpty(t, cmd.Long) testutil.Contains(t, cmd.Long, "OAuth") }) } func TestExtractAuthCode(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -111,6 +118,7 @@ func TestExtractAuthCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := extractAuthCode(tt.input) testutil.Equal(t, result, tt.expected) }) @@ -118,6 +126,7 @@ func TestExtractAuthCode(t *testing.T) { } func TestIsAuthError(t *testing.T) { + t.Parallel() tests := []struct { name string err error @@ -177,6 +186,7 @@ func TestIsAuthError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := isAuthError(tt.err) testutil.Equal(t, result, tt.expected) }) diff --git a/internal/cmd/mail/output_test.go b/internal/cmd/mail/output_test.go index 028321c..87fb1e1 100644 --- a/internal/cmd/mail/output_test.go +++ b/internal/cmd/mail/output_test.go @@ -7,7 +7,9 @@ import ( ) func TestMessagePrintOptions(t *testing.T) { + t.Parallel() t.Run("default options are all false", func(t *testing.T) { + t.Parallel() opts := MessagePrintOptions{} testutil.False(t, opts.IncludeThreadID) testutil.False(t, opts.IncludeTo) @@ -16,6 +18,7 @@ func TestMessagePrintOptions(t *testing.T) { }) t.Run("options can be set individually", func(t *testing.T) { + t.Parallel() opts := MessagePrintOptions{ IncludeThreadID: true, IncludeBody: true, diff --git a/internal/cmd/mail/sanitize_test.go b/internal/cmd/mail/sanitize_test.go index 074cf35..9025f92 100644 --- a/internal/cmd/mail/sanitize_test.go +++ b/internal/cmd/mail/sanitize_test.go @@ -7,6 +7,7 @@ import ( ) func TestSanitizeOutput(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -121,6 +122,7 @@ func TestSanitizeOutput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := SanitizeOutput(tt.input) testutil.Equal(t, result, tt.expected) }) @@ -128,6 +130,7 @@ func TestSanitizeOutput(t *testing.T) { } func TestSanitizeFilename(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -182,6 +185,7 @@ func TestSanitizeFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := SanitizeFilename(tt.input) testutil.Equal(t, result, tt.expected) }) @@ -189,6 +193,7 @@ func TestSanitizeFilename(t *testing.T) { } func TestSanitizeOutput_RealWorldExamples(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -213,6 +218,7 @@ func TestSanitizeOutput_RealWorldExamples(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := SanitizeOutput(tt.input) testutil.Equal(t, result, tt.expected) }) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 46687fd..74079e8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -92,6 +92,7 @@ func TestGetTokenPath(t *testing.T) { } func TestShortenPath(t *testing.T) { + t.Parallel() home, err := os.UserHomeDir() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -131,6 +132,7 @@ func TestShortenPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := ShortenPath(tt.input) if result != tt.expected { t.Errorf("got %v, want %v", result, tt.expected) @@ -140,6 +142,7 @@ func TestShortenPath(t *testing.T) { } func TestConstants(t *testing.T) { + t.Parallel() if DirName != "google-readonly" { t.Errorf("got %v, want %v", DirName, "google-readonly") } diff --git a/internal/contacts/client_test.go b/internal/contacts/client_test.go index edb4cef..883a700 100644 --- a/internal/contacts/client_test.go +++ b/internal/contacts/client_test.go @@ -5,7 +5,9 @@ import ( ) func TestClientStructure(t *testing.T) { + t.Parallel() t.Run("Client has private service field", func(t *testing.T) { + t.Parallel() client := &Client{} if client.service != nil { t.Errorf("got %v, want nil", client.service) diff --git a/internal/contacts/contacts_test.go b/internal/contacts/contacts_test.go index 05cf8cd..614a8b9 100644 --- a/internal/contacts/contacts_test.go +++ b/internal/contacts/contacts_test.go @@ -7,7 +7,9 @@ import ( ) func TestParseContact(t *testing.T) { + t.Parallel() t.Run("parses basic contact", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c123", Names: []*people.Name{ @@ -39,6 +41,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with email", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c456", Names: []*people.Name{ @@ -80,6 +83,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with phone numbers", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c789", PhoneNumbers: []*people.PhoneNumber{ @@ -102,6 +106,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with organization", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c101", Organizations: []*people.Organization{ @@ -130,6 +135,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with address", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c102", Addresses: []*people.Address{ @@ -161,6 +167,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with URLs", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c103", Urls: []*people.Url{ @@ -180,6 +187,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with biography", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c104", Biographies: []*people.Biography{ @@ -195,6 +203,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with birthday including year", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c105", Birthdays: []*people.Birthday{ @@ -210,6 +219,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with birthday month/day only", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c106", Birthdays: []*people.Birthday{ @@ -225,6 +235,7 @@ func TestParseContact(t *testing.T) { }) t.Run("parses contact with photo", func(t *testing.T) { + t.Parallel() p := &people.Person{ ResourceName: "people/c107", Photos: []*people.Photo{ @@ -240,6 +251,7 @@ func TestParseContact(t *testing.T) { }) t.Run("handles nil person", func(t *testing.T) { + t.Parallel() contact := ParseContact(nil) if contact != nil { t.Errorf("got %v, want nil", contact) @@ -248,7 +260,9 @@ func TestParseContact(t *testing.T) { } func TestParseContactGroup(t *testing.T) { + t.Parallel() t.Run("parses contact group", func(t *testing.T) { + t.Parallel() g := &people.ContactGroup{ ResourceName: "contactGroups/abc123", Name: "Work", @@ -273,6 +287,7 @@ func TestParseContactGroup(t *testing.T) { }) t.Run("handles nil group", func(t *testing.T) { + t.Parallel() group := ParseContactGroup(nil) if group != nil { t.Errorf("got %v, want nil", group) @@ -281,7 +296,9 @@ func TestParseContactGroup(t *testing.T) { } func TestContactGetDisplayName(t *testing.T) { + t.Parallel() t.Run("returns display name when set", func(t *testing.T) { + t.Parallel() c := &Contact{ ResourceName: "people/c1", DisplayName: "John Doe", @@ -292,6 +309,7 @@ func TestContactGetDisplayName(t *testing.T) { }) t.Run("falls back to names array", func(t *testing.T) { + t.Parallel() c := &Contact{ ResourceName: "people/c2", Names: []Name{ @@ -304,6 +322,7 @@ func TestContactGetDisplayName(t *testing.T) { }) t.Run("falls back to email", func(t *testing.T) { + t.Parallel() c := &Contact{ ResourceName: "people/c3", Emails: []Email{ @@ -316,6 +335,7 @@ func TestContactGetDisplayName(t *testing.T) { }) t.Run("falls back to resource name", func(t *testing.T) { + t.Parallel() c := &Contact{ ResourceName: "people/c4", } @@ -326,7 +346,9 @@ func TestContactGetDisplayName(t *testing.T) { } func TestContactGetPrimaryEmail(t *testing.T) { + t.Parallel() t.Run("returns primary email when marked", func(t *testing.T) { + t.Parallel() c := &Contact{ Emails: []Email{ {Value: "work@example.com", Primary: false}, @@ -339,6 +361,7 @@ func TestContactGetPrimaryEmail(t *testing.T) { }) t.Run("returns first email when no primary", func(t *testing.T) { + t.Parallel() c := &Contact{ Emails: []Email{ {Value: "first@example.com"}, @@ -351,6 +374,7 @@ func TestContactGetPrimaryEmail(t *testing.T) { }) t.Run("returns empty string when no emails", func(t *testing.T) { + t.Parallel() c := &Contact{} if c.GetPrimaryEmail() != "" { t.Errorf("got %v, want %v", c.GetPrimaryEmail(), "") @@ -359,7 +383,9 @@ func TestContactGetPrimaryEmail(t *testing.T) { } func TestContactGetPrimaryPhone(t *testing.T) { + t.Parallel() t.Run("returns first phone", func(t *testing.T) { + t.Parallel() c := &Contact{ Phones: []Phone{ {Value: "+1-555-123-4567"}, @@ -372,6 +398,7 @@ func TestContactGetPrimaryPhone(t *testing.T) { }) t.Run("returns empty string when no phones", func(t *testing.T) { + t.Parallel() c := &Contact{} if c.GetPrimaryPhone() != "" { t.Errorf("got %v, want %v", c.GetPrimaryPhone(), "") @@ -380,7 +407,9 @@ func TestContactGetPrimaryPhone(t *testing.T) { } func TestContactGetOrganization(t *testing.T) { + t.Parallel() t.Run("returns organization name", func(t *testing.T) { + t.Parallel() c := &Contact{ Organizations: []Organization{ {Name: "Acme Corp", Title: "Engineer"}, @@ -392,6 +421,7 @@ func TestContactGetOrganization(t *testing.T) { }) t.Run("returns title when no name", func(t *testing.T) { + t.Parallel() c := &Contact{ Organizations: []Organization{ {Title: "Freelance Developer"}, @@ -403,6 +433,7 @@ func TestContactGetOrganization(t *testing.T) { }) t.Run("returns empty string when no organizations", func(t *testing.T) { + t.Parallel() c := &Contact{} if c.GetOrganization() != "" { t.Errorf("got %v, want %v", c.GetOrganization(), "") @@ -411,6 +442,7 @@ func TestContactGetOrganization(t *testing.T) { } func TestFormatDate(t *testing.T) { + t.Parallel() tests := []struct { name string year int64 @@ -425,6 +457,7 @@ func TestFormatDate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := formatDate(tt.year, tt.month, tt.day) if result != tt.expect { t.Errorf("got %v, want %v", result, tt.expect) @@ -434,6 +467,7 @@ func TestFormatDate(t *testing.T) { } func TestFormatMonthDay(t *testing.T) { + t.Parallel() tests := []struct { name string month int64 @@ -447,6 +481,7 @@ func TestFormatMonthDay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := formatMonthDay(tt.month, tt.day) if result != tt.expect { t.Errorf("got %v, want %v", result, tt.expect) diff --git a/internal/drive/files_test.go b/internal/drive/files_test.go index 9218d9f..6990c73 100644 --- a/internal/drive/files_test.go +++ b/internal/drive/files_test.go @@ -10,7 +10,9 @@ import ( ) func TestParseFile(t *testing.T) { + t.Parallel() t.Run("parses basic file", func(t *testing.T) { + t.Parallel() f := &drive.File{ Id: "123", Name: "test.txt", @@ -55,6 +57,7 @@ func TestParseFile(t *testing.T) { }) t.Run("parses file with owners", func(t *testing.T) { + t.Parallel() f := &drive.File{ Id: "123", Name: "shared.txt", @@ -74,6 +77,7 @@ func TestParseFile(t *testing.T) { }) t.Run("handles empty timestamps", func(t *testing.T) { + t.Parallel() f := &drive.File{ Id: "123", Name: "no-times.txt", @@ -93,6 +97,7 @@ func TestParseFile(t *testing.T) { }) t.Run("handles malformed timestamps", func(t *testing.T) { + t.Parallel() f := &drive.File{ Id: "123", Name: "bad-times.txt", @@ -112,6 +117,7 @@ func TestParseFile(t *testing.T) { }) t.Run("handles nil owners", func(t *testing.T) { + t.Parallel() f := &drive.File{ Id: "123", Name: "no-owners.txt", @@ -127,6 +133,7 @@ func TestParseFile(t *testing.T) { }) t.Run("handles empty owners slice", func(t *testing.T) { + t.Parallel() f := &drive.File{ Id: "123", Name: "empty-owners.txt", @@ -143,6 +150,7 @@ func TestParseFile(t *testing.T) { } func TestGetTypeName(t *testing.T) { + t.Parallel() tests := []struct { mimeType string expected string @@ -176,6 +184,7 @@ func TestGetTypeName(t *testing.T) { for _, tt := range tests { t.Run(tt.mimeType, func(t *testing.T) { + t.Parallel() result := GetTypeName(tt.mimeType) if result != tt.expected { t.Errorf("got %v, want %v", result, tt.expected) @@ -185,7 +194,9 @@ func TestGetTypeName(t *testing.T) { } func TestIsGoogleWorkspaceFile(t *testing.T) { + t.Parallel() t.Run("returns true for Google Workspace files", func(t *testing.T) { + t.Parallel() workspaceTypes := []string{ MimeTypeDocument, MimeTypeSpreadsheet, @@ -203,6 +214,7 @@ func TestIsGoogleWorkspaceFile(t *testing.T) { }) t.Run("returns false for non-Workspace files", func(t *testing.T) { + t.Parallel() nonWorkspaceTypes := []string{ MimeTypeFolder, MimeTypeShortcut, @@ -222,7 +234,9 @@ func TestIsGoogleWorkspaceFile(t *testing.T) { } func TestGetExportMimeType(t *testing.T) { + t.Parallel() t.Run("returns correct MIME type for Document exports", func(t *testing.T) { + t.Parallel() tests := []struct { format string expected string @@ -236,6 +250,7 @@ func TestGetExportMimeType(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { + t.Parallel() result, err := GetExportMimeType(MimeTypeDocument, tt.format) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -248,6 +263,7 @@ func TestGetExportMimeType(t *testing.T) { }) t.Run("returns correct MIME type for Spreadsheet exports", func(t *testing.T) { + t.Parallel() tests := []struct { format string expected string @@ -259,6 +275,7 @@ func TestGetExportMimeType(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { + t.Parallel() result, err := GetExportMimeType(MimeTypeSpreadsheet, tt.format) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -271,6 +288,7 @@ func TestGetExportMimeType(t *testing.T) { }) t.Run("returns correct MIME type for Presentation exports", func(t *testing.T) { + t.Parallel() result, err := GetExportMimeType(MimeTypePresentation, "pptx") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -281,6 +299,7 @@ func TestGetExportMimeType(t *testing.T) { }) t.Run("returns correct MIME type for Drawing exports", func(t *testing.T) { + t.Parallel() result, err := GetExportMimeType(MimeTypeDrawing, "png") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -291,6 +310,7 @@ func TestGetExportMimeType(t *testing.T) { }) t.Run("returns error for unsupported format", func(t *testing.T) { + t.Parallel() _, err := GetExportMimeType(MimeTypeDocument, "xyz") if err == nil { t.Fatal("expected error, got nil") @@ -301,6 +321,7 @@ func TestGetExportMimeType(t *testing.T) { }) t.Run("returns error for non-exportable file type", func(t *testing.T) { + t.Parallel() _, err := GetExportMimeType("application/pdf", "docx") if err == nil { t.Fatal("expected error, got nil") @@ -311,6 +332,7 @@ func TestGetExportMimeType(t *testing.T) { }) t.Run("returns error for format not matching file type", func(t *testing.T) { + t.Parallel() // csv is valid for spreadsheets but not documents _, err := GetExportMimeType(MimeTypeDocument, "csv") if err == nil { @@ -323,7 +345,9 @@ func TestGetExportMimeType(t *testing.T) { } func TestGetSupportedExportFormats(t *testing.T) { + t.Parallel() t.Run("returns formats for Document", func(t *testing.T) { + t.Parallel() formats := GetSupportedExportFormats(MimeTypeDocument) if !slices.Contains(formats, "pdf") { t.Errorf("expected formats to contain %q", "pdf") @@ -337,6 +361,7 @@ func TestGetSupportedExportFormats(t *testing.T) { }) t.Run("returns formats for Spreadsheet", func(t *testing.T) { + t.Parallel() formats := GetSupportedExportFormats(MimeTypeSpreadsheet) if !slices.Contains(formats, "xlsx") { t.Errorf("expected formats to contain %q", "xlsx") @@ -347,6 +372,7 @@ func TestGetSupportedExportFormats(t *testing.T) { }) t.Run("returns nil for non-exportable file", func(t *testing.T) { + t.Parallel() formats := GetSupportedExportFormats("application/pdf") if formats != nil { t.Errorf("got %v, want nil", formats) @@ -355,6 +381,7 @@ func TestGetSupportedExportFormats(t *testing.T) { } func TestGetFileExtension(t *testing.T) { + t.Parallel() tests := []struct { format string expected string @@ -375,6 +402,7 @@ func TestGetFileExtension(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { + t.Parallel() result := GetFileExtension(tt.format) if result != tt.expected { t.Errorf("got %v, want %v", result, tt.expected) diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 0157f4a..b69536a 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -8,6 +8,7 @@ import ( ) func TestUserError(t *testing.T) { + t.Parallel() tests := []struct { name string err UserError @@ -27,17 +28,20 @@ func TestUserError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() testutil.Equal(t, tt.err.Error(), tt.expected) }) } } func TestNewUserError(t *testing.T) { + t.Parallel() err := NewUserError("invalid value: %d", 42) testutil.Equal(t, err.Error(), "invalid value: 42") } func TestSystemError(t *testing.T) { + t.Parallel() tests := []struct { name string err SystemError @@ -64,12 +68,14 @@ func TestSystemError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() testutil.Equal(t, tt.err.Error(), tt.expected) }) } } func TestSystemErrorUnwrap(t *testing.T) { + t.Parallel() cause := errors.New("underlying error") err := SystemError{ Message: "wrapper", @@ -81,6 +87,7 @@ func TestSystemErrorUnwrap(t *testing.T) { } func TestNewSystemError(t *testing.T) { + t.Parallel() cause := errors.New("network timeout") err := NewSystemError("API call failed", cause, true) @@ -90,6 +97,7 @@ func TestNewSystemError(t *testing.T) { } func TestIsRetryable(t *testing.T) { + t.Parallel() tests := []struct { name string err error @@ -119,6 +127,7 @@ func TestIsRetryable(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() testutil.Equal(t, IsRetryable(tt.err), tt.expected) }) } diff --git a/internal/format/format_test.go b/internal/format/format_test.go index 6c126ca..eae57a7 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -7,6 +7,7 @@ import ( ) func TestTruncate(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -23,6 +24,7 @@ func TestTruncate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := Truncate(tt.input, tt.maxLen) testutil.Equal(t, result, tt.expected) }) @@ -30,6 +32,7 @@ func TestTruncate(t *testing.T) { } func TestSize(t *testing.T) { + t.Parallel() tests := []struct { name string bytes int64 @@ -47,6 +50,7 @@ func TestSize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := Size(tt.bytes) testutil.Equal(t, result, tt.expected) }) diff --git a/internal/gmail/attachments_test.go b/internal/gmail/attachments_test.go index eaa7c18..aa4e901 100644 --- a/internal/gmail/attachments_test.go +++ b/internal/gmail/attachments_test.go @@ -7,7 +7,9 @@ import ( ) func TestFindPart(t *testing.T) { + t.Parallel() t.Run("returns payload for empty path", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "text/plain", } @@ -18,6 +20,7 @@ func TestFindPart(t *testing.T) { }) t.Run("finds part at index 0", func(t *testing.T) { + t.Parallel() child := &gmail.MessagePart{MimeType: "text/plain", Filename: "file.txt"} payload := &gmail.MessagePart{ MimeType: "multipart/mixed", @@ -30,6 +33,7 @@ func TestFindPart(t *testing.T) { }) t.Run("finds nested part", func(t *testing.T) { + t.Parallel() deepChild := &gmail.MessagePart{MimeType: "application/pdf", Filename: "nested.pdf"} payload := &gmail.MessagePart{ MimeType: "multipart/mixed", @@ -50,6 +54,7 @@ func TestFindPart(t *testing.T) { }) t.Run("returns nil for invalid index", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{{MimeType: "text/plain"}}, @@ -61,6 +66,7 @@ func TestFindPart(t *testing.T) { }) t.Run("returns nil for negative index", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{{MimeType: "text/plain"}}, @@ -72,6 +78,7 @@ func TestFindPart(t *testing.T) { }) t.Run("returns nil for non-numeric path", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{{MimeType: "text/plain"}}, @@ -83,6 +90,7 @@ func TestFindPart(t *testing.T) { }) t.Run("returns nil for out of bounds nested path", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{ @@ -99,6 +107,7 @@ func TestFindPart(t *testing.T) { }) t.Run("handles deeply nested path", func(t *testing.T) { + t.Parallel() deepest := &gmail.MessagePart{Filename: "deep.txt"} payload := &gmail.MessagePart{ Parts: []*gmail.MessagePart{ diff --git a/internal/gmail/client_test.go b/internal/gmail/client_test.go index 8af4eb4..00aef2f 100644 --- a/internal/gmail/client_test.go +++ b/internal/gmail/client_test.go @@ -7,7 +7,9 @@ import ( ) func TestGetLabelName(t *testing.T) { + t.Parallel() t.Run("returns name for cached label", func(t *testing.T) { + t.Parallel() client := &Client{ labels: map[string]*gmailapi.Label{ "Label_123": {Id: "Label_123", Name: "Work"}, @@ -25,6 +27,7 @@ func TestGetLabelName(t *testing.T) { }) t.Run("returns ID for uncached label", func(t *testing.T) { + t.Parallel() client := &Client{ labels: map[string]*gmailapi.Label{}, labelsLoaded: true, @@ -36,6 +39,7 @@ func TestGetLabelName(t *testing.T) { }) t.Run("returns ID when labels not loaded", func(t *testing.T) { + t.Parallel() client := &Client{ labels: nil, labelsLoaded: false, @@ -48,7 +52,9 @@ func TestGetLabelName(t *testing.T) { } func TestGetLabels(t *testing.T) { + t.Parallel() t.Run("returns nil when labels not loaded", func(t *testing.T) { + t.Parallel() client := &Client{ labels: nil, labelsLoaded: false, @@ -61,6 +67,7 @@ func TestGetLabels(t *testing.T) { }) t.Run("returns all cached labels", func(t *testing.T) { + t.Parallel() label1 := &gmailapi.Label{Id: "Label_1", Name: "Work"} label2 := &gmailapi.Label{Id: "Label_2", Name: "Personal"} @@ -94,6 +101,7 @@ func TestGetLabels(t *testing.T) { }) t.Run("returns empty slice for empty cache", func(t *testing.T) { + t.Parallel() client := &Client{ labels: map[string]*gmailapi.Label{}, labelsLoaded: true, diff --git a/internal/gmail/messages_test.go b/internal/gmail/messages_test.go index 38f8edb..f8c96a1 100644 --- a/internal/gmail/messages_test.go +++ b/internal/gmail/messages_test.go @@ -9,7 +9,9 @@ import ( ) func TestParseMessage(t *testing.T) { + t.Parallel() t.Run("extracts headers correctly", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", ThreadId: "thread456", @@ -50,6 +52,7 @@ func TestParseMessage(t *testing.T) { }) t.Run("extracts thread ID", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", ThreadId: "thread789", @@ -69,6 +72,7 @@ func TestParseMessage(t *testing.T) { }) t.Run("handles nil payload", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", ThreadId: "thread456", @@ -98,6 +102,7 @@ func TestParseMessage(t *testing.T) { }) t.Run("handles case-insensitive headers", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", Payload: &gmail.MessagePart{ @@ -123,6 +128,7 @@ func TestParseMessage(t *testing.T) { }) t.Run("handles missing headers gracefully", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", Payload: &gmail.MessagePart{ @@ -151,7 +157,9 @@ func TestParseMessage(t *testing.T) { } func TestExtractBody(t *testing.T) { + t.Parallel() t.Run("extracts plain text body", func(t *testing.T) { + t.Parallel() bodyText := "Hello, this is the message body." encoded := base64.URLEncoding.EncodeToString([]byte(bodyText)) @@ -169,6 +177,7 @@ func TestExtractBody(t *testing.T) { }) t.Run("extracts plain text from multipart message", func(t *testing.T) { + t.Parallel() bodyText := "Plain text content" encoded := base64.URLEncoding.EncodeToString([]byte(bodyText)) @@ -197,6 +206,7 @@ func TestExtractBody(t *testing.T) { }) t.Run("falls back to HTML if no plain text", func(t *testing.T) { + t.Parallel() htmlContent := "

HTML only

" encoded := base64.URLEncoding.EncodeToString([]byte(htmlContent)) @@ -214,6 +224,7 @@ func TestExtractBody(t *testing.T) { }) t.Run("handles nested multipart", func(t *testing.T) { + t.Parallel() bodyText := "Nested plain text" encoded := base64.URLEncoding.EncodeToString([]byte(bodyText)) @@ -241,6 +252,7 @@ func TestExtractBody(t *testing.T) { }) t.Run("returns empty string for empty body", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "text/plain", Body: &gmail.MessagePartBody{}, @@ -253,6 +265,7 @@ func TestExtractBody(t *testing.T) { }) t.Run("returns empty string for nil body", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "text/plain", } @@ -264,6 +277,7 @@ func TestExtractBody(t *testing.T) { }) t.Run("handles invalid base64 gracefully", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "text/plain", Body: &gmail.MessagePartBody{ @@ -279,7 +293,9 @@ func TestExtractBody(t *testing.T) { } func TestMessageStruct(t *testing.T) { + t.Parallel() t.Run("message struct has all fields", func(t *testing.T) { + t.Parallel() msg := &Message{ ID: "test-id", ThreadID: "thread-id", @@ -319,7 +335,9 @@ func TestMessageStruct(t *testing.T) { } func TestParseMessageWithBody(t *testing.T) { + t.Parallel() t.Run("includes body when requested", func(t *testing.T) { + t.Parallel() bodyText := "This is the full body" encoded := base64.URLEncoding.EncodeToString([]byte(bodyText)) @@ -343,6 +361,7 @@ func TestParseMessageWithBody(t *testing.T) { }) t.Run("excludes body when not requested", func(t *testing.T) { + t.Parallel() bodyText := "This should not appear" encoded := base64.URLEncoding.EncodeToString([]byte(bodyText)) @@ -367,7 +386,9 @@ func TestParseMessageWithBody(t *testing.T) { } func TestExtractAttachments(t *testing.T) { + t.Parallel() t.Run("detects attachment by filename", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{ @@ -408,6 +429,7 @@ func TestExtractAttachments(t *testing.T) { }) t.Run("detects attachment by Content-Disposition header", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{ @@ -435,6 +457,7 @@ func TestExtractAttachments(t *testing.T) { }) t.Run("detects inline attachment", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/related", Parts: []*gmail.MessagePart{ @@ -462,6 +485,7 @@ func TestExtractAttachments(t *testing.T) { }) t.Run("handles nested multipart with multiple attachments", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{ @@ -504,6 +528,7 @@ func TestExtractAttachments(t *testing.T) { }) t.Run("handles message with no attachments", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "text/plain", Body: &gmail.MessagePartBody{Data: "simple message"}, @@ -516,6 +541,7 @@ func TestExtractAttachments(t *testing.T) { }) t.Run("generates correct part paths for deeply nested", func(t *testing.T) { + t.Parallel() payload := &gmail.MessagePart{ MimeType: "multipart/mixed", Parts: []*gmail.MessagePart{ @@ -552,7 +578,9 @@ func TestExtractAttachments(t *testing.T) { } func TestIsAttachment(t *testing.T) { + t.Parallel() t.Run("returns true for part with filename", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{Filename: "test.pdf"} if !isAttachment(part) { t.Error("got false, want true") @@ -560,6 +588,7 @@ func TestIsAttachment(t *testing.T) { }) t.Run("returns true for Content-Disposition attachment", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{ Headers: []*gmail.MessagePartHeader{ {Name: "Content-Disposition", Value: "attachment; filename=\"test.pdf\""}, @@ -571,6 +600,7 @@ func TestIsAttachment(t *testing.T) { }) t.Run("returns false for plain text part", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{ MimeType: "text/plain", Body: &gmail.MessagePartBody{Data: "text"}, @@ -581,6 +611,7 @@ func TestIsAttachment(t *testing.T) { }) t.Run("handles case-insensitive Content-Disposition", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{ Headers: []*gmail.MessagePartHeader{ {Name: "CONTENT-DISPOSITION", Value: "ATTACHMENT"}, @@ -593,7 +624,9 @@ func TestIsAttachment(t *testing.T) { } func TestIsInlineAttachment(t *testing.T) { + t.Parallel() t.Run("returns true for inline disposition", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{ Filename: "image.png", Headers: []*gmail.MessagePartHeader{ @@ -606,6 +639,7 @@ func TestIsInlineAttachment(t *testing.T) { }) t.Run("returns false for attachment disposition", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{ Filename: "doc.pdf", Headers: []*gmail.MessagePartHeader{ @@ -618,6 +652,7 @@ func TestIsInlineAttachment(t *testing.T) { }) t.Run("returns false for no disposition header", func(t *testing.T) { + t.Parallel() part := &gmail.MessagePart{Filename: "file.txt"} if isInlineAttachment(part) { t.Error("got true, want false") @@ -626,7 +661,9 @@ func TestIsInlineAttachment(t *testing.T) { } func TestParseMessageWithAttachments(t *testing.T) { + t.Parallel() t.Run("extracts attachments when body is requested", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", Payload: &gmail.MessagePart{ @@ -663,6 +700,7 @@ func TestParseMessageWithAttachments(t *testing.T) { }) t.Run("does not extract attachments when body not requested", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", Payload: &gmail.MessagePart{ @@ -685,7 +723,9 @@ func TestParseMessageWithAttachments(t *testing.T) { } func TestExtractLabelsAndCategories(t *testing.T) { + t.Parallel() t.Run("separates user labels from categories", func(t *testing.T) { + t.Parallel() labelIDs := []string{"Label_1", "CATEGORY_UPDATES", "Label_2", "CATEGORY_SOCIAL"} resolver := func(id string) string { return id } @@ -718,6 +758,7 @@ func TestExtractLabelsAndCategories(t *testing.T) { }) t.Run("filters out system labels", func(t *testing.T) { + t.Parallel() labelIDs := []string{"INBOX", "Label_1", "UNREAD", "STARRED", "IMPORTANT"} resolver := func(id string) string { return id } @@ -732,6 +773,7 @@ func TestExtractLabelsAndCategories(t *testing.T) { }) t.Run("filters out CATEGORY_PERSONAL", func(t *testing.T) { + t.Parallel() labelIDs := []string{"CATEGORY_PERSONAL", "CATEGORY_UPDATES"} resolver := func(id string) string { return id } @@ -746,6 +788,7 @@ func TestExtractLabelsAndCategories(t *testing.T) { }) t.Run("uses resolver to translate label IDs", func(t *testing.T) { + t.Parallel() labelIDs := []string{"Label_123", "Label_456"} resolver := func(id string) string { if id == "Label_123" { @@ -777,6 +820,7 @@ func TestExtractLabelsAndCategories(t *testing.T) { }) t.Run("handles nil resolver", func(t *testing.T) { + t.Parallel() labelIDs := []string{"Label_1", "CATEGORY_SOCIAL"} labels, categories := extractLabelsAndCategories(labelIDs, nil) @@ -790,6 +834,7 @@ func TestExtractLabelsAndCategories(t *testing.T) { }) t.Run("handles empty label IDs", func(t *testing.T) { + t.Parallel() labels, categories := extractLabelsAndCategories([]string{}, nil) if len(labels) != 0 { @@ -801,6 +846,7 @@ func TestExtractLabelsAndCategories(t *testing.T) { }) t.Run("handles nil label IDs", func(t *testing.T) { + t.Parallel() labels, categories := extractLabelsAndCategories(nil, nil) if len(labels) != 0 { @@ -813,7 +859,9 @@ func TestExtractLabelsAndCategories(t *testing.T) { } func TestParseMessageWithLabels(t *testing.T) { + t.Parallel() t.Run("extracts labels and categories from message", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", Payload: &gmail.MessagePart{ @@ -841,6 +889,7 @@ func TestParseMessageWithLabels(t *testing.T) { }) t.Run("handles message with no labels", func(t *testing.T) { + t.Parallel() msg := &gmail.Message{ Id: "msg123", Payload: &gmail.MessagePart{ diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 3494185..766afe8 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -9,6 +9,7 @@ import ( ) func TestJSON(t *testing.T) { + t.Parallel() tests := []struct { name string data any @@ -38,6 +39,7 @@ func TestJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() var buf bytes.Buffer err := JSON(&buf, tt.data) testutil.NoError(t, err) @@ -47,6 +49,7 @@ func TestJSON(t *testing.T) { } func TestJSON_indentation(t *testing.T) { + t.Parallel() data := struct { Nested struct { Value string @@ -66,6 +69,7 @@ func TestJSON_indentation(t *testing.T) { } func TestJSON_error(t *testing.T) { + t.Parallel() // Channels cannot be encoded to JSON data := make(chan int) var buf bytes.Buffer diff --git a/internal/testutil/assert_test.go b/internal/testutil/assert_test.go index f726eee..ffc6ea9 100644 --- a/internal/testutil/assert_test.go +++ b/internal/testutil/assert_test.go @@ -18,7 +18,9 @@ func (m *mockT) Fatalf(_ string, _ ...any) { m.failed = true } func (m *mockT) Fatal(_ ...any) { m.failed = true } func TestEqual(t *testing.T) { + t.Parallel() t.Run("passes on equal values", func(t *testing.T) { + t.Parallel() mt := &mockT{} Equal(mt, 42, 42) if mt.failed { @@ -27,6 +29,7 @@ func TestEqual(t *testing.T) { }) t.Run("fails on unequal values", func(t *testing.T) { + t.Parallel() mt := &mockT{} Equal(mt, 1, 2) if !mt.failed { @@ -35,6 +38,7 @@ func TestEqual(t *testing.T) { }) t.Run("works with strings", func(t *testing.T) { + t.Parallel() mt := &mockT{} Equal(mt, "hello", "hello") if mt.failed { @@ -44,7 +48,9 @@ func TestEqual(t *testing.T) { } func TestNoError(t *testing.T) { + t.Parallel() t.Run("passes on nil error", func(t *testing.T) { + t.Parallel() mt := &mockT{} NoError(mt, nil) if mt.failed { @@ -53,6 +59,7 @@ func TestNoError(t *testing.T) { }) t.Run("fails on non-nil error", func(t *testing.T) { + t.Parallel() mt := &mockT{} NoError(mt, errors.New("boom")) if !mt.failed { @@ -62,7 +69,9 @@ func TestNoError(t *testing.T) { } func TestError(t *testing.T) { + t.Parallel() t.Run("passes on non-nil error", func(t *testing.T) { + t.Parallel() mt := &mockT{} Error(mt, errors.New("boom")) if mt.failed { @@ -71,6 +80,7 @@ func TestError(t *testing.T) { }) t.Run("fails on nil error", func(t *testing.T) { + t.Parallel() mt := &mockT{} Error(mt, nil) if !mt.failed { @@ -80,9 +90,11 @@ func TestError(t *testing.T) { } func TestErrorIs(t *testing.T) { + t.Parallel() sentinel := errors.New("sentinel") t.Run("passes when errors match", func(t *testing.T) { + t.Parallel() mt := &mockT{} ErrorIs(mt, sentinel, sentinel) if mt.failed { @@ -91,6 +103,7 @@ func TestErrorIs(t *testing.T) { }) t.Run("fails when errors don't match", func(t *testing.T) { + t.Parallel() mt := &mockT{} ErrorIs(mt, errors.New("other"), sentinel) if !mt.failed { @@ -100,7 +113,9 @@ func TestErrorIs(t *testing.T) { } func TestContains(t *testing.T) { + t.Parallel() t.Run("passes when string contains substr", func(t *testing.T) { + t.Parallel() mt := &mockT{} Contains(mt, "hello world", "world") if mt.failed { @@ -109,6 +124,7 @@ func TestContains(t *testing.T) { }) t.Run("fails when string doesn't contain substr", func(t *testing.T) { + t.Parallel() mt := &mockT{} Contains(mt, "hello world", "xyz") if !mt.failed { @@ -118,7 +134,9 @@ func TestContains(t *testing.T) { } func TestNotContains(t *testing.T) { + t.Parallel() t.Run("passes when string doesn't contain substr", func(t *testing.T) { + t.Parallel() mt := &mockT{} NotContains(mt, "hello world", "xyz") if mt.failed { @@ -127,6 +145,7 @@ func TestNotContains(t *testing.T) { }) t.Run("fails when string contains substr", func(t *testing.T) { + t.Parallel() mt := &mockT{} NotContains(mt, "hello world", "world") if !mt.failed { @@ -136,7 +155,9 @@ func TestNotContains(t *testing.T) { } func TestLen(t *testing.T) { + t.Parallel() t.Run("passes on correct length", func(t *testing.T) { + t.Parallel() mt := &mockT{} Len(mt, []int{1, 2, 3}, 3) if mt.failed { @@ -145,6 +166,7 @@ func TestLen(t *testing.T) { }) t.Run("fails on wrong length", func(t *testing.T) { + t.Parallel() mt := &mockT{} Len(mt, []int{1, 2}, 3) if !mt.failed { @@ -153,6 +175,7 @@ func TestLen(t *testing.T) { }) t.Run("works with empty slice", func(t *testing.T) { + t.Parallel() mt := &mockT{} Len(mt, []string{}, 0) if mt.failed { @@ -162,7 +185,9 @@ func TestLen(t *testing.T) { } func TestNil(t *testing.T) { + t.Parallel() t.Run("passes on nil", func(t *testing.T) { + t.Parallel() mt := &mockT{} Nil(mt, nil) if mt.failed { @@ -171,6 +196,7 @@ func TestNil(t *testing.T) { }) t.Run("fails on non-nil", func(t *testing.T) { + t.Parallel() mt := &mockT{} Nil(mt, "something") if !mt.failed { @@ -180,7 +206,9 @@ func TestNil(t *testing.T) { } func TestNotNil(t *testing.T) { + t.Parallel() t.Run("passes on non-nil", func(t *testing.T) { + t.Parallel() mt := &mockT{} NotNil(mt, "something") if mt.failed { @@ -189,6 +217,7 @@ func TestNotNil(t *testing.T) { }) t.Run("fails on nil", func(t *testing.T) { + t.Parallel() mt := &mockT{} NotNil(mt, nil) if !mt.failed { @@ -198,7 +227,9 @@ func TestNotNil(t *testing.T) { } func TestTrue(t *testing.T) { + t.Parallel() t.Run("passes on true", func(t *testing.T) { + t.Parallel() mt := &mockT{} True(mt, true) if mt.failed { @@ -207,6 +238,7 @@ func TestTrue(t *testing.T) { }) t.Run("fails on false", func(t *testing.T) { + t.Parallel() mt := &mockT{} True(mt, false) if !mt.failed { @@ -216,7 +248,9 @@ func TestTrue(t *testing.T) { } func TestFalse(t *testing.T) { + t.Parallel() t.Run("passes on false", func(t *testing.T) { + t.Parallel() mt := &mockT{} False(mt, false) if mt.failed { @@ -225,6 +259,7 @@ func TestFalse(t *testing.T) { }) t.Run("fails on true", func(t *testing.T) { + t.Parallel() mt := &mockT{} False(mt, true) if !mt.failed { @@ -234,7 +269,9 @@ func TestFalse(t *testing.T) { } func TestEmpty(t *testing.T) { + t.Parallel() t.Run("passes on empty string", func(t *testing.T) { + t.Parallel() mt := &mockT{} Empty(mt, "") if mt.failed { @@ -243,6 +280,7 @@ func TestEmpty(t *testing.T) { }) t.Run("fails on non-empty string", func(t *testing.T) { + t.Parallel() mt := &mockT{} Empty(mt, "hello") if !mt.failed { @@ -252,7 +290,9 @@ func TestEmpty(t *testing.T) { } func TestNotEmpty(t *testing.T) { + t.Parallel() t.Run("passes on non-empty string", func(t *testing.T) { + t.Parallel() mt := &mockT{} NotEmpty(mt, "hello") if mt.failed { @@ -261,6 +301,7 @@ func TestNotEmpty(t *testing.T) { }) t.Run("fails on empty string", func(t *testing.T) { + t.Parallel() mt := &mockT{} NotEmpty(mt, "") if !mt.failed { @@ -270,7 +311,9 @@ func TestNotEmpty(t *testing.T) { } func TestGreater(t *testing.T) { + t.Parallel() t.Run("passes when a > b", func(t *testing.T) { + t.Parallel() mt := &mockT{} Greater(mt, 5, 3) if mt.failed { @@ -279,6 +322,7 @@ func TestGreater(t *testing.T) { }) t.Run("fails when a == b", func(t *testing.T) { + t.Parallel() mt := &mockT{} Greater(mt, 3, 3) if !mt.failed { @@ -287,6 +331,7 @@ func TestGreater(t *testing.T) { }) t.Run("fails when a < b", func(t *testing.T) { + t.Parallel() mt := &mockT{} Greater(mt, 2, 3) if !mt.failed { @@ -296,7 +341,9 @@ func TestGreater(t *testing.T) { } func TestGreaterOrEqual(t *testing.T) { + t.Parallel() t.Run("passes when a > b", func(t *testing.T) { + t.Parallel() mt := &mockT{} GreaterOrEqual(mt, 5, 3) if mt.failed { @@ -305,6 +352,7 @@ func TestGreaterOrEqual(t *testing.T) { }) t.Run("passes when a == b", func(t *testing.T) { + t.Parallel() mt := &mockT{} GreaterOrEqual(mt, 3, 3) if mt.failed { @@ -313,6 +361,7 @@ func TestGreaterOrEqual(t *testing.T) { }) t.Run("fails when a < b", func(t *testing.T) { + t.Parallel() mt := &mockT{} GreaterOrEqual(mt, 2, 3) if !mt.failed { @@ -322,7 +371,9 @@ func TestGreaterOrEqual(t *testing.T) { } func TestLess(t *testing.T) { + t.Parallel() t.Run("passes when a < b", func(t *testing.T) { + t.Parallel() mt := &mockT{} Less(mt, 2, 5) if mt.failed { @@ -331,6 +382,7 @@ func TestLess(t *testing.T) { }) t.Run("fails when a == b", func(t *testing.T) { + t.Parallel() mt := &mockT{} Less(mt, 3, 3) if !mt.failed { @@ -339,6 +391,7 @@ func TestLess(t *testing.T) { }) t.Run("fails when a > b", func(t *testing.T) { + t.Parallel() mt := &mockT{} Less(mt, 5, 3) if !mt.failed { From f161300f556e3eef8c016ed58904c8b03d53ca2e Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sun, 15 Feb 2026 10:48:30 -0500 Subject: [PATCH 11/13] refactor: add context.Context to all API client methods and align CI Thread context.Context through all API client methods (gmail, calendar, contacts, drive) so cancellation and deadlines propagate to Google API calls. Fix error wrapping in NewClient constructors to follow standards. Update PersistentTokenSource to accept context instead of using context.Background(). Align Makefile check target to use tidy instead of fmt, and update CI Go version from 1.21 to 1.24 to match go.mod. --- .github/workflows/ci.yml | 14 ++----- Makefile | 2 +- internal/auth/auth.go | 2 +- internal/calendar/client.go | 16 ++++---- internal/cmd/calendar/events.go | 2 +- internal/cmd/calendar/events_helper.go | 5 ++- internal/cmd/calendar/get.go | 2 +- internal/cmd/calendar/handlers_test.go | 24 +++++------ internal/cmd/calendar/list.go | 2 +- internal/cmd/calendar/mock_test.go | 20 +++++---- internal/cmd/calendar/output.go | 6 +-- internal/cmd/calendar/today.go | 2 +- internal/cmd/calendar/week.go | 2 +- internal/cmd/config/config.go | 4 +- internal/cmd/contacts/get.go | 2 +- internal/cmd/contacts/groups.go | 2 +- internal/cmd/contacts/handlers_test.go | 30 +++++++------- internal/cmd/contacts/list.go | 2 +- internal/cmd/contacts/mock_test.go | 26 ++++++------ internal/cmd/contacts/output.go | 8 ++-- internal/cmd/contacts/search.go | 2 +- internal/cmd/drive/download.go | 8 ++-- internal/cmd/drive/drives.go | 7 ++-- internal/cmd/drive/drives_test.go | 19 +++++---- internal/cmd/drive/get.go | 2 +- internal/cmd/drive/handlers_test.go | 48 +++++++++++----------- internal/cmd/drive/list.go | 10 +++-- internal/cmd/drive/mock_test.go | 40 +++++++++--------- internal/cmd/drive/output.go | 12 +++--- internal/cmd/drive/search.go | 5 ++- internal/cmd/drive/tree.go | 17 ++++---- internal/cmd/drive/tree_test.go | 27 ++++++------ internal/cmd/initcmd/init.go | 2 +- internal/cmd/mail/attachments_download.go | 11 ++--- internal/cmd/mail/attachments_list.go | 2 +- internal/cmd/mail/handlers_test.go | 30 +++++++------- internal/cmd/mail/labels.go | 2 +- internal/cmd/mail/mock_test.go | 50 ++++++++++++----------- internal/cmd/mail/output.go | 16 ++++---- internal/cmd/mail/read.go | 2 +- internal/cmd/mail/search.go | 2 +- internal/cmd/mail/thread.go | 2 +- internal/contacts/client.go | 18 ++++---- internal/drive/client.go | 26 ++++++------ internal/gmail/attachments.go | 13 +++--- internal/gmail/client.go | 12 +++--- internal/gmail/messages.go | 23 ++++++----- internal/keychain/token_source.go | 4 +- 48 files changed, 302 insertions(+), 283 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0cda4c..8091f45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,10 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.24' - - name: Download dependencies - run: go mod download - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v -race -coverprofile=coverage.out ./... + - name: Tidy, test, and build + run: make tidy test build lint: runs-on: ubuntu-latest @@ -32,7 +26,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.24' - name: golangci-lint uses: golangci/golangci-lint-action@v7 diff --git a/Makefile b/Makefile index 81d4a6d..c39946e 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ verify: go mod verify # CI gate: everything that must pass before merge -check: fmt lint test build +check: tidy lint test build clean: rm -rf bin/ $(DIST_DIR)/ coverage.out coverage.html $(BINARY) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 7eae877..cf0e336 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -63,7 +63,7 @@ func GetHTTPClient(ctx context.Context) (*http.Client, error) { } // Create persistent token source that saves refreshed tokens - tokenSource := keychain.NewPersistentTokenSource(config, tok) + tokenSource := keychain.NewPersistentTokenSource(ctx, config, tok) return oauth2.NewClient(ctx, tokenSource), nil } diff --git a/internal/calendar/client.go b/internal/calendar/client.go index 27e430b..1c4e077 100644 --- a/internal/calendar/client.go +++ b/internal/calendar/client.go @@ -20,12 +20,12 @@ type Client struct { func NewClient(ctx context.Context) (*Client, error) { client, err := auth.GetHTTPClient(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("loading OAuth client: %w", err) } srv, err := calendar.NewService(ctx, option.WithHTTPClient(client)) if err != nil { - return nil, fmt.Errorf("unable to create Calendar service: %w", err) + return nil, fmt.Errorf("creating Calendar service: %w", err) } return &Client{ @@ -34,8 +34,8 @@ func NewClient(ctx context.Context) (*Client, error) { } // ListCalendars returns all calendars the user has access to -func (c *Client) ListCalendars() ([]*calendar.CalendarListEntry, error) { - resp, err := c.service.CalendarList.List().Do() +func (c *Client) ListCalendars(ctx context.Context) ([]*calendar.CalendarListEntry, error) { + resp, err := c.service.CalendarList.List().Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing calendars: %w", err) } @@ -43,7 +43,7 @@ func (c *Client) ListCalendars() ([]*calendar.CalendarListEntry, error) { } // ListEvents returns events from the specified calendar within the given time range -func (c *Client) ListEvents(calendarID string, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { +func (c *Client) ListEvents(ctx context.Context, calendarID string, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { call := c.service.Events.List(calendarID). SingleEvents(true). OrderBy("startTime") @@ -58,7 +58,7 @@ func (c *Client) ListEvents(calendarID string, timeMin, timeMax string, maxResul call = call.MaxResults(maxResults) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing events: %w", err) } @@ -66,8 +66,8 @@ func (c *Client) ListEvents(calendarID string, timeMin, timeMax string, maxResul } // GetEvent retrieves a single event by ID -func (c *Client) GetEvent(calendarID, eventID string) (*calendar.Event, error) { - event, err := c.service.Events.Get(calendarID, eventID).Do() +func (c *Client) GetEvent(ctx context.Context, calendarID, eventID string) (*calendar.Event, error) { + event, err := c.service.Events.Get(calendarID, eventID).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("getting event: %w", err) } diff --git a/internal/cmd/calendar/events.go b/internal/cmd/calendar/events.go index 56e6d2f..9128860 100644 --- a/internal/cmd/calendar/events.go +++ b/internal/cmd/calendar/events.go @@ -66,7 +66,7 @@ Examples: timeMax = endOfDay(t).Format(time.RFC3339) } - return listAndPrintEvents(client, EventListOptions{ + return listAndPrintEvents(cmd.Context(), client, EventListOptions{ CalendarID: calID, TimeMin: timeMin, TimeMax: timeMax, diff --git a/internal/cmd/calendar/events_helper.go b/internal/cmd/calendar/events_helper.go index d3d7109..c080be0 100644 --- a/internal/cmd/calendar/events_helper.go +++ b/internal/cmd/calendar/events_helper.go @@ -1,6 +1,7 @@ package calendar import ( + "context" "fmt" "github.com/open-cli-collective/google-readonly/internal/calendar" @@ -19,8 +20,8 @@ type EventListOptions struct { // listAndPrintEvents fetches events and prints them according to the options. // This is a shared helper used by today, week, and events commands. -func listAndPrintEvents(client CalendarClient, opts EventListOptions) error { - events, err := client.ListEvents(opts.CalendarID, opts.TimeMin, opts.TimeMax, opts.MaxResults) +func listAndPrintEvents(ctx context.Context, client CalendarClient, opts EventListOptions) error { + events, err := client.ListEvents(ctx, opts.CalendarID, opts.TimeMin, opts.TimeMax, opts.MaxResults) if err != nil { return err } diff --git a/internal/cmd/calendar/get.go b/internal/cmd/calendar/get.go index 6eeaa84..9c21b22 100644 --- a/internal/cmd/calendar/get.go +++ b/internal/cmd/calendar/get.go @@ -34,7 +34,7 @@ Examples: return fmt.Errorf("creating Calendar client: %w", err) } - event, err := client.GetEvent(calendarID, eventID) + event, err := client.GetEvent(cmd.Context(), calendarID, eventID) if err != nil { return fmt.Errorf("getting event: %w", err) } diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index a1c9ca9..83bca80 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -54,7 +54,7 @@ func withFailingClientFactory(f func()) { func TestListCommand_Success(t *testing.T) { mock := &MockCalendarClient{ - ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { + ListCalendarsFunc: func(_ context.Context) ([]*calendar.CalendarListEntry, error) { return testutil.SampleCalendars(), nil }, } @@ -75,7 +75,7 @@ func TestListCommand_Success(t *testing.T) { func TestListCommand_JSONOutput(t *testing.T) { mock := &MockCalendarClient{ - ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { + ListCalendarsFunc: func(_ context.Context) ([]*calendar.CalendarListEntry, error) { return testutil.SampleCalendars(), nil }, } @@ -98,7 +98,7 @@ func TestListCommand_JSONOutput(t *testing.T) { func TestListCommand_Empty(t *testing.T) { mock := &MockCalendarClient{ - ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { + ListCalendarsFunc: func(_ context.Context) ([]*calendar.CalendarListEntry, error) { return []*calendar.CalendarListEntry{}, nil }, } @@ -117,7 +117,7 @@ func TestListCommand_Empty(t *testing.T) { func TestListCommand_APIError(t *testing.T) { mock := &MockCalendarClient{ - ListCalendarsFunc: func() ([]*calendar.CalendarListEntry, error) { + ListCalendarsFunc: func(_ context.Context) ([]*calendar.CalendarListEntry, error) { return nil, errors.New("API error") }, } @@ -143,7 +143,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { func TestEventsCommand_Success(t *testing.T) { mock := &MockCalendarClient{ - ListEventsFunc: func(calendarID, _, _ string, _ int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_ context.Context, calendarID, _, _ string, _ int64) ([]*calendar.Event, error) { testutil.Equal(t, calendarID, "primary") return []*calendar.Event{testutil.SampleEvent("event1")}, nil }, @@ -165,7 +165,7 @@ func TestEventsCommand_Success(t *testing.T) { func TestEventsCommand_WithDateRange(t *testing.T) { var capturedTimeMin, capturedTimeMax string mock := &MockCalendarClient{ - ListEventsFunc: func(_, timeMin, timeMax string, _ int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_ context.Context, _, timeMin, timeMax string, _ int64) ([]*calendar.Event, error) { capturedTimeMin = timeMin capturedTimeMax = timeMax return []*calendar.Event{}, nil @@ -190,7 +190,7 @@ func TestEventsCommand_WithDateRange(t *testing.T) { func TestEventsCommand_JSONOutput(t *testing.T) { mock := &MockCalendarClient{ - ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_ context.Context, _, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{testutil.SampleEvent("event1")}, nil }, } @@ -235,7 +235,7 @@ func TestEventsCommand_InvalidToDate(t *testing.T) { func TestGetCommand_Success(t *testing.T) { mock := &MockCalendarClient{ - GetEventFunc: func(calendarID, eventID string) (*calendar.Event, error) { + GetEventFunc: func(_ context.Context, calendarID, eventID string) (*calendar.Event, error) { testutil.Equal(t, calendarID, "primary") testutil.Equal(t, eventID, "event123") return testutil.SampleEvent("event123"), nil @@ -259,7 +259,7 @@ func TestGetCommand_Success(t *testing.T) { func TestGetCommand_JSONOutput(t *testing.T) { mock := &MockCalendarClient{ - GetEventFunc: func(_, _ string) (*calendar.Event, error) { + GetEventFunc: func(_ context.Context, _, _ string) (*calendar.Event, error) { return testutil.SampleEvent("event123"), nil }, } @@ -282,7 +282,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { func TestGetCommand_NotFound(t *testing.T) { mock := &MockCalendarClient{ - GetEventFunc: func(_, _ string) (*calendar.Event, error) { + GetEventFunc: func(_ context.Context, _, _ string) (*calendar.Event, error) { return nil, errors.New("event not found") }, } @@ -299,7 +299,7 @@ func TestGetCommand_NotFound(t *testing.T) { func TestTodayCommand_Success(t *testing.T) { mock := &MockCalendarClient{ - ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_ context.Context, _, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{testutil.SampleEvent("today_event")}, nil }, } @@ -318,7 +318,7 @@ func TestTodayCommand_Success(t *testing.T) { func TestWeekCommand_Success(t *testing.T) { mock := &MockCalendarClient{ - ListEventsFunc: func(_, _, _ string, _ int64) ([]*calendar.Event, error) { + ListEventsFunc: func(_ context.Context, _, _, _ string, _ int64) ([]*calendar.Event, error) { return []*calendar.Event{ testutil.SampleEvent("week_event1"), testutil.SampleEvent("week_event2"), diff --git a/internal/cmd/calendar/list.go b/internal/cmd/calendar/list.go index d3561f0..97e8b53 100644 --- a/internal/cmd/calendar/list.go +++ b/internal/cmd/calendar/list.go @@ -28,7 +28,7 @@ Examples: return fmt.Errorf("creating Calendar client: %w", err) } - calendars, err := client.ListCalendars() + calendars, err := client.ListCalendars(cmd.Context()) if err != nil { return fmt.Errorf("listing calendars: %w", err) } diff --git a/internal/cmd/calendar/mock_test.go b/internal/cmd/calendar/mock_test.go index 71df4cc..46e49cb 100644 --- a/internal/cmd/calendar/mock_test.go +++ b/internal/cmd/calendar/mock_test.go @@ -1,36 +1,38 @@ package calendar import ( + "context" + "google.golang.org/api/calendar/v3" ) // MockCalendarClient is a configurable mock for CalendarClient. type MockCalendarClient struct { - ListCalendarsFunc func() ([]*calendar.CalendarListEntry, error) - ListEventsFunc func(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) - GetEventFunc func(calendarID, eventID string) (*calendar.Event, error) + ListCalendarsFunc func(ctx context.Context) ([]*calendar.CalendarListEntry, error) + ListEventsFunc func(ctx context.Context, calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) + GetEventFunc func(ctx context.Context, calendarID, eventID string) (*calendar.Event, error) } // Verify MockCalendarClient implements CalendarClient var _ CalendarClient = (*MockCalendarClient)(nil) -func (m *MockCalendarClient) ListCalendars() ([]*calendar.CalendarListEntry, error) { +func (m *MockCalendarClient) ListCalendars(ctx context.Context) ([]*calendar.CalendarListEntry, error) { if m.ListCalendarsFunc != nil { - return m.ListCalendarsFunc() + return m.ListCalendarsFunc(ctx) } return nil, nil } -func (m *MockCalendarClient) ListEvents(calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { +func (m *MockCalendarClient) ListEvents(ctx context.Context, calendarID, timeMin, timeMax string, maxResults int64) ([]*calendar.Event, error) { if m.ListEventsFunc != nil { - return m.ListEventsFunc(calendarID, timeMin, timeMax, maxResults) + return m.ListEventsFunc(ctx, calendarID, timeMin, timeMax, maxResults) } return nil, nil } -func (m *MockCalendarClient) GetEvent(calendarID, eventID string) (*calendar.Event, error) { +func (m *MockCalendarClient) GetEvent(ctx context.Context, calendarID, eventID string) (*calendar.Event, error) { if m.GetEventFunc != nil { - return m.GetEventFunc(calendarID, eventID) + return m.GetEventFunc(ctx, calendarID, eventID) } return nil, nil } diff --git a/internal/cmd/calendar/output.go b/internal/cmd/calendar/output.go index 697b2c9..2d7ce7b 100644 --- a/internal/cmd/calendar/output.go +++ b/internal/cmd/calendar/output.go @@ -12,9 +12,9 @@ import ( // CalendarClient defines the interface for Calendar client operations used by calendar commands. type CalendarClient interface { - ListCalendars() ([]*calendarv3.CalendarListEntry, error) - ListEvents(calendarID string, timeMin, timeMax string, maxResults int64) ([]*calendarv3.Event, error) - GetEvent(calendarID, eventID string) (*calendarv3.Event, error) + ListCalendars(ctx context.Context) ([]*calendarv3.CalendarListEntry, error) + ListEvents(ctx context.Context, calendarID string, timeMin, timeMax string, maxResults int64) ([]*calendarv3.Event, error) + GetEvent(ctx context.Context, calendarID, eventID string) (*calendarv3.Event, error) } // ClientFactory is the function used to create Calendar clients. diff --git a/internal/cmd/calendar/today.go b/internal/cmd/calendar/today.go index 34be3b1..e2930c9 100644 --- a/internal/cmd/calendar/today.go +++ b/internal/cmd/calendar/today.go @@ -34,7 +34,7 @@ Examples: now := time.Now() startOfDay, endOfDayTime := todayBounds(now) - return listAndPrintEvents(client, EventListOptions{ + return listAndPrintEvents(cmd.Context(), client, EventListOptions{ CalendarID: calendarID, TimeMin: startOfDay.Format(time.RFC3339), TimeMax: endOfDayTime.Format(time.RFC3339), diff --git a/internal/cmd/calendar/week.go b/internal/cmd/calendar/week.go index c403db2..ee1b848 100644 --- a/internal/cmd/calendar/week.go +++ b/internal/cmd/calendar/week.go @@ -34,7 +34,7 @@ Examples: now := time.Now() startOfWeek, endOfWeek := weekBounds(now) - return listAndPrintEvents(client, EventListOptions{ + return listAndPrintEvents(cmd.Context(), client, EventListOptions{ CalendarID: calendarID, TimeMin: startOfWeek.Format(time.RFC3339), TimeMax: endOfWeek.Format(time.RFC3339), diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index f2c8dd0..898da2f 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -113,7 +113,7 @@ func runShow(cmd *cobra.Command, _ []string) error { // Show email if we can get it without triggering auth if keychain.HasStoredToken() && credStatus == "OK" { if client, err := gmail.NewClient(cmd.Context()); err == nil { - if profile, err := client.GetProfile(); err == nil { + if profile, err := client.GetProfile(cmd.Context()); err == nil { fmt.Printf("Email: %s\n", profile.EmailAddress) } } @@ -153,7 +153,7 @@ func runTest(cmd *cobra.Command, _ []string) error { fmt.Println(" Token valid: OK") // Test API access - profile, err := client.GetProfile() + profile, err := client.GetProfile(cmd.Context()) if err != nil { fmt.Println(" Gmail API: FAILED") return fmt.Errorf("accessing Gmail API: %w", err) diff --git a/internal/cmd/contacts/get.go b/internal/cmd/contacts/get.go index 70c75c6..cc24cd4 100644 --- a/internal/cmd/contacts/get.go +++ b/internal/cmd/contacts/get.go @@ -31,7 +31,7 @@ Examples: return fmt.Errorf("creating Contacts client: %w", err) } - person, err := client.GetContact(resourceName) + person, err := client.GetContact(cmd.Context(), resourceName) if err != nil { return fmt.Errorf("getting contact: %w", err) } diff --git a/internal/cmd/contacts/groups.go b/internal/cmd/contacts/groups.go index cf370af..c914554 100644 --- a/internal/cmd/contacts/groups.go +++ b/internal/cmd/contacts/groups.go @@ -32,7 +32,7 @@ Examples: return fmt.Errorf("creating Contacts client: %w", err) } - resp, err := client.ListContactGroups("", maxResults) + resp, err := client.ListContactGroups(cmd.Context(), "", maxResults) if err != nil { return fmt.Errorf("listing contact groups: %w", err) } diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index 4d264a0..1557c0a 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -54,7 +54,7 @@ func withFailingClientFactory(f func()) { func TestListCommand_Success(t *testing.T) { mock := &MockContactsClient{ - ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ context.Context, _ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{ testutil.SamplePerson("people/c123"), @@ -80,7 +80,7 @@ func TestListCommand_Success(t *testing.T) { func TestListCommand_JSONOutput(t *testing.T) { mock := &MockContactsClient{ - ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ context.Context, _ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{ testutil.SamplePerson("people/c123"), @@ -107,7 +107,7 @@ func TestListCommand_JSONOutput(t *testing.T) { func TestListCommand_Empty(t *testing.T) { mock := &MockContactsClient{ - ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ context.Context, _ string, _ int64) (*people.ListConnectionsResponse, error) { return &people.ListConnectionsResponse{ Connections: []*people.Person{}, }, nil @@ -128,7 +128,7 @@ func TestListCommand_Empty(t *testing.T) { func TestListCommand_APIError(t *testing.T) { mock := &MockContactsClient{ - ListContactsFunc: func(_ string, _ int64) (*people.ListConnectionsResponse, error) { + ListContactsFunc: func(_ context.Context, _ string, _ int64) (*people.ListConnectionsResponse, error) { return nil, errors.New("API error") }, } @@ -154,7 +154,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { func TestSearchCommand_Success(t *testing.T) { mock := &MockContactsClient{ - SearchContactsFunc: func(query string, _ int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ context.Context, query string, _ int64) (*people.SearchResponse, error) { testutil.Equal(t, query, "John") return &people.SearchResponse{ Results: []*people.SearchResult{ @@ -180,7 +180,7 @@ func TestSearchCommand_Success(t *testing.T) { func TestSearchCommand_JSONOutput(t *testing.T) { mock := &MockContactsClient{ - SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ context.Context, _ string, _ int64) (*people.SearchResponse, error) { return &people.SearchResponse{ Results: []*people.SearchResult{ {Person: testutil.SamplePerson("people/c123")}, @@ -207,7 +207,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { func TestSearchCommand_NoResults(t *testing.T) { mock := &MockContactsClient{ - SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ context.Context, _ string, _ int64) (*people.SearchResponse, error) { return &people.SearchResponse{ Results: []*people.SearchResult{}, }, nil @@ -229,7 +229,7 @@ func TestSearchCommand_NoResults(t *testing.T) { func TestSearchCommand_APIError(t *testing.T) { mock := &MockContactsClient{ - SearchContactsFunc: func(_ string, _ int64) (*people.SearchResponse, error) { + SearchContactsFunc: func(_ context.Context, _ string, _ int64) (*people.SearchResponse, error) { return nil, errors.New("API error") }, } @@ -246,7 +246,7 @@ func TestSearchCommand_APIError(t *testing.T) { func TestGetCommand_Success(t *testing.T) { mock := &MockContactsClient{ - GetContactFunc: func(resourceName string) (*people.Person, error) { + GetContactFunc: func(_ context.Context, resourceName string) (*people.Person, error) { testutil.Equal(t, resourceName, "people/c123") return testutil.SamplePerson("people/c123"), nil }, @@ -269,7 +269,7 @@ func TestGetCommand_Success(t *testing.T) { func TestGetCommand_JSONOutput(t *testing.T) { mock := &MockContactsClient{ - GetContactFunc: func(_ string) (*people.Person, error) { + GetContactFunc: func(_ context.Context, _ string) (*people.Person, error) { return testutil.SamplePerson("people/c123"), nil }, } @@ -292,7 +292,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { func TestGetCommand_NotFound(t *testing.T) { mock := &MockContactsClient{ - GetContactFunc: func(_ string) (*people.Person, error) { + GetContactFunc: func(_ context.Context, _ string) (*people.Person, error) { return nil, errors.New("contact not found") }, } @@ -309,7 +309,7 @@ func TestGetCommand_NotFound(t *testing.T) { func TestGroupsCommand_Success(t *testing.T) { mock := &MockContactsClient{ - ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ context.Context, _ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{ { @@ -345,7 +345,7 @@ func TestGroupsCommand_Success(t *testing.T) { func TestGroupsCommand_JSONOutput(t *testing.T) { mock := &MockContactsClient{ - ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ context.Context, _ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{ { @@ -378,7 +378,7 @@ func TestGroupsCommand_JSONOutput(t *testing.T) { func TestGroupsCommand_Empty(t *testing.T) { mock := &MockContactsClient{ - ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ context.Context, _ string, _ int64) (*people.ListContactGroupsResponse, error) { return &people.ListContactGroupsResponse{ ContactGroups: []*people.ContactGroup{}, }, nil @@ -399,7 +399,7 @@ func TestGroupsCommand_Empty(t *testing.T) { func TestGroupsCommand_APIError(t *testing.T) { mock := &MockContactsClient{ - ListContactGroupsFunc: func(_ string, _ int64) (*people.ListContactGroupsResponse, error) { + ListContactGroupsFunc: func(_ context.Context, _ string, _ int64) (*people.ListContactGroupsResponse, error) { return nil, errors.New("API error") }, } diff --git a/internal/cmd/contacts/list.go b/internal/cmd/contacts/list.go index 03ac67a..ffbc8d4 100644 --- a/internal/cmd/contacts/list.go +++ b/internal/cmd/contacts/list.go @@ -32,7 +32,7 @@ Examples: return fmt.Errorf("creating Contacts client: %w", err) } - resp, err := client.ListContacts("", maxResults) + resp, err := client.ListContacts(cmd.Context(), "", maxResults) if err != nil { return fmt.Errorf("listing contacts: %w", err) } diff --git a/internal/cmd/contacts/mock_test.go b/internal/cmd/contacts/mock_test.go index 6544df3..d0299d4 100644 --- a/internal/cmd/contacts/mock_test.go +++ b/internal/cmd/contacts/mock_test.go @@ -1,44 +1,46 @@ package contacts import ( + "context" + "google.golang.org/api/people/v1" ) // MockContactsClient is a configurable mock for ContactsClient. type MockContactsClient struct { - ListContactsFunc func(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) - SearchContactsFunc func(query string, pageSize int64) (*people.SearchResponse, error) - GetContactFunc func(resourceName string) (*people.Person, error) - ListContactGroupsFunc func(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) + ListContactsFunc func(ctx context.Context, pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) + SearchContactsFunc func(ctx context.Context, query string, pageSize int64) (*people.SearchResponse, error) + GetContactFunc func(ctx context.Context, resourceName string) (*people.Person, error) + ListContactGroupsFunc func(ctx context.Context, pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) } // Verify MockContactsClient implements ContactsClient var _ ContactsClient = (*MockContactsClient)(nil) -func (m *MockContactsClient) ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { +func (m *MockContactsClient) ListContacts(ctx context.Context, pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { if m.ListContactsFunc != nil { - return m.ListContactsFunc(pageToken, pageSize) + return m.ListContactsFunc(ctx, pageToken, pageSize) } return nil, nil } -func (m *MockContactsClient) SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) { +func (m *MockContactsClient) SearchContacts(ctx context.Context, query string, pageSize int64) (*people.SearchResponse, error) { if m.SearchContactsFunc != nil { - return m.SearchContactsFunc(query, pageSize) + return m.SearchContactsFunc(ctx, query, pageSize) } return nil, nil } -func (m *MockContactsClient) GetContact(resourceName string) (*people.Person, error) { +func (m *MockContactsClient) GetContact(ctx context.Context, resourceName string) (*people.Person, error) { if m.GetContactFunc != nil { - return m.GetContactFunc(resourceName) + return m.GetContactFunc(ctx, resourceName) } return nil, nil } -func (m *MockContactsClient) ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { +func (m *MockContactsClient) ListContactGroups(ctx context.Context, pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { if m.ListContactGroupsFunc != nil { - return m.ListContactGroupsFunc(pageToken, pageSize) + return m.ListContactGroupsFunc(ctx, pageToken, pageSize) } return nil, nil } diff --git a/internal/cmd/contacts/output.go b/internal/cmd/contacts/output.go index 0266a07..38fd86c 100644 --- a/internal/cmd/contacts/output.go +++ b/internal/cmd/contacts/output.go @@ -12,10 +12,10 @@ import ( // ContactsClient defines the interface for Contacts client operations used by contacts commands. type ContactsClient interface { - ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) - SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) - GetContact(resourceName string) (*people.Person, error) - ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) + ListContacts(ctx context.Context, pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) + SearchContacts(ctx context.Context, query string, pageSize int64) (*people.SearchResponse, error) + GetContact(ctx context.Context, resourceName string) (*people.Person, error) + ListContactGroups(ctx context.Context, pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) } // ClientFactory is the function used to create Contacts clients. diff --git a/internal/cmd/contacts/search.go b/internal/cmd/contacts/search.go index d9d312b..0f7deb5 100644 --- a/internal/cmd/contacts/search.go +++ b/internal/cmd/contacts/search.go @@ -40,7 +40,7 @@ Examples: return fmt.Errorf("creating Contacts client: %w", err) } - resp, err := client.SearchContacts(query, maxResults) + resp, err := client.SearchContacts(cmd.Context(), query, maxResults) if err != nil { return fmt.Errorf("searching contacts: %w", err) } diff --git a/internal/cmd/drive/download.go b/internal/cmd/drive/download.go index a33d18c..53b492e 100644 --- a/internal/cmd/drive/download.go +++ b/internal/cmd/drive/download.go @@ -49,8 +49,10 @@ Export formats: fileID := args[0] + ctx := cmd.Context() + // Get file metadata first - file, err := client.GetFile(fileID) + file, err := client.GetFile(ctx, fileID) if err != nil { return fmt.Errorf("getting file info: %w", err) } @@ -75,7 +77,7 @@ Export formats: fmt.Printf("Format: %s\n", format) } - data, err = client.ExportFile(fileID, exportMime) + data, err = client.ExportFile(ctx, fileID, exportMime) if err != nil { return fmt.Errorf("exporting file: %w", err) } @@ -90,7 +92,7 @@ Export formats: fmt.Printf("Downloading: %s\n", file.Name) } - data, err = client.DownloadFile(fileID) + data, err = client.DownloadFile(ctx, fileID) if err != nil { return fmt.Errorf("downloading file: %w", err) } diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index 838b078..3b75e50 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -1,6 +1,7 @@ package drive import ( + "context" "fmt" "os" "strings" @@ -66,7 +67,7 @@ Examples: // Fetch from API if no cache hit if drives == nil { - drives, err = client.ListSharedDrives(100) + drives, err = client.ListSharedDrives(cmd.Context(), 100) if err != nil { return fmt.Errorf("listing shared drives: %w", err) } @@ -118,7 +119,7 @@ func printSharedDrives(drives []*drive.SharedDrive) { } // resolveDriveScope converts command flags to a DriveScope, resolving drive names via cache -func resolveDriveScope(client DriveClient, myDrive bool, driveFlag string) (drive.DriveScope, error) { +func resolveDriveScope(ctx context.Context, client DriveClient, myDrive bool, driveFlag string) (drive.DriveScope, error) { // --my-drive flag if myDrive { return drive.DriveScope{MyDriveOnly: true}, nil @@ -145,7 +146,7 @@ func resolveDriveScope(client DriveClient, myDrive bool, driveFlag string) (driv cached, _ := c.GetDrives() if cached == nil { // Cache miss - fetch from API - drives, err := client.ListSharedDrives(100) + drives, err := client.ListSharedDrives(ctx, 100) if err != nil { return drive.DriveScope{}, fmt.Errorf("listing shared drives: %w", err) } diff --git a/internal/cmd/drive/drives_test.go b/internal/cmd/drive/drives_test.go index a906f4b..977d717 100644 --- a/internal/cmd/drive/drives_test.go +++ b/internal/cmd/drive/drives_test.go @@ -1,6 +1,7 @@ package drive import ( + "context" "testing" "github.com/open-cli-collective/google-readonly/internal/drive" @@ -105,7 +106,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("returns MyDriveOnly when myDrive flag is true", func(t *testing.T) { mock := &MockDriveClient{} - scope, err := resolveDriveScope(mock, true, "") + scope, err := resolveDriveScope(context.Background(), mock, true, "") testutil.NoError(t, err) testutil.True(t, scope.MyDriveOnly) @@ -116,7 +117,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("returns AllDrives when no flags provided", func(t *testing.T) { mock := &MockDriveClient{} - scope, err := resolveDriveScope(mock, false, "") + scope, err := resolveDriveScope(context.Background(), mock, false, "") testutil.NoError(t, err) testutil.True(t, scope.AllDrives) @@ -127,7 +128,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("returns DriveID directly when input looks like ID", func(t *testing.T) { mock := &MockDriveClient{} - scope, err := resolveDriveScope(mock, false, "0ALengineering123456") + scope, err := resolveDriveScope(context.Background(), mock, false, "0ALengineering123456") testutil.NoError(t, err) testutil.Equal(t, scope.DriveID, "0ALengineering123456") @@ -137,7 +138,7 @@ func TestResolveDriveScope(t *testing.T) { t.Run("resolves drive name to ID via API", func(t *testing.T) { mock := &MockDriveClient{ - ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { + ListSharedDrivesFunc: func(_ context.Context, _ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, {ID: "0ALfin456", Name: "Finance"}, @@ -145,7 +146,7 @@ func TestResolveDriveScope(t *testing.T) { }, } - scope, err := resolveDriveScope(mock, false, "Engineering") + scope, err := resolveDriveScope(context.Background(), mock, false, "Engineering") testutil.NoError(t, err) testutil.Equal(t, scope.DriveID, "0ALeng123") @@ -153,14 +154,14 @@ func TestResolveDriveScope(t *testing.T) { t.Run("resolves drive name case-insensitively", func(t *testing.T) { mock := &MockDriveClient{ - ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { + ListSharedDrivesFunc: func(_ context.Context, _ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, }, nil }, } - scope, err := resolveDriveScope(mock, false, "ENGINEERING") + scope, err := resolveDriveScope(context.Background(), mock, false, "ENGINEERING") testutil.NoError(t, err) testutil.Equal(t, scope.DriveID, "0ALeng123") @@ -168,14 +169,14 @@ func TestResolveDriveScope(t *testing.T) { t.Run("returns error when drive name not found", func(t *testing.T) { mock := &MockDriveClient{ - ListSharedDrivesFunc: func(_ int64) ([]*drive.SharedDrive, error) { + ListSharedDrivesFunc: func(_ context.Context, _ int64) ([]*drive.SharedDrive, error) { return []*drive.SharedDrive{ {ID: "0ALeng123", Name: "Engineering"}, }, nil }, } - _, err := resolveDriveScope(mock, false, "NonExistent") + _, err := resolveDriveScope(context.Background(), mock, false, "NonExistent") testutil.Error(t, err) testutil.Contains(t, err.Error(), "shared drive not found") diff --git a/internal/cmd/drive/get.go b/internal/cmd/drive/get.go index b71b552..f06bb85 100644 --- a/internal/cmd/drive/get.go +++ b/internal/cmd/drive/get.go @@ -29,7 +29,7 @@ Examples: } fileID := args[0] - file, err := client.GetFile(fileID) + file, err := client.GetFile(cmd.Context(), fileID) if err != nil { return fmt.Errorf("getting file %s: %w", fileID, err) } diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index 95b28c9..2d59123 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -52,7 +52,7 @@ func withFailingClientFactory(f func()) { func TestListCommand_Success(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "'root' in parents") return testutil.SampleDriveFiles(2), nil }, @@ -73,7 +73,7 @@ func TestListCommand_Success(t *testing.T) { func TestListCommand_JSONOutput(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { return testutil.SampleDriveFiles(1), nil }, } @@ -96,7 +96,7 @@ func TestListCommand_JSONOutput(t *testing.T) { func TestListCommand_Empty(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { return []*driveapi.File{}, nil }, } @@ -115,7 +115,7 @@ func TestListCommand_Empty(t *testing.T) { func TestListCommand_WithFolder(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "'folder123' in parents") return testutil.SampleDriveFiles(1), nil }, @@ -136,7 +136,7 @@ func TestListCommand_WithFolder(t *testing.T) { func TestListCommand_WithTypeFilter(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "mimeType") return testutil.SampleDriveFiles(1), nil }, @@ -168,7 +168,7 @@ func TestListCommand_InvalidType(t *testing.T) { func TestListCommand_APIError(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { return nil, errors.New("API error") }, } @@ -194,7 +194,7 @@ func TestListCommand_ClientCreationError(t *testing.T) { func TestSearchCommand_Success(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "fullText contains 'report'") return testutil.SampleDriveFiles(2), nil }, @@ -216,7 +216,7 @@ func TestSearchCommand_Success(t *testing.T) { func TestSearchCommand_NameOnly(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(query string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, query string, _ int64) ([]*driveapi.File, error) { testutil.Contains(t, query, "name contains 'budget'") return testutil.SampleDriveFiles(1), nil }, @@ -237,7 +237,7 @@ func TestSearchCommand_NameOnly(t *testing.T) { func TestSearchCommand_JSONOutput(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { return testutil.SampleDriveFiles(1), nil }, } @@ -260,7 +260,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { func TestSearchCommand_NoResults(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { return []*driveapi.File{}, nil }, } @@ -280,7 +280,7 @@ func TestSearchCommand_NoResults(t *testing.T) { func TestSearchCommand_APIError(t *testing.T) { mock := &MockDriveClient{ - ListFilesFunc: func(_ string, _ int64) ([]*driveapi.File, error) { + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { return nil, errors.New("API error") }, } @@ -297,7 +297,7 @@ func TestSearchCommand_APIError(t *testing.T) { func TestGetCommand_Success(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(fileID string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, fileID string) (*driveapi.File, error) { testutil.Equal(t, fileID, "file123") return testutil.SampleDriveFile("file123"), nil }, @@ -320,7 +320,7 @@ func TestGetCommand_Success(t *testing.T) { func TestGetCommand_JSONOutput(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, } @@ -343,7 +343,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { func TestGetCommand_NotFound(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return nil, errors.New("file not found") }, } @@ -366,10 +366,10 @@ func TestDownloadCommand_RegularFile(t *testing.T) { defer os.Chdir(origDir) mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, - DownloadFileFunc: func(fileID string) ([]byte, error) { + DownloadFileFunc: func(_ context.Context, fileID string) ([]byte, error) { testutil.Equal(t, fileID, "file123") return []byte("test content"), nil }, @@ -391,10 +391,10 @@ func TestDownloadCommand_RegularFile(t *testing.T) { func TestDownloadCommand_ToStdout(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, - DownloadFileFunc: func(_ string) ([]byte, error) { + DownloadFileFunc: func(_ context.Context, _ string) ([]byte, error) { return []byte("test content"), nil }, } @@ -414,7 +414,7 @@ func TestDownloadCommand_ToStdout(t *testing.T) { func TestDownloadCommand_GoogleDocRequiresFormat(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleGoogleDoc("doc123"), nil }, } @@ -437,10 +437,10 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { defer os.Chdir(origDir) mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleGoogleDoc("doc123"), nil }, - ExportFileFunc: func(fileID, mimeType string) ([]byte, error) { + ExportFileFunc: func(_ context.Context, fileID, mimeType string) ([]byte, error) { testutil.Equal(t, fileID, "doc123") testutil.Contains(t, mimeType, "pdf") return []byte("pdf content"), nil @@ -463,7 +463,7 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, } @@ -480,10 +480,10 @@ func TestDownloadCommand_RegularFileCannotUseFormat(t *testing.T) { func TestDownloadCommand_APIError(t *testing.T) { mock := &MockDriveClient{ - GetFileFunc: func(_ string) (*driveapi.File, error) { + GetFileFunc: func(_ context.Context, _ string) (*driveapi.File, error) { return testutil.SampleDriveFile("file123"), nil }, - DownloadFileFunc: func(_ string) ([]byte, error) { + DownloadFileFunc: func(_ context.Context, _ string) ([]byte, error) { return nil, errors.New("download failed") }, } diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index 5cad92a..c84ed03 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -1,6 +1,7 @@ package drive import ( + "context" "fmt" "os" "strings" @@ -56,7 +57,8 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi } // Resolve drive scope for listing - scope, err := resolveDriveScopeForList(client, myDrive, driveFlag, folderID) + ctx := cmd.Context() + scope, err := resolveDriveScopeForList(ctx, client, myDrive, driveFlag, folderID) if err != nil { return fmt.Errorf("resolving drive scope: %w", err) } @@ -66,7 +68,7 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi return fmt.Errorf("building query: %w", err) } - files, err := client.ListFilesWithScope(query, maxResults, scope) + files, err := client.ListFilesWithScope(ctx, query, maxResults, scope) if err != nil { return fmt.Errorf("listing files: %w", err) } @@ -141,14 +143,14 @@ func buildListQueryWithScope(folderID, fileType string, scope drive.DriveScope) // resolveDriveScopeForList resolves the scope for list operations // List has slightly different behavior - defaults to My Drive root if no flags -func resolveDriveScopeForList(client DriveClient, myDrive bool, driveFlag, folderID string) (drive.DriveScope, error) { +func resolveDriveScopeForList(ctx context.Context, client DriveClient, myDrive bool, driveFlag, folderID string) (drive.DriveScope, error) { // If a folder ID is provided, we need to support all drives to access it if folderID != "" && !myDrive && driveFlag == "" { return drive.DriveScope{AllDrives: true}, nil } // Otherwise use the standard resolution - return resolveDriveScope(client, myDrive, driveFlag) + return resolveDriveScope(ctx, client, myDrive, driveFlag) } // getMimeTypeFilter returns the Drive API query filter for a file type diff --git a/internal/cmd/drive/mock_test.go b/internal/cmd/drive/mock_test.go index a66fa63..005a789 100644 --- a/internal/cmd/drive/mock_test.go +++ b/internal/cmd/drive/mock_test.go @@ -1,64 +1,66 @@ package drive import ( + "context" + driveapi "github.com/open-cli-collective/google-readonly/internal/drive" ) // MockDriveClient is a configurable mock for DriveClient. type MockDriveClient struct { - ListFilesFunc func(query string, pageSize int64) ([]*driveapi.File, error) - ListFilesWithScopeFunc func(query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) - GetFileFunc func(fileID string) (*driveapi.File, error) - DownloadFileFunc func(fileID string) ([]byte, error) - ExportFileFunc func(fileID, mimeType string) ([]byte, error) - ListSharedDrivesFunc func(pageSize int64) ([]*driveapi.SharedDrive, error) + ListFilesFunc func(ctx context.Context, query string, pageSize int64) ([]*driveapi.File, error) + ListFilesWithScopeFunc func(ctx context.Context, query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) + GetFileFunc func(ctx context.Context, fileID string) (*driveapi.File, error) + DownloadFileFunc func(ctx context.Context, fileID string) ([]byte, error) + ExportFileFunc func(ctx context.Context, fileID, mimeType string) ([]byte, error) + ListSharedDrivesFunc func(ctx context.Context, pageSize int64) ([]*driveapi.SharedDrive, error) } // Verify MockDriveClient implements DriveClient var _ DriveClient = (*MockDriveClient)(nil) -func (m *MockDriveClient) ListFiles(query string, pageSize int64) ([]*driveapi.File, error) { +func (m *MockDriveClient) ListFiles(ctx context.Context, query string, pageSize int64) ([]*driveapi.File, error) { if m.ListFilesFunc != nil { - return m.ListFilesFunc(query, pageSize) + return m.ListFilesFunc(ctx, query, pageSize) } return nil, nil } -func (m *MockDriveClient) ListFilesWithScope(query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) { +func (m *MockDriveClient) ListFilesWithScope(ctx context.Context, query string, pageSize int64, scope driveapi.DriveScope) ([]*driveapi.File, error) { if m.ListFilesWithScopeFunc != nil { - return m.ListFilesWithScopeFunc(query, pageSize, scope) + return m.ListFilesWithScopeFunc(ctx, query, pageSize, scope) } // Fall back to ListFiles if no scope function defined if m.ListFilesFunc != nil { - return m.ListFilesFunc(query, pageSize) + return m.ListFilesFunc(ctx, query, pageSize) } return nil, nil } -func (m *MockDriveClient) GetFile(fileID string) (*driveapi.File, error) { +func (m *MockDriveClient) GetFile(ctx context.Context, fileID string) (*driveapi.File, error) { if m.GetFileFunc != nil { - return m.GetFileFunc(fileID) + return m.GetFileFunc(ctx, fileID) } return nil, nil } -func (m *MockDriveClient) DownloadFile(fileID string) ([]byte, error) { +func (m *MockDriveClient) DownloadFile(ctx context.Context, fileID string) ([]byte, error) { if m.DownloadFileFunc != nil { - return m.DownloadFileFunc(fileID) + return m.DownloadFileFunc(ctx, fileID) } return nil, nil } -func (m *MockDriveClient) ExportFile(fileID, mimeType string) ([]byte, error) { +func (m *MockDriveClient) ExportFile(ctx context.Context, fileID, mimeType string) ([]byte, error) { if m.ExportFileFunc != nil { - return m.ExportFileFunc(fileID, mimeType) + return m.ExportFileFunc(ctx, fileID, mimeType) } return nil, nil } -func (m *MockDriveClient) ListSharedDrives(pageSize int64) ([]*driveapi.SharedDrive, error) { +func (m *MockDriveClient) ListSharedDrives(ctx context.Context, pageSize int64) ([]*driveapi.SharedDrive, error) { if m.ListSharedDrivesFunc != nil { - return m.ListSharedDrivesFunc(pageSize) + return m.ListSharedDrivesFunc(ctx, pageSize) } return nil, nil } diff --git a/internal/cmd/drive/output.go b/internal/cmd/drive/output.go index 159cdf9..ea0d36b 100644 --- a/internal/cmd/drive/output.go +++ b/internal/cmd/drive/output.go @@ -9,12 +9,12 @@ import ( // DriveClient defines the interface for Drive client operations used by drive commands. type DriveClient interface { - ListFiles(query string, pageSize int64) ([]*drive.File, error) - ListFilesWithScope(query string, pageSize int64, scope drive.DriveScope) ([]*drive.File, error) - GetFile(fileID string) (*drive.File, error) - DownloadFile(fileID string) ([]byte, error) - ExportFile(fileID string, mimeType string) ([]byte, error) - ListSharedDrives(pageSize int64) ([]*drive.SharedDrive, error) + ListFiles(ctx context.Context, query string, pageSize int64) ([]*drive.File, error) + ListFilesWithScope(ctx context.Context, query string, pageSize int64, scope drive.DriveScope) ([]*drive.File, error) + GetFile(ctx context.Context, fileID string) (*drive.File, error) + DownloadFile(ctx context.Context, fileID string) ([]byte, error) + ExportFile(ctx context.Context, fileID string, mimeType string) ([]byte, error) + ListSharedDrives(ctx context.Context, pageSize int64) ([]*drive.SharedDrive, error) } // ClientFactory is the function used to create Drive clients. diff --git a/internal/cmd/drive/search.go b/internal/cmd/drive/search.go index 65e394b..419bc7a 100644 --- a/internal/cmd/drive/search.go +++ b/internal/cmd/drive/search.go @@ -66,12 +66,13 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi } // Resolve drive scope - scope, err := resolveDriveScope(client, myDrive, driveFlag) + ctx := cmd.Context() + scope, err := resolveDriveScope(ctx, client, myDrive, driveFlag) if err != nil { return fmt.Errorf("resolving drive scope: %w", err) } - files, err := client.ListFilesWithScope(searchQuery, maxResults, scope) + files, err := client.ListFilesWithScope(ctx, searchQuery, maxResults, scope) if err != nil { return fmt.Errorf("searching files: %w", err) } diff --git a/internal/cmd/drive/tree.go b/internal/cmd/drive/tree.go index b5575e4..a55f45d 100644 --- a/internal/cmd/drive/tree.go +++ b/internal/cmd/drive/tree.go @@ -1,6 +1,7 @@ package drive import ( + "context" "fmt" "sort" @@ -61,7 +62,7 @@ Examples: rootName = "" // Will be fetched from folder info } else if driveFlag != "" { // Resolve shared drive - scope, err := resolveDriveScope(client, false, driveFlag) + scope, err := resolveDriveScope(cmd.Context(), client, false, driveFlag) if err != nil { return fmt.Errorf("resolving drive: %w", err) } @@ -70,7 +71,7 @@ Examples: } // Build the tree - tree, err := buildTreeWithScope(client, folderID, rootName, depth, files) + tree, err := buildTreeWithScope(cmd.Context(), client, folderID, rootName, depth, files) if err != nil { return fmt.Errorf("building folder tree: %w", err) } @@ -94,12 +95,12 @@ Examples: } // buildTree recursively builds the folder tree structure -func buildTree(client DriveClient, folderID string, depth int, includeFiles bool) (*TreeNode, error) { - return buildTreeWithScope(client, folderID, "", depth, includeFiles) +func buildTree(ctx context.Context, client DriveClient, folderID string, depth int, includeFiles bool) (*TreeNode, error) { + return buildTreeWithScope(ctx, client, folderID, "", depth, includeFiles) } // buildTreeWithScope builds folder tree with optional root name override -func buildTreeWithScope(client DriveClient, folderID, rootName string, depth int, includeFiles bool) (*TreeNode, error) { +func buildTreeWithScope(ctx context.Context, client DriveClient, folderID, rootName string, depth int, includeFiles bool) (*TreeNode, error) { // Get folder info var folderName string var folderType string @@ -111,7 +112,7 @@ func buildTreeWithScope(client DriveClient, folderID, rootName string, depth int folderName = rootName folderType = "Shared Drive" } else { - folder, err := client.GetFile(folderID) + folder, err := client.GetFile(ctx, folderID) if err != nil { return nil, fmt.Errorf("getting folder info: %w", err) } @@ -138,7 +139,7 @@ func buildTreeWithScope(client DriveClient, folderID, rootName string, depth int // Use ListFilesWithScope to support shared drives scope := drive.DriveScope{AllDrives: true} - children, err := client.ListFilesWithScope(query, 100, scope) + children, err := client.ListFilesWithScope(ctx, query, 100, scope) if err != nil { return nil, fmt.Errorf("listing children: %w", err) } @@ -157,7 +158,7 @@ func buildTreeWithScope(client DriveClient, folderID, rootName string, depth int for _, child := range children { if child.MimeType == drive.MimeTypeFolder { // Recursively build subtree for folders (don't pass rootName on recursion) - childNode, err := buildTreeWithScope(client, child.ID, "", depth-1, includeFiles) + childNode, err := buildTreeWithScope(ctx, client, child.ID, "", depth-1, includeFiles) if err != nil { // Log error but continue with other children continue diff --git a/internal/cmd/drive/tree_test.go b/internal/cmd/drive/tree_test.go index a7668f6..e49026b 100644 --- a/internal/cmd/drive/tree_test.go +++ b/internal/cmd/drive/tree_test.go @@ -2,6 +2,7 @@ package drive import ( "bytes" + "context" "fmt" "io" "os" @@ -228,14 +229,14 @@ func newMockDriveClient() *mockDriveClient { } } -func (m *mockDriveClient) GetFile(fileID string) (*drive.File, error) { +func (m *mockDriveClient) GetFile(_ context.Context, fileID string) (*drive.File, error) { if f, ok := m.files[fileID]; ok { return f, nil } return nil, fmt.Errorf("file not found: %s", fileID) } -func (m *mockDriveClient) ListFiles(query string, _ int64) ([]*drive.File, error) { +func (m *mockDriveClient) ListFiles(_ context.Context, query string, _ int64) ([]*drive.File, error) { // Extract folderID from query like "'folder123' in parents and trashed = false" // The query format is: "'' in parents and trashed = false" for folderID, files := range m.children { @@ -247,20 +248,20 @@ func (m *mockDriveClient) ListFiles(query string, _ int64) ([]*drive.File, error return []*drive.File{}, nil } -func (m *mockDriveClient) ListFilesWithScope(query string, pageSize int64, _ drive.DriveScope) ([]*drive.File, error) { +func (m *mockDriveClient) ListFilesWithScope(ctx context.Context, query string, pageSize int64, _ drive.DriveScope) ([]*drive.File, error) { // Delegate to ListFiles for testing purposes - return m.ListFiles(query, pageSize) + return m.ListFiles(ctx, query, pageSize) } -func (m *mockDriveClient) DownloadFile(_ string) ([]byte, error) { +func (m *mockDriveClient) DownloadFile(_ context.Context, _ string) ([]byte, error) { return nil, fmt.Errorf("not implemented") } -func (m *mockDriveClient) ExportFile(_ string, _ string) ([]byte, error) { +func (m *mockDriveClient) ExportFile(_ context.Context, _ string, _ string) ([]byte, error) { return nil, fmt.Errorf("not implemented") } -func (m *mockDriveClient) ListSharedDrives(_ int64) ([]*drive.SharedDrive, error) { +func (m *mockDriveClient) ListSharedDrives(_ context.Context, _ int64) ([]*drive.SharedDrive, error) { return nil, fmt.Errorf("not implemented") } @@ -276,7 +277,7 @@ func TestBuildTree(t *testing.T) { mock.files["folder1"] = &drive.File{ID: "folder1", Name: "Documents", MimeType: drive.MimeTypeFolder} mock.files["folder2"] = &drive.File{ID: "folder2", Name: "Photos", MimeType: drive.MimeTypeFolder} - tree, err := buildTree(mock, "root", 1, false) + tree, err := buildTree(context.Background(), mock, "root", 1, false) testutil.NoError(t, err) testutil.Equal(t, tree.ID, "root") @@ -296,7 +297,7 @@ func TestBuildTree(t *testing.T) { {ID: "doc1", Name: "Notes.txt", MimeType: "text/plain"}, } - tree, err := buildTree(mock, "folder123", 1, true) + tree, err := buildTree(context.Background(), mock, "folder123", 1, true) testutil.NoError(t, err) testutil.Equal(t, tree.ID, "folder123") @@ -317,7 +318,7 @@ func TestBuildTree(t *testing.T) { mock.files["folder2"] = &drive.File{ID: "folder2", Name: "Level2", MimeType: drive.MimeTypeFolder} // With depth 1, should not recurse into Level1 - tree, err := buildTree(mock, "root", 1, false) + tree, err := buildTree(context.Background(), mock, "root", 1, false) testutil.NoError(t, err) testutil.Len(t, tree.Children, 1) @@ -332,7 +333,7 @@ func TestBuildTree(t *testing.T) { {ID: "folder1", Name: "Folder", MimeType: drive.MimeTypeFolder}, } - tree, err := buildTree(mock, "root", 0, false) + tree, err := buildTree(context.Background(), mock, "root", 0, false) testutil.NoError(t, err) testutil.Equal(t, tree.Name, "My Drive") @@ -347,7 +348,7 @@ func TestBuildTree(t *testing.T) { } mock.files["folder1"] = &drive.File{ID: "folder1", Name: "Docs", MimeType: drive.MimeTypeFolder} - tree, err := buildTree(mock, "root", 1, true) + tree, err := buildTree(context.Background(), mock, "root", 1, true) testutil.NoError(t, err) testutil.Len(t, tree.Children, 2) @@ -361,7 +362,7 @@ func TestBuildTree(t *testing.T) { } mock.files["folder1"] = &drive.File{ID: "folder1", Name: "zzz-folder", MimeType: drive.MimeTypeFolder} - tree, err := buildTree(mock, "root", 1, true) + tree, err := buildTree(context.Background(), mock, "root", 1, true) testutil.NoError(t, err) testutil.Len(t, tree.Children, 2) diff --git a/internal/cmd/initcmd/init.go b/internal/cmd/initcmd/init.go index fe33a45..5d7ab5f 100644 --- a/internal/cmd/initcmd/init.go +++ b/internal/cmd/initcmd/init.go @@ -202,7 +202,7 @@ func verifyConnectivity(ctx context.Context) error { fmt.Println(" OAuth token: OK") // Get profile to verify connectivity and get email address - profile, err := client.GetProfile() + profile, err := client.GetProfile(ctx) if err != nil { fmt.Println(" Gmail API: FAILED") return fmt.Errorf("accessing Gmail API: %w", err) diff --git a/internal/cmd/mail/attachments_download.go b/internal/cmd/mail/attachments_download.go index 0a800c8..40337cf 100644 --- a/internal/cmd/mail/attachments_download.go +++ b/internal/cmd/mail/attachments_download.go @@ -1,6 +1,7 @@ package mail import ( + "context" "fmt" "os" "path/filepath" @@ -49,7 +50,7 @@ Examples: } messageID := args[0] - attachments, err := client.GetAttachments(messageID) + attachments, err := client.GetAttachments(cmd.Context(), messageID) if err != nil { return fmt.Errorf("getting attachments: %w", err) } @@ -94,7 +95,7 @@ Examples: continue } - data, err := downloadAttachment(client, messageID, att) + data, err := downloadAttachment(cmd.Context(), client, messageID, att) if err != nil { fmt.Fprintf(os.Stderr, "Error downloading %s: %v\n", safeFilename, err) continue @@ -135,11 +136,11 @@ Examples: return cmd } -func downloadAttachment(client MailClient, messageID string, att *gmail.Attachment) ([]byte, error) { +func downloadAttachment(ctx context.Context, client MailClient, messageID string, att *gmail.Attachment) ([]byte, error) { if att.AttachmentID != "" { - return client.DownloadAttachment(messageID, att.AttachmentID) + return client.DownloadAttachment(ctx, messageID, att.AttachmentID) } - return client.DownloadInlineAttachment(messageID, att.PartID) + return client.DownloadInlineAttachment(ctx, messageID, att.PartID) } func saveAttachment(path string, data []byte) error { diff --git a/internal/cmd/mail/attachments_list.go b/internal/cmd/mail/attachments_list.go index eabcfe8..5825507 100644 --- a/internal/cmd/mail/attachments_list.go +++ b/internal/cmd/mail/attachments_list.go @@ -28,7 +28,7 @@ Examples: return fmt.Errorf("creating Gmail client: %w", err) } - attachments, err := client.GetAttachments(args[0]) + attachments, err := client.GetAttachments(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("getting attachments: %w", err) } diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index 9855e55..0b5fb50 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -54,7 +54,7 @@ func withFailingClientFactory(f func()) { func TestSearchCommand_Success(t *testing.T) { mock := &MockGmailClient{ - SearchMessagesFunc: func(query string, maxResults int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ context.Context, query string, maxResults int64) ([]*gmailapi.Message, int, error) { testutil.Equal(t, query, "is:unread") testutil.Equal(t, maxResults, int64(10)) return testutil.SampleMessages(2), 0, nil @@ -79,7 +79,7 @@ func TestSearchCommand_Success(t *testing.T) { func TestSearchCommand_JSONOutput(t *testing.T) { mock := &MockGmailClient{ - SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { return testutil.SampleMessages(1), 0, nil }, } @@ -104,7 +104,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { func TestSearchCommand_NoResults(t *testing.T) { mock := &MockGmailClient{ - SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { return []*gmailapi.Message{}, 0, nil }, } @@ -124,7 +124,7 @@ func TestSearchCommand_NoResults(t *testing.T) { func TestSearchCommand_APIError(t *testing.T) { mock := &MockGmailClient{ - SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { return nil, 0, errors.New("API quota exceeded") }, } @@ -152,7 +152,7 @@ func TestSearchCommand_ClientCreationError(t *testing.T) { func TestSearchCommand_SkippedMessages(t *testing.T) { mock := &MockGmailClient{ - SearchMessagesFunc: func(_ string, _ int64) ([]*gmailapi.Message, int, error) { + SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { return testutil.SampleMessages(2), 3, nil // 3 messages skipped }, } @@ -172,7 +172,7 @@ func TestSearchCommand_SkippedMessages(t *testing.T) { func TestReadCommand_Success(t *testing.T) { mock := &MockGmailClient{ - GetMessageFunc: func(messageID string, includeBody bool) (*gmailapi.Message, error) { + GetMessageFunc: func(_ context.Context, messageID string, includeBody bool) (*gmailapi.Message, error) { testutil.Equal(t, messageID, "msg123") testutil.True(t, includeBody) return testutil.SampleMessage("msg123"), nil @@ -196,7 +196,7 @@ func TestReadCommand_Success(t *testing.T) { func TestReadCommand_JSONOutput(t *testing.T) { mock := &MockGmailClient{ - GetMessageFunc: func(_ string, _ bool) (*gmailapi.Message, error) { + GetMessageFunc: func(_ context.Context, _ string, _ bool) (*gmailapi.Message, error) { return testutil.SampleMessage("msg123"), nil }, } @@ -219,7 +219,7 @@ func TestReadCommand_JSONOutput(t *testing.T) { func TestReadCommand_NotFound(t *testing.T) { mock := &MockGmailClient{ - GetMessageFunc: func(_ string, _ bool) (*gmailapi.Message, error) { + GetMessageFunc: func(_ context.Context, _ string, _ bool) (*gmailapi.Message, error) { return nil, errors.New("message not found") }, } @@ -236,7 +236,7 @@ func TestReadCommand_NotFound(t *testing.T) { func TestThreadCommand_Success(t *testing.T) { mock := &MockGmailClient{ - GetThreadFunc: func(id string) ([]*gmailapi.Message, error) { + GetThreadFunc: func(_ context.Context, id string) ([]*gmailapi.Message, error) { testutil.Equal(t, id, "thread123") return testutil.SampleMessages(3), nil }, @@ -260,7 +260,7 @@ func TestThreadCommand_Success(t *testing.T) { func TestThreadCommand_JSONOutput(t *testing.T) { mock := &MockGmailClient{ - GetThreadFunc: func(_ string) ([]*gmailapi.Message, error) { + GetThreadFunc: func(_ context.Context, _ string) ([]*gmailapi.Message, error) { return testutil.SampleMessages(2), nil }, } @@ -283,7 +283,7 @@ func TestThreadCommand_JSONOutput(t *testing.T) { func TestLabelsCommand_Success(t *testing.T) { mock := &MockGmailClient{ - FetchLabelsFunc: func() error { + FetchLabelsFunc: func(_ context.Context) error { return nil }, GetLabelsFunc: func() []*gmail.Label { @@ -308,7 +308,7 @@ func TestLabelsCommand_Success(t *testing.T) { func TestLabelsCommand_JSONOutput(t *testing.T) { mock := &MockGmailClient{ - FetchLabelsFunc: func() error { + FetchLabelsFunc: func(_ context.Context) error { return nil }, GetLabelsFunc: func() []*gmail.Label { @@ -334,7 +334,7 @@ func TestLabelsCommand_JSONOutput(t *testing.T) { func TestLabelsCommand_Empty(t *testing.T) { mock := &MockGmailClient{ - FetchLabelsFunc: func() error { + FetchLabelsFunc: func(_ context.Context) error { return nil }, GetLabelsFunc: func() []*gmail.Label { @@ -356,7 +356,7 @@ func TestLabelsCommand_Empty(t *testing.T) { func TestListAttachmentsCommand_Success(t *testing.T) { mock := &MockGmailClient{ - GetAttachmentsFunc: func(_ string) ([]*gmailapi.Attachment, error) { + GetAttachmentsFunc: func(_ context.Context, _ string) ([]*gmailapi.Attachment, error) { return []*gmailapi.Attachment{ testutil.SampleAttachment("report.pdf"), testutil.SampleAttachment("data.xlsx"), @@ -381,7 +381,7 @@ func TestListAttachmentsCommand_Success(t *testing.T) { func TestListAttachmentsCommand_NoAttachments(t *testing.T) { mock := &MockGmailClient{ - GetAttachmentsFunc: func(_ string) ([]*gmailapi.Attachment, error) { + GetAttachmentsFunc: func(_ context.Context, _ string) ([]*gmailapi.Attachment, error) { return []*gmailapi.Attachment{}, nil }, } diff --git a/internal/cmd/mail/labels.go b/internal/cmd/mail/labels.go index c05b1f5..318fe81 100644 --- a/internal/cmd/mail/labels.go +++ b/internal/cmd/mail/labels.go @@ -40,7 +40,7 @@ Examples: return fmt.Errorf("creating Gmail client: %w", err) } - if err := client.FetchLabels(); err != nil { + if err := client.FetchLabels(cmd.Context()); err != nil { return fmt.Errorf("fetching labels: %w", err) } diff --git a/internal/cmd/mail/mock_test.go b/internal/cmd/mail/mock_test.go index 0bed671..75c3a83 100644 --- a/internal/cmd/mail/mock_test.go +++ b/internal/cmd/mail/mock_test.go @@ -1,6 +1,8 @@ package mail import ( + "context" + "google.golang.org/api/gmail/v1" gmailapi "github.com/open-cli-collective/google-readonly/internal/gmail" @@ -9,45 +11,45 @@ import ( // MockGmailClient is a configurable mock for MailClient. // Set the function fields to control behavior in tests. type MockGmailClient struct { - GetMessageFunc func(messageID string, includeBody bool) (*gmailapi.Message, error) - SearchMessagesFunc func(query string, maxResults int64) ([]*gmailapi.Message, int, error) - GetThreadFunc func(id string) ([]*gmailapi.Message, error) - FetchLabelsFunc func() error + GetMessageFunc func(ctx context.Context, messageID string, includeBody bool) (*gmailapi.Message, error) + SearchMessagesFunc func(ctx context.Context, query string, maxResults int64) ([]*gmailapi.Message, int, error) + GetThreadFunc func(ctx context.Context, id string) ([]*gmailapi.Message, error) + FetchLabelsFunc func(ctx context.Context) error GetLabelNameFunc func(labelID string) string GetLabelsFunc func() []*gmail.Label - GetAttachmentsFunc func(messageID string) ([]*gmailapi.Attachment, error) - DownloadAttachmentFunc func(messageID, attachmentID string) ([]byte, error) - DownloadInlineAttachmentFunc func(messageID, partID string) ([]byte, error) - GetProfileFunc func() (*gmailapi.Profile, error) + GetAttachmentsFunc func(ctx context.Context, messageID string) ([]*gmailapi.Attachment, error) + DownloadAttachmentFunc func(ctx context.Context, messageID, attachmentID string) ([]byte, error) + DownloadInlineAttachmentFunc func(ctx context.Context, messageID, partID string) ([]byte, error) + GetProfileFunc func(ctx context.Context) (*gmailapi.Profile, error) } // Verify MockGmailClient implements MailClient var _ MailClient = (*MockGmailClient)(nil) -func (m *MockGmailClient) GetMessage(messageID string, includeBody bool) (*gmailapi.Message, error) { +func (m *MockGmailClient) GetMessage(ctx context.Context, messageID string, includeBody bool) (*gmailapi.Message, error) { if m.GetMessageFunc != nil { - return m.GetMessageFunc(messageID, includeBody) + return m.GetMessageFunc(ctx, messageID, includeBody) } return nil, nil } -func (m *MockGmailClient) SearchMessages(query string, maxResults int64) ([]*gmailapi.Message, int, error) { +func (m *MockGmailClient) SearchMessages(ctx context.Context, query string, maxResults int64) ([]*gmailapi.Message, int, error) { if m.SearchMessagesFunc != nil { - return m.SearchMessagesFunc(query, maxResults) + return m.SearchMessagesFunc(ctx, query, maxResults) } return nil, 0, nil } -func (m *MockGmailClient) GetThread(id string) ([]*gmailapi.Message, error) { +func (m *MockGmailClient) GetThread(ctx context.Context, id string) ([]*gmailapi.Message, error) { if m.GetThreadFunc != nil { - return m.GetThreadFunc(id) + return m.GetThreadFunc(ctx, id) } return nil, nil } -func (m *MockGmailClient) FetchLabels() error { +func (m *MockGmailClient) FetchLabels(ctx context.Context) error { if m.FetchLabelsFunc != nil { - return m.FetchLabelsFunc() + return m.FetchLabelsFunc(ctx) } return nil } @@ -66,30 +68,30 @@ func (m *MockGmailClient) GetLabels() []*gmail.Label { return nil } -func (m *MockGmailClient) GetAttachments(messageID string) ([]*gmailapi.Attachment, error) { +func (m *MockGmailClient) GetAttachments(ctx context.Context, messageID string) ([]*gmailapi.Attachment, error) { if m.GetAttachmentsFunc != nil { - return m.GetAttachmentsFunc(messageID) + return m.GetAttachmentsFunc(ctx, messageID) } return nil, nil } -func (m *MockGmailClient) DownloadAttachment(messageID, attachmentID string) ([]byte, error) { +func (m *MockGmailClient) DownloadAttachment(ctx context.Context, messageID, attachmentID string) ([]byte, error) { if m.DownloadAttachmentFunc != nil { - return m.DownloadAttachmentFunc(messageID, attachmentID) + return m.DownloadAttachmentFunc(ctx, messageID, attachmentID) } return nil, nil } -func (m *MockGmailClient) DownloadInlineAttachment(messageID, partID string) ([]byte, error) { +func (m *MockGmailClient) DownloadInlineAttachment(ctx context.Context, messageID, partID string) ([]byte, error) { if m.DownloadInlineAttachmentFunc != nil { - return m.DownloadInlineAttachmentFunc(messageID, partID) + return m.DownloadInlineAttachmentFunc(ctx, messageID, partID) } return nil, nil } -func (m *MockGmailClient) GetProfile() (*gmailapi.Profile, error) { +func (m *MockGmailClient) GetProfile(ctx context.Context) (*gmailapi.Profile, error) { if m.GetProfileFunc != nil { - return m.GetProfileFunc() + return m.GetProfileFunc(ctx) } return nil, nil } diff --git a/internal/cmd/mail/output.go b/internal/cmd/mail/output.go index c90ea7b..741d738 100644 --- a/internal/cmd/mail/output.go +++ b/internal/cmd/mail/output.go @@ -13,16 +13,16 @@ import ( // MailClient defines the interface for Gmail client operations used by mail commands. type MailClient interface { - GetMessage(messageID string, includeBody bool) (*gmail.Message, error) - SearchMessages(query string, maxResults int64) ([]*gmail.Message, int, error) - GetThread(id string) ([]*gmail.Message, error) - FetchLabels() error + GetMessage(ctx context.Context, messageID string, includeBody bool) (*gmail.Message, error) + SearchMessages(ctx context.Context, query string, maxResults int64) ([]*gmail.Message, int, error) + GetThread(ctx context.Context, id string) ([]*gmail.Message, error) + FetchLabels(ctx context.Context) error GetLabelName(labelID string) string GetLabels() []*gmailv1.Label - GetAttachments(messageID string) ([]*gmail.Attachment, error) - DownloadAttachment(messageID string, attachmentID string) ([]byte, error) - DownloadInlineAttachment(messageID string, partID string) ([]byte, error) - GetProfile() (*gmail.Profile, error) + GetAttachments(ctx context.Context, messageID string) ([]*gmail.Attachment, error) + DownloadAttachment(ctx context.Context, messageID string, attachmentID string) ([]byte, error) + DownloadInlineAttachment(ctx context.Context, messageID string, partID string) ([]byte, error) + GetProfile(ctx context.Context) (*gmail.Profile, error) } // ClientFactory is the function used to create Gmail clients. diff --git a/internal/cmd/mail/read.go b/internal/cmd/mail/read.go index 8cc7c0f..1c05c85 100644 --- a/internal/cmd/mail/read.go +++ b/internal/cmd/mail/read.go @@ -26,7 +26,7 @@ Examples: return fmt.Errorf("creating Gmail client: %w", err) } - msg, err := client.GetMessage(args[0], true) + msg, err := client.GetMessage(cmd.Context(), args[0], true) if err != nil { return fmt.Errorf("reading message: %w", err) } diff --git a/internal/cmd/mail/search.go b/internal/cmd/mail/search.go index ca922a0..b0979e0 100644 --- a/internal/cmd/mail/search.go +++ b/internal/cmd/mail/search.go @@ -31,7 +31,7 @@ For more query operators, see: https://support.google.com/mail/answer/7190`, return fmt.Errorf("creating Gmail client: %w", err) } - messages, skipped, err := client.SearchMessages(args[0], maxResults) + messages, skipped, err := client.SearchMessages(cmd.Context(), args[0], maxResults) if err != nil { return fmt.Errorf("searching messages: %w", err) } diff --git a/internal/cmd/mail/thread.go b/internal/cmd/mail/thread.go index 572e67f..a2a074c 100644 --- a/internal/cmd/mail/thread.go +++ b/internal/cmd/mail/thread.go @@ -29,7 +29,7 @@ Examples: return fmt.Errorf("creating Gmail client: %w", err) } - messages, err := client.GetThread(args[0]) + messages, err := client.GetThread(cmd.Context(), args[0]) if err != nil { return fmt.Errorf("getting thread: %w", err) } diff --git a/internal/contacts/client.go b/internal/contacts/client.go index 1870bc2..dfbfb0b 100644 --- a/internal/contacts/client.go +++ b/internal/contacts/client.go @@ -20,12 +20,12 @@ type Client struct { func NewClient(ctx context.Context) (*Client, error) { client, err := auth.GetHTTPClient(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("loading OAuth client: %w", err) } srv, err := people.NewService(ctx, option.WithHTTPClient(client)) if err != nil { - return nil, fmt.Errorf("unable to create People service: %w", err) + return nil, fmt.Errorf("creating People service: %w", err) } return &Client{ @@ -34,7 +34,7 @@ func NewClient(ctx context.Context) (*Client, error) { } // ListContacts retrieves contacts from the user's account -func (c *Client) ListContacts(pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { +func (c *Client) ListContacts(ctx context.Context, pageToken string, pageSize int64) (*people.ListConnectionsResponse, error) { call := c.service.People.Connections.List("people/me"). PersonFields("names,emailAddresses,phoneNumbers,organizations,addresses,biographies,photos"). PageSize(pageSize). @@ -44,7 +44,7 @@ func (c *Client) ListContacts(pageToken string, pageSize int64) (*people.ListCon call = call.PageToken(pageToken) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing contacts: %w", err) } @@ -53,11 +53,12 @@ func (c *Client) ListContacts(pageToken string, pageSize int64) (*people.ListCon } // SearchContacts searches for contacts matching a query -func (c *Client) SearchContacts(query string, pageSize int64) (*people.SearchResponse, error) { +func (c *Client) SearchContacts(ctx context.Context, query string, pageSize int64) (*people.SearchResponse, error) { resp, err := c.service.People.SearchContacts(). Query(query). ReadMask("names,emailAddresses,phoneNumbers,organizations,addresses,biographies,photos"). PageSize(int64(pageSize)). + Context(ctx). Do() if err != nil { return nil, fmt.Errorf("searching contacts: %w", err) @@ -67,9 +68,10 @@ func (c *Client) SearchContacts(query string, pageSize int64) (*people.SearchRes } // GetContact retrieves a specific contact by resource name -func (c *Client) GetContact(resourceName string) (*people.Person, error) { +func (c *Client) GetContact(ctx context.Context, resourceName string) (*people.Person, error) { resp, err := c.service.People.Get(resourceName). PersonFields("names,emailAddresses,phoneNumbers,organizations,addresses,biographies,urls,birthdays,events,relations,photos,metadata"). + Context(ctx). Do() if err != nil { return nil, fmt.Errorf("getting contact: %w", err) @@ -79,7 +81,7 @@ func (c *Client) GetContact(resourceName string) (*people.Person, error) { } // ListContactGroups retrieves all contact groups -func (c *Client) ListContactGroups(pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { +func (c *Client) ListContactGroups(ctx context.Context, pageToken string, pageSize int64) (*people.ListContactGroupsResponse, error) { call := c.service.ContactGroups.List(). PageSize(pageSize). GroupFields("name,groupType,memberCount") @@ -88,7 +90,7 @@ func (c *Client) ListContactGroups(pageToken string, pageSize int64) (*people.Li call = call.PageToken(pageToken) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing contact groups: %w", err) } diff --git a/internal/drive/client.go b/internal/drive/client.go index 8c21156..fdc69ba 100644 --- a/internal/drive/client.go +++ b/internal/drive/client.go @@ -21,12 +21,12 @@ type Client struct { func NewClient(ctx context.Context) (*Client, error) { client, err := auth.GetHTTPClient(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("loading OAuth client: %w", err) } srv, err := drive.NewService(ctx, option.WithHTTPClient(client)) if err != nil { - return nil, fmt.Errorf("unable to create Drive service: %w", err) + return nil, fmt.Errorf("creating Drive service: %w", err) } return &Client{ @@ -38,7 +38,7 @@ func NewClient(ctx context.Context) (*Client, error) { const fileFields = "id,name,mimeType,size,createdTime,modifiedTime,parents,owners,webViewLink,shared,driveId" // ListFiles returns files matching the query (searches My Drive only for backwards compatibility) -func (c *Client) ListFiles(query string, pageSize int64) ([]*File, error) { +func (c *Client) ListFiles(ctx context.Context, query string, pageSize int64) ([]*File, error) { call := c.service.Files.List(). Fields("files(" + fileFields + ")"). OrderBy("modifiedTime desc") @@ -50,7 +50,7 @@ func (c *Client) ListFiles(query string, pageSize int64) ([]*File, error) { call = call.PageSize(pageSize) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing files: %w", err) } @@ -63,7 +63,7 @@ func (c *Client) ListFiles(query string, pageSize int64) ([]*File, error) { } // ListFilesWithScope returns files matching the query within the specified scope -func (c *Client) ListFilesWithScope(query string, pageSize int64, scope DriveScope) ([]*File, error) { +func (c *Client) ListFilesWithScope(ctx context.Context, query string, pageSize int64, scope DriveScope) ([]*File, error) { call := c.service.Files.List(). Fields("files(" + fileFields + ")"). OrderBy("modifiedTime desc"). @@ -90,7 +90,7 @@ func (c *Client) ListFilesWithScope(query string, pageSize int64, scope DriveSco call = call.PageSize(pageSize) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing files: %w", err) } @@ -103,10 +103,11 @@ func (c *Client) ListFilesWithScope(query string, pageSize int64, scope DriveSco } // GetFile retrieves a single file by ID (supports files in shared drives) -func (c *Client) GetFile(fileID string) (*File, error) { +func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) { f, err := c.service.Files.Get(fileID). Fields(fileFields). SupportsAllDrives(true). + Context(ctx). Do() if err != nil { return nil, fmt.Errorf("getting file: %w", err) @@ -115,9 +116,10 @@ func (c *Client) GetFile(fileID string) (*File, error) { } // DownloadFile downloads a regular (non-Google Workspace) file -func (c *Client) DownloadFile(fileID string) ([]byte, error) { +func (c *Client) DownloadFile(ctx context.Context, fileID string) ([]byte, error) { resp, err := c.service.Files.Get(fileID). SupportsAllDrives(true). + Context(ctx). Download() if err != nil { return nil, fmt.Errorf("downloading file: %w", err) @@ -132,8 +134,8 @@ func (c *Client) DownloadFile(fileID string) ([]byte, error) { } // ExportFile exports a Google Workspace file to the specified MIME type -func (c *Client) ExportFile(fileID string, mimeType string) ([]byte, error) { - resp, err := c.service.Files.Export(fileID, mimeType).Download() +func (c *Client) ExportFile(ctx context.Context, fileID string, mimeType string) ([]byte, error) { + resp, err := c.service.Files.Export(fileID, mimeType).Context(ctx).Download() if err != nil { return nil, fmt.Errorf("exporting file: %w", err) } @@ -147,7 +149,7 @@ func (c *Client) ExportFile(fileID string, mimeType string) ([]byte, error) { } // ListSharedDrives returns all shared drives accessible to the user -func (c *Client) ListSharedDrives(pageSize int64) ([]*SharedDrive, error) { +func (c *Client) ListSharedDrives(ctx context.Context, pageSize int64) ([]*SharedDrive, error) { var allDrives []*SharedDrive pageToken := "" @@ -162,7 +164,7 @@ func (c *Client) ListSharedDrives(pageSize int64) ([]*SharedDrive, error) { call = call.PageToken(pageToken) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, fmt.Errorf("listing shared drives: %w", err) } diff --git a/internal/gmail/attachments.go b/internal/gmail/attachments.go index c664c1b..d12b6a6 100644 --- a/internal/gmail/attachments.go +++ b/internal/gmail/attachments.go @@ -1,6 +1,7 @@ package gmail import ( + "context" "encoding/base64" "fmt" "strconv" @@ -10,8 +11,8 @@ import ( ) // GetAttachments retrieves attachment metadata for a message -func (c *Client) GetAttachments(messageID string) ([]*Attachment, error) { - msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format("full").Do() +func (c *Client) GetAttachments(ctx context.Context, messageID string) ([]*Attachment, error) { + msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format("full").Context(ctx).Do() if err != nil { return nil, fmt.Errorf("getting message: %w", err) } @@ -20,8 +21,8 @@ func (c *Client) GetAttachments(messageID string) ([]*Attachment, error) { } // DownloadAttachment downloads a single attachment by message ID and attachment ID -func (c *Client) DownloadAttachment(messageID string, attachmentID string) ([]byte, error) { - att, err := c.service.Users.Messages.Attachments.Get(c.userID, messageID, attachmentID).Do() +func (c *Client) DownloadAttachment(ctx context.Context, messageID string, attachmentID string) ([]byte, error) { + att, err := c.service.Users.Messages.Attachments.Get(c.userID, messageID, attachmentID).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("downloading attachment: %w", err) } @@ -35,8 +36,8 @@ func (c *Client) DownloadAttachment(messageID string, attachmentID string) ([]by } // DownloadInlineAttachment downloads an attachment that has inline data -func (c *Client) DownloadInlineAttachment(messageID string, partID string) ([]byte, error) { - msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format("full").Do() +func (c *Client) DownloadInlineAttachment(ctx context.Context, messageID string, partID string) ([]byte, error) { + msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format("full").Context(ctx).Do() if err != nil { return nil, fmt.Errorf("getting message: %w", err) } diff --git a/internal/gmail/client.go b/internal/gmail/client.go index 775b5d9..b3c2b97 100644 --- a/internal/gmail/client.go +++ b/internal/gmail/client.go @@ -25,12 +25,12 @@ type Client struct { func NewClient(ctx context.Context) (*Client, error) { client, err := auth.GetHTTPClient(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("loading OAuth client: %w", err) } srv, err := gmail.NewService(ctx, option.WithHTTPClient(client)) if err != nil { - return nil, fmt.Errorf("unable to create Gmail service: %w", err) + return nil, fmt.Errorf("creating Gmail service: %w", err) } return &Client{ @@ -40,7 +40,7 @@ func NewClient(ctx context.Context) (*Client, error) { } // FetchLabels retrieves and caches all labels from the Gmail account -func (c *Client) FetchLabels() error { +func (c *Client) FetchLabels(ctx context.Context) error { // Check with read lock first to avoid unnecessary API calls c.labelsMu.RLock() if c.labelsLoaded { @@ -58,7 +58,7 @@ func (c *Client) FetchLabels() error { return nil } - resp, err := c.service.Users.Labels.List(c.userID).Do() + resp, err := c.service.Users.Labels.List(c.userID).Context(ctx).Do() if err != nil { return fmt.Errorf("fetching labels: %w", err) } @@ -106,8 +106,8 @@ type Profile struct { } // GetProfile retrieves the authenticated user's profile -func (c *Client) GetProfile() (*Profile, error) { - profile, err := c.service.Users.GetProfile(c.userID).Do() +func (c *Client) GetProfile(ctx context.Context) (*Profile, error) { + profile, err := c.service.Users.GetProfile(c.userID).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("getting profile: %w", err) } diff --git a/internal/gmail/messages.go b/internal/gmail/messages.go index 91907f0..e611e8e 100644 --- a/internal/gmail/messages.go +++ b/internal/gmail/messages.go @@ -1,6 +1,7 @@ package gmail import ( + "context" "encoding/base64" "fmt" "strings" @@ -37,13 +38,13 @@ type Attachment struct { // SearchMessages searches for messages matching the query. // Returns messages, the count of messages that failed to fetch, and any error. -func (c *Client) SearchMessages(query string, maxResults int64) ([]*Message, int, error) { +func (c *Client) SearchMessages(ctx context.Context, query string, maxResults int64) ([]*Message, int, error) { call := c.service.Users.Messages.List(c.userID).Q(query) if maxResults > 0 { call = call.MaxResults(maxResults) } - resp, err := call.Do() + resp, err := call.Context(ctx).Do() if err != nil { return nil, 0, fmt.Errorf("searching messages: %w", err) } @@ -51,7 +52,7 @@ func (c *Client) SearchMessages(query string, maxResults int64) ([]*Message, int var messages []*Message var skipped int for _, msg := range resp.Messages { - m, err := c.GetMessage(msg.Id, false) + m, err := c.GetMessage(ctx, msg.Id, false) if err != nil { skipped++ log.Debug("skipped message %s: %v", msg.Id, err) @@ -68,18 +69,18 @@ func (c *Client) SearchMessages(query string, maxResults int64) ([]*Message, int } // GetMessage retrieves a single message by ID -func (c *Client) GetMessage(messageID string, includeBody bool) (*Message, error) { +func (c *Client) GetMessage(ctx context.Context, messageID string, includeBody bool) (*Message, error) { format := "metadata" if includeBody { format = "full" } // Fetch labels for resolution - if err := c.FetchLabels(); err != nil { + if err := c.FetchLabels(ctx); err != nil { return nil, err } - msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format(format).Do() + msg, err := c.service.Users.Messages.Get(c.userID, messageID).Format(format).Context(ctx).Do() if err != nil { return nil, fmt.Errorf("getting message: %w", err) } @@ -90,22 +91,22 @@ func (c *Client) GetMessage(messageID string, includeBody bool) (*Message, error // GetThread retrieves all messages in a thread. // The id parameter can be either a thread ID or a message ID. // If a message ID is provided, the thread ID is resolved automatically. -func (c *Client) GetThread(id string) ([]*Message, error) { +func (c *Client) GetThread(ctx context.Context, id string) ([]*Message, error) { // Fetch labels for resolution - if err := c.FetchLabels(); err != nil { + if err := c.FetchLabels(ctx); err != nil { return nil, err } - thread, err := c.service.Users.Threads.Get(c.userID, id).Format("full").Do() + thread, err := c.service.Users.Threads.Get(c.userID, id).Format("full").Context(ctx).Do() if err != nil { // If the ID wasn't found as a thread ID, try treating it as a message ID - msg, msgErr := c.service.Users.Messages.Get(c.userID, id).Format("minimal").Do() + msg, msgErr := c.service.Users.Messages.Get(c.userID, id).Format("minimal").Context(ctx).Do() if msgErr != nil { // Return the original thread error if message lookup also fails return nil, fmt.Errorf("getting thread: %w", err) } // Use the thread ID from the message - thread, err = c.service.Users.Threads.Get(c.userID, msg.ThreadId).Format("full").Do() + thread, err = c.service.Users.Threads.Get(c.userID, msg.ThreadId).Format("full").Context(ctx).Do() if err != nil { return nil, fmt.Errorf("getting thread: %w", err) } diff --git a/internal/keychain/token_source.go b/internal/keychain/token_source.go index 586edee..f9e2985 100644 --- a/internal/keychain/token_source.go +++ b/internal/keychain/token_source.go @@ -21,9 +21,9 @@ type PersistentTokenSource struct { // NewPersistentTokenSource creates a TokenSource that persists refreshed tokens. // When the underlying oauth2 package refreshes an expired token, this wrapper // detects the change and saves the new token to secure storage. -func NewPersistentTokenSource(config *oauth2.Config, initial *oauth2.Token) oauth2.TokenSource { +func NewPersistentTokenSource(ctx context.Context, config *oauth2.Config, initial *oauth2.Token) oauth2.TokenSource { // Create base token source that handles refresh - base := config.TokenSource(context.Background(), initial) + base := config.TokenSource(ctx, initial) return &PersistentTokenSource{ base: base, From 64a7d01f1c447a40d4c40c7d5e322b6581458c27 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sun, 15 Feb 2026 11:26:56 -0500 Subject: [PATCH 12/13] refactor: add structural tests, centralize test helpers, restructure docs Add harness engineering infrastructure to mechanically enforce codebase conventions and improve agent legibility: - Structural tests (internal/architecture/) enforce client interfaces, ClientFactory, NewCommand(), --json flags, dependency direction, and read-only scopes across all domain packages - Centralize captureOutput/withMockClient/withFailingClientFactory into testutil.CaptureStdout and testutil.WithFactory[T] generics - Restructure AGENT.md (338->93 lines) into progressive-disclosure docs/ with architecture.md, golden-principles.md, and adding-a-domain.md - Clean up stale lint suppressions in .golangci.yml - Add coverage gate (60% floor) to Makefile and CI --- .github/workflows/ci.yml | 3 + .golangci.yml | 13 +- CLAUDE.md | 324 +++--------------- Makefile | 11 +- docs/adding-a-domain.md | 112 ++++++ docs/architecture.md | 79 +++++ docs/golden-principles.md | 82 +++++ internal/architecture/architecture_test.go | 375 +++++++++++++++++++++ internal/architecture/doc.go | 5 + internal/cmd/calendar/handlers_test.go | 54 +-- internal/cmd/contacts/handlers_test.go | 56 +-- internal/cmd/drive/get_test.go | 17 +- internal/cmd/drive/handlers_test.go | 61 +--- internal/cmd/drive/tree_test.go | 17 +- internal/cmd/mail/handlers_test.go | 60 +--- internal/testutil/helpers.go | 44 +++ 16 files changed, 818 insertions(+), 495 deletions(-) create mode 100644 docs/adding-a-domain.md create mode 100644 docs/architecture.md create mode 100644 docs/golden-principles.md create mode 100644 internal/architecture/architecture_test.go create mode 100644 internal/architecture/doc.go create mode 100644 internal/testutil/helpers.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8091f45..2bd0546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: - name: Tidy, test, and build run: make tidy test build + - name: Coverage gate + run: make test-cover-check + lint: runs-on: ubuntu-latest steps: diff --git a/.golangci.yml b/.golangci.yml index b749d25..86ef99d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,20 +27,11 @@ linters: - linters: - errcheck source: "defer.*\\.Close\\(\\)" - # Stuttering names will be fixed when interfaces are relocated (commit 5) + # Stuttering names (e.g., mail.MailClient) are accepted for clarity at call sites - linters: - revive text: "stutters" - # Package comments will be added in commit 9 - - linters: - - revive - text: "package-comments" - # Mock exported methods will be deleted when mocks move to test files (commit 5) - - linters: - - revive - text: "exported" - path: testutil/mocks\.go - # Standard library package name conflicts are intentional + # Standard library package name conflicts are intentional (e.g., calendar, contacts) - linters: - revive text: "avoid package names that conflict" diff --git a/CLAUDE.md b/CLAUDE.md index c70331f..9a54028 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,282 +9,66 @@ gro is a **read-only** command-line interface for Google services written in Go. **Binary name:** `gro` **Module:** `github.com/open-cli-collective/google-readonly` -### Current Features +### Features - Gmail: Search, read, thread viewing, labels, attachments - Google Calendar: List calendars, view events, today/week shortcuts - Google Contacts: List contacts, search, view details, list groups - -### Planned Features -- Google Drive: List files, download content +- Google Drive: List files, search, get details, download, tree view, shared drives ## Quick Commands ```bash -# Build -make build - -# Run tests -make test - -# Run tests with coverage -make test-cover - -# Lint -make lint - -# Format code -make fmt - -# All checks (format, lint, test) -make verify - -# Install locally -make install - -# Clean build artifacts -make clean +make build # Build binary +make test # Run tests with race detection +make test-cover # Tests with HTML coverage report +make lint # Run golangci-lint +make fmt # Format code +make check # CI gate: tidy, lint, test, build +make install # Install to /usr/local/bin ``` -## Architecture - -``` -google-readonly/ -├── main.go # Entry point -├── cmd/gro/ # Main package -│ └── main.go -├── internal/ -│ ├── cmd/ -│ │ ├── root/ # Root command, version -│ │ │ └── root.go -│ │ ├── initcmd/ # OAuth setup (gro init) -│ │ │ ├── init.go -│ │ │ └── init_test.go -│ │ ├── config/ # gro config {show,test,clear} -│ │ │ ├── config.go -│ │ │ └── config_test.go -│ │ ├── mail/ # gro mail {search,read,thread,labels,attachments} -│ │ │ ├── mail.go # Parent command -│ │ │ ├── search.go -│ │ │ ├── read.go -│ │ │ ├── thread.go -│ │ │ ├── labels.go -│ │ │ ├── attachments.go -│ │ │ ├── attachments_list.go -│ │ │ ├── attachments_download.go -│ │ │ ├── output.go # Shared output helpers -│ │ │ └── *_test.go -│ │ │ -│ │ ├── calendar/ # gro calendar {list,events,get,today,week} -│ │ │ ├── calendar.go # Parent command with 'cal' alias -│ │ │ ├── list.go -│ │ │ ├── events.go -│ │ │ ├── get.go -│ │ │ ├── today.go -│ │ │ ├── week.go -│ │ │ ├── dates.go # Date parsing/formatting helpers -│ │ │ ├── output.go # Shared output helpers -│ │ │ └── *_test.go -│ │ │ -│ │ └── contacts/ # gro contacts {list,search,get,groups} -│ │ ├── contacts.go # Parent command with 'ppl' alias -│ │ ├── list.go -│ │ ├── search.go -│ │ ├── get.go -│ │ ├── groups.go -│ │ ├── output.go # Shared output helpers -│ │ └── *_test.go -│ │ -│ ├── gmail/ # Gmail API client -│ │ ├── client.go -│ │ ├── messages.go -│ │ ├── attachments.go -│ │ └── *_test.go -│ │ -│ ├── calendar/ # Google Calendar API client -│ │ ├── client.go -│ │ ├── events.go -│ │ └── *_test.go -│ │ -│ ├── contacts/ # Google People API client (Contacts) -│ │ ├── client.go -│ │ ├── contacts.go -│ │ └── *_test.go -│ │ -│ ├── keychain/ # Secure credential storage -│ │ ├── keychain.go -│ │ ├── keychain_darwin.go # macOS Keychain support -│ │ ├── keychain_linux.go # Linux secret-tool support -│ │ ├── keychain_windows.go # Windows file fallback -│ │ ├── token_source.go # Persistent token source wrapper -│ │ └── keychain_test.go -│ │ -│ ├── zip/ # Secure zip extraction -│ │ ├── extract.go -│ │ └── extract_test.go -│ │ -│ └── version/ # Build-time version injection -│ └── version.go -│ -├── .github/workflows/ -│ ├── ci.yml # Lint and test on PR/push -│ ├── auto-release.yml # Create tags on main push -│ └── release.yml # Build and release binaries -│ -├── packaging/ -│ ├── chocolatey/ # Windows Chocolatey package -│ └── winget/ # Windows Winget manifests -│ -├── Makefile # Build, test, lint targets -├── .goreleaser.yml # Cross-platform builds -└── .golangci.yml # Linter config (v2 format) -``` - -## Key Patterns - -### Read-Only by Design - -This CLI intentionally only supports read operations: -- Uses `gmail.GmailReadonlyScope` exclusively -- Only calls `.List()` and `.Get()` Gmail API methods -- No `.Send()`, `.Delete()`, `.Modify()`, or `.Trash()` operations - -### OAuth2 Configuration +## Documentation -Credentials are stored in `~/.config/google-readonly/`: -- `credentials.json` - OAuth client credentials (from Google Cloud Console) +| Document | Contents | +|----------|----------| +| `docs/architecture.md` | Dependency graph, package responsibilities, file naming conventions | +| `docs/golden-principles.md` | Mechanical rules enforced by structural tests | +| `docs/adding-a-domain.md` | Step-by-step checklist for adding a new Google API | -OAuth tokens are stored securely based on platform: -- **macOS**: System Keychain (via `security` CLI) -- **Linux**: libsecret (via `secret-tool`) if available, otherwise config file -- **Fallback**: `~/.config/google-readonly/token.json` with 0600 permissions - -### Command Patterns - -All commands use the factory pattern with `NewCommand()`: - -```go -func NewCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "search ", - Short: "Search for messages", - Args: cobra.ExactArgs(1), - RunE: runSearch, - } - cmd.Flags().Int64VarP(&searchMaxResults, "max", "m", 10, "Maximum results") - return cmd -} - -func runSearch(cmd *cobra.Command, args []string) error { - client, err := newGmailClient() - if err != nil { - return err - } - // ... use client -} -``` +## Key Constraints -### Output Formats +- **Read-only by design**: Only `*ReadonlyScope` in `auth.AllScopes`. No write API methods. +- **Interface-at-consumer**: Each `internal/cmd/{domain}/output.go` defines its client interface. +- **ClientFactory DI**: Swappable factory for test mock injection. +- **--json on all leaf commands**: Every leaf subcommand supports `--json/-j`. +- **Structural enforcement**: `internal/architecture/architecture_test.go` enforces all patterns at CI time. -Commands support two output modes: -- **Text** (default): Human-readable formatted output -- **JSON** (`--json`): Machine-readable JSON for scripting - -```go -if jsonOutput { - return printJSON(messages) -} -// ... text output -``` +See `docs/golden-principles.md` for the full set of enforced rules. ## Testing -Tests use `testify` for assertions and table-driven test patterns: - -```go -func TestParseMessage(t *testing.T) { - tests := []struct { - name string - input *gmail.Message - expected *Message - }{ - {"basic message", ...}, - {"multipart message", ...}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseMessage(tt.input, true) - assert.Equal(t, tt.expected.Subject, result.Subject) - }) - } -} -``` - Run tests: `make test` -Coverage report: `make test-cover && open coverage.html` +Coverage: `make test-cover && open coverage.html` -## Adding a New Command +Tests use `internal/testutil/` for assertions (`testutil.Equal`, `testutil.NoError`, etc.) and fixtures (`testutil.SampleMessage()`, `testutil.SampleEvent()`, etc.). See `docs/golden-principles.md` for mock and test helper patterns. -1. Create new file in appropriate `internal/cmd/` directory -2. Define the command with `NewCommand()` factory function -3. Register in parent command's `NewCommand()` with `AddCommand()` -4. Add flags if needed -5. Write tests in `*_test.go` +## OAuth2 Configuration -Example: +Credentials: `~/.config/google-readonly/credentials.json` (from Google Cloud Console) -```go -func NewCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "labels", - Short: "List Gmail labels", - RunE: runLabels, - } - cmd.Flags().BoolVarP(&labelsJSON, "json", "j", false, "Output as JSON") - return cmd -} -``` - -## Dependencies - -Key dependencies: -- `github.com/spf13/cobra` - CLI framework -- `golang.org/x/oauth2` - OAuth2 client -- `google.golang.org/api/gmail/v1` - Gmail API client -- `google.golang.org/api/calendar/v3` - Calendar API client -- `google.golang.org/api/people/v1` - People API client (Contacts) -- `github.com/stretchr/testify` - Testing assertions (dev) - -## Error Message Conventions - -Follow [Go Code Review Comments](https://github.com/go/wiki/wiki/CodeReviewComments#error-strings): - -- Start with lowercase -- Don't end with punctuation -- Be descriptive but concise +Tokens stored securely per platform: +- **macOS**: System Keychain (via `security` CLI) +- **Linux**: libsecret (via `secret-tool`) if available, otherwise config file +- **Fallback**: `~/.config/google-readonly/token.json` with 0600 permissions -```go -// Good -return fmt.Errorf("failed to get message: %w", err) -return fmt.Errorf("attachment not found: %s", filename) +## Error Conventions -// Bad -return fmt.Errorf("Failed to get message: %w", err) // capitalized -return fmt.Errorf("attachment not found.") // ends with punctuation -``` +Follow [Go conventions](https://github.com/go/wiki/wiki/CodeReviewComments#error-strings): lowercase, no trailing punctuation, use `%w` for wrapping. ## Commit Conventions -Use conventional commits: - -``` -type(scope): description - -feat(mail): add attachment download command -fix(keychain): handle missing secret-tool -docs(readme): add installation instructions -``` +Use conventional commits: `type(scope): description` | Prefix | Purpose | Triggers Release? | |--------|---------|-------------------| @@ -292,46 +76,18 @@ docs(readme): add installation instructions | `fix:` | Bug fixes | Yes | | `docs:` | Documentation only | No | | `test:` | Adding/updating tests | No | -| `refactor:` | Code changes that don't fix bugs or add features | No | +| `refactor:` | Code changes (no bug fix or feature) | No | | `chore:` | Maintenance tasks | No | | `ci:` | CI/CD changes | No | -## CI & Release Workflow - -Releases are automated with a dual-gate system: - -**Gate 1 - Path filter:** Only triggers when Go code changes (`**.go`, `go.mod`, `go.sum`) -**Gate 2 - Commit prefix:** Only `feat:` and `fix:` commits create releases +## Dependencies -This means: -- `feat: add command` + Go files changed → release -- `fix: handle edge case` + Go files changed → release -- `docs:`, `ci:`, `test:`, `refactor:` → no release -- Changes only to docs, packaging, workflows → no release +- `github.com/spf13/cobra` - CLI framework +- `golang.org/x/oauth2` - OAuth2 client +- `google.golang.org/api/*` - Google API clients (Gmail, Calendar, People, Drive) ## Common Issues -### "Unable to read credentials file" - -Ensure OAuth credentials are set up: -```bash -mkdir -p ~/.config/google-readonly -# Download credentials.json from Google Cloud Console -mv ~/Downloads/client_secret_*.json ~/.config/google-readonly/credentials.json -``` - -### "Token has been expired or revoked" - -Clear the token and re-authenticate: -```bash -gro config clear -gro init -``` - -## Security +**"Unable to read credentials file"**: Run `gro init` and follow the OAuth setup wizard. -- **Read-only scope**: Cannot modify, send, or delete data -- **Secure token storage**: OAuth tokens stored in system keychain when available -- **File fallback**: When secure storage is unavailable, tokens stored with 0600 permissions -- **Token refresh persistence**: Refreshed tokens are automatically saved -- **No credential exposure**: Credentials never logged or transmitted +**"Token has been expired or revoked"**: Run `gro config clear && gro init`. diff --git a/Makefile b/Makefile index c39946e..68bc92d 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ LDFLAGS := -ldflags "-s -w \ DIST_DIR = dist -.PHONY: all build test test-cover test-short lint fmt tidy deps verify check clean release checksums install uninstall +.PHONY: all build test test-cover test-cover-check test-short lint fmt tidy deps verify check clean release checksums install uninstall all: build @@ -23,6 +23,15 @@ test-cover: go test -v -race -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html +test-cover-check: + @go test -race -coverprofile=coverage.out ./... > /dev/null 2>&1 + @total=$$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $$3}' | tr -d '%'); \ + threshold=60; \ + echo "Total coverage: $${total}% (threshold: $${threshold}%)"; \ + if [ $$(echo "$$total < $$threshold" | bc) -eq 1 ]; then \ + echo "FAIL: coverage below threshold"; exit 1; \ + fi + test-short: go test -v -short ./... diff --git a/docs/adding-a-domain.md b/docs/adding-a-domain.md new file mode 100644 index 0000000..518b5f6 --- /dev/null +++ b/docs/adding-a-domain.md @@ -0,0 +1,112 @@ +# Adding a New Google API Domain + +This checklist covers adding a new Google API (e.g., Google Tasks, Google Sheets) to gro. The structural tests in `internal/architecture/architecture_test.go` automatically enforce steps marked with [enforced]. + +## Checklist + +### 1. Add the OAuth scope + +In `internal/auth/auth.go`, add the readonly scope to `AllScopes`: +```go +var AllScopes = []string{ + gmail.GmailReadonlyScope, + calendar.CalendarReadonlyScope, + people.ContactsReadonlyScope, + drive.DriveReadonlyScope, + tasks.TasksReadonlyScope, // new +} +``` + +[enforced] Only `*ReadonlyScope` constants are permitted. + +### 2. Create the API client package + +Create `internal/{domain}/` with: +- `client.go` — `Client` struct, `NewClient(ctx context.Context) (*Client, error)`, methods +- Data model files as needed +- `*_test.go` — Unit tests for parsing and data models + +The constructor must follow the established pattern: +```go +func NewClient(ctx context.Context) (*Client, error) { + client, err := auth.GetHTTPClient(ctx) + if err != nil { + return nil, fmt.Errorf("loading OAuth client: %w", err) + } + srv, err := tasks.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return nil, fmt.Errorf("creating Tasks service: %w", err) + } + return &Client{service: srv}, nil +} +``` + +[enforced] This package must NOT import any `internal/cmd/` package. + +### 3. Create the command package + +Create `internal/cmd/{domain}/` with these files: + +**`output.go`** — [enforced] Must contain: +- An exported interface ending in `Client` (e.g., `TasksClient`) +- A `ClientFactory` variable +- A `newXClient()` wrapper function +- A `printJSON()` function + +**`{domain}.go`** — [enforced] Must contain: +- An exported `NewCommand()` function returning `*cobra.Command` +- `AddCommand()` calls for all subcommands + +**Each subcommand file** — [enforced] Each leaf command must have `--json/-j` flag: +- Unexported `new{Sub}Command()` factory +- `--json/-j` flag for JSON output (exempt for binary download commands) + +### 4. Create test infrastructure + +**`mock_test.go`** — Function-field mock with compile-time interface check: +```go +type MockTasksClient struct { + ListTasksFunc func(ctx context.Context, ...) (...) +} + +var _ TasksClient = (*MockTasksClient)(nil) +``` + +**`handlers_test.go`** — Test helpers using centralized utilities: +```go +func withMockClient(mock TasksClient, f func()) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (TasksClient, error) { + return mock, nil + }, f) +} + +func withFailingClientFactory(f func()) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (TasksClient, error) { + return nil, errors.New("connection failed") + }, f) +} +``` + +Use `testutil.CaptureStdout(t, func() { ... })` for output capture. + +### 5. Add test fixtures + +In `internal/testutil/fixtures.go`, add `SampleX()` functions for the new API types. + +### 6. Register the domain command + +In `internal/cmd/root/root.go`, add: +```go +cmd.AddCommand(tasks.NewCommand()) +``` + +### 7. Update structural test registration + +In `internal/architecture/architecture_test.go`, add the new domain to: +- `domainPackages` slice (e.g., `"tasks"`) +- `apiClientPackages` slice (e.g., `"tasks"`) +- `domainCommands()` map + +### 8. Verify + +Run `make check`. The structural tests will catch any missing patterns. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5923591 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,79 @@ +# Architecture + +## Dependency Graph + +``` +cmd/gro/main.go + -> internal/cmd/root/ + -> internal/cmd/mail/ (MailClient interface + ClientFactory) + -> internal/cmd/calendar/ (CalendarClient interface + ClientFactory) + -> internal/cmd/contacts/ (ContactsClient interface + ClientFactory) + -> internal/cmd/drive/ (DriveClient interface + ClientFactory) + -> internal/cmd/initcmd/ (OAuth setup wizard) + -> internal/cmd/config/ (Credential management) + +Each cmd/ package depends on its API client: + internal/cmd/mail/ -> internal/gmail/ + internal/cmd/calendar/ -> internal/calendar/ + internal/cmd/contacts/ -> internal/contacts/ + internal/cmd/drive/ -> internal/drive/ + +All API clients depend on: + internal/auth/ -> internal/keychain/, internal/config/ + +Shared utilities (no internal deps): + internal/testutil/ Test fixtures and assertion helpers + internal/output/ JSON output encoding + internal/format/ Human-readable formatting + internal/errors/ Error types + internal/log/ Logging + internal/cache/ Response caching + internal/zip/ Secure zip extraction + internal/version/ Build-time version injection +``` + +## Data Flow + +``` +User -> cobra command -> ClientFactory(ctx) -> API Client -> auth.GetHTTPClient -> Google API + | + internal/{gmail,calendar,contacts,drive}/ +``` + +## Package Responsibilities + +| Package | Responsibility | +|---------|---------------| +| `cmd/gro/` | Entry point, calls `root.NewCommand()` | +| `internal/cmd/root/` | Root cobra command, registers all domain commands | +| `internal/cmd/{domain}/` | Command handlers, client interface, output formatting | +| `internal/{gmail,calendar,contacts,drive}/` | API client, data models, response parsing | +| `internal/auth/` | OAuth2 config loading, HTTP client creation | +| `internal/keychain/` | Platform-specific secure token storage | +| `internal/testutil/` | Test assertions, fixtures, helpers | +| `internal/architecture/` | Structural tests enforcing codebase conventions | + +## File Naming Conventions + +Each domain command package (`internal/cmd/{domain}/`) contains: + +| File | Purpose | +|------|---------| +| `{domain}.go` | Parent command with `NewCommand()` and `AddCommand()` calls | +| `output.go` | Client interface, `ClientFactory`, `printJSON()`, text formatters | +| `{subcommand}.go` | One file per subcommand with `new{Sub}Command()` factory | +| `mock_test.go` | Mock client with function fields + compile-time interface check | +| `handlers_test.go` | `withMockClient()`, `withFailingClientFactory()`, integration tests | +| `*_test.go` | Additional unit tests | + +Each API client package (`internal/{domain}/`) contains: + +| File | Purpose | +|------|---------| +| `client.go` | `Client` struct, `NewClient(ctx)`, client methods | +| Additional `.go` | Data models, parsing helpers | +| `*_test.go` | Unit tests | + +## Structural Enforcement + +Architectural invariants are enforced by tests in `internal/architecture/architecture_test.go`. These run as part of `make check` and CI. See `docs/golden-principles.md` for the rules being enforced. diff --git a/docs/golden-principles.md b/docs/golden-principles.md new file mode 100644 index 0000000..837d2ef --- /dev/null +++ b/docs/golden-principles.md @@ -0,0 +1,82 @@ +# Golden Principles + +These are the mechanical rules that keep the codebase consistent. Each rule is enforced by structural tests in `internal/architecture/architecture_test.go` and runs automatically in CI via `make check`. + +## 1. Interface-at-consumer + +Every domain command package (`internal/cmd/{domain}/`) defines its own client interface in `output.go`. The API client package (`internal/{domain}/`) does NOT define an interface — it returns a concrete `*Client` struct. + +**Enforced by:** `TestDomainPackagesDefineClientInterface` + +## 2. ClientFactory for dependency injection + +Every domain command package declares a package-level `ClientFactory` variable. Production code calls `ClientFactory(ctx)`. Tests override it to inject mocks. + +```go +var ClientFactory = func(ctx context.Context) (XClient, error) { + return x.NewClient(ctx) +} +``` + +**Enforced by:** `TestDomainPackagesHaveClientFactory` + +## 3. NewCommand() factory + +Parent commands export `NewCommand()` returning `*cobra.Command`. Subcommands use unexported `new{Sub}Command()`. Parent commands register subcommands via `cmd.AddCommand()`. + +**Enforced by:** `TestDomainPackagesExportNewCommand` + +## 4. --json on every leaf command + +All leaf subcommands (commands with no children) support `--json/-j` for machine-readable output. Download commands that output binary file data are exempt. + +**Enforced by:** `TestAllLeafCommandsHaveJSONFlag` + +## 5. Read-only only + +Only `*ReadonlyScope` constants may appear in `auth.AllScopes`. No write API methods (`.Send()`, `.Trash()`, `.BatchModify()`, etc.) in production code. + +**Enforced by:** `TestAllScopesAreReadOnly`, `TestNoWriteAPIMethodsInProductionCode` + +## 6. Dependency direction + +- API client packages must NOT import `internal/cmd/` (clients don't know about commands) +- `internal/auth/` must NOT import API client packages (auth is lower-level) + +**Enforced by:** `TestAPIClientPackagesDoNotImportCmd`, `TestAuthPackageDoesNotImportAPIClients` + +## 7. context.Context on all I/O methods + +Every public method that performs I/O takes `context.Context` as its first parameter. The only exceptions are pure getter methods that return cached data (e.g., `GetLabelName`, `GetLabels`). + +## 8. Error wrapping + +Use `fmt.Errorf("doing X: %w", err)` at every level. Error messages are lowercase and have no trailing punctuation, following [Go conventions](https://github.com/go/wiki/wiki/CodeReviewComments#error-strings). + +## 9. Mock pattern + +Mocks use function fields in `mock_test.go` with a compile-time interface check: + +```go +type MockXClient struct { + MethodFunc func(...) (...) +} + +var _ XClient = (*MockXClient)(nil) + +func (m *MockXClient) Method(...) (...) { + if m.MethodFunc != nil { + return m.MethodFunc(...) + } + return zero, nil +} +``` + +Test helpers `withMockClient` and `withFailingClientFactory` use `testutil.WithFactory` to swap the `ClientFactory`. + +## 10. Centralized test helpers + +- `testutil.CaptureStdout(t, func())` — captures stdout during command execution +- `testutil.WithFactory(&factory, replacement, func())` — generic factory swap +- `testutil.SampleX()` functions — fixture data for all API types +- `testutil.Equal`, `testutil.NoError`, etc. — assertion helpers diff --git a/internal/architecture/architecture_test.go b/internal/architecture/architecture_test.go new file mode 100644 index 0000000..dca606a --- /dev/null +++ b/internal/architecture/architecture_test.go @@ -0,0 +1,375 @@ +package architecture + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/google-readonly/internal/auth" + calcmd "github.com/open-cli-collective/google-readonly/internal/cmd/calendar" + contactscmd "github.com/open-cli-collective/google-readonly/internal/cmd/contacts" + drivecmd "github.com/open-cli-collective/google-readonly/internal/cmd/drive" + mailcmd "github.com/open-cli-collective/google-readonly/internal/cmd/mail" +) + +// domainPackages lists the command packages that must follow structural conventions. +var domainPackages = []string{"mail", "calendar", "contacts", "drive"} + +// apiClientPackages lists the internal API client package directory names. +var apiClientPackages = []string{"gmail", "calendar", "contacts", "drive"} + +// jsonExemptCommands lists leaf commands exempt from the --json flag requirement. +// Key format: "parent subcommand" (e.g., "mail attachments download"). +// Only add exemptions for commands that output binary file data where JSON is inapplicable. +var jsonExemptCommands = map[string]bool{ + "mail attachments download": true, // writes binary attachment files to disk + "drive download": true, // writes binary file data to disk +} + +// domainCommands returns the top-level cobra.Command for each domain package. +func domainCommands() map[string]*cobra.Command { + return map[string]*cobra.Command{ + "mail": mailcmd.NewCommand(), + "calendar": calcmd.NewCommand(), + "contacts": contactscmd.NewCommand(), + "drive": drivecmd.NewCommand(), + } +} + +// findModuleRoot walks up from the working directory to locate go.mod. +func findModuleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getting working directory: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find module root (go.mod)") + } + dir = parent + } +} + +// parseNonTestFiles parses all non-test .go files in a directory. +func parseNonTestFiles(t *testing.T, dir string) []*ast.File { + t.Helper() + fset := token.NewFileSet() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading directory %s: %v", dir, err) + } + var files []*ast.File + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ParseComments) + if err != nil { + t.Fatalf("parsing %s: %v", name, err) + } + files = append(files, f) + } + return files +} + +// collectImports returns all import paths from a set of parsed files. +func collectImports(files []*ast.File) []string { + var imports []string + for _, f := range files { + for _, imp := range f.Imports { + path := strings.Trim(imp.Path.Value, `"`) + imports = append(imports, path) + } + } + return imports +} + +type leafInfo struct { + path string + cmd *cobra.Command +} + +// leafCommands recursively collects all leaf commands (commands with no subcommands). +func leafCommands(cmd *cobra.Command, parentPath string) []leafInfo { + subs := cmd.Commands() + if len(subs) == 0 { + return []leafInfo{{path: parentPath, cmd: cmd}} + } + var leaves []leafInfo + for _, sub := range subs { + subPath := parentPath + " " + sub.Name() + leaves = append(leaves, leafCommands(sub, subPath)...) + } + return leaves +} + +// --------------------------------------------------------------------------- +// Structural tests +// --------------------------------------------------------------------------- + +// TestDomainPackagesDefineClientInterface verifies that every domain command package +// declares an exported interface type whose name ends in "Client". +func TestDomainPackagesDefineClientInterface(t *testing.T) { + t.Parallel() + root := findModuleRoot(t) + + for _, pkg := range domainPackages { + t.Run(pkg, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(root, "internal", "cmd", pkg) + files := parseNonTestFiles(t, dir) + + var found bool + for _, f := range files { + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + _, isInterface := typeSpec.Type.(*ast.InterfaceType) + if isInterface && strings.HasSuffix(typeSpec.Name.Name, "Client") { + found = true + if !typeSpec.Name.IsExported() { + t.Errorf("client interface %s must be exported", typeSpec.Name.Name) + } + } + } + } + } + + if !found { + t.Errorf("package internal/cmd/%s must define an exported interface ending in 'Client' (see docs/golden-principles.md)", pkg) + } + }) + } +} + +// TestDomainPackagesHaveClientFactory verifies that every domain command package +// declares a package-level ClientFactory variable for dependency injection. +func TestDomainPackagesHaveClientFactory(t *testing.T) { + t.Parallel() + root := findModuleRoot(t) + + for _, pkg := range domainPackages { + t.Run(pkg, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(root, "internal", "cmd", pkg) + files := parseNonTestFiles(t, dir) + + var found bool + for _, f := range files { + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.VAR { + continue + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for _, name := range valueSpec.Names { + if name.Name == "ClientFactory" { + found = true + } + } + } + } + } + + if !found { + t.Errorf("package internal/cmd/%s must define a ClientFactory variable for dependency injection (see docs/golden-principles.md)", pkg) + } + }) + } +} + +// TestDomainPackagesExportNewCommand verifies that every domain command package +// exports a NewCommand() function (top-level, not a method). +func TestDomainPackagesExportNewCommand(t *testing.T) { + t.Parallel() + root := findModuleRoot(t) + + for _, pkg := range domainPackages { + t.Run(pkg, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(root, "internal", "cmd", pkg) + files := parseNonTestFiles(t, dir) + + var found bool + for _, f := range files { + for _, decl := range f.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + // Must be a package-level function (no receiver), named NewCommand + if funcDecl.Name.Name == "NewCommand" && funcDecl.Recv == nil { + found = true + } + } + } + + if !found { + t.Errorf("package internal/cmd/%s must export a NewCommand() function (see docs/golden-principles.md)", pkg) + } + }) + } +} + +// TestAllLeafCommandsHaveJSONFlag verifies that every leaf subcommand +// (commands with no children) declares a --json/-j flag. +func TestAllLeafCommandsHaveJSONFlag(t *testing.T) { + t.Parallel() + + for name, cmd := range domainCommands() { + for _, leaf := range leafCommands(cmd, name) { + t.Run(strings.TrimSpace(leaf.path), func(t *testing.T) { + t.Parallel() + key := strings.TrimSpace(leaf.path) + if jsonExemptCommands[key] { + t.Skipf("exempt from --json requirement") + } + flag := leaf.cmd.Flags().Lookup("json") + if flag == nil { + t.Errorf("leaf command %q must have a --json flag (see docs/golden-principles.md)", key) + return + } + if flag.Shorthand != "j" { + t.Errorf("leaf command %q --json flag must have shorthand 'j', got %q", key, flag.Shorthand) + } + }) + } + } +} + +// TestAPIClientPackagesDoNotImportCmd verifies that API client packages +// (internal/gmail, internal/calendar, etc.) never import command packages. +// Dependency direction must be: cmd -> api client, never the reverse. +func TestAPIClientPackagesDoNotImportCmd(t *testing.T) { + t.Parallel() + root := findModuleRoot(t) + + for _, pkg := range apiClientPackages { + t.Run(pkg, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(root, "internal", pkg) + files := parseNonTestFiles(t, dir) + imports := collectImports(files) + + for _, imp := range imports { + if strings.Contains(imp, "internal/cmd") { + t.Errorf("API client package internal/%s must not import cmd packages, but imports %q", pkg, imp) + } + } + }) + } +} + +// TestAuthPackageDoesNotImportAPIClients verifies that the auth package +// does not depend on any internal API client packages. +// Dependency direction must be: api client -> auth, never the reverse. +func TestAuthPackageDoesNotImportAPIClients(t *testing.T) { + t.Parallel() + root := findModuleRoot(t) + + dir := filepath.Join(root, "internal", "auth") + files := parseNonTestFiles(t, dir) + imports := collectImports(files) + + for _, imp := range imports { + for _, apiPkg := range apiClientPackages { + if strings.HasSuffix(imp, "/internal/"+apiPkg) { + t.Errorf("auth package must not import API client package internal/%s", apiPkg) + } + } + } +} + +// TestAllScopesAreReadOnly verifies that every OAuth scope in auth.AllScopes +// is a read-only scope. This is the primary mechanical enforcement of the +// read-only-by-design principle. +func TestAllScopesAreReadOnly(t *testing.T) { + t.Parallel() + + if len(auth.AllScopes) == 0 { + t.Fatal("auth.AllScopes must not be empty") + } + + for _, scope := range auth.AllScopes { + if !strings.Contains(scope, "readonly") { + t.Errorf("scope %q is not a readonly scope; all scopes in AllScopes must be read-only", scope) + } + } +} + +// TestNoWriteAPIMethodsInProductionCode scans all non-test Go source files +// for Google API write method calls. This is defense-in-depth on top of the +// scope check — even with readonly scopes, we don't want write method calls +// in the codebase since they indicate incorrect intent. +func TestNoWriteAPIMethodsInProductionCode(t *testing.T) { + t.Parallel() + root := findModuleRoot(t) + + // These patterns are specific to Google API client libraries and unlikely + // to appear in other contexts. Generic method names like .Delete() or + // .Insert() are intentionally excluded to avoid false positives. + forbiddenPatterns := []string{ + ".Send(", + ".Trash(", + ".Untrash(", + ".BatchModify(", + ".BatchDelete(", + } + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if name == "vendor" || name == ".git" || name == "dist" || name == "bin" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Errorf("reading %s: %v", path, readErr) + return nil + } + content := string(data) + rel, _ := filepath.Rel(root, path) + + for _, pattern := range forbiddenPatterns { + if strings.Contains(content, pattern) { + t.Errorf("file %s contains forbidden write API method %q — this CLI is read-only by design", rel, pattern) + } + } + return nil + }) + if err != nil { + t.Fatalf("walking source tree: %v", err) + } +} diff --git a/internal/architecture/doc.go b/internal/architecture/doc.go new file mode 100644 index 0000000..fbc1d9d --- /dev/null +++ b/internal/architecture/doc.go @@ -0,0 +1,5 @@ +// Package architecture contains structural tests that enforce codebase conventions. +// These tests verify that all domain packages follow established patterns: +// client interfaces, factory functions, command structure, and dependency direction. +// See docs/golden-principles.md for the rules these tests enforce. +package architecture diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index 83bca80..ca15979 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -1,12 +1,9 @@ package calendar import ( - "bytes" "context" "encoding/json" "errors" - "io" - "os" "testing" "google.golang.org/api/calendar/v3" @@ -15,41 +12,18 @@ import ( "github.com/open-cli-collective/google-readonly/internal/testutil" ) -// captureOutput captures stdout during test execution -func captureOutput(t *testing.T, f func()) string { - t.Helper() - old := os.Stdout - r, w, err := os.Pipe() - testutil.NoError(t, err) - os.Stdout = w - - f() - - w.Close() - os.Stdout = old - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() -} - // withMockClient sets up a mock client factory for tests func withMockClient(mock CalendarClient, f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (CalendarClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (CalendarClient, error) { return mock, nil - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (CalendarClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (CalendarClient, error) { return nil, errors.New("connection failed") - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } func TestListCommand_Success(t *testing.T) { @@ -62,7 +36,7 @@ func TestListCommand_Success(t *testing.T) { cmd := newListCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -84,7 +58,7 @@ func TestListCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -106,7 +80,7 @@ func TestListCommand_Empty(t *testing.T) { cmd := newListCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -153,7 +127,7 @@ func TestEventsCommand_Success(t *testing.T) { cmd.SetArgs([]string{}) // Uses default "primary" calendar withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -176,7 +150,7 @@ func TestEventsCommand_WithDateRange(t *testing.T) { cmd.SetArgs([]string{"--from", "2024-01-01", "--to", "2024-01-31"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -199,7 +173,7 @@ func TestEventsCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -246,7 +220,7 @@ func TestGetCommand_Success(t *testing.T) { cmd.SetArgs([]string{"event123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -268,7 +242,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"event123", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -307,7 +281,7 @@ func TestTodayCommand_Success(t *testing.T) { cmd := newTodayCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -329,7 +303,7 @@ func TestWeekCommand_Success(t *testing.T) { cmd := newWeekCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index 1557c0a..5fd0826 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -1,12 +1,9 @@ package contacts import ( - "bytes" "context" "encoding/json" "errors" - "io" - "os" "testing" "google.golang.org/api/people/v1" @@ -15,41 +12,18 @@ import ( "github.com/open-cli-collective/google-readonly/internal/testutil" ) -// captureOutput captures stdout during test execution -func captureOutput(t *testing.T, f func()) string { - t.Helper() - old := os.Stdout - r, w, err := os.Pipe() - testutil.NoError(t, err) - os.Stdout = w - - f() - - w.Close() - os.Stdout = old - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() -} - // withMockClient sets up a mock client factory for tests func withMockClient(mock ContactsClient, f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (ContactsClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (ContactsClient, error) { return mock, nil - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (ContactsClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (ContactsClient, error) { return nil, errors.New("connection failed") - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } func TestListCommand_Success(t *testing.T) { @@ -67,7 +41,7 @@ func TestListCommand_Success(t *testing.T) { cmd := newListCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -93,7 +67,7 @@ func TestListCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -117,7 +91,7 @@ func TestListCommand_Empty(t *testing.T) { cmd := newListCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -168,7 +142,7 @@ func TestSearchCommand_Success(t *testing.T) { cmd.SetArgs([]string{"John"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -193,7 +167,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"John", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -218,7 +192,7 @@ func TestSearchCommand_NoResults(t *testing.T) { cmd.SetArgs([]string{"nonexistent"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -256,7 +230,7 @@ func TestGetCommand_Success(t *testing.T) { cmd.SetArgs([]string{"people/c123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -278,7 +252,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"people/c123", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -332,7 +306,7 @@ func TestGroupsCommand_Success(t *testing.T) { cmd := newGroupsCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -363,7 +337,7 @@ func TestGroupsCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -388,7 +362,7 @@ func TestGroupsCommand_Empty(t *testing.T) { cmd := newGroupsCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) diff --git a/internal/cmd/drive/get_test.go b/internal/cmd/drive/get_test.go index 0bcc25c..b9a6598 100644 --- a/internal/cmd/drive/get_test.go +++ b/internal/cmd/drive/get_test.go @@ -1,9 +1,6 @@ package drive import ( - "bytes" - "io" - "os" "testing" "time" @@ -42,20 +39,8 @@ func TestGetCommand(t *testing.T) { } func TestPrintFileDetails(t *testing.T) { - // Capture stdout for testing captureOutput := func(fn func()) string { - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() + return testutil.CaptureStdout(t, fn) } t.Run("prints all fields for complete file", func(t *testing.T) { diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index 2d59123..c36c43f 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -1,11 +1,9 @@ package drive import ( - "bytes" "context" "encoding/json" "errors" - "io" "os" "testing" @@ -13,41 +11,18 @@ import ( "github.com/open-cli-collective/google-readonly/internal/testutil" ) -// captureOutput captures stdout during test execution -func captureOutput(t *testing.T, f func()) string { - t.Helper() - old := os.Stdout - r, w, err := os.Pipe() - testutil.NoError(t, err) - os.Stdout = w - - f() - - w.Close() - os.Stdout = old - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() -} - // withMockClient sets up a mock client factory for tests func withMockClient(mock DriveClient, f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (DriveClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (DriveClient, error) { return mock, nil - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (DriveClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (DriveClient, error) { return nil, errors.New("connection failed") - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } func TestListCommand_Success(t *testing.T) { @@ -61,7 +36,7 @@ func TestListCommand_Success(t *testing.T) { cmd := newListCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -82,7 +57,7 @@ func TestListCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -104,7 +79,7 @@ func TestListCommand_Empty(t *testing.T) { cmd := newListCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -125,7 +100,7 @@ func TestListCommand_WithFolder(t *testing.T) { cmd.SetArgs([]string{"folder123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -146,7 +121,7 @@ func TestListCommand_WithTypeFilter(t *testing.T) { cmd.SetArgs([]string{"--type", "document"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -204,7 +179,7 @@ func TestSearchCommand_Success(t *testing.T) { cmd.SetArgs([]string{"report"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -226,7 +201,7 @@ func TestSearchCommand_NameOnly(t *testing.T) { cmd.SetArgs([]string{"budget", "--name"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -246,7 +221,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"test", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -269,7 +244,7 @@ func TestSearchCommand_NoResults(t *testing.T) { cmd.SetArgs([]string{"nonexistent"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -307,7 +282,7 @@ func TestGetCommand_Success(t *testing.T) { cmd.SetArgs([]string{"file123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -329,7 +304,7 @@ func TestGetCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"file123", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -379,7 +354,7 @@ func TestDownloadCommand_RegularFile(t *testing.T) { cmd.SetArgs([]string{"file123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -403,7 +378,7 @@ func TestDownloadCommand_ToStdout(t *testing.T) { cmd.SetArgs([]string{"file123", "--stdout"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -451,7 +426,7 @@ func TestDownloadCommand_ExportGoogleDoc(t *testing.T) { cmd.SetArgs([]string{"doc123", "--format", "pdf"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) diff --git a/internal/cmd/drive/tree_test.go b/internal/cmd/drive/tree_test.go index e49026b..6f3b538 100644 --- a/internal/cmd/drive/tree_test.go +++ b/internal/cmd/drive/tree_test.go @@ -1,11 +1,8 @@ package drive import ( - "bytes" "context" "fmt" - "io" - "os" "strings" "testing" @@ -57,20 +54,8 @@ func TestTreeCommand(t *testing.T) { } func TestPrintTree(t *testing.T) { - // Capture stdout for testing captureOutput := func(fn func()) string { - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - os.Stdout = old - - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() + return testutil.CaptureStdout(t, fn) } t.Run("prints single node", func(t *testing.T) { diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index 0b5fb50..47e5635 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -1,12 +1,9 @@ package mail import ( - "bytes" "context" "encoding/json" "errors" - "io" - "os" "testing" "google.golang.org/api/gmail/v1" @@ -15,41 +12,18 @@ import ( "github.com/open-cli-collective/google-readonly/internal/testutil" ) -// captureOutput captures stdout during test execution -func captureOutput(t *testing.T, f func()) string { - t.Helper() - old := os.Stdout - r, w, err := os.Pipe() - testutil.NoError(t, err) - os.Stdout = w - - f() - - w.Close() - os.Stdout = old - var buf bytes.Buffer - io.Copy(&buf, r) - return buf.String() -} - // withMockClient sets up a mock client factory for tests func withMockClient(mock MailClient, f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (MailClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (MailClient, error) { return mock, nil - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } // withFailingClientFactory sets up a factory that returns an error func withFailingClientFactory(f func()) { - originalFactory := ClientFactory - ClientFactory = func(_ context.Context) (MailClient, error) { + testutil.WithFactory(&ClientFactory, func(_ context.Context) (MailClient, error) { return nil, errors.New("connection failed") - } - defer func() { ClientFactory = originalFactory }() - f() + }, f) } func TestSearchCommand_Success(t *testing.T) { @@ -65,7 +39,7 @@ func TestSearchCommand_Success(t *testing.T) { cmd.SetArgs([]string{"is:unread"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -88,7 +62,7 @@ func TestSearchCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"is:unread", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -113,7 +87,7 @@ func TestSearchCommand_NoResults(t *testing.T) { cmd.SetArgs([]string{"nonexistent"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -161,7 +135,7 @@ func TestSearchCommand_SkippedMessages(t *testing.T) { cmd.SetArgs([]string{"is:unread"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -183,7 +157,7 @@ func TestReadCommand_Success(t *testing.T) { cmd.SetArgs([]string{"msg123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -205,7 +179,7 @@ func TestReadCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"msg123", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -246,7 +220,7 @@ func TestThreadCommand_Success(t *testing.T) { cmd.SetArgs([]string{"thread123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -269,7 +243,7 @@ func TestThreadCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"thread123", "--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -294,7 +268,7 @@ func TestLabelsCommand_Success(t *testing.T) { cmd := newLabelsCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -320,7 +294,7 @@ func TestLabelsCommand_JSONOutput(t *testing.T) { cmd.SetArgs([]string{"--json"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -345,7 +319,7 @@ func TestLabelsCommand_Empty(t *testing.T) { cmd := newLabelsCommand() withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -368,7 +342,7 @@ func TestListAttachmentsCommand_Success(t *testing.T) { cmd.SetArgs([]string{"msg123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) @@ -390,7 +364,7 @@ func TestListAttachmentsCommand_NoAttachments(t *testing.T) { cmd.SetArgs([]string{"msg123"}) withMockClient(mock, func() { - output := captureOutput(t, func() { + output := testutil.CaptureStdout(t, func() { err := cmd.Execute() testutil.NoError(t, err) }) diff --git a/internal/testutil/helpers.go b/internal/testutil/helpers.go new file mode 100644 index 0000000..85bc072 --- /dev/null +++ b/internal/testutil/helpers.go @@ -0,0 +1,44 @@ +package testutil + +import ( + "bytes" + "io" + "os" + "testing" +) + +// CaptureStdout captures everything written to os.Stdout during the execution +// of f and returns it as a string. This is useful for testing commands that +// print output directly to stdout. +func CaptureStdout(t testing.TB, f func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + NoError(t, err) + os.Stdout = w + + f() + + // Close error is non-fatal for pipe operations in tests + _ = w.Close() + os.Stdout = old + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +// WithFactory temporarily replaces a factory function variable with a +// replacement value, executes f, then restores the original. This is the +// generic building block for per-package withMockClient helpers. +// +// Usage: +// +// testutil.WithFactory(&ClientFactory, mockFactory, func() { +// // ClientFactory now returns the mock +// }) +func WithFactory[T any](factoryPtr *T, replacement T, f func()) { + original := *factoryPtr + *factoryPtr = replacement + defer func() { *factoryPtr = original }() + f() +} From 8de3fe784e6f41299dde4143c5dcf8074967ecc1 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sun, 15 Feb 2026 12:11:02 -0500 Subject: [PATCH 13/13] fix: output empty JSON array when --json flag used with zero results All list/search commands now output [] instead of plaintext "No X found" messages when --json is active and there are no results. This fixes JSON piping (e.g., `gro mail search "..." --json | jq ...`) which previously failed with a parse error on empty result sets. Affected commands: mail search, mail thread, mail labels, mail attachments list, calendar events/today/week, calendar list, contacts list/search/groups, drive list/search/drives. Adds 12 unit tests covering the empty-results-with-JSON path for each affected command. --- internal/cmd/calendar/events_helper.go | 4 ++ internal/cmd/calendar/handlers_test.go | 69 +++++++++++++++++++ internal/cmd/calendar/list.go | 4 ++ internal/cmd/contacts/groups.go | 4 ++ internal/cmd/contacts/handlers_test.go | 75 ++++++++++++++++++++ internal/cmd/contacts/list.go | 4 ++ internal/cmd/contacts/search.go | 4 ++ internal/cmd/drive/drives.go | 4 ++ internal/cmd/drive/handlers_test.go | 46 +++++++++++++ internal/cmd/drive/list.go | 4 ++ internal/cmd/drive/search.go | 4 ++ internal/cmd/mail/attachments_list.go | 4 ++ internal/cmd/mail/handlers_test.go | 95 ++++++++++++++++++++++++++ internal/cmd/mail/labels.go | 4 ++ internal/cmd/mail/search.go | 4 ++ internal/cmd/mail/thread.go | 4 ++ 16 files changed, 333 insertions(+) diff --git a/internal/cmd/calendar/events_helper.go b/internal/cmd/calendar/events_helper.go index c080be0..2002d12 100644 --- a/internal/cmd/calendar/events_helper.go +++ b/internal/cmd/calendar/events_helper.go @@ -27,6 +27,10 @@ func listAndPrintEvents(ctx context.Context, client CalendarClient, opts EventLi } if len(events) == 0 { + if opts.JSONOutput { + fmt.Println("[]") + return nil + } if opts.EmptyMessage != "" { fmt.Println(opts.EmptyMessage) } else { diff --git a/internal/cmd/calendar/handlers_test.go b/internal/cmd/calendar/handlers_test.go index ca15979..6ed15dd 100644 --- a/internal/cmd/calendar/handlers_test.go +++ b/internal/cmd/calendar/handlers_test.go @@ -89,6 +89,29 @@ func TestListCommand_Empty(t *testing.T) { }) } +func TestListCommand_Empty_JSON(t *testing.T) { + mock := &MockCalendarClient{ + ListCalendarsFunc: func(_ context.Context) ([]*calendar.CalendarListEntry, error) { + return []*calendar.CalendarListEntry{}, nil + }, + } + + cmd := newListCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var calendars []any + err := json.Unmarshal([]byte(output), &calendars) + testutil.NoError(t, err) + testutil.Len(t, calendars, 0) + }) +} + func TestListCommand_APIError(t *testing.T) { mock := &MockCalendarClient{ ListCalendarsFunc: func(_ context.Context) ([]*calendar.CalendarListEntry, error) { @@ -185,6 +208,29 @@ func TestEventsCommand_JSONOutput(t *testing.T) { }) } +func TestEventsCommand_Empty_JSON(t *testing.T) { + mock := &MockCalendarClient{ + ListEventsFunc: func(_ context.Context, _, _, _ string, _ int64) ([]*calendar.Event, error) { + return []*calendar.Event{}, nil + }, + } + + cmd := newEventsCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var events []any + err := json.Unmarshal([]byte(output), &events) + testutil.NoError(t, err) + testutil.Len(t, events, 0) + }) +} + func TestEventsCommand_InvalidFromDate(t *testing.T) { cmd := newEventsCommand() cmd.SetArgs([]string{"--from", "invalid-date"}) @@ -290,6 +336,29 @@ func TestTodayCommand_Success(t *testing.T) { }) } +func TestTodayCommand_Empty_JSON(t *testing.T) { + mock := &MockCalendarClient{ + ListEventsFunc: func(_ context.Context, _, _, _ string, _ int64) ([]*calendar.Event, error) { + return []*calendar.Event{}, nil + }, + } + + cmd := newTodayCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var events []any + err := json.Unmarshal([]byte(output), &events) + testutil.NoError(t, err) + testutil.Len(t, events, 0) + }) +} + func TestWeekCommand_Success(t *testing.T) { mock := &MockCalendarClient{ ListEventsFunc: func(_ context.Context, _, _, _ string, _ int64) ([]*calendar.Event, error) { diff --git a/internal/cmd/calendar/list.go b/internal/cmd/calendar/list.go index 97e8b53..d607916 100644 --- a/internal/cmd/calendar/list.go +++ b/internal/cmd/calendar/list.go @@ -34,6 +34,10 @@ Examples: } if len(calendars) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No calendars found.") return nil } diff --git a/internal/cmd/contacts/groups.go b/internal/cmd/contacts/groups.go index c914554..d9de898 100644 --- a/internal/cmd/contacts/groups.go +++ b/internal/cmd/contacts/groups.go @@ -38,6 +38,10 @@ Examples: } if len(resp.ContactGroups) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No contact groups found.") return nil } diff --git a/internal/cmd/contacts/handlers_test.go b/internal/cmd/contacts/handlers_test.go index 5fd0826..7a96385 100644 --- a/internal/cmd/contacts/handlers_test.go +++ b/internal/cmd/contacts/handlers_test.go @@ -100,6 +100,31 @@ func TestListCommand_Empty(t *testing.T) { }) } +func TestListCommand_Empty_JSON(t *testing.T) { + mock := &MockContactsClient{ + ListContactsFunc: func(_ context.Context, _ string, _ int64) (*people.ListConnectionsResponse, error) { + return &people.ListConnectionsResponse{ + Connections: []*people.Person{}, + }, nil + }, + } + + cmd := newListCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var contacts []any + err := json.Unmarshal([]byte(output), &contacts) + testutil.NoError(t, err) + testutil.Len(t, contacts, 0) + }) +} + func TestListCommand_APIError(t *testing.T) { mock := &MockContactsClient{ ListContactsFunc: func(_ context.Context, _ string, _ int64) (*people.ListConnectionsResponse, error) { @@ -201,6 +226,31 @@ func TestSearchCommand_NoResults(t *testing.T) { }) } +func TestSearchCommand_NoResults_JSON(t *testing.T) { + mock := &MockContactsClient{ + SearchContactsFunc: func(_ context.Context, _ string, _ int64) (*people.SearchResponse, error) { + return &people.SearchResponse{ + Results: []*people.SearchResult{}, + }, nil + }, + } + + cmd := newSearchCommand() + cmd.SetArgs([]string{"nonexistent", "--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var contacts []any + err := json.Unmarshal([]byte(output), &contacts) + testutil.NoError(t, err) + testutil.Len(t, contacts, 0) + }) +} + func TestSearchCommand_APIError(t *testing.T) { mock := &MockContactsClient{ SearchContactsFunc: func(_ context.Context, _ string, _ int64) (*people.SearchResponse, error) { @@ -371,6 +421,31 @@ func TestGroupsCommand_Empty(t *testing.T) { }) } +func TestGroupsCommand_Empty_JSON(t *testing.T) { + mock := &MockContactsClient{ + ListContactGroupsFunc: func(_ context.Context, _ string, _ int64) (*people.ListContactGroupsResponse, error) { + return &people.ListContactGroupsResponse{ + ContactGroups: []*people.ContactGroup{}, + }, nil + }, + } + + cmd := newGroupsCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var groups []any + err := json.Unmarshal([]byte(output), &groups) + testutil.NoError(t, err) + testutil.Len(t, groups, 0) + }) +} + func TestGroupsCommand_APIError(t *testing.T) { mock := &MockContactsClient{ ListContactGroupsFunc: func(_ context.Context, _ string, _ int64) (*people.ListContactGroupsResponse, error) { diff --git a/internal/cmd/contacts/list.go b/internal/cmd/contacts/list.go index ffbc8d4..9a4ed0d 100644 --- a/internal/cmd/contacts/list.go +++ b/internal/cmd/contacts/list.go @@ -38,6 +38,10 @@ Examples: } if len(resp.Connections) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No contacts found.") return nil } diff --git a/internal/cmd/contacts/search.go b/internal/cmd/contacts/search.go index 0f7deb5..36ac7d2 100644 --- a/internal/cmd/contacts/search.go +++ b/internal/cmd/contacts/search.go @@ -46,6 +46,10 @@ Examples: } if len(resp.Results) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Printf("No contacts found matching \"%s\".\n", query) return nil } diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index 3b75e50..11d3f91 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -87,6 +87,10 @@ Examples: } if len(drives) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No shared drives found.") return nil } diff --git a/internal/cmd/drive/handlers_test.go b/internal/cmd/drive/handlers_test.go index c36c43f..9a02b4a 100644 --- a/internal/cmd/drive/handlers_test.go +++ b/internal/cmd/drive/handlers_test.go @@ -88,6 +88,29 @@ func TestListCommand_Empty(t *testing.T) { }) } +func TestListCommand_Empty_JSON(t *testing.T) { + mock := &MockDriveClient{ + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { + return []*driveapi.File{}, nil + }, + } + + cmd := newListCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var files []any + err := json.Unmarshal([]byte(output), &files) + testutil.NoError(t, err) + testutil.Len(t, files, 0) + }) +} + func TestListCommand_WithFolder(t *testing.T) { mock := &MockDriveClient{ ListFilesFunc: func(_ context.Context, query string, _ int64) ([]*driveapi.File, error) { @@ -253,6 +276,29 @@ func TestSearchCommand_NoResults(t *testing.T) { }) } +func TestSearchCommand_NoResults_JSON(t *testing.T) { + mock := &MockDriveClient{ + ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { + return []*driveapi.File{}, nil + }, + } + + cmd := newSearchCommand() + cmd.SetArgs([]string{"nonexistent", "--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var files []any + err := json.Unmarshal([]byte(output), &files) + testutil.NoError(t, err) + testutil.Len(t, files, 0) + }) +} + func TestSearchCommand_APIError(t *testing.T) { mock := &MockDriveClient{ ListFilesFunc: func(_ context.Context, _ string, _ int64) ([]*driveapi.File, error) { diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index c84ed03..cf3c9d4 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -74,6 +74,10 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi } if len(files) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No files found.") return nil } diff --git a/internal/cmd/drive/search.go b/internal/cmd/drive/search.go index 419bc7a..0d9fa47 100644 --- a/internal/cmd/drive/search.go +++ b/internal/cmd/drive/search.go @@ -78,6 +78,10 @@ File types: document, spreadsheet, presentation, folder, pdf, image, video, audi } if len(files) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } if query != "" { fmt.Printf("No files found matching \"%s\".\n", query) } else { diff --git a/internal/cmd/mail/attachments_list.go b/internal/cmd/mail/attachments_list.go index 5825507..658e741 100644 --- a/internal/cmd/mail/attachments_list.go +++ b/internal/cmd/mail/attachments_list.go @@ -34,6 +34,10 @@ Examples: } if len(attachments) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No attachments found for message.") return nil } diff --git a/internal/cmd/mail/handlers_test.go b/internal/cmd/mail/handlers_test.go index 47e5635..22e265e 100644 --- a/internal/cmd/mail/handlers_test.go +++ b/internal/cmd/mail/handlers_test.go @@ -96,6 +96,29 @@ func TestSearchCommand_NoResults(t *testing.T) { }) } +func TestSearchCommand_NoResults_JSON(t *testing.T) { + mock := &MockGmailClient{ + SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { + return []*gmailapi.Message{}, 0, nil + }, + } + + cmd := newSearchCommand() + cmd.SetArgs([]string{"nonexistent", "--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var messages []any + err := json.Unmarshal([]byte(output), &messages) + testutil.NoError(t, err) + testutil.Len(t, messages, 0) + }) +} + func TestSearchCommand_APIError(t *testing.T) { mock := &MockGmailClient{ SearchMessagesFunc: func(_ context.Context, _ string, _ int64) ([]*gmailapi.Message, int, error) { @@ -306,6 +329,29 @@ func TestLabelsCommand_JSONOutput(t *testing.T) { }) } +func TestThreadCommand_NoResults_JSON(t *testing.T) { + mock := &MockGmailClient{ + GetThreadFunc: func(_ context.Context, _ string) ([]*gmailapi.Message, error) { + return []*gmailapi.Message{}, nil + }, + } + + cmd := newThreadCommand() + cmd.SetArgs([]string{"thread123", "--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var messages []any + err := json.Unmarshal([]byte(output), &messages) + testutil.NoError(t, err) + testutil.Len(t, messages, 0) + }) +} + func TestLabelsCommand_Empty(t *testing.T) { mock := &MockGmailClient{ FetchLabelsFunc: func(_ context.Context) error { @@ -328,6 +374,32 @@ func TestLabelsCommand_Empty(t *testing.T) { }) } +func TestLabelsCommand_Empty_JSON(t *testing.T) { + mock := &MockGmailClient{ + FetchLabelsFunc: func(_ context.Context) error { + return nil + }, + GetLabelsFunc: func() []*gmail.Label { + return []*gmail.Label{} + }, + } + + cmd := newLabelsCommand() + cmd.SetArgs([]string{"--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var labels []any + err := json.Unmarshal([]byte(output), &labels) + testutil.NoError(t, err) + testutil.Len(t, labels, 0) + }) +} + func TestListAttachmentsCommand_Success(t *testing.T) { mock := &MockGmailClient{ GetAttachmentsFunc: func(_ context.Context, _ string) ([]*gmailapi.Attachment, error) { @@ -372,3 +444,26 @@ func TestListAttachmentsCommand_NoAttachments(t *testing.T) { testutil.Contains(t, output, "No attachments found") }) } + +func TestListAttachmentsCommand_NoAttachments_JSON(t *testing.T) { + mock := &MockGmailClient{ + GetAttachmentsFunc: func(_ context.Context, _ string) ([]*gmailapi.Attachment, error) { + return []*gmailapi.Attachment{}, nil + }, + } + + cmd := newListAttachmentsCommand() + cmd.SetArgs([]string{"msg123", "--json"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + err := cmd.Execute() + testutil.NoError(t, err) + }) + + var attachments []any + err := json.Unmarshal([]byte(output), &attachments) + testutil.NoError(t, err) + testutil.Len(t, attachments, 0) + }) +} diff --git a/internal/cmd/mail/labels.go b/internal/cmd/mail/labels.go index 318fe81..d9b5563 100644 --- a/internal/cmd/mail/labels.go +++ b/internal/cmd/mail/labels.go @@ -46,6 +46,10 @@ Examples: gmailLabels := client.GetLabels() if len(gmailLabels) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No labels found.") return nil } diff --git a/internal/cmd/mail/search.go b/internal/cmd/mail/search.go index b0979e0..66baf1e 100644 --- a/internal/cmd/mail/search.go +++ b/internal/cmd/mail/search.go @@ -37,6 +37,10 @@ For more query operators, see: https://support.google.com/mail/answer/7190`, } if len(messages) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No messages found.") return nil } diff --git a/internal/cmd/mail/thread.go b/internal/cmd/mail/thread.go index a2a074c..6742b28 100644 --- a/internal/cmd/mail/thread.go +++ b/internal/cmd/mail/thread.go @@ -35,6 +35,10 @@ Examples: } if len(messages) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } fmt.Println("No messages found in thread.") return nil }