diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35b9eb..5253a4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.24' + - name: Tidy cfl + run: cd tools/cfl && go mod tidy && git diff --exit-code go.mod go.sum - name: Build cfl run: go build -v ./tools/cfl/... - name: Test cfl @@ -48,6 +50,8 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.24' + - name: Tidy jtk + run: cd tools/jtk && go mod tidy && git diff --exit-code go.mod go.sum - name: Build jtk run: go build -v ./tools/jtk/... - name: Test jtk @@ -90,6 +94,8 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.24' + - name: Tidy shared + run: cd shared && go mod tidy && git diff --exit-code go.mod go.sum - name: Build shared run: go build -v ./shared/... - name: Test shared diff --git a/Makefile b/Makefile index 965c8de..0ea777c 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,35 @@ -.PHONY: build test lint all build-cfl build-jtk test-shared lint-shared install-hooks +.PHONY: build test lint tidy check all build-cfl build-jtk test-shared lint-shared install-hooks -all: build test lint +# CI gate: everything that must pass before merge +check: tidy lint test build +all: check + +# Build all binaries into bin/ build: - go build -v ./shared/... - go build -v ./tools/cfl/cmd/cfl - go build -v ./tools/jtk/cmd/jtk + go build -v -o bin/cfl ./tools/cfl/cmd/cfl + go build -v -o bin/jtk ./tools/jtk/cmd/jtk +# Run tests with race detector test: - go test -v ./shared/... - go test -v ./tools/cfl/... - go test -v ./tools/jtk/... + go test -race ./shared/... + go test -race ./tools/cfl/... + go test -race ./tools/jtk/... +# Lint with golangci-lint (config in each module's .golangci.yml) lint: cd shared && golangci-lint run cd tools/cfl && golangci-lint run cd tools/jtk && golangci-lint run +# Tidy and verify modules are clean +tidy: + cd shared && go mod tidy + cd tools/cfl && go mod tidy + cd tools/jtk && go mod tidy + git diff --exit-code shared/go.mod shared/go.sum tools/cfl/go.mod tools/cfl/go.sum tools/jtk/go.mod tools/jtk/go.sum + +# Build individual tools to bin/ build-cfl: go build -v -o bin/cfl ./tools/cfl/cmd/cfl 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/shared/.golangci.yml b/shared/.golangci.yml index 818b4f9..7fd7c57 100644 --- a/shared/.golangci.yml +++ b/shared/.golangci.yml @@ -8,6 +8,10 @@ linters: - staticcheck - unused - misspell + - revive + - gosec + - errorlint + - exhaustive exclusions: rules: - path: _test\.go diff --git a/shared/adf/convert.go b/shared/adf/convert.go index 95cd785..9f49e8c 100644 --- a/shared/adf/convert.go +++ b/shared/adf/convert.go @@ -1,3 +1,4 @@ +// Package adf provides Atlassian Document Format (ADF) conversion from markdown. package adf import ( @@ -142,17 +143,17 @@ func (c *converter) convertParagraph(n *ast.Paragraph) *Node { func (c *converter) convertHeading(n *ast.Heading) *Node { return &Node{ Type: "heading", - Attrs: map[string]interface{}{"level": n.Level}, + Attrs: map[string]any{"level": n.Level}, Content: c.convertInlineChildren(n), } } func (c *converter) convertList(n *ast.List) *Node { listType := "bulletList" - var attrs map[string]interface{} + var attrs map[string]any if n.IsOrdered() { listType = "orderedList" - attrs = map[string]interface{}{"order": n.Start} + attrs = map[string]any{"order": n.Start} } return &Node{ @@ -223,7 +224,7 @@ func (c *converter) convertFencedCodeBlock(n *ast.FencedCodeBlock) *Node { } if lang := string(n.Language(c.source)); lang != "" { - node.Attrs = map[string]interface{}{"language": lang} + node.Attrs = map[string]any{"language": lang} } return node @@ -270,7 +271,7 @@ func (c *converter) convertTable(n *extast.Table) *Node { return &Node{ Type: "table", - Attrs: map[string]interface{}{"layout": "default"}, + Attrs: map[string]any{"layout": "default"}, Content: rows, } } @@ -317,7 +318,7 @@ func (c *converter) convertTableCell(n *extast.TableCell, isHeader bool) *Node { return &Node{ Type: cellType, - Attrs: map[string]interface{}{"colspan": 1, "rowspan": 1}, + Attrs: map[string]any{"colspan": 1, "rowspan": 1}, Content: []*Node{para}, } } @@ -396,7 +397,7 @@ func (c *converter) convertInlineNode(n ast.Node, marks []*Mark) []*Node { case *ast.Link: linkMark := &Mark{ Type: "link", - Attrs: map[string]interface{}{"href": string(node.Destination)}, + Attrs: map[string]any{"href": string(node.Destination)}, } newMarks := append(copyMarks(marks), linkMark) var nodes []*Node @@ -409,7 +410,7 @@ func (c *converter) convertInlineNode(n ast.Node, marks []*Mark) []*Node { url := string(node.URL(c.source)) linkMark := &Mark{ Type: "link", - Attrs: map[string]interface{}{"href": url}, + Attrs: map[string]any{"href": url}, } newMarks := append(copyMarks(marks), linkMark) return []*Node{{Type: "text", Text: url, Marks: newMarks}} diff --git a/shared/adf/convert_test.go b/shared/adf/convert_test.go index a804635..237f4e0 100644 --- a/shared/adf/convert_test.go +++ b/shared/adf/convert_test.go @@ -2,33 +2,35 @@ package adf import ( "encoding/json" + "fmt" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestToJSON_Paragraph(t *testing.T) { + t.Parallel() input := "Hello world" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "doc", doc.Type) - assert.Equal(t, 1, doc.Version) - require.Len(t, doc.Content, 1) + testutil.Equal(t, doc.Type, "doc") + testutil.Equal(t, doc.Version, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) - require.Len(t, para.Content, 1) - assert.Equal(t, "text", para.Content[0].Type) - assert.Equal(t, "Hello world", para.Content[0].Text) + testutil.Equal(t, para.Type, "paragraph") + testutil.RequireEqual(t, len(para.Content), 1) + testutil.Equal(t, para.Content[0].Type, "text") + testutil.Equal(t, para.Content[0].Text, "Hello world") } func TestToJSON_Headings(t *testing.T) { + t.Parallel() tests := []struct { name string markdown string @@ -46,23 +48,24 @@ func TestToJSON_Headings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := ToJSON([]byte(tt.markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) heading := doc.Content[0] - assert.Equal(t, "heading", heading.Type) - assert.EqualValues(t, tt.level, heading.Attrs["level"]) - require.Len(t, heading.Content, 1) - assert.Equal(t, tt.text, heading.Content[0].Text) + testutil.Equal(t, heading.Type, "heading") + testutil.Equal(t, heading.Attrs["level"], float64(tt.level)) + testutil.RequireEqual(t, len(heading.Content), 1) + testutil.Equal(t, heading.Content[0].Text, tt.text) }) } } func TestToJSON_Formatting(t *testing.T) { + t.Parallel() tests := []struct { name string markdown string @@ -76,16 +79,18 @@ func TestToJSON_Formatting(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToJSON([]byte(tt.markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) + testutil.Equal(t, para.Type, "paragraph") var foundMark bool for _, node := range para.Content { @@ -98,21 +103,22 @@ func TestToJSON_Formatting(t *testing.T) { } } } - assert.True(t, foundMark, "expected to find mark %s", tt.mark) + testutil.True(t, foundMark, fmt.Sprintf("expected to find mark %s", tt.mark)) }) } } func TestToJSON_Links(t *testing.T) { + t.Parallel() input := "[Example](https://example.com)" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] var foundLink bool @@ -120,56 +126,59 @@ func TestToJSON_Links(t *testing.T) { for _, mark := range node.Marks { if mark.Type == "link" { foundLink = true - assert.Equal(t, "https://example.com", mark.Attrs["href"]) - assert.Equal(t, "Example", node.Text) + testutil.Equal(t, mark.Attrs["href"], "https://example.com") + testutil.Equal(t, node.Text, "Example") } } } - assert.True(t, foundLink, "expected to find link mark") + testutil.True(t, foundLink, "expected to find link mark") } func TestToJSON_BulletList(t *testing.T) { + t.Parallel() input := "- Item one\n- Item two\n- Item three" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) list := doc.Content[0] - assert.Equal(t, "bulletList", list.Type) - assert.Len(t, list.Content, 3) + testutil.Equal(t, list.Type, "bulletList") + testutil.Len(t, list.Content, 3) for i, item := range list.Content { - assert.Equal(t, "listItem", item.Type) - require.Len(t, item.Content, 1) + testutil.Equal(t, item.Type, "listItem") + testutil.RequireEqual(t, len(item.Content), 1) para := item.Content[0] - assert.Equal(t, "paragraph", para.Type) + testutil.Equal(t, para.Type, "paragraph") expected := []string{"Item one", "Item two", "Item three"}[i] - require.Len(t, para.Content, 1) - assert.Equal(t, expected, para.Content[0].Text) + testutil.RequireEqual(t, len(para.Content), 1) + testutil.Equal(t, para.Content[0].Text, expected) } } func TestToJSON_OrderedList(t *testing.T) { + t.Parallel() input := "1. First\n2. Second\n3. Third" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) list := doc.Content[0] - assert.Equal(t, "orderedList", list.Type) - assert.EqualValues(t, 1, list.Attrs["order"]) - assert.Len(t, list.Content, 3) + testutil.Equal(t, list.Type, "orderedList") + testutil.Equal(t, list.Attrs["order"], float64(1)) + testutil.Len(t, list.Content, 3) } func TestToJSON_CodeBlock(t *testing.T) { + t.Parallel() tests := []struct { name string markdown string @@ -198,132 +207,139 @@ func TestToJSON_CodeBlock(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToJSON([]byte(tt.markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) block := doc.Content[0] - assert.Equal(t, "codeBlock", block.Type) + testutil.Equal(t, block.Type, "codeBlock") if tt.language != "" { - assert.Equal(t, tt.language, block.Attrs["language"]) + testutil.Equal(t, block.Attrs["language"], tt.language) } - require.Len(t, block.Content, 1) - assert.Equal(t, tt.code, block.Content[0].Text) + testutil.RequireEqual(t, len(block.Content), 1) + testutil.Equal(t, block.Content[0].Text, tt.code) }) } } func TestToJSON_Blockquote(t *testing.T) { + t.Parallel() input := "> This is a quote" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) quote := doc.Content[0] - assert.Equal(t, "blockquote", quote.Type) - require.Len(t, quote.Content, 1) - assert.Equal(t, "paragraph", quote.Content[0].Type) + testutil.Equal(t, quote.Type, "blockquote") + testutil.RequireEqual(t, len(quote.Content), 1) + testutil.Equal(t, quote.Content[0].Type, "paragraph") } func TestToJSON_HorizontalRule(t *testing.T) { + t.Parallel() input := "Above\n\n---\n\nBelow" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Len(t, doc.Content, 3) - assert.Equal(t, "paragraph", doc.Content[0].Type) - assert.Equal(t, "rule", doc.Content[1].Type) - assert.Equal(t, "paragraph", doc.Content[2].Type) + testutil.Len(t, doc.Content, 3) + testutil.Equal(t, doc.Content[0].Type, "paragraph") + testutil.Equal(t, doc.Content[1].Type, "rule") + testutil.Equal(t, doc.Content[2].Type, "paragraph") } func TestToJSON_Table(t *testing.T) { + t.Parallel() input := "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) table := doc.Content[0] - assert.Equal(t, "table", table.Type) - assert.Len(t, table.Content, 2) + testutil.Equal(t, table.Type, "table") + testutil.Len(t, table.Content, 2) headerRow := table.Content[0] - assert.Equal(t, "tableRow", headerRow.Type) - assert.Len(t, headerRow.Content, 2) - assert.Equal(t, "tableHeader", headerRow.Content[0].Type) + testutil.Equal(t, headerRow.Type, "tableRow") + testutil.Len(t, headerRow.Content, 2) + testutil.Equal(t, headerRow.Content[0].Type, "tableHeader") dataRow := table.Content[1] - assert.Equal(t, "tableRow", dataRow.Type) - assert.Len(t, dataRow.Content, 2) - assert.Equal(t, "tableCell", dataRow.Content[0].Type) + testutil.Equal(t, dataRow.Type, "tableRow") + testutil.Len(t, dataRow.Content, 2) + testutil.Equal(t, dataRow.Content[0].Type, "tableCell") } func TestToJSON_EmptyInput(t *testing.T) { + t.Parallel() result, err := ToJSON([]byte("")) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "doc", doc.Type) - assert.Equal(t, 1, doc.Version) - assert.Empty(t, doc.Content) + testutil.Equal(t, doc.Type, "doc") + testutil.Equal(t, doc.Version, 1) + testutil.Empty(t, doc.Content) } func TestToJSON_NestedList(t *testing.T) { + t.Parallel() input := "- Item one\n - Nested one\n - Nested two\n- Item two" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) list := doc.Content[0] - assert.Equal(t, "bulletList", list.Type) + testutil.Equal(t, list.Type, "bulletList") firstItem := list.Content[0] - assert.Equal(t, "listItem", firstItem.Type) + testutil.Equal(t, firstItem.Type, "listItem") var foundNestedList bool for _, child := range firstItem.Content { if child.Type == "bulletList" { foundNestedList = true - assert.Len(t, child.Content, 2) + testutil.Len(t, child.Content, 2) } } - assert.True(t, foundNestedList, "expected nested bullet list") + testutil.True(t, foundNestedList, "expected nested bullet list") } func TestToJSON_BoldAndItalicCombined(t *testing.T) { + t.Parallel() input := "***bold and italic***" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] var foundStrong, foundEm bool @@ -337,11 +353,12 @@ func TestToJSON_BoldAndItalicCombined(t *testing.T) { } } } - assert.True(t, foundStrong, "expected strong mark") - assert.True(t, foundEm, "expected em mark") + testutil.True(t, foundStrong, "expected strong mark") + testutil.True(t, foundEm, "expected em mark") } func TestToJSON_OutputIsValidJSON(t *testing.T) { + t.Parallel() inputs := []string{ "# Simple heading", "Paragraph with **bold** and *italic*", @@ -352,79 +369,83 @@ func TestToJSON_OutputIsValidJSON(t *testing.T) { for _, input := range inputs { result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var parsed map[string]interface{} err = json.Unmarshal([]byte(result), &parsed) - require.NoError(t, err, "Output should be valid JSON for input: %s", input) + testutil.RequireNoError(t, err) - assert.Equal(t, "doc", parsed["type"]) - assert.EqualValues(t, 1, parsed["version"]) + testutil.Equal(t, parsed["type"], "doc") + testutil.Equal(t, parsed["version"], float64(1)) } } func TestToJSON_Images_AltText(t *testing.T) { + t.Parallel() input := "![Alt text](https://example.com/image.png)" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) - require.Len(t, para.Content, 1) - assert.Equal(t, "Alt text", para.Content[0].Text) + testutil.Equal(t, para.Type, "paragraph") + testutil.RequireEqual(t, len(para.Content), 1) + testutil.Equal(t, para.Content[0].Text, "Alt text") } func TestToJSON_WhitespaceInCodeBlock(t *testing.T) { + t.Parallel() input := "```\n indented code\n more indented\n```" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) block := doc.Content[0] - assert.Equal(t, "codeBlock", block.Type) - require.Len(t, block.Content, 1) + testutil.Equal(t, block.Type, "codeBlock") + testutil.RequireEqual(t, len(block.Content), 1) text := block.Content[0].Text - assert.Contains(t, text, " indented") - assert.Contains(t, text, " more indented") + testutil.Contains(t, text, " indented") + testutil.Contains(t, text, " more indented") } func TestToJSON_NestedBlockquote(t *testing.T) { + t.Parallel() input := "> Quote with **bold** text\n>\n> And a list:\n> - Item 1\n> - Item 2" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) quote := doc.Content[0] - assert.Equal(t, "blockquote", quote.Type) - assert.True(t, len(quote.Content) > 0, "blockquote should have content") + testutil.Equal(t, quote.Type, "blockquote") + testutil.True(t, len(quote.Content) > 0, "blockquote should have content") } func TestToJSON_HardLineBreak(t *testing.T) { + t.Parallel() input := "Line one \nLine two" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) + testutil.Equal(t, para.Type, "paragraph") var foundBreak bool for _, node := range para.Content { @@ -433,19 +454,20 @@ func TestToJSON_HardLineBreak(t *testing.T) { break } } - assert.True(t, foundBreak, "expected hardBreak node") + testutil.True(t, foundBreak, "expected hardBreak node") } func TestToJSON_InlineCodePreservesContent(t *testing.T) { + t.Parallel() input := "Use `fmt.Println()` to print" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] var foundCode bool @@ -453,73 +475,79 @@ func TestToJSON_InlineCodePreservesContent(t *testing.T) { for _, mark := range node.Marks { if mark.Type == "code" { foundCode = true - assert.Equal(t, "fmt.Println()", node.Text) + testutil.Equal(t, node.Text, "fmt.Println()") } } } - assert.True(t, foundCode, "expected code mark") + testutil.True(t, foundCode, "expected code mark") } func TestToDocument_Empty(t *testing.T) { + t.Parallel() doc := ToDocument("") - assert.Nil(t, doc) + testutil.Nil(t, doc) } func TestToDocument_PlainText(t *testing.T) { + t.Parallel() doc := ToDocument("Hello world") - require.NotNil(t, doc) - assert.Equal(t, "doc", doc.Type) - assert.Equal(t, 1, doc.Version) - require.Len(t, doc.Content, 1) - assert.Equal(t, "paragraph", doc.Content[0].Type) - require.Len(t, doc.Content[0].Content, 1) - assert.Equal(t, "Hello world", doc.Content[0].Content[0].Text) + testutil.NotNil(t, doc) + testutil.Equal(t, doc.Type, "doc") + testutil.Equal(t, doc.Version, 1) + testutil.RequireEqual(t, len(doc.Content), 1) + testutil.Equal(t, doc.Content[0].Type, "paragraph") + testutil.RequireEqual(t, len(doc.Content[0].Content), 1) + testutil.Equal(t, doc.Content[0].Content[0].Text, "Hello world") } func TestToDocument_ToPlainText(t *testing.T) { + t.Parallel() doc := ToDocument("# Title\n\nSome text\n\n- Item 1\n- Item 2") - require.NotNil(t, doc) + testutil.NotNil(t, doc) text := doc.ToPlainText() - assert.Contains(t, text, "Title") - assert.Contains(t, text, "Some text") - assert.Contains(t, text, "Item 1") - assert.Contains(t, text, "Item 2") + testutil.Contains(t, text, "Title") + testutil.Contains(t, text, "Some text") + testutil.Contains(t, text, "Item 1") + testutil.Contains(t, text, "Item 2") } func TestToPlainText_Nil(t *testing.T) { + t.Parallel() var doc *Document - assert.Equal(t, "", doc.ToPlainText()) + testutil.Equal(t, doc.ToPlainText(), "") } func TestToJSON_IndentedCodeBlock(t *testing.T) { + t.Parallel() input := " code line one\n code line two" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) block := doc.Content[0] - assert.Equal(t, "codeBlock", block.Type) - assert.Nil(t, block.Attrs, "indented code blocks should have no language attr") - require.Len(t, block.Content, 1) - assert.Contains(t, block.Content[0].Text, "code line one") - assert.Contains(t, block.Content[0].Text, "code line two") + testutil.Equal(t, block.Type, "codeBlock") + testutil.Nil(t, block.Attrs) + testutil.RequireEqual(t, len(block.Content), 1) + testutil.Contains(t, block.Content[0].Text, "code line one") + testutil.Contains(t, block.Content[0].Text, "code line two") } func TestToJSON_AutoLink(t *testing.T) { + t.Parallel() input := "Visit for info" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] var foundAutoLink bool @@ -527,24 +555,25 @@ func TestToJSON_AutoLink(t *testing.T) { for _, mark := range node.Marks { if mark.Type == "link" { foundAutoLink = true - assert.Equal(t, "https://example.com", mark.Attrs["href"]) - assert.Equal(t, "https://example.com", node.Text) + testutil.Equal(t, mark.Attrs["href"], "https://example.com") + testutil.Equal(t, node.Text, "https://example.com") } } } - assert.True(t, foundAutoLink, "expected to find auto-linked URL") + testutil.True(t, foundAutoLink, "expected to find auto-linked URL") } func TestToJSON_RawHTMLDropped(t *testing.T) { + t.Parallel() input := "Before raw after" result, err := ToJSON([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc Document err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.RequireEqual(t, len(doc.Content), 1) para := doc.Content[0] // Raw HTML should be dropped; surrounding text should remain @@ -552,12 +581,13 @@ func TestToJSON_RawHTMLDropped(t *testing.T) { for _, node := range para.Content { allText += node.Text } - assert.Contains(t, allText, "Before") - assert.Contains(t, allText, "after") - assert.NotContains(t, allText, "") + testutil.Contains(t, allText, "Before") + testutil.Contains(t, allText, "after") + testutil.NotContains(t, allText, "") } func TestSplitLines(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -572,13 +602,15 @@ func TestSplitLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := splitLines(tt.input) - assert.Equal(t, tt.want, got) + testutil.Equal(t, got, tt.want) }) } } func TestToPlainText_CodeBlock(t *testing.T) { + t.Parallel() doc := &Document{ Type: "doc", Version: 1, @@ -593,10 +625,11 @@ func TestToPlainText_CodeBlock(t *testing.T) { } text := doc.ToPlainText() - assert.Contains(t, text, "fmt.Println()") + testutil.Contains(t, text, "fmt.Println()") } func TestToPlainText_Blockquote(t *testing.T) { + t.Parallel() doc := &Document{ Type: "doc", Version: 1, @@ -616,10 +649,11 @@ func TestToPlainText_Blockquote(t *testing.T) { } text := doc.ToPlainText() - assert.Contains(t, text, "> Quoted") + testutil.Contains(t, text, "> Quoted") } func TestToPlainText_Rule(t *testing.T) { + t.Parallel() doc := &Document{ Type: "doc", Version: 1, @@ -629,10 +663,11 @@ func TestToPlainText_Rule(t *testing.T) { } text := doc.ToPlainText() - assert.Contains(t, text, "---") + testutil.Contains(t, text, "---") } func TestToPlainText_UnknownNodeType(t *testing.T) { + t.Parallel() doc := &Document{ Type: "doc", Version: 1, @@ -642,10 +677,11 @@ func TestToPlainText_UnknownNodeType(t *testing.T) { } text := doc.ToPlainText() - assert.Contains(t, text, "fallback text") + testutil.Contains(t, text, "fallback text") } func TestToPlainText_UnknownNodeWithChildren(t *testing.T) { + t.Parallel() doc := &Document{ Type: "doc", Version: 1, @@ -665,5 +701,5 @@ func TestToPlainText_UnknownNodeWithChildren(t *testing.T) { } text := doc.ToPlainText() - assert.Contains(t, text, "inner") + testutil.Contains(t, text, "inner") } diff --git a/shared/adf/types.go b/shared/adf/types.go index d88d26d..127f27b 100644 --- a/shared/adf/types.go +++ b/shared/adf/types.go @@ -11,17 +11,17 @@ type Document struct { // Node represents a node in an ADF document. type Node struct { - Type string `json:"type"` - Attrs map[string]interface{} `json:"attrs,omitempty"` - Content []*Node `json:"content,omitempty"` - Text string `json:"text,omitempty"` - Marks []*Mark `json:"marks,omitempty"` + Type string `json:"type"` + Attrs map[string]any `json:"attrs,omitempty"` + Content []*Node `json:"content,omitempty"` + Text string `json:"text,omitempty"` + Marks []*Mark `json:"marks,omitempty"` } // Mark represents text formatting (bold, italic, link, etc.) in ADF. type Mark struct { - Type string `json:"type"` - Attrs map[string]interface{} `json:"attrs,omitempty"` + Type string `json:"type"` + Attrs map[string]any `json:"attrs,omitempty"` } // ToPlainText extracts plain text from a Document. diff --git a/shared/auth/auth_test.go b/shared/auth/auth_test.go index 5300816..99be540 100644 --- a/shared/auth/auth_test.go +++ b/shared/auth/auth_test.go @@ -7,6 +7,7 @@ import ( ) func TestBasicAuthHeader(t *testing.T) { + t.Parallel() tests := []struct { name string email string @@ -41,6 +42,7 @@ func TestBasicAuthHeader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := BasicAuthHeader(tt.email, tt.apiToken) if got != tt.want { t.Errorf("BasicAuthHeader() = %v, want %v", got, tt.want) diff --git a/shared/client/client.go b/shared/client/client.go index 11037e3..dd445d1 100644 --- a/shared/client/client.go +++ b/shared/client/client.go @@ -69,7 +69,7 @@ func New(baseURL, email, apiToken string, opts *Options) *Client { // or an absolute URL (e.g., "https://example.com/api/resource"). // If body is not nil, it will be JSON-encoded. // Returns the response body or an error (which may be an *errors.APIError). -func (c *Client) Do(ctx context.Context, method, path string, body interface{}) ([]byte, error) { +func (c *Client) Do(ctx context.Context, method, path string, body any) ([]byte, error) { var url string // Check if path is an absolute URL @@ -87,14 +87,14 @@ func (c *Client) Do(ctx context.Context, method, path string, body interface{}) if body != nil { jsonBody, err := json.Marshal(body) if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) + return nil, fmt.Errorf("marshaling request body: %w", err) } reqBody = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, url, reqBody) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return nil, fmt.Errorf("creating request: %w", err) } // Set headers @@ -114,7 +114,7 @@ func (c *Client) Do(ctx context.Context, method, path string, body interface{}) respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, fmt.Errorf("reading response body: %w", err) } if c.Verbose { @@ -135,12 +135,12 @@ func (c *Client) Get(ctx context.Context, path string) ([]byte, error) { } // Post performs a POST request with a JSON body. -func (c *Client) Post(ctx context.Context, path string, body interface{}) ([]byte, error) { +func (c *Client) Post(ctx context.Context, path string, body any) ([]byte, error) { return c.Do(ctx, http.MethodPost, path, body) } // Put performs a PUT request with a JSON body. -func (c *Client) Put(ctx context.Context, path string, body interface{}) ([]byte, error) { +func (c *Client) Put(ctx context.Context, path string, body any) ([]byte, error) { return c.Do(ctx, http.MethodPut, path, body) } diff --git a/shared/client/client_test.go b/shared/client/client_test.go index 378e510..f4dfaac 100644 --- a/shared/client/client_test.go +++ b/shared/client/client_test.go @@ -15,7 +15,9 @@ import ( ) func TestNew(t *testing.T) { + t.Parallel() t.Run("basic creation", func(t *testing.T) { + t.Parallel() c := New("https://example.atlassian.net", "user@example.com", "token", nil) if c.BaseURL != "https://example.atlassian.net" { @@ -32,6 +34,7 @@ func TestNew(t *testing.T) { }) t.Run("trims trailing slash", func(t *testing.T) { + t.Parallel() c := New("https://example.atlassian.net/", "user@example.com", "token", nil) if c.BaseURL != "https://example.atlassian.net" { @@ -40,6 +43,7 @@ func TestNew(t *testing.T) { }) t.Run("with options", func(t *testing.T) { + t.Parallel() verboseOut := &bytes.Buffer{} opts := &Options{ Timeout: 90 * time.Second, @@ -64,7 +68,9 @@ func TestNew(t *testing.T) { } func TestClient_Do(t *testing.T) { + t.Parallel() t.Run("GET request", func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify headers if r.Method != http.MethodGet { @@ -84,7 +90,7 @@ func TestClient_Do(t *testing.T) { } w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"result": "success"}`)) + _, _ = w.Write([]byte(`{"result": "success"}`)) })) defer server.Close() @@ -101,6 +107,7 @@ func TestClient_Do(t *testing.T) { }) t.Run("POST request with body", func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Method = %v, want POST", r.Method) @@ -113,14 +120,14 @@ func TestClient_Do(t *testing.T) { // Read and verify body body, _ := io.ReadAll(r.Body) var data map[string]string - json.Unmarshal(body, &data) + _ = json.Unmarshal(body, &data) if data["name"] != "test" { t.Errorf("Body name = %v, want test", data["name"]) } w.WriteHeader(http.StatusCreated) - w.Write([]byte(`{"id": "123"}`)) + _, _ = w.Write([]byte(`{"id": "123"}`)) })) defer server.Close() @@ -137,6 +144,7 @@ func TestClient_Do(t *testing.T) { }) t.Run("PUT request", func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Errorf("Method = %v, want PUT", r.Method) @@ -154,6 +162,7 @@ func TestClient_Do(t *testing.T) { }) t.Run("DELETE request", func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("Method = %v, want DELETE", r.Method) @@ -171,6 +180,7 @@ func TestClient_Do(t *testing.T) { }) t.Run("path without leading slash", func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/test" { t.Errorf("Path = %v, want /api/test", r.URL.Path) @@ -188,13 +198,14 @@ func TestClient_Do(t *testing.T) { }) t.Run("absolute URL bypasses BaseURL", func(t *testing.T) { + t.Parallel() // Create a server that the client will actually hit server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/custom/endpoint" { t.Errorf("Path = %v, want /custom/endpoint", r.URL.Path) } w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"success": true}`)) + _, _ = w.Write([]byte(`{"success": true}`)) })) defer server.Close() @@ -218,6 +229,7 @@ func TestClient_Do(t *testing.T) { } func TestClient_ErrorHandling(t *testing.T) { + t.Parallel() tests := []struct { name string statusCode int @@ -264,9 +276,10 @@ func TestClient_ErrorHandling(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tt.statusCode) - w.Write([]byte(tt.body)) + _, _ = w.Write([]byte(tt.body)) })) defer server.Close() @@ -277,13 +290,13 @@ func TestClient_ErrorHandling(t *testing.T) { t.Fatal("Expected error, got nil") } - if !errors.IsNotFound(err) && tt.wantErr == errors.ErrNotFound { + if errors.IsNotFound(tt.wantErr) && !errors.IsNotFound(err) { t.Errorf("Expected ErrNotFound, got %v", err) } - if !errors.IsUnauthorized(err) && tt.wantErr == errors.ErrUnauthorized { + if errors.IsUnauthorized(tt.wantErr) && !errors.IsUnauthorized(err) { t.Errorf("Expected ErrUnauthorized, got %v", err) } - if !errors.IsForbidden(err) && tt.wantErr == errors.ErrForbidden { + if errors.IsForbidden(tt.wantErr) && !errors.IsForbidden(err) { t.Errorf("Expected ErrForbidden, got %v", err) } }) @@ -291,9 +304,10 @@ func TestClient_ErrorHandling(t *testing.T) { } func TestClient_VerboseOutput(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) })) defer server.Close() @@ -322,7 +336,8 @@ func TestClient_VerboseOutput(t *testing.T) { } func TestClient_ContextCancellation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(100 * time.Millisecond) w.WriteHeader(http.StatusOK) })) @@ -341,7 +356,9 @@ func TestClient_ContextCancellation(t *testing.T) { } func TestOptions_timeoutOrDefault(t *testing.T) { + t.Parallel() t.Run("nil options", func(t *testing.T) { + t.Parallel() var opts *Options if got := opts.timeoutOrDefault(); got != DefaultTimeout { t.Errorf("timeoutOrDefault() = %v, want %v", got, DefaultTimeout) @@ -349,6 +366,7 @@ func TestOptions_timeoutOrDefault(t *testing.T) { }) t.Run("zero timeout", func(t *testing.T) { + t.Parallel() opts := &Options{} if got := opts.timeoutOrDefault(); got != DefaultTimeout { t.Errorf("timeoutOrDefault() = %v, want %v", got, DefaultTimeout) @@ -356,6 +374,7 @@ func TestOptions_timeoutOrDefault(t *testing.T) { }) t.Run("custom timeout", func(t *testing.T) { + t.Parallel() opts := &Options{Timeout: 90 * time.Second} if got := opts.timeoutOrDefault(); got != 90*time.Second { t.Errorf("timeoutOrDefault() = %v, want 90s", got) diff --git a/shared/config/env_test.go b/shared/config/env_test.go index b61a3eb..2da3fe6 100644 --- a/shared/config/env_test.go +++ b/shared/config/env_test.go @@ -1,34 +1,13 @@ package config import ( - "os" "testing" ) func TestGetEnvWithFallback(t *testing.T) { - // Save and restore environment - cleanup := func(keys ...string) func() { - saved := make(map[string]string) - for _, key := range keys { - saved[key] = os.Getenv(key) - } - return func() { - for key, val := range saved { - if val == "" { - os.Unsetenv(key) - } else { - os.Setenv(key, val) - } - } - } - } - t.Run("primary set", func(t *testing.T) { - restore := cleanup("TEST_PRIMARY", "TEST_FALLBACK") - defer restore() - - os.Setenv("TEST_PRIMARY", "primary-value") - os.Setenv("TEST_FALLBACK", "fallback-value") + t.Setenv("TEST_PRIMARY", "primary-value") + t.Setenv("TEST_FALLBACK", "fallback-value") got := GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK") if got != "primary-value" { @@ -37,11 +16,8 @@ func TestGetEnvWithFallback(t *testing.T) { }) t.Run("primary empty uses fallback", func(t *testing.T) { - restore := cleanup("TEST_PRIMARY", "TEST_FALLBACK") - defer restore() - - os.Unsetenv("TEST_PRIMARY") - os.Setenv("TEST_FALLBACK", "fallback-value") + t.Setenv("TEST_PRIMARY", "") + t.Setenv("TEST_FALLBACK", "fallback-value") got := GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK") if got != "fallback-value" { @@ -50,11 +26,8 @@ func TestGetEnvWithFallback(t *testing.T) { }) t.Run("both empty", func(t *testing.T) { - restore := cleanup("TEST_PRIMARY", "TEST_FALLBACK") - defer restore() - - os.Unsetenv("TEST_PRIMARY") - os.Unsetenv("TEST_FALLBACK") + t.Setenv("TEST_PRIMARY", "") + t.Setenv("TEST_FALLBACK", "") got := GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK") if got != "" { @@ -63,11 +36,8 @@ func TestGetEnvWithFallback(t *testing.T) { }) t.Run("primary explicitly empty string", func(t *testing.T) { - restore := cleanup("TEST_PRIMARY", "TEST_FALLBACK") - defer restore() - - os.Setenv("TEST_PRIMARY", "") - os.Setenv("TEST_FALLBACK", "fallback-value") + t.Setenv("TEST_PRIMARY", "") + t.Setenv("TEST_FALLBACK", "fallback-value") got := GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK") if got != "fallback-value" { @@ -77,22 +47,8 @@ func TestGetEnvWithFallback(t *testing.T) { } func TestGetEnvWithDefault(t *testing.T) { - cleanup := func(key string) func() { - saved := os.Getenv(key) - return func() { - if saved == "" { - os.Unsetenv(key) - } else { - os.Setenv(key, saved) - } - } - } - t.Run("env set", func(t *testing.T) { - restore := cleanup("TEST_ENV") - defer restore() - - os.Setenv("TEST_ENV", "env-value") + t.Setenv("TEST_ENV", "env-value") got := GetEnvWithDefault("TEST_ENV", "default-value") if got != "env-value" { @@ -101,10 +57,7 @@ func TestGetEnvWithDefault(t *testing.T) { }) t.Run("env not set uses default", func(t *testing.T) { - restore := cleanup("TEST_ENV") - defer restore() - - os.Unsetenv("TEST_ENV") + t.Setenv("TEST_ENV", "") got := GetEnvWithDefault("TEST_ENV", "default-value") if got != "default-value" { @@ -113,10 +66,7 @@ func TestGetEnvWithDefault(t *testing.T) { }) t.Run("env empty uses default", func(t *testing.T) { - restore := cleanup("TEST_ENV") - defer restore() - - os.Setenv("TEST_ENV", "") + t.Setenv("TEST_ENV", "") got := GetEnvWithDefault("TEST_ENV", "default-value") if got != "default-value" { diff --git a/shared/errors/errors.go b/shared/errors/errors.go index 947106f..deb1c33 100644 --- a/shared/errors/errors.go +++ b/shared/errors/errors.go @@ -1,5 +1,5 @@ // Package errors provides error types for Atlassian API responses. -package errors +package errors //nolint:revive // intentional shadow of stdlib errors for ergonomic API import ( "encoding/json" @@ -43,7 +43,7 @@ func (e *APIError) UnmarshalJSON(data []byte) error { } if err := json.Unmarshal(data, aux); err != nil { - return err + return fmt.Errorf("unmarshaling API error: %w", err) } // Handle "errors" field which can be object or array @@ -114,6 +114,7 @@ func ParseAPIError(statusCode int, body []byte) error { apiErr := &APIError{StatusCode: statusCode} if len(body) > 0 { + // Best-effort parse; unparseable bodies leave apiErr with only StatusCode set. _ = json.Unmarshal(body, apiErr) } diff --git a/shared/errors/errors_test.go b/shared/errors/errors_test.go index e26b42d..d473106 100644 --- a/shared/errors/errors_test.go +++ b/shared/errors/errors_test.go @@ -1,4 +1,4 @@ -package errors +package errors //nolint:revive // test file for errors package import ( "encoding/json" @@ -10,6 +10,7 @@ import ( ) func TestAPIError_UnmarshalJSON(t *testing.T) { + t.Parallel() tests := []struct { name string json string @@ -54,6 +55,7 @@ func TestAPIError_UnmarshalJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() var apiErr APIError err := json.Unmarshal([]byte(tt.json), &apiErr) if err != nil { @@ -80,6 +82,7 @@ func TestAPIError_UnmarshalJSON(t *testing.T) { } func TestAPIError_Error(t *testing.T) { + t.Parallel() tests := []struct { name string apiErr APIError @@ -128,6 +131,7 @@ func TestAPIError_Error(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := tt.apiErr.Error() for _, want := range tt.contains { if !strings.Contains(got, want) { @@ -139,6 +143,7 @@ func TestAPIError_Error(t *testing.T) { } func TestParseAPIError(t *testing.T) { + t.Parallel() tests := []struct { name string statusCode int @@ -199,6 +204,7 @@ func TestParseAPIError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() err := ParseAPIError(tt.statusCode, tt.body) if !errors.Is(err, tt.wantErr) { t.Errorf("ParseAPIError() = %v, want %v", err, tt.wantErr) @@ -216,6 +222,7 @@ func TestParseAPIError(t *testing.T) { } func TestParseAPIError_ReturnsAPIError(t *testing.T) { + t.Parallel() // For 4xx errors without sentinel, should return APIError body := []byte(`{"message": "Custom error", "errorMessages": ["Detail 1"]}`) err := ParseAPIError(422, body) @@ -231,6 +238,7 @@ func TestParseAPIError_ReturnsAPIError(t *testing.T) { } func TestIsHelpers(t *testing.T) { + t.Parallel() tests := []struct { name string err error @@ -256,6 +264,7 @@ func TestIsHelpers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() if got := tt.isFunc(tt.err); got != tt.wantMatch { t.Errorf("%s = %v, want %v", tt.name, got, tt.wantMatch) } @@ -264,6 +273,7 @@ func TestIsHelpers(t *testing.T) { } func TestParseAPIError_WithComplexJiraResponse(t *testing.T) { + t.Parallel() body := []byte(`{ "errorMessages": ["Issue Does Not Exist"], "errors": { @@ -285,6 +295,7 @@ func TestParseAPIError_WithComplexJiraResponse(t *testing.T) { } func TestParseAPIError_WithAutomationResponse(t *testing.T) { + t.Parallel() body := []byte(`{ "errors": [ { @@ -309,6 +320,7 @@ func TestParseAPIError_WithAutomationResponse(t *testing.T) { } func TestParseAPIError_WithComplexConfluenceResponse(t *testing.T) { + t.Parallel() body := []byte(`{ "statusCode": 404, "message": "No content found with id: 12345", diff --git a/shared/go.mod b/shared/go.mod index b15a915..5535369 100644 --- a/shared/go.mod +++ b/shared/go.mod @@ -4,15 +4,11 @@ go 1.24.0 require ( github.com/fatih/color v1.18.0 - github.com/stretchr/testify v1.11.1 github.com/yuin/goldmark v1.7.16 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.38.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/shared/go.sum b/shared/go.sum index 19c2733..bd543e7 100644 --- a/shared/go.sum +++ b/shared/go.sum @@ -1,5 +1,3 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -7,17 +5,9 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shared/prompt/confirm_test.go b/shared/prompt/confirm_test.go index 89a5f9a..14aaeab 100644 --- a/shared/prompt/confirm_test.go +++ b/shared/prompt/confirm_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestConfirm(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -54,18 +54,20 @@ func TestConfirm(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got, err := Confirm(strings.NewReader(tt.input)) if tt.wantErr { - require.Error(t, err) + testutil.RequireError(t, err) return } - require.NoError(t, err) - assert.Equal(t, tt.want, got) + testutil.RequireNoError(t, err) + testutil.Equal(t, got, tt.want) }) } } func TestConfirmOrForce(t *testing.T) { + t.Parallel() tests := []struct { name string force bool @@ -101,13 +103,14 @@ func TestConfirmOrForce(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got, err := ConfirmOrForce(tt.force, strings.NewReader(tt.input)) if tt.wantErr { - require.Error(t, err) + testutil.RequireError(t, err) return } - require.NoError(t, err) - assert.Equal(t, tt.want, got) + testutil.RequireNoError(t, err) + testutil.Equal(t, got, tt.want) }) } } diff --git a/shared/testutil/assert.go b/shared/testutil/assert.go new file mode 100644 index 0000000..78510f0 --- /dev/null +++ b/shared/testutil/assert.go @@ -0,0 +1,229 @@ +// Package testutil provides lightweight test assertion helpers. +// +// All helpers call t.Helper() so test failures report the caller's line number. +// "Require" variants call t.Fatal (stop the test); plain variants call t.Error +// (mark failed but continue). +package testutil + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +// Equal checks that got and want are deeply equal. +func Equal(t *testing.T, got, want any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +// RequireEqual checks that got and want are deeply equal, stopping the test on failure. +func RequireEqual(t *testing.T, got, want any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +// NoError checks that err is nil. +func NoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// RequireNoError checks that err is nil, stopping the test on failure. +func RequireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// Error checks that err is not nil. +func Error(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Errorf("expected error, got nil") + } +} + +// RequireError checks that err is not nil, stopping the test on failure. +func RequireError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +// Contains checks that s contains substr. +func Contains(t *testing.T, s, substr string) { + t.Helper() + if !strings.Contains(s, substr) { + t.Errorf("string %q does not contain %q", s, substr) + } +} + +// True checks that the condition is true. +func True(t *testing.T, condition bool, msgAndArgs ...any) { + t.Helper() + if !condition { + if len(msgAndArgs) > 0 { + t.Errorf("expected true: %s", fmt.Sprint(msgAndArgs...)) + } else { + t.Errorf("expected true, got false") + } + } +} + +// False checks that the condition is false. +func False(t *testing.T, condition bool, msgAndArgs ...any) { + t.Helper() + if condition { + if len(msgAndArgs) > 0 { + t.Errorf("expected false: %s", fmt.Sprint(msgAndArgs...)) + } else { + t.Errorf("expected false, got true") + } + } +} + +// Nil checks that v is nil. +func Nil(t *testing.T, v any) { + t.Helper() + if v == nil { + return + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface || + rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || + rv.Kind() == reflect.Chan || rv.Kind() == reflect.Func { + if rv.IsNil() { + return + } + } + t.Errorf("expected nil, got %v", v) +} + +// NotNil checks that v is not nil. +func NotNil(t *testing.T, v any) { + t.Helper() + if v == nil { + t.Errorf("expected not nil, got nil") + return + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface || + rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || + rv.Kind() == reflect.Chan || rv.Kind() == reflect.Func { + if rv.IsNil() { + t.Errorf("expected not nil, got nil") + } + } +} + +// Len checks that the length of v equals expected. +// v must be a string, slice, array, map, or channel. +func Len(t *testing.T, v any, expected int) { + t.Helper() + rv := reflect.ValueOf(v) + switch rv.Kind() { //nolint:exhaustive // covered by default case + case reflect.String, reflect.Slice, reflect.Array, reflect.Map, reflect.Chan: + if rv.Len() != expected { + t.Errorf("expected length %d, got %d", expected, rv.Len()) + } + default: + t.Errorf("Len called on unsupported type %T", v) + } +} + +// Empty checks that v has length 0. +func Empty(t *testing.T, v any) { + t.Helper() + rv := reflect.ValueOf(v) + switch rv.Kind() { //nolint:exhaustive // covered by default case + case reflect.String, reflect.Slice, reflect.Array, reflect.Map, reflect.Chan: + if rv.Len() != 0 { + t.Errorf("expected empty, got length %d", rv.Len()) + } + default: + t.Errorf("Empty called on unsupported type %T", v) + } +} + +// NotEmpty checks that v has length > 0. +func NotEmpty(t *testing.T, v any) { + t.Helper() + rv := reflect.ValueOf(v) + switch rv.Kind() { //nolint:exhaustive // covered by default case + case reflect.String, reflect.Slice, reflect.Array, reflect.Map, reflect.Chan: + if rv.Len() == 0 { + t.Errorf("expected not empty") + } + default: + t.Errorf("NotEmpty called on unsupported type %T", v) + } +} + +// Greater checks that a > b (both must be comparable numeric types or strings). +func Greater(t *testing.T, a, b int) { + t.Helper() + if a <= b { + t.Errorf("expected %d > %d", a, b) + } +} + +// GreaterOrEqual checks that a >= b. +func GreaterOrEqual(t *testing.T, a, b int) { + t.Helper() + if a < b { + t.Errorf("expected %d >= %d", a, b) + } +} + +// NotContains checks that s does not contain substr. +func NotContains(t *testing.T, s, substr string) { + t.Helper() + if strings.Contains(s, substr) { + t.Errorf("string %q should not contain %q", s, substr) + } +} + +// HasPrefix checks that s starts with prefix. +func HasPrefix(t *testing.T, s, prefix string) { + t.Helper() + if !strings.HasPrefix(s, prefix) { + t.Errorf("string %q does not start with %q", s, prefix) + } +} + +// HasSuffix checks that s ends with suffix. +func HasSuffix(t *testing.T, s, suffix string) { + t.Helper() + if !strings.HasSuffix(s, suffix) { + t.Errorf("string %q does not end with %q", s, suffix) + } +} + +// NotEqual checks that got and want are not deeply equal. +func NotEqual(t *testing.T, got, want any) { + t.Helper() + if reflect.DeepEqual(got, want) { + t.Errorf("expected values to differ, both are %v", got) + } +} + +// ErrorContains checks that err is not nil and its message contains substr. +func ErrorContains(t *testing.T, err error, substr string) { + t.Helper() + if err == nil { + t.Fatalf("expected error containing %q, got nil", substr) + } + if !strings.Contains(err.Error(), substr) { + t.Errorf("error %q does not contain %q", err.Error(), substr) + } +} diff --git a/shared/url/url_test.go b/shared/url/url_test.go index 26646e2..76ad91d 100644 --- a/shared/url/url_test.go +++ b/shared/url/url_test.go @@ -1,8 +1,9 @@ -package url +package url //nolint:revive // test file for url package import "testing" func TestNormalizeURL(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -57,6 +58,7 @@ func TestNormalizeURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := NormalizeURL(tt.input) if got != tt.want { t.Errorf("NormalizeURL(%q) = %q, want %q", tt.input, got, tt.want) @@ -66,6 +68,7 @@ func TestNormalizeURL(t *testing.T) { } func TestHasScheme(t *testing.T) { + t.Parallel() tests := []struct { input string want bool @@ -80,6 +83,7 @@ func TestHasScheme(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { + t.Parallel() got := HasScheme(tt.input) if got != tt.want { t.Errorf("HasScheme(%q) = %v, want %v", tt.input, got, tt.want) @@ -89,6 +93,7 @@ func TestHasScheme(t *testing.T) { } func TestTrimTrailingSlashes(t *testing.T) { + t.Parallel() tests := []struct { input string want string @@ -102,6 +107,7 @@ func TestTrimTrailingSlashes(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { + t.Parallel() got := TrimTrailingSlashes(tt.input) if got != tt.want { t.Errorf("TrimTrailingSlashes(%q) = %q, want %q", tt.input, got, tt.want) diff --git a/shared/version/version.go b/shared/version/version.go index b309fae..38596ae 100644 --- a/shared/version/version.go +++ b/shared/version/version.go @@ -5,7 +5,7 @@ // go build -ldflags "-X github.com/open-cli-collective/atlassian-go/version.Version=1.0.0 \ // -X github.com/open-cli-collective/atlassian-go/version.Commit=abc123 \ // -X github.com/open-cli-collective/atlassian-go/version.BuildDate=2024-01-01" -package version +package version //nolint:revive // package name does not conflict in practice // Build information, set via ldflags var ( diff --git a/shared/view/view.go b/shared/view/view.go index bf43f96..9f5d119 100644 --- a/shared/view/view.go +++ b/shared/view/view.go @@ -49,10 +49,6 @@ type View struct { // New creates a new View with the given format. // If noColor is true, colorized output is disabled. func New(format Format, noColor bool) *View { - if noColor { - color.NoColor = true - } - return &View{ Format: format, NoColor: noColor, @@ -122,7 +118,7 @@ func (v *View) tableAsJSON(headers []string, rows [][]string) error { } // JSON renders data as formatted JSON. -func (v *View) JSON(data interface{}) error { +func (v *View) JSON(data any) error { enc := json.NewEncoder(v.Out) enc.SetIndent("", " ") return enc.Encode(data) @@ -140,19 +136,21 @@ func (v *View) Plain(rows [][]string) error { // For table format, uses headers and rows. // For JSON format, uses jsonData. // For plain format, uses rows without headers. -func (v *View) Render(headers []string, rows [][]string, jsonData interface{}) error { +func (v *View) Render(headers []string, rows [][]string, jsonData any) error { switch v.Format { case FormatJSON: return v.JSON(jsonData) case FormatPlain: return v.Plain(rows) + case FormatTable: + return v.Table(headers, rows) default: return v.Table(headers, rows) } } // Success prints a success message with a green checkmark. -func (v *View) Success(format string, args ...interface{}) { +func (v *View) Success(format string, args ...any) { msg := fmt.Sprintf(format, args...) if v.NoColor { _, _ = fmt.Fprintln(v.Out, "✓ "+msg) @@ -162,7 +160,7 @@ func (v *View) Success(format string, args ...interface{}) { } // Error prints an error message with a red X. -func (v *View) Error(format string, args ...interface{}) { +func (v *View) Error(format string, args ...any) { msg := fmt.Sprintf(format, args...) if v.NoColor { _, _ = fmt.Fprintln(v.Err, "✗ "+msg) @@ -172,7 +170,7 @@ func (v *View) Error(format string, args ...interface{}) { } // Warning prints a warning message with a yellow warning sign. -func (v *View) Warning(format string, args ...interface{}) { +func (v *View) Warning(format string, args ...any) { msg := fmt.Sprintf(format, args...) if v.NoColor { _, _ = fmt.Fprintln(v.Err, "⚠ "+msg) @@ -182,18 +180,18 @@ func (v *View) Warning(format string, args ...interface{}) { } // Info prints an informational message. -func (v *View) Info(format string, args ...interface{}) { +func (v *View) Info(format string, args ...any) { msg := fmt.Sprintf(format, args...) _, _ = fmt.Fprintln(v.Out, msg) } // Print prints a message without newline. -func (v *View) Print(format string, args ...interface{}) { +func (v *View) Print(format string, args ...any) { _, _ = fmt.Fprintf(v.Out, format, args...) } // Println prints a message with newline. -func (v *View) Println(format string, args ...interface{}) { +func (v *View) Println(format string, args ...any) { _, _ = fmt.Fprintln(v.Out, fmt.Sprintf(format, args...)) } diff --git a/shared/view/view_test.go b/shared/view/view_test.go index e436785..96b9aee 100644 --- a/shared/view/view_test.go +++ b/shared/view/view_test.go @@ -8,6 +8,7 @@ import ( ) func TestValidFormats(t *testing.T) { + t.Parallel() formats := ValidFormats() expected := []string{"table", "json", "plain"} @@ -30,6 +31,7 @@ func TestValidFormats(t *testing.T) { } func TestValidateFormat(t *testing.T) { + t.Parallel() tests := []struct { format string wantErr bool @@ -45,6 +47,7 @@ func TestValidateFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.format, func(t *testing.T) { + t.Parallel() err := ValidateFormat(tt.format) if (err != nil) != tt.wantErr { t.Errorf("ValidateFormat(%q) error = %v, wantErr = %v", tt.format, err, tt.wantErr) @@ -54,7 +57,9 @@ func TestValidateFormat(t *testing.T) { } func TestNew(t *testing.T) { + t.Parallel() t.Run("default options", func(t *testing.T) { + t.Parallel() v := New(FormatTable, false) if v.Format != FormatTable { @@ -75,6 +80,7 @@ func TestNew(t *testing.T) { }) t.Run("with noColor", func(t *testing.T) { + t.Parallel() v := New(FormatJSON, true) if !v.NoColor { @@ -84,6 +90,7 @@ func TestNew(t *testing.T) { } func TestNewWithFormat(t *testing.T) { + t.Parallel() v := NewWithFormat("json", false) if v.Format != FormatJSON { @@ -92,6 +99,7 @@ func TestNewWithFormat(t *testing.T) { } func TestView_Table(t *testing.T) { + t.Parallel() headers := []string{"ID", "NAME", "STATUS"} rows := [][]string{ {"1", "Item One", "Active"}, @@ -99,6 +107,7 @@ func TestView_Table(t *testing.T) { } t.Run("table format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) // noColor for predictable output v.SetOutput(buf) @@ -128,6 +137,7 @@ func TestView_Table(t *testing.T) { }) t.Run("json format via Table", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatJSON, false) v.SetOutput(buf) @@ -153,6 +163,7 @@ func TestView_Table(t *testing.T) { }) t.Run("plain format via Table", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatPlain, false) v.SetOutput(buf) @@ -178,6 +189,7 @@ func TestView_Table(t *testing.T) { } func TestView_JSON(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatJSON, false) v.SetOutput(buf) @@ -204,6 +216,7 @@ func TestView_JSON(t *testing.T) { } func TestView_Plain(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatPlain, false) v.SetOutput(buf) @@ -231,11 +244,13 @@ func TestView_Plain(t *testing.T) { } func TestView_Render(t *testing.T) { + t.Parallel() headers := []string{"KEY", "VALUE"} rows := [][]string{{"k1", "v1"}} jsonData := map[string]string{"key": "value"} t.Run("table format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) v.SetOutput(buf) @@ -251,6 +266,7 @@ func TestView_Render(t *testing.T) { }) t.Run("json format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatJSON, false) v.SetOutput(buf) @@ -266,6 +282,7 @@ func TestView_Render(t *testing.T) { }) t.Run("plain format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatPlain, false) v.SetOutput(buf) @@ -286,7 +303,9 @@ func TestView_Render(t *testing.T) { } func TestView_Messages(t *testing.T) { + t.Parallel() t.Run("Success", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) v.SetOutput(buf) @@ -302,6 +321,7 @@ func TestView_Messages(t *testing.T) { }) t.Run("Error", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) v.SetError(buf) @@ -317,6 +337,7 @@ func TestView_Messages(t *testing.T) { }) t.Run("Warning", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) v.SetError(buf) @@ -332,6 +353,7 @@ func TestView_Messages(t *testing.T) { }) t.Run("Info", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, false) v.SetOutput(buf) @@ -344,6 +366,7 @@ func TestView_Messages(t *testing.T) { }) t.Run("Print", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, false) v.SetOutput(buf) @@ -357,6 +380,7 @@ func TestView_Messages(t *testing.T) { }) t.Run("Println", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, false) v.SetOutput(buf) @@ -371,6 +395,7 @@ func TestView_Messages(t *testing.T) { } func TestTruncate(t *testing.T) { + t.Parallel() tests := []struct { input string maxLen int @@ -388,6 +413,7 @@ func TestTruncate(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { + t.Parallel() got := Truncate(tt.input, tt.maxLen) if got != tt.want { t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) @@ -397,6 +423,7 @@ func TestTruncate(t *testing.T) { } func TestView_SetOutput(t *testing.T) { + t.Parallel() v := New(FormatTable, false) buf := &bytes.Buffer{} @@ -410,6 +437,7 @@ func TestView_SetOutput(t *testing.T) { } func TestView_SetError(t *testing.T) { + t.Parallel() v := New(FormatTable, true) buf := &bytes.Buffer{} @@ -423,6 +451,7 @@ func TestView_SetError(t *testing.T) { } func TestView_RenderList(t *testing.T) { + t.Parallel() headers := []string{"ID", "NAME"} rows := [][]string{ {"1", "First"}, @@ -430,6 +459,7 @@ func TestView_RenderList(t *testing.T) { } t.Run("table format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) v.SetOutput(buf) @@ -449,6 +479,7 @@ func TestView_RenderList(t *testing.T) { }) t.Run("json format with hasMore=false", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatJSON, false) v.SetOutput(buf) @@ -478,6 +509,7 @@ func TestView_RenderList(t *testing.T) { }) t.Run("json format with hasMore=true", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatJSON, false) v.SetOutput(buf) @@ -499,7 +531,9 @@ func TestView_RenderList(t *testing.T) { } func TestView_RenderKeyValue(t *testing.T) { + t.Parallel() t.Run("table format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, true) v.SetOutput(buf) @@ -516,6 +550,7 @@ func TestView_RenderKeyValue(t *testing.T) { }) t.Run("json format", func(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatJSON, false) v.SetOutput(buf) @@ -533,6 +568,7 @@ func TestView_RenderKeyValue(t *testing.T) { } func TestView_RenderText(t *testing.T) { + t.Parallel() buf := &bytes.Buffer{} v := New(FormatTable, false) v.SetOutput(buf) diff --git a/tools/cfl/.golangci.yml b/tools/cfl/.golangci.yml index 42d618c..8e1b15f 100644 --- a/tools/cfl/.golangci.yml +++ b/tools/cfl/.golangci.yml @@ -8,6 +8,10 @@ linters: - staticcheck - unused - misspell + - revive + - gosec + - errorlint + - exhaustive exclusions: rules: - path: _test\.go diff --git a/tools/cfl/CLAUDE.md b/tools/cfl/CLAUDE.md index ec649b9..c272e13 100644 --- a/tools/cfl/CLAUDE.md +++ b/tools/cfl/CLAUDE.md @@ -128,7 +128,7 @@ type pageAPI interface { - `*_test.go` next to implementation - `testdata/` for JSON fixtures - Table-driven tests with `t.Run()` -- Use `github.com/stretchr/testify/assert` and `require` +- Use `shared/testutil` for assertions (no testify) ### Integration Tests After significant code changes, run through the manual integration test suite in [integration-tests.md](integration-tests.md). These tests verify real-world behavior against a live Confluence instance and catch edge cases that unit tests miss. diff --git a/tools/cfl/Makefile b/tools/cfl/Makefile index 89c7f2f..5fc82a0 100644 --- a/tools/cfl/Makefile +++ b/tools/cfl/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-cover test-short lint clean install deps +.PHONY: build test test-cover test-short lint tidy check clean install deps BINARY := cfl VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") @@ -39,6 +39,14 @@ clean: install: build cp bin/$(BINARY) $(shell go env GOPATH)/bin/ +# 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 + # Download dependencies deps: go mod download diff --git a/tools/cfl/api/attachments.go b/tools/cfl/api/attachments.go index b5b0c00..6ab27f8 100644 --- a/tools/cfl/api/attachments.go +++ b/tools/cfl/api/attachments.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "bytes" @@ -43,12 +43,12 @@ func (c *Client) ListAttachments(ctx context.Context, pageID string, opts *ListA path := fmt.Sprintf("/api/v2/pages/%s/attachments?%s", pageID, params.Encode()) body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("listing attachments: %w", err) } var result PaginatedResponse[Attachment] if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse attachments response: %w", err) + return nil, fmt.Errorf("parsing attachments response: %w", err) } return &result, nil @@ -59,12 +59,12 @@ func (c *Client) GetAttachment(ctx context.Context, attachmentID string) (*Attac path := fmt.Sprintf("/api/v2/attachments/%s", attachmentID) body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("getting attachment: %w", err) } var att Attachment if err := json.Unmarshal(body, &att); err != nil { - return nil, fmt.Errorf("failed to parse attachment response: %w", err) + return nil, fmt.Errorf("parsing attachment response: %w", err) } return &att, nil @@ -75,7 +75,7 @@ func (c *Client) DownloadAttachment(ctx context.Context, attachmentID string) (i // Get attachment metadata which includes the download URL att, err := c.GetAttachment(ctx, attachmentID) if err != nil { - return nil, fmt.Errorf("failed to get attachment: %w", err) + return nil, fmt.Errorf("getting attachment: %w", err) } if att.DownloadLink == "" { @@ -87,18 +87,18 @@ func (c *Client) DownloadAttachment(ctx context.Context, attachmentID string) (i req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("creating download request: %w", err) } req.Header.Set("Authorization", c.GetAuthHeader()) resp, err := c.GetHTTPClient().Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("downloading attachment: %w", err) } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() - return nil, fmt.Errorf("download failed with status %d", resp.StatusCode) + return nil, fmt.Errorf("downloading attachment: status %d", resp.StatusCode) } return resp.Body, nil @@ -114,28 +114,28 @@ func (c *Client) UploadAttachment(ctx context.Context, pageID, filename string, // Add file part part, err := writer.CreateFormFile("file", filename) if err != nil { - return nil, fmt.Errorf("failed to create form file: %w", err) + return nil, fmt.Errorf("creating form file: %w", err) } if _, err := io.Copy(part, content); err != nil { - return nil, fmt.Errorf("failed to copy file content: %w", err) + return nil, fmt.Errorf("copying file content: %w", err) } // Add comment if provided if comment != "" { if err := writer.WriteField("comment", comment); err != nil { - return nil, fmt.Errorf("failed to write comment field: %w", err) + return nil, fmt.Errorf("writing comment field: %w", err) } } if err := writer.Close(); err != nil { - return nil, fmt.Errorf("failed to close multipart writer: %w", err) + return nil, fmt.Errorf("closing multipart writer: %w", err) } // Use v1 API for uploads path := fmt.Sprintf("/rest/api/content/%s/child/attachment", pageID) req, err := http.NewRequestWithContext(ctx, "POST", c.GetBaseURL()+path, &buf) if err != nil { - return nil, err + return nil, fmt.Errorf("creating upload request: %w", err) } req.Header.Set("Authorization", c.GetAuthHeader()) @@ -144,19 +144,19 @@ func (c *Client) UploadAttachment(ctx context.Context, pageID, filename string, resp, err := c.GetHTTPClient().Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("uploading attachment: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("reading upload response: %w", err) } if resp.StatusCode >= 400 { var errResp ErrorResponse if err := json.Unmarshal(respBody, &errResp); err != nil { - return nil, fmt.Errorf("upload failed (status %d): %s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("uploading attachment (status %d): %s", resp.StatusCode, string(respBody)) } return nil, &errResp } @@ -166,7 +166,7 @@ func (c *Client) UploadAttachment(ctx context.Context, pageID, filename string, Results []Attachment `json:"results"` } if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse upload response: %w", err) + return nil, fmt.Errorf("parsing upload response: %w", err) } if len(result.Results) == 0 { @@ -180,5 +180,8 @@ func (c *Client) UploadAttachment(ctx context.Context, pageID, filename string, func (c *Client) DeleteAttachment(ctx context.Context, attachmentID string) error { path := fmt.Sprintf("/api/v2/attachments/%s", attachmentID) _, err := c.Delete(ctx, path) - return err + if err != nil { + return fmt.Errorf("deleting attachment %s: %w", attachmentID, err) + } + return nil } diff --git a/tools/cfl/api/attachments_test.go b/tools/cfl/api/attachments_test.go index 0c6eeb2..16da2b6 100644 --- a/tools/cfl/api/attachments_test.go +++ b/tools/cfl/api/attachments_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -6,16 +6,16 @@ import ( "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestClient_ListAttachments(t *testing.T) { + t.Parallel() testData := loadTestData(t, "attachments.json") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/pages/98765/attachments", r.URL.Path) - assert.Equal(t, "GET", r.Method) + testutil.Equal(t, "/api/v2/pages/98765/attachments", r.URL.Path) + testutil.Equal(t, "GET", r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write(testData) @@ -25,21 +25,22 @@ func TestClient_ListAttachments(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListAttachments(context.Background(), "98765", nil) - require.NoError(t, err) - assert.Len(t, result.Results, 2) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 2) // Check first attachment att := result.Results[0] - assert.Equal(t, "att111", att.ID) - assert.Equal(t, "screenshot.png", att.Title) - assert.Equal(t, "image/png", att.MediaType) - assert.Equal(t, int64(245678), att.FileSize) + testutil.Equal(t, "att111", att.ID) + testutil.Equal(t, "screenshot.png", att.Title) + testutil.Equal(t, "image/png", att.MediaType) + testutil.Equal(t, int64(245678), att.FileSize) } func TestClient_ListAttachments_WithOptions(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "50", r.URL.Query().Get("limit")) - assert.Equal(t, "image/png", r.URL.Query().Get("mediaType")) + testutil.Equal(t, "50", r.URL.Query().Get("limit")) + testutil.Equal(t, "image/png", r.URL.Query().Get("mediaType")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -52,13 +53,14 @@ func TestClient_ListAttachments_WithOptions(t *testing.T) { MediaType: "image/png", } _, err := client.ListAttachments(context.Background(), "98765", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_GetAttachment(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/attachments/att111", r.URL.Path) - assert.Equal(t, "GET", r.Method) + testutil.Equal(t, "/api/v2/attachments/att111", r.URL.Path) + testutil.Equal(t, "GET", r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -74,13 +76,14 @@ func TestClient_GetAttachment(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") att, err := client.GetAttachment(context.Background(), "att111") - require.NoError(t, err) - assert.Equal(t, "att111", att.ID) - assert.Equal(t, "screenshot.png", att.Title) - assert.Equal(t, int64(245678), att.FileSize) + testutil.RequireNoError(t, err) + testutil.Equal(t, "att111", att.ID) + testutil.Equal(t, "screenshot.png", att.Title) + testutil.Equal(t, int64(245678), att.FileSize) } func TestClient_DownloadAttachment(t *testing.T) { + t.Parallel() fileContent := []byte("fake image content") downloadCalled := false @@ -111,19 +114,20 @@ func TestClient_DownloadAttachment(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") reader, err := client.DownloadAttachment(context.Background(), "att111") - require.NoError(t, err) + testutil.RequireNoError(t, err) defer func() { _ = reader.Close() }() - assert.True(t, downloadCalled, "should have called download link") + testutil.True(t, downloadCalled, "should have called download link") // Read and verify content buf := make([]byte, 100) n, _ := reader.Read(buf) - assert.Equal(t, fileContent, buf[:n]) + testutil.Equal(t, fileContent, buf[:n]) } func TestClient_DownloadAttachment_NoDownloadLink(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"id": "att123", "title": "test.txt"}`)) })) @@ -131,25 +135,27 @@ func TestClient_DownloadAttachment_NoDownloadLink(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.DownloadAttachment(context.Background(), "att123") - require.Error(t, err) - assert.Contains(t, err.Error(), "no download link") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "no download link") } func TestClient_DeleteAttachment(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/attachments/att111", r.URL.Path) - assert.Equal(t, "DELETE", r.Method) + testutil.Equal(t, "/api/v2/attachments/att111", r.URL.Path) + testutil.Equal(t, "DELETE", r.Method) w.WriteHeader(http.StatusNoContent) })) defer server.Close() client := NewClient(server.URL, "user@example.com", "token") err := client.DeleteAttachment(context.Background(), "att111") - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_DeleteAttachment_NotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Attachment not found"}`)) })) @@ -157,5 +163,5 @@ func TestClient_DeleteAttachment_NotFound(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.DeleteAttachment(context.Background(), "invalid") - require.Error(t, err) + testutil.RequireError(t, err) } diff --git a/tools/cfl/api/client.go b/tools/cfl/api/client.go index cf51f89..871fcfb 100644 --- a/tools/cfl/api/client.go +++ b/tools/cfl/api/client.go @@ -1,5 +1,5 @@ // Package api provides a client for the Confluence REST API. -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -50,12 +50,12 @@ func (c *Client) GetCurrentUser(ctx context.Context) (*User, error) { body, err := c.Get(ctx, url) if err != nil { - return nil, fmt.Errorf("failed to get current user: %w", err) + return nil, fmt.Errorf("getting current user: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { - return nil, fmt.Errorf("failed to decode user response: %w", err) + return nil, fmt.Errorf("decoding user response: %w", err) } return &user, nil diff --git a/tools/cfl/api/client_test.go b/tools/cfl/api/client_test.go index adadca3..ec3684d 100644 --- a/tools/cfl/api/client_test.go +++ b/tools/cfl/api/client_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -8,19 +8,20 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestNewClient(t *testing.T) { + t.Parallel() client := NewClient("https://example.atlassian.net/wiki", "user@example.com", "token123") - assert.NotNil(t, client) - assert.Equal(t, "https://example.atlassian.net/wiki", client.GetBaseURL()) - assert.Contains(t, client.GetAuthHeader(), "Basic ") + testutil.NotNil(t, client) + testutil.Equal(t, "https://example.atlassian.net/wiki", client.GetBaseURL()) + testutil.Contains(t, client.GetAuthHeader(), "Basic ") } func TestClient_AuthHeader(t *testing.T) { + t.Parallel() var capturedAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -32,17 +33,18 @@ func TestClient_AuthHeader(t *testing.T) { client := NewClient(server.URL, "user@example.com", "mytoken") _, err := client.Get(context.Background(), "/test") - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify Basic auth header - require.True(t, strings.HasPrefix(capturedAuth, "Basic ")) + testutil.True(t, strings.HasPrefix(capturedAuth, "Basic ")) encoded := strings.TrimPrefix(capturedAuth, "Basic ") decoded, err := base64.StdEncoding.DecodeString(encoded) - require.NoError(t, err) - assert.Equal(t, "user@example.com:mytoken", string(decoded)) + testutil.RequireNoError(t, err) + testutil.Equal(t, "user@example.com:mytoken", string(decoded)) } func TestClient_Headers(t *testing.T) { + t.Parallel() var capturedHeaders http.Header server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -54,13 +56,14 @@ func TestClient_Headers(t *testing.T) { client := NewClient(server.URL, "user@example.com", "mytoken") _, err := client.Get(context.Background(), "/test") - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "application/json", capturedHeaders.Get("Accept")) - assert.Equal(t, "application/json", capturedHeaders.Get("Content-Type")) + testutil.Equal(t, "application/json", capturedHeaders.Get("Accept")) + testutil.Equal(t, "application/json", capturedHeaders.Get("Content-Type")) } func TestClient_ErrorResponse(t *testing.T) { + t.Parallel() tests := []struct { name string statusCode int @@ -101,6 +104,7 @@ func TestClient_ErrorResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tt.statusCode) _, _ = w.Write([]byte(tt.responseBody)) @@ -110,13 +114,14 @@ func TestClient_ErrorResponse(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.Get(context.Background(), "/test") - require.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedErrMsg) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), tt.expectedErrMsg) }) } } func TestClient_ContextCancellation(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { // Slow response <-r.Context().Done() @@ -129,10 +134,11 @@ func TestClient_ContextCancellation(t *testing.T) { cancel() // Cancel immediately _, err := client.Get(ctx, "/test") - require.Error(t, err) + testutil.RequireError(t, err) } func TestClient_URLConstruction(t *testing.T) { + t.Parallel() var capturedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -154,7 +160,7 @@ func TestClient_URLConstruction(t *testing.T) { for _, tt := range tests { _, err := client.Get(context.Background(), tt.inputPath) - require.NoError(t, err) - assert.Equal(t, tt.expectedPath, capturedPath) + testutil.RequireNoError(t, err) + testutil.Equal(t, tt.expectedPath, capturedPath) } } diff --git a/tools/cfl/api/pages.go b/tools/cfl/api/pages.go index 0a442c4..34ab6c7 100644 --- a/tools/cfl/api/pages.go +++ b/tools/cfl/api/pages.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -52,12 +52,12 @@ func (c *Client) ListPages(ctx context.Context, spaceID string, opts *ListPagesO path := fmt.Sprintf("/api/v2/spaces/%s/pages?%s", spaceID, params.Encode()) body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("listing pages: %w", err) } var result PaginatedResponse[Page] if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse pages response: %w", err) + return nil, fmt.Errorf("parsing pages response: %w", err) } return &result, nil @@ -77,12 +77,12 @@ func (c *Client) GetPage(ctx context.Context, pageID string, opts *GetPageOption body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("getting page: %w", err) } var page Page if err := json.Unmarshal(body, &page); err != nil { - return nil, fmt.Errorf("failed to parse page response: %w", err) + return nil, fmt.Errorf("parsing page response: %w", err) } return &page, nil @@ -92,12 +92,12 @@ func (c *Client) GetPage(ctx context.Context, pageID string, opts *GetPageOption func (c *Client) CreatePage(ctx context.Context, req *CreatePageRequest) (*Page, error) { body, err := c.Post(ctx, "/api/v2/pages", req) if err != nil { - return nil, err + return nil, fmt.Errorf("creating page: %w", err) } var page Page if err := json.Unmarshal(body, &page); err != nil { - return nil, fmt.Errorf("failed to parse create page response: %w", err) + return nil, fmt.Errorf("parsing create page response: %w", err) } return &page, nil @@ -108,12 +108,12 @@ func (c *Client) UpdatePage(ctx context.Context, pageID string, req *UpdatePageR path := fmt.Sprintf("/api/v2/pages/%s", pageID) body, err := c.Put(ctx, path, req) if err != nil { - return nil, err + return nil, fmt.Errorf("updating page: %w", err) } var page Page if err := json.Unmarshal(body, &page); err != nil { - return nil, fmt.Errorf("failed to parse update page response: %w", err) + return nil, fmt.Errorf("parsing update page response: %w", err) } return &page, nil @@ -123,7 +123,10 @@ func (c *Client) UpdatePage(ctx context.Context, pageID string, req *UpdatePageR func (c *Client) DeletePage(ctx context.Context, pageID string) error { path := fmt.Sprintf("/api/v2/pages/%s", pageID) _, err := c.Delete(ctx, path) - return err + if err != nil { + return fmt.Errorf("deleting page %s: %w", pageID, err) + } + return nil } // MovePage moves a page to be a child of the target parent page. @@ -131,7 +134,10 @@ func (c *Client) DeletePage(ctx context.Context, pageID string) error { func (c *Client) MovePage(ctx context.Context, pageID, targetParentID string) error { path := fmt.Sprintf("/rest/api/content/%s/move/append/%s", pageID, targetParentID) _, err := c.Put(ctx, path, nil) - return err + if err != nil { + return fmt.Errorf("moving page %s to parent %s: %w", pageID, targetParentID, err) + } + return nil } // CopyPageOptions configures page copy behavior. @@ -220,12 +226,12 @@ func (c *Client) CopyPage(ctx context.Context, pageID string, opts *CopyPageOpti path := fmt.Sprintf("/rest/api/content/%s/copy", pageID) body, err := c.Post(ctx, path, req) if err != nil { - return nil, err + return nil, fmt.Errorf("copying page: %w", err) } var response v1PageResponse if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse copy response: %w", err) + return nil, fmt.Errorf("parsing copy response: %w", err) } return response.toPage(), nil diff --git a/tools/cfl/api/pages_test.go b/tools/cfl/api/pages_test.go index 3192120..24ad160 100644 --- a/tools/cfl/api/pages_test.go +++ b/tools/cfl/api/pages_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -8,17 +8,17 @@ import ( "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestClient_ListPages(t *testing.T) { + t.Parallel() testData := loadTestData(t, "pages.json") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/spaces/123456/pages", r.URL.Path) - assert.Equal(t, "GET", r.Method) - assert.Equal(t, "25", r.URL.Query().Get("limit")) + testutil.Equal(t, "/api/v2/spaces/123456/pages", r.URL.Path) + testutil.Equal(t, "GET", r.Method) + testutil.Equal(t, "25", r.URL.Query().Get("limit")) w.WriteHeader(http.StatusOK) _, _ = w.Write(testData) @@ -28,22 +28,23 @@ func TestClient_ListPages(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListPages(context.Background(), "123456", nil) - require.NoError(t, err) - assert.Len(t, result.Results, 2) - assert.True(t, result.HasMore()) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 2) + testutil.True(t, result.HasMore()) // Check first page page := result.Results[0] - assert.Equal(t, "98765", page.ID) - assert.Equal(t, "Getting Started Guide", page.Title) - assert.Equal(t, "123456", page.SpaceID) + testutil.Equal(t, "98765", page.ID) + testutil.Equal(t, "Getting Started Guide", page.Title) + testutil.Equal(t, "123456", page.SpaceID) } func TestClient_ListPages_WithOptions(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "50", r.URL.Query().Get("limit")) - assert.Equal(t, "current", r.URL.Query().Get("status")) - assert.Equal(t, "title", r.URL.Query().Get("sort")) + testutil.Equal(t, "50", r.URL.Query().Get("limit")) + testutil.Equal(t, "current", r.URL.Query().Get("status")) + testutil.Equal(t, "title", r.URL.Query().Get("sort")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -57,15 +58,16 @@ func TestClient_ListPages_WithOptions(t *testing.T) { Sort: "title", } _, err := client.ListPages(context.Background(), "123456", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_GetPage(t *testing.T) { + t.Parallel() testData := loadTestData(t, "page.json") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/pages/98765", r.URL.Path) - assert.Equal(t, "GET", r.Method) + testutil.Equal(t, "/api/v2/pages/98765", r.URL.Path) + testutil.Equal(t, "GET", r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write(testData) @@ -75,19 +77,20 @@ func TestClient_GetPage(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") page, err := client.GetPage(context.Background(), "98765", nil) - require.NoError(t, err) - assert.Equal(t, "98765", page.ID) - assert.Equal(t, "Getting Started Guide", page.Title) - assert.Equal(t, "123456", page.SpaceID) - assert.Equal(t, 5, page.Version.Number) - assert.NotNil(t, page.Body) - assert.NotNil(t, page.Body.Storage) - assert.Contains(t, page.Body.Storage.Value, "

Getting Started

") + testutil.RequireNoError(t, err) + testutil.Equal(t, "98765", page.ID) + testutil.Equal(t, "Getting Started Guide", page.Title) + testutil.Equal(t, "123456", page.SpaceID) + testutil.Equal(t, 5, page.Version.Number) + testutil.NotNil(t, page.Body) + testutil.NotNil(t, page.Body.Storage) + testutil.Contains(t, page.Body.Storage.Value, "

Getting Started

") } func TestClient_GetPage_WithBodyFormat(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "storage", r.URL.Query().Get("body-format")) + testutil.Equal(t, "storage", r.URL.Query().Get("body-format")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"id": "98765", "title": "Test"}`)) @@ -97,26 +100,27 @@ func TestClient_GetPage_WithBodyFormat(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") opts := &GetPageOptions{BodyFormat: "storage"} _, err := client.GetPage(context.Background(), "98765", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_CreatePage(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/pages", r.URL.Path) - assert.Equal(t, "POST", r.Method) + testutil.Equal(t, "/api/v2/pages", r.URL.Path) + testutil.Equal(t, "POST", r.Method) body, err := io.ReadAll(r.Body) - require.NoError(t, err) + testutil.RequireNoError(t, err) var req CreatePageRequest err = json.Unmarshal(body, &req) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "123456", req.SpaceID) - assert.Equal(t, "New Page", req.Title) - assert.NotNil(t, req.Body) - assert.NotNil(t, req.Body.Storage) - assert.Equal(t, "

Content

", req.Body.Storage.Value) + testutil.Equal(t, "123456", req.SpaceID) + testutil.Equal(t, "New Page", req.Title) + testutil.NotNil(t, req.Body) + testutil.NotNil(t, req.Body.Storage) + testutil.Equal(t, "

Content

", req.Body.Storage.Value) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -141,26 +145,27 @@ func TestClient_CreatePage(t *testing.T) { } page, err := client.CreatePage(context.Background(), req) - require.NoError(t, err) - assert.Equal(t, "99999", page.ID) - assert.Equal(t, "New Page", page.Title) + testutil.RequireNoError(t, err) + testutil.Equal(t, "99999", page.ID) + testutil.Equal(t, "New Page", page.Title) } func TestClient_UpdatePage(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/pages/98765", r.URL.Path) - assert.Equal(t, "PUT", r.Method) + testutil.Equal(t, "/api/v2/pages/98765", r.URL.Path) + testutil.Equal(t, "PUT", r.Method) body, err := io.ReadAll(r.Body) - require.NoError(t, err) + testutil.RequireNoError(t, err) var req UpdatePageRequest err = json.Unmarshal(body, &req) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "98765", req.ID) - assert.Equal(t, "Updated Title", req.Title) - assert.Equal(t, 6, req.Version.Number) + testutil.Equal(t, "98765", req.ID) + testutil.Equal(t, "Updated Title", req.Title) + testutil.Equal(t, 6, req.Version.Number) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -186,15 +191,16 @@ func TestClient_UpdatePage(t *testing.T) { } page, err := client.UpdatePage(context.Background(), "98765", req) - require.NoError(t, err) - assert.Equal(t, "Updated Title", page.Title) - assert.Equal(t, 6, page.Version.Number) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Updated Title", page.Title) + testutil.Equal(t, 6, page.Version.Number) } func TestClient_DeletePage(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/pages/98765", r.URL.Path) - assert.Equal(t, "DELETE", r.Method) + testutil.Equal(t, "/api/v2/pages/98765", r.URL.Path) + testutil.Equal(t, "DELETE", r.Method) w.WriteHeader(http.StatusNoContent) })) @@ -203,13 +209,14 @@ func TestClient_DeletePage(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.DeletePage(context.Background(), "98765") - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_MovePage_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/content/12345/move/append/67890", r.URL.Path) - assert.Equal(t, "PUT", r.Method) + testutil.Equal(t, "/rest/api/content/12345/move/append/67890", r.URL.Path) + testutil.Equal(t, "PUT", r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{}`)) @@ -219,11 +226,12 @@ func TestClient_MovePage_Success(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.MovePage(context.Background(), "12345", "67890") - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_MovePage_NotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) @@ -232,11 +240,12 @@ func TestClient_MovePage_NotFound(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.MovePage(context.Background(), "99999", "67890") - require.Error(t, err) + testutil.RequireError(t, err) } func TestClient_MovePage_PermissionDenied(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"message": "You do not have permission to move this page"}`)) })) @@ -245,31 +254,32 @@ func TestClient_MovePage_PermissionDenied(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.MovePage(context.Background(), "12345", "67890") - require.Error(t, err) + testutil.RequireError(t, err) } func TestClient_CopyPage_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/content/12345/copy", r.URL.Path) - assert.Equal(t, "POST", r.Method) + testutil.Equal(t, "/rest/api/content/12345/copy", r.URL.Path) + testutil.Equal(t, "POST", r.Method) body, err := io.ReadAll(r.Body) - require.NoError(t, err) + testutil.RequireNoError(t, err) - var req map[string]interface{} + var req map[string]any err = json.Unmarshal(body, &req) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "New Title", req["pageTitle"]) - assert.Equal(t, true, req["copyAttachments"]) - assert.Equal(t, true, req["copyPermissions"]) - assert.Equal(t, true, req["copyProperties"]) - assert.Equal(t, true, req["copyLabels"]) - assert.Equal(t, true, req["copyCustomContents"]) + testutil.Equal(t, "New Title", req["pageTitle"]) + testutil.Equal(t, true, req["copyAttachments"]) + testutil.Equal(t, true, req["copyPermissions"]) + testutil.Equal(t, true, req["copyProperties"]) + testutil.Equal(t, true, req["copyLabels"]) + testutil.Equal(t, true, req["copyCustomContents"]) - dest := req["destination"].(map[string]interface{}) - assert.Equal(t, "space", dest["type"]) - assert.Equal(t, "TEST", dest["value"]) + dest := req["destination"].(map[string]any) + testutil.Equal(t, "space", dest["type"]) + testutil.Equal(t, "TEST", dest["value"]) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -296,32 +306,35 @@ func TestClient_CopyPage_Success(t *testing.T) { } page, err := client.CopyPage(context.Background(), "12345", opts) - require.NoError(t, err) - assert.Equal(t, "99999", page.ID) - assert.Equal(t, "New Title", page.Title) - assert.Equal(t, "TEST", page.SpaceID) - assert.Equal(t, 1, page.Version.Number) - assert.Equal(t, "/spaces/TEST/pages/99999", page.Links.WebUI) + testutil.RequireNoError(t, err) + testutil.Equal(t, "99999", page.ID) + testutil.Equal(t, "New Title", page.Title) + testutil.Equal(t, "TEST", page.SpaceID) + testutil.Equal(t, 1, page.Version.Number) + testutil.Equal(t, "/spaces/TEST/pages/99999", page.Links.WebUI) } func TestClient_CopyPage_MissingTitle(t *testing.T) { + t.Parallel() client := NewClient("http://unused", "user@example.com", "token") _, err := client.CopyPage(context.Background(), "12345", &CopyPageOptions{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "title is required") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "title is required") } func TestClient_CopyPage_NilOptions(t *testing.T) { + t.Parallel() client := NewClient("http://unused", "user@example.com", "token") _, err := client.CopyPage(context.Background(), "12345", nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "title is required") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "title is required") } func TestClient_CopyPage_APIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) @@ -334,19 +347,20 @@ func TestClient_CopyPage_APIError(t *testing.T) { } _, err := client.CopyPage(context.Background(), "99999", opts) - require.Error(t, err) + testutil.RequireError(t, err) } func TestClient_CopyPage_WithoutAttachments(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) - require.NoError(t, err) + testutil.RequireNoError(t, err) - var req map[string]interface{} + var req map[string]any err = json.Unmarshal(body, &req) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, false, req["copyAttachments"]) + testutil.Equal(t, false, req["copyAttachments"]) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -367,21 +381,22 @@ func TestClient_CopyPage_WithoutAttachments(t *testing.T) { } _, err := client.CopyPage(context.Background(), "12345", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_CopyPage_ToDifferentSpace(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) - require.NoError(t, err) + testutil.RequireNoError(t, err) - var req map[string]interface{} + var req map[string]any err = json.Unmarshal(body, &req) - require.NoError(t, err) + testutil.RequireNoError(t, err) - dest := req["destination"].(map[string]interface{}) - assert.Equal(t, "space", dest["type"]) - assert.Equal(t, "OTHERSPACE", dest["value"]) + dest := req["destination"].(map[string]any) + testutil.Equal(t, "space", dest["type"]) + testutil.Equal(t, "OTHERSPACE", dest["value"]) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -401,21 +416,22 @@ func TestClient_CopyPage_ToDifferentSpace(t *testing.T) { } page, err := client.CopyPage(context.Background(), "12345", opts) - require.NoError(t, err) - assert.Equal(t, "OTHERSPACE", page.SpaceID) + testutil.RequireNoError(t, err) + testutil.Equal(t, "OTHERSPACE", page.SpaceID) } func TestClient_CopyPage_WithoutLabels(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) - require.NoError(t, err) + testutil.RequireNoError(t, err) - var req map[string]interface{} + var req map[string]any err = json.Unmarshal(body, &req) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, false, req["copyLabels"]) - assert.Equal(t, true, req["copyAttachments"]) // others should still be true + testutil.Equal(t, false, req["copyLabels"]) + testutil.Equal(t, true, req["copyAttachments"]) // others should still be true w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -437,11 +453,12 @@ func TestClient_CopyPage_WithoutLabels(t *testing.T) { } _, err := client.CopyPage(context.Background(), "12345", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_UpdatePage_VersionConflict(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusConflict) _, _ = w.Write([]byte(`{ "message": "Version conflict: expected version 5 but page is at version 6", @@ -459,12 +476,13 @@ func TestClient_UpdatePage_VersionConflict(t *testing.T) { } _, err := client.UpdatePage(context.Background(), "98765", req) - require.Error(t, err) - assert.Contains(t, err.Error(), "conflict") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "conflict") } func TestClient_GetPage_MissingBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "98765", @@ -478,14 +496,15 @@ func TestClient_GetPage_MissingBody(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") page, err := client.GetPage(context.Background(), "98765", nil) - require.NoError(t, err) - assert.Equal(t, "98765", page.ID) - assert.Equal(t, "Page Without Body", page.Title) - assert.Nil(t, page.Body) + testutil.RequireNoError(t, err) + testutil.Equal(t, "98765", page.ID) + testutil.Equal(t, "Page Without Body", page.Title) + testutil.Nil(t, page.Body) } func TestClient_GetPage_EmptyBodyStorage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "98765", @@ -502,19 +521,20 @@ func TestClient_GetPage_EmptyBodyStorage(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") page, err := client.GetPage(context.Background(), "98765", nil) - require.NoError(t, err) - assert.NotNil(t, page.Body) - assert.Nil(t, page.Body.Storage) + testutil.RequireNoError(t, err) + testutil.NotNil(t, page.Body) + testutil.Nil(t, page.Body.Storage) } func TestClient_ListPages_WithCursor(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ if callCount == 1 { // First call - return results with cursor - assert.Empty(t, r.URL.Query().Get("cursor")) + testutil.Empty(t, r.URL.Query().Get("cursor")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [{"id": "1", "title": "Page 1"}], @@ -522,7 +542,7 @@ func TestClient_ListPages_WithCursor(t *testing.T) { }`)) } else { // Second call - verify cursor is passed - assert.Equal(t, "abc123", r.URL.Query().Get("cursor")) + testutil.Equal(t, "abc123", r.URL.Query().Get("cursor")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [{"id": "2", "title": "Page 2"}], @@ -536,20 +556,21 @@ func TestClient_ListPages_WithCursor(t *testing.T) { // First request result1, err := client.ListPages(context.Background(), "123", nil) - require.NoError(t, err) - assert.True(t, result1.HasMore()) + testutil.RequireNoError(t, err) + testutil.True(t, result1.HasMore()) // Second request with cursor opts := &ListPagesOptions{Cursor: "abc123"} result2, err := client.ListPages(context.Background(), "123", opts) - require.NoError(t, err) - assert.False(t, result2.HasMore()) + testutil.RequireNoError(t, err) + testutil.False(t, result2.HasMore()) - assert.Equal(t, 2, callCount) + testutil.Equal(t, 2, callCount) } func TestClient_ListPages_EmptyResults(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) })) @@ -558,13 +579,14 @@ func TestClient_ListPages_EmptyResults(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListPages(context.Background(), "123", nil) - require.NoError(t, err) - assert.Empty(t, result.Results) - assert.False(t, result.HasMore()) + testutil.RequireNoError(t, err) + testutil.Empty(t, result.Results) + testutil.False(t, result.HasMore()) } func TestClient_ListPages_NullVersion(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [{ @@ -580,7 +602,7 @@ func TestClient_ListPages_NullVersion(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListPages(context.Background(), "123", nil) - require.NoError(t, err) - require.Len(t, result.Results, 1) - assert.Nil(t, result.Results[0].Version) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 1) + testutil.Nil(t, result.Results[0].Version) } diff --git a/tools/cfl/api/search.go b/tools/cfl/api/search.go index b30513a..7dd9b30 100644 --- a/tools/cfl/api/search.go +++ b/tools/cfl/api/search.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -94,12 +94,12 @@ func (c *Client) Search(ctx context.Context, opts *SearchOptions) (*SearchRespon path := "/rest/api/search?" + params.Encode() body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("searching: %w", err) } var result SearchResponse if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse search response: %w", err) + return nil, fmt.Errorf("parsing search response: %w", err) } return &result, nil diff --git a/tools/cfl/api/search_test.go b/tools/cfl/api/search_test.go index 650c924..4b3745f 100644 --- a/tools/cfl/api/search_test.go +++ b/tools/cfl/api/search_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -6,18 +6,18 @@ import ( "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestClient_Search_Success(t *testing.T) { + t.Parallel() testData := loadTestData(t, "search.json") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/search", r.URL.Path) - assert.Equal(t, "GET", r.Method) - assert.Contains(t, r.URL.Query().Get("cql"), `text ~ "test"`) - assert.Equal(t, "highlight", r.URL.Query().Get("excerpt")) + testutil.Equal(t, "/rest/api/search", r.URL.Path) + testutil.Equal(t, "GET", r.Method) + testutil.Contains(t, r.URL.Query().Get("cql"), `text ~ "test"`) + testutil.Equal(t, "highlight", r.URL.Query().Get("excerpt")) w.WriteHeader(http.StatusOK) _, _ = w.Write(testData) @@ -29,21 +29,22 @@ func TestClient_Search_Success(t *testing.T) { Text: "test", }) - require.NoError(t, err) - assert.Len(t, result.Results, 2) - assert.Equal(t, 50, result.TotalSize) - assert.True(t, result.HasMore()) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 2) + testutil.Equal(t, 50, result.TotalSize) + testutil.True(t, result.HasMore()) // Check first result first := result.Results[0] - assert.Equal(t, "12345", first.Content.ID) - assert.Equal(t, "page", first.Content.Type) - assert.Equal(t, "Getting Started Guide", first.Content.Title) - assert.Equal(t, "Development", first.ResultGlobalContainer.Title) + testutil.Equal(t, "12345", first.Content.ID) + testutil.Equal(t, "page", first.Content.Type) + testutil.Equal(t, "Getting Started Guide", first.Content.Title) + testutil.Equal(t, "Development", first.ResultGlobalContainer.Title) } func TestClient_Search_EmptyResults(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [], @@ -61,20 +62,21 @@ func TestClient_Search_EmptyResults(t *testing.T) { Text: "nonexistent", }) - require.NoError(t, err) - assert.Len(t, result.Results, 0) - assert.False(t, result.HasMore()) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 0) + testutil.False(t, result.HasMore()) } func TestClient_Search_WithAllOptions(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") - assert.Contains(t, cql, `text ~ "search term"`) - assert.Contains(t, cql, `space = "DEV"`) - assert.Contains(t, cql, `type = "page"`) - assert.Contains(t, cql, `title ~ "guide"`) - assert.Contains(t, cql, `label = "documentation"`) - assert.Equal(t, "50", r.URL.Query().Get("limit")) + testutil.Contains(t, cql, `text ~ "search term"`) + testutil.Contains(t, cql, `space = "DEV"`) + testutil.Contains(t, cql, `type = "page"`) + testutil.Contains(t, cql, `title ~ "guide"`) + testutil.Contains(t, cql, `label = "documentation"`) + testutil.Equal(t, "50", r.URL.Query().Get("limit")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -90,14 +92,15 @@ func TestClient_Search_WithAllOptions(t *testing.T) { Label: "documentation", Limit: 50, }) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_Search_RawCQL(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Raw CQL should be used as-is cql := r.URL.Query().Get("cql") - assert.Equal(t, `type=page AND lastModified > now("-7d")`, cql) + testutil.Equal(t, `type=page AND lastModified > now("-7d")`, cql) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -109,26 +112,29 @@ func TestClient_Search_RawCQL(t *testing.T) { CQL: `type=page AND lastModified > now("-7d")`, Text: "ignored", // Should be ignored when CQL is set }) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_Search_NoQuery(t *testing.T) { + t.Parallel() client := NewClient("http://unused", "user@example.com", "token") _, err := client.Search(context.Background(), &SearchOptions{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "search requires a query or filters") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "search requires a query or filters") } func TestClient_Search_NilOptions(t *testing.T) { + t.Parallel() client := NewClient("http://unused", "user@example.com", "token") _, err := client.Search(context.Background(), nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "search requires a query or filters") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "search requires a query or filters") } func TestClient_Search_APIError(t *testing.T) { + t.Parallel() tests := []struct { name string statusCode int @@ -157,6 +163,7 @@ func TestClient_Search_APIError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tt.statusCode) _, _ = w.Write([]byte(tt.response)) @@ -166,13 +173,14 @@ func TestClient_Search_APIError(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.Search(context.Background(), &SearchOptions{Text: "test"}) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errContain) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), tt.errContain) }) } } func TestClient_Search_MalformedResponse(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{invalid json`)) @@ -182,11 +190,12 @@ func TestClient_Search_MalformedResponse(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.Search(context.Background(), &SearchOptions{Text: "test"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse search response") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "parsing search response") } func TestClient_Search_Pagination(t *testing.T) { + t.Parallel() tests := []struct { name string start int @@ -202,68 +211,77 @@ func TestClient_Search_Pagination(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() resp := &SearchResponse{ Start: tt.start, Size: tt.size, TotalSize: tt.total, } - assert.Equal(t, tt.expected, resp.HasMore()) + testutil.Equal(t, tt.expected, resp.HasMore()) }) } } func TestBuildCQL_TextOnly(t *testing.T) { + t.Parallel() opts := &SearchOptions{Text: "hello world"} cql := buildCQL(opts) - assert.Equal(t, `text ~ "hello world"`, cql) + testutil.Equal(t, `text ~ "hello world"`, cql) } func TestBuildCQL_SpaceFilter(t *testing.T) { + t.Parallel() opts := &SearchOptions{Space: "DEV"} cql := buildCQL(opts) - assert.Equal(t, `space = "DEV"`, cql) + testutil.Equal(t, `space = "DEV"`, cql) } func TestBuildCQL_TypeFilter(t *testing.T) { + t.Parallel() opts := &SearchOptions{Type: "page"} cql := buildCQL(opts) - assert.Equal(t, `type = "page"`, cql) + testutil.Equal(t, `type = "page"`, cql) } func TestBuildCQL_TitleFilter(t *testing.T) { + t.Parallel() opts := &SearchOptions{Title: "Getting Started"} cql := buildCQL(opts) - assert.Equal(t, `title ~ "Getting Started"`, cql) + testutil.Equal(t, `title ~ "Getting Started"`, cql) } func TestBuildCQL_LabelFilter(t *testing.T) { + t.Parallel() opts := &SearchOptions{Label: "documentation"} cql := buildCQL(opts) - assert.Equal(t, `label = "documentation"`, cql) + testutil.Equal(t, `label = "documentation"`, cql) } func TestBuildCQL_Combined(t *testing.T) { + t.Parallel() opts := &SearchOptions{ Text: "api", Space: "DEV", Type: "page", } cql := buildCQL(opts) - assert.Contains(t, cql, `text ~ "api"`) - assert.Contains(t, cql, `space = "DEV"`) - assert.Contains(t, cql, `type = "page"`) - assert.Contains(t, cql, " AND ") + testutil.Contains(t, cql, `text ~ "api"`) + testutil.Contains(t, cql, `space = "DEV"`) + testutil.Contains(t, cql, `type = "page"`) + testutil.Contains(t, cql, " AND ") } func TestBuildCQL_Empty(t *testing.T) { + t.Parallel() opts := &SearchOptions{} cql := buildCQL(opts) - assert.Empty(t, cql) + testutil.Empty(t, cql) } func TestBuildCQL_QuotesInValue(t *testing.T) { + t.Parallel() opts := &SearchOptions{Text: `search "quoted" term`} cql := buildCQL(opts) // Go's %q escapes quotes properly - assert.Contains(t, cql, `text ~ "search \"quoted\" term"`) + testutil.Contains(t, cql, `text ~ "search \"quoted\" term"`) } diff --git a/tools/cfl/api/space_management.go b/tools/cfl/api/space_management.go index 96e5af8..581f03b 100644 --- a/tools/cfl/api/space_management.go +++ b/tools/cfl/api/space_management.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -75,12 +75,12 @@ func (r *v1SpaceResponse) toSpace() *Space { func (c *Client) CreateSpace(ctx context.Context, req *CreateSpaceRequest) (*Space, error) { body, err := c.Post(ctx, "/api/v2/spaces", req) if err != nil { - return nil, err + return nil, fmt.Errorf("creating space: %w", err) } var space Space if err := json.Unmarshal(body, &space); err != nil { - return nil, fmt.Errorf("failed to parse create space response: %w", err) + return nil, fmt.Errorf("parsing create space response: %w", err) } return &space, nil @@ -92,12 +92,12 @@ func (c *Client) UpdateSpace(ctx context.Context, spaceKey string, req *UpdateSp path := fmt.Sprintf("/rest/api/space/%s", spaceKey) body, err := c.Put(ctx, path, req) if err != nil { - return nil, err + return nil, fmt.Errorf("updating space: %w", err) } var response v1SpaceResponse if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse update space response: %w", err) + return nil, fmt.Errorf("parsing update space response: %w", err) } return response.toSpace(), nil @@ -109,5 +109,8 @@ func (c *Client) UpdateSpace(ctx context.Context, spaceKey string, req *UpdateSp func (c *Client) DeleteSpace(ctx context.Context, spaceKey string) error { path := fmt.Sprintf("/rest/api/space/%s", spaceKey) _, err := c.Delete(ctx, path) - return err + if err != nil { + return fmt.Errorf("deleting space %s: %w", spaceKey, err) + } + return nil } diff --git a/tools/cfl/api/space_management_test.go b/tools/cfl/api/space_management_test.go index b5951ba..bc7e71e 100644 --- a/tools/cfl/api/space_management_test.go +++ b/tools/cfl/api/space_management_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -7,21 +7,21 @@ import ( "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestClient_CreateSpace(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v2/spaces", r.URL.Path) + testutil.Equal(t, "POST", r.Method) + testutil.Equal(t, "/api/v2/spaces", r.URL.Path) var req CreateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.Equal(t, "TEST", req.Key) - assert.Equal(t, "Test Space", req.Name) - assert.Equal(t, "global", req.Type) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", req.Key) + testutil.Equal(t, "Test Space", req.Name) + testutil.Equal(t, "global", req.Type) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -42,21 +42,22 @@ func TestClient_CreateSpace(t *testing.T) { Type: "global", }) - require.NoError(t, err) - assert.Equal(t, "123456", space.ID) - assert.Equal(t, "TEST", space.Key) - assert.Equal(t, "Test Space", space.Name) - assert.Equal(t, "global", space.Type) - assert.Equal(t, "/spaces/TEST", space.Links.WebUI) + testutil.RequireNoError(t, err) + testutil.Equal(t, "123456", space.ID) + testutil.Equal(t, "TEST", space.Key) + testutil.Equal(t, "Test Space", space.Name) + testutil.Equal(t, "global", space.Type) + testutil.Equal(t, "/spaces/TEST", space.Links.WebUI) } func TestClient_CreateSpace_WithDescription(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req CreateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.NotNil(t, req.Description) - assert.Equal(t, "A test space", req.Description.Plain.Value) + testutil.RequireNoError(t, err) + testutil.NotNil(t, req.Description) + testutil.Equal(t, "A test space", req.Description.Plain.Value) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -79,13 +80,14 @@ func TestClient_CreateSpace_WithDescription(t *testing.T) { }, }) - require.NoError(t, err) - assert.Equal(t, "TEST", space.Key) - assert.NotNil(t, space.Description) - assert.Equal(t, "A test space", space.Description.Plain.Value) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", space.Key) + testutil.NotNil(t, space.Description) + testutil.Equal(t, "A test space", space.Description.Plain.Value) } func TestClient_CreateSpace_Error(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"message": "Space key already exists"}`)) @@ -98,19 +100,20 @@ func TestClient_CreateSpace_Error(t *testing.T) { Name: "Duplicate", }) - require.Error(t, err) + testutil.RequireError(t, err) } func TestClient_UpdateSpace(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - assert.Equal(t, "/rest/api/space/TEST", r.URL.Path) + testutil.Equal(t, "PUT", r.Method) + testutil.Equal(t, "/rest/api/space/TEST", r.URL.Path) var req UpdateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.Equal(t, "TEST", req.Key) - assert.Equal(t, "Updated Name", req.Name) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", req.Key) + testutil.Equal(t, "Updated Name", req.Name) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -130,23 +133,24 @@ func TestClient_UpdateSpace(t *testing.T) { Name: "Updated Name", }) - require.NoError(t, err) - assert.Equal(t, "123456", space.ID) - assert.Equal(t, "TEST", space.Key) - assert.Equal(t, "Updated Name", space.Name) - assert.Equal(t, "global", space.Type) - assert.NotNil(t, space.Description) - assert.Equal(t, "Description", space.Description.Plain.Value) + testutil.RequireNoError(t, err) + testutil.Equal(t, "123456", space.ID) + testutil.Equal(t, "TEST", space.Key) + testutil.Equal(t, "Updated Name", space.Name) + testutil.Equal(t, "global", space.Type) + testutil.NotNil(t, space.Description) + testutil.Equal(t, "Description", space.Description.Plain.Value) } func TestClient_UpdateSpace_WithDescription(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req UpdateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.NotNil(t, req.Description) - assert.Equal(t, "New description", req.Description.Plain.Value) - assert.Equal(t, "plain", req.Description.Plain.Representation) + testutil.RequireNoError(t, err) + testutil.NotNil(t, req.Description) + testutil.Equal(t, "New description", req.Description.Plain.Value) + testutil.Equal(t, "plain", req.Description.Plain.Representation) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -172,11 +176,12 @@ func TestClient_UpdateSpace_WithDescription(t *testing.T) { }, }) - require.NoError(t, err) - assert.Equal(t, "New description", space.Description.Plain.Value) + testutil.RequireNoError(t, err) + testutil.Equal(t, "New description", space.Description.Plain.Value) } func TestClient_UpdateSpace_NotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Space not found"}`)) @@ -189,10 +194,11 @@ func TestClient_UpdateSpace_NotFound(t *testing.T) { Name: "Updated", }) - require.Error(t, err) + testutil.RequireError(t, err) } func TestClient_UpdateSpace_NoDescription(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -212,14 +218,15 @@ func TestClient_UpdateSpace_NoDescription(t *testing.T) { Name: "Test Space", }) - require.NoError(t, err) - assert.Nil(t, space.Description) + testutil.RequireNoError(t, err) + testutil.Nil(t, space.Description) } func TestClient_DeleteSpace(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - assert.Equal(t, "/rest/api/space/TEST", r.URL.Path) + testutil.Equal(t, "DELETE", r.Method) + testutil.Equal(t, "/rest/api/space/TEST", r.URL.Path) w.WriteHeader(http.StatusAccepted) })) @@ -228,10 +235,11 @@ func TestClient_DeleteSpace(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.DeleteSpace(context.Background(), "TEST") - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_DeleteSpace_NotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Space not found"}`)) @@ -241,10 +249,11 @@ func TestClient_DeleteSpace_NotFound(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") err := client.DeleteSpace(context.Background(), "NOPE") - require.Error(t, err) + testutil.RequireError(t, err) } func TestV1SpaceResponse_ToSpace(t *testing.T) { + t.Parallel() response := &v1SpaceResponse{ ID: 123456, Key: "TEST", @@ -257,16 +266,17 @@ func TestV1SpaceResponse_ToSpace(t *testing.T) { space := response.toSpace() - assert.Equal(t, "123456", space.ID) - assert.Equal(t, "TEST", space.Key) - assert.Equal(t, "Test Space", space.Name) - assert.Equal(t, "global", space.Type) - assert.Equal(t, "/spaces/TEST", space.Links.WebUI) - assert.NotNil(t, space.Description) - assert.Equal(t, "A test space", space.Description.Plain.Value) + testutil.Equal(t, "123456", space.ID) + testutil.Equal(t, "TEST", space.Key) + testutil.Equal(t, "Test Space", space.Name) + testutil.Equal(t, "global", space.Type) + testutil.Equal(t, "/spaces/TEST", space.Links.WebUI) + testutil.NotNil(t, space.Description) + testutil.Equal(t, "A test space", space.Description.Plain.Value) } func TestV1SpaceResponse_ToSpace_EmptyDescription(t *testing.T) { + t.Parallel() response := &v1SpaceResponse{ ID: 123456, Key: "TEST", @@ -276,5 +286,5 @@ func TestV1SpaceResponse_ToSpace_EmptyDescription(t *testing.T) { space := response.toSpace() - assert.Nil(t, space.Description) + testutil.Nil(t, space.Description) } diff --git a/tools/cfl/api/spaces.go b/tools/cfl/api/spaces.go index 5b60e7c..1aed0da 100644 --- a/tools/cfl/api/spaces.go +++ b/tools/cfl/api/spaces.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -43,12 +43,12 @@ func (c *Client) ListSpaces(ctx context.Context, opts *ListSpacesOptions) (*Pagi path := "/api/v2/spaces?" + params.Encode() body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("listing spaces: %w", err) } var result PaginatedResponse[Space] if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse spaces response: %w", err) + return nil, fmt.Errorf("parsing spaces response: %w", err) } return &result, nil @@ -59,12 +59,12 @@ func (c *Client) GetSpace(ctx context.Context, spaceID string) (*Space, error) { path := fmt.Sprintf("/api/v2/spaces/%s", spaceID) body, err := c.Get(ctx, path) if err != nil { - return nil, err + return nil, fmt.Errorf("getting space: %w", err) } var space Space if err := json.Unmarshal(body, &space); err != nil { - return nil, fmt.Errorf("failed to parse space response: %w", err) + return nil, fmt.Errorf("parsing space response: %w", err) } return &space, nil @@ -78,7 +78,7 @@ func (c *Client) GetSpaceByKey(ctx context.Context, key string) (*Space, error) } result, err := c.ListSpaces(ctx, opts) if err != nil { - return nil, err + return nil, fmt.Errorf("getting space by key %s: %w", key, err) } if len(result.Results) == 0 { diff --git a/tools/cfl/api/spaces_test.go b/tools/cfl/api/spaces_test.go index 9decb7c..951d629 100644 --- a/tools/cfl/api/spaces_test.go +++ b/tools/cfl/api/spaces_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name is intentional import ( "context" @@ -6,28 +6,29 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func loadTestData(t *testing.T, filename string) []byte { t.Helper() - data, err := os.ReadFile(filepath.Join("testdata", filename)) - require.NoError(t, err) + data, err := os.ReadFile(filepath.Join("testdata", filename)) //nolint:gosec // reading test fixture data + testutil.RequireNoError(t, err) return data } func TestClient_ListSpaces(t *testing.T) { + t.Parallel() testData := loadTestData(t, "spaces.json") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/spaces", r.URL.Path) - assert.Equal(t, "GET", r.Method) + testutil.Equal(t, "/api/v2/spaces", r.URL.Path) + testutil.Equal(t, "GET", r.Method) // Check query params - assert.Equal(t, "25", r.URL.Query().Get("limit")) + testutil.Equal(t, "25", r.URL.Query().Get("limit")) w.WriteHeader(http.StatusOK) _, _ = w.Write(testData) @@ -37,23 +38,24 @@ func TestClient_ListSpaces(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListSpaces(context.Background(), nil) - require.NoError(t, err) - assert.Len(t, result.Results, 2) - assert.True(t, result.HasMore()) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 2) + testutil.True(t, result.HasMore()) // Check first space space := result.Results[0] - assert.Equal(t, "123456", space.ID) - assert.Equal(t, "DEV", space.Key) - assert.Equal(t, "Development", space.Name) - assert.Equal(t, "global", space.Type) + testutil.Equal(t, "123456", space.ID) + testutil.Equal(t, "DEV", space.Key) + testutil.Equal(t, "Development", space.Name) + testutil.Equal(t, "global", space.Type) } func TestClient_ListSpaces_WithOptions(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "50", r.URL.Query().Get("limit")) - assert.Equal(t, "global", r.URL.Query().Get("type")) - assert.Equal(t, "current", r.URL.Query().Get("status")) + testutil.Equal(t, "50", r.URL.Query().Get("limit")) + testutil.Equal(t, "global", r.URL.Query().Get("type")) + testutil.Equal(t, "current", r.URL.Query().Get("status")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -67,13 +69,14 @@ func TestClient_ListSpaces_WithOptions(t *testing.T) { Status: "current", } _, err := client.ListSpaces(context.Background(), opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestClient_GetSpace(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/spaces/123456", r.URL.Path) - assert.Equal(t, "GET", r.Method) + testutil.Equal(t, "/api/v2/spaces/123456", r.URL.Path) + testutil.Equal(t, "GET", r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -88,13 +91,14 @@ func TestClient_GetSpace(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") space, err := client.GetSpace(context.Background(), "123456") - require.NoError(t, err) - assert.Equal(t, "123456", space.ID) - assert.Equal(t, "DEV", space.Key) - assert.Equal(t, "Development", space.Name) + testutil.RequireNoError(t, err) + testutil.Equal(t, "123456", space.ID) + testutil.Equal(t, "DEV", space.Key) + testutil.Equal(t, "Development", space.Name) } func TestClient_GetSpace_NotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Space not found"}`)) @@ -104,15 +108,16 @@ func TestClient_GetSpace_NotFound(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.GetSpace(context.Background(), "invalid") - require.Error(t, err) - assert.Contains(t, err.Error(), "Space not found") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "Space not found") } func TestClient_GetSpaceByKey_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/spaces", r.URL.Path) - assert.Equal(t, "DEV", r.URL.Query().Get("keys")) - assert.Equal(t, "1", r.URL.Query().Get("limit")) + testutil.Equal(t, "/api/v2/spaces", r.URL.Path) + testutil.Equal(t, "DEV", r.URL.Query().Get("keys")) + testutil.Equal(t, "1", r.URL.Query().Get("limit")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -129,15 +134,16 @@ func TestClient_GetSpaceByKey_Success(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") space, err := client.GetSpaceByKey(context.Background(), "DEV") - require.NoError(t, err) - assert.Equal(t, "123456", space.ID) - assert.Equal(t, "DEV", space.Key) - assert.Equal(t, "Development", space.Name) + testutil.RequireNoError(t, err) + testutil.Equal(t, "123456", space.ID) + testutil.Equal(t, "DEV", space.Key) + testutil.Equal(t, "Development", space.Name) } func TestClient_GetSpaceByKey_NotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "NONEXISTENT", r.URL.Query().Get("keys")) + testutil.Equal(t, "NONEXISTENT", r.URL.Query().Get("keys")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -147,17 +153,19 @@ func TestClient_GetSpaceByKey_NotFound(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.GetSpaceByKey(context.Background(), "NONEXISTENT") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "not found") } func TestClient_ListSpaces_WithMultipleKeys(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check that multiple keys are passed correctly keys := r.URL.Query()["keys"] - assert.Contains(t, keys, "DEV") - assert.Contains(t, keys, "PROD") - assert.Contains(t, keys, "TEST") + keysStr := strings.Join(keys, ",") + testutil.Contains(t, keysStr, "DEV") + testutil.Contains(t, keysStr, "PROD") + testutil.Contains(t, keysStr, "TEST") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -176,12 +184,13 @@ func TestClient_ListSpaces_WithMultipleKeys(t *testing.T) { } result, err := client.ListSpaces(context.Background(), opts) - require.NoError(t, err) - assert.Len(t, result.Results, 3) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 3) } func TestClient_ListSpaces_EmptyResults(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) })) @@ -190,13 +199,14 @@ func TestClient_ListSpaces_EmptyResults(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListSpaces(context.Background(), nil) - require.NoError(t, err) - assert.Empty(t, result.Results) - assert.False(t, result.HasMore()) + testutil.RequireNoError(t, err) + testutil.Empty(t, result.Results) + testutil.False(t, result.HasMore()) } func TestClient_ListSpaces_NullDescription(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [{ @@ -212,13 +222,14 @@ func TestClient_ListSpaces_NullDescription(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") result, err := client.ListSpaces(context.Background(), nil) - require.NoError(t, err) - require.Len(t, result.Results, 1) - assert.Equal(t, "TEST", result.Results[0].Key) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Results, 1) + testutil.Equal(t, "TEST", result.Results[0].Key) } func TestClient_ListSpaces_APIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"message": "Authentication required"}`)) })) @@ -227,5 +238,5 @@ func TestClient_ListSpaces_APIError(t *testing.T) { client := NewClient(server.URL, "user@example.com", "bad-token") _, err := client.ListSpaces(context.Background(), nil) - require.Error(t, err) + testutil.RequireError(t, err) } diff --git a/tools/cfl/api/types.go b/tools/cfl/api/types.go index 15ff512..00ce16c 100644 --- a/tools/cfl/api/types.go +++ b/tools/cfl/api/types.go @@ -1,7 +1,9 @@ -// Package api provides the Confluence Cloud REST API client. -package api +package api //nolint:revive // package name is intentional -import "time" +import ( + "fmt" + "time" +) // PaginatedResponse wraps paginated API responses. type PaginatedResponse[T any] struct { @@ -126,7 +128,7 @@ func (t *Time) UnmarshalJSON(data []byte) error { // Try alternative format parsed, err = time.Parse("2006-01-02T15:04:05.000Z", s) if err != nil { - return err + return fmt.Errorf("parsing time %q: %w", s, err) } } diff --git a/tools/cfl/cmd/cfl/main.go b/tools/cfl/cmd/cfl/main.go index cbf9418..6681062 100644 --- a/tools/cfl/cmd/cfl/main.go +++ b/tools/cfl/cmd/cfl/main.go @@ -2,8 +2,11 @@ package main import ( + "context" "fmt" "os" + "os/signal" + "syscall" "github.com/open-cli-collective/atlassian-go/exitcode" @@ -18,6 +21,9 @@ import ( ) func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + cmd, opts := root.NewCmd() root.RegisterCommands(cmd, opts, @@ -30,7 +36,7 @@ func main() { completion.Register, ) - if err := cmd.Execute(); err != nil { + if err := cmd.ExecuteContext(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(exitcode.GeneralError) } diff --git a/tools/cfl/go.mod b/tools/cfl/go.mod index 3e7bd90..40c99fb 100644 --- a/tools/cfl/go.mod +++ b/tools/cfl/go.mod @@ -7,8 +7,8 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/open-cli-collective/atlassian-go v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.11.1 github.com/yuin/goldmark v1.7.16 + golang.org/x/text v0.31.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,7 +29,6 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect @@ -50,5 +49,4 @@ require ( golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect ) diff --git a/tools/cfl/go.sum b/tools/cfl/go.sum index 4c61c5a..7396594 100644 --- a/tools/cfl/go.sum +++ b/tools/cfl/go.sum @@ -47,8 +47,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -89,8 +87,6 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= diff --git a/tools/cfl/internal/cmd/attachment/delete.go b/tools/cfl/internal/cmd/attachment/delete.go index d01624d..cd4c168 100644 --- a/tools/cfl/internal/cmd/attachment/delete.go +++ b/tools/cfl/internal/cmd/attachment/delete.go @@ -28,8 +28,8 @@ func newDeleteCmd(rootOpts *root.Options) *cobra.Command { # Delete without confirmation cfl attachment delete att123 --force`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runDeleteAttachment(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runDeleteAttachment(cmd.Context(), args[0], opts) }, } @@ -38,22 +38,22 @@ func newDeleteCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runDeleteAttachment(attachmentID string, opts *deleteOptions) error { +func runDeleteAttachment(ctx context.Context, attachmentID string, opts *deleteOptions) error { client, err := opts.APIClient() if err != nil { return err } - attachment, err := client.GetAttachment(context.Background(), attachmentID) + attachment, err := client.GetAttachment(ctx, attachmentID) if err != nil { - return fmt.Errorf("failed to get attachment: %w", err) + return fmt.Errorf("getting attachment: %w", err) } v := opts.View() if !opts.force { - fmt.Printf("About to delete attachment: %s (ID: %s)\n", attachment.Title, attachment.ID) - fmt.Print("Are you sure? [y/N]: ") + _, _ = fmt.Fprintf(opts.Stderr, "About to delete attachment: %s (ID: %s)\n", attachment.Title, attachment.ID) + _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") scanner := bufio.NewScanner(opts.Stdin) var confirm string @@ -62,13 +62,13 @@ func runDeleteAttachment(attachmentID string, opts *deleteOptions) error { } if confirm != "y" && confirm != "Y" { - fmt.Println("Deletion cancelled.") + _, _ = fmt.Fprintln(opts.Stderr, "Deletion cancelled.") return nil } } - if err := client.DeleteAttachment(context.Background(), attachmentID); err != nil { - return fmt.Errorf("failed to delete attachment: %w", err) + if err := client.DeleteAttachment(ctx, attachmentID); err != nil { + return fmt.Errorf("deleting attachment: %w", err) } if opts.Output == "json" { diff --git a/tools/cfl/internal/cmd/attachment/delete_test.go b/tools/cfl/internal/cmd/attachment/delete_test.go index b1effc9..3c27159 100644 --- a/tools/cfl/internal/cmd/attachment/delete_test.go +++ b/tools/cfl/internal/cmd/attachment/delete_test.go @@ -2,20 +2,20 @@ package attachment import ( "bytes" + "context" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" ) // mockAttachmentServer creates a test server that handles attachment get and delete -func mockAttachmentServer(t *testing.T, getHandler, deleteHandler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { +func mockAttachmentServer(_ *testing.T, getHandler, deleteHandler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v2/attachments/") { if getHandler != nil { @@ -51,9 +51,10 @@ func newTestRootOptions() *root.Options { } func TestRunDeleteAttachment_ForceDelete(t *testing.T) { + t.Parallel() server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - assert.Equal(t, "/api/v2/attachments/att123", r.URL.Path) + testutil.Equal(t, "DELETE", r.Method) + testutil.Equal(t, "/api/v2/attachments/att123", r.URL.Path) w.WriteHeader(http.StatusNoContent) }) defer server.Close() @@ -67,13 +68,14 @@ func TestRunDeleteAttachment_ForceDelete(t *testing.T) { force: true, } - err := runDeleteAttachment("att123", opts) - require.NoError(t, err) + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) } func TestRunDeleteAttachment_ConfirmWithY(t *testing.T) { + t.Parallel() deleted := false - server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { deleted = true w.WriteHeader(http.StatusNoContent) }) @@ -89,14 +91,15 @@ func TestRunDeleteAttachment_ConfirmWithY(t *testing.T) { force: false, } - err := runDeleteAttachment("att123", opts) - require.NoError(t, err) - assert.True(t, deleted, "attachment should have been deleted") + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) + testutil.True(t, deleted, "attachment should have been deleted") } func TestRunDeleteAttachment_ConfirmWithUpperY(t *testing.T) { + t.Parallel() deleted := false - server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { deleted = true w.WriteHeader(http.StatusNoContent) }) @@ -112,14 +115,15 @@ func TestRunDeleteAttachment_ConfirmWithUpperY(t *testing.T) { force: false, } - err := runDeleteAttachment("att123", opts) - require.NoError(t, err) - assert.True(t, deleted, "attachment should have been deleted") + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) + testutil.True(t, deleted, "attachment should have been deleted") } func TestRunDeleteAttachment_CancelWithN(t *testing.T) { + t.Parallel() deleted := false - server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { deleted = true w.WriteHeader(http.StatusNoContent) }) @@ -135,14 +139,15 @@ func TestRunDeleteAttachment_CancelWithN(t *testing.T) { force: false, } - err := runDeleteAttachment("att123", opts) - require.NoError(t, err) - assert.False(t, deleted, "attachment should NOT have been deleted") + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) + testutil.False(t, deleted, "attachment should NOT have been deleted") } func TestRunDeleteAttachment_CancelWithEmpty(t *testing.T) { + t.Parallel() deleted := false - server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { deleted = true w.WriteHeader(http.StatusNoContent) }) @@ -158,14 +163,15 @@ func TestRunDeleteAttachment_CancelWithEmpty(t *testing.T) { force: false, } - err := runDeleteAttachment("att123", opts) - require.NoError(t, err) - assert.False(t, deleted, "attachment should NOT have been deleted") + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) + testutil.False(t, deleted, "attachment should NOT have been deleted") } func TestRunDeleteAttachment_CancelWithOther(t *testing.T) { + t.Parallel() deleted := false - server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + server := mockAttachmentServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { deleted = true w.WriteHeader(http.StatusNoContent) }) @@ -181,14 +187,15 @@ func TestRunDeleteAttachment_CancelWithOther(t *testing.T) { force: false, } - err := runDeleteAttachment("att123", opts) - require.NoError(t, err) - assert.False(t, deleted, "attachment should NOT have been deleted") + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) + testutil.False(t, deleted, "attachment should NOT have been deleted") } func TestRunDeleteAttachment_GetAttachmentFails(t *testing.T) { + t.Parallel() server := mockAttachmentServer(t, - func(w http.ResponseWriter, r *http.Request) { + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Attachment not found"}`)) }, @@ -205,14 +212,15 @@ func TestRunDeleteAttachment_GetAttachmentFails(t *testing.T) { force: true, } - err := runDeleteAttachment("invalid", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get attachment") + err := runDeleteAttachment(context.Background(), "invalid", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting attachment") } func TestRunDeleteAttachment_DeleteFails(t *testing.T) { + t.Parallel() server := mockAttachmentServer(t, nil, - func(w http.ResponseWriter, r *http.Request) { + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"message": "Permission denied"}`)) }, @@ -228,7 +236,7 @@ func TestRunDeleteAttachment_DeleteFails(t *testing.T) { force: true, } - err := runDeleteAttachment("att123", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to delete attachment") + err := runDeleteAttachment(context.Background(), "att123", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "deleting attachment") } diff --git a/tools/cfl/internal/cmd/attachment/download.go b/tools/cfl/internal/cmd/attachment/download.go index ded5860..283c104 100644 --- a/tools/cfl/internal/cmd/attachment/download.go +++ b/tools/cfl/internal/cmd/attachment/download.go @@ -31,8 +31,8 @@ func newDownloadCmd(rootOpts *root.Options) *cobra.Command { # Download to a specific file cfl attachment download abc123 -O document.pdf`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runDownload(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runDownload(cmd.Context(), args[0], opts) }, } @@ -42,15 +42,15 @@ func newDownloadCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runDownload(attachmentID string, opts *downloadOptions) error { +func runDownload(ctx context.Context, attachmentID string, opts *downloadOptions) error { client, err := opts.APIClient() if err != nil { return err } - attachment, err := client.GetAttachment(context.Background(), attachmentID) + attachment, err := client.GetAttachment(ctx, attachmentID) if err != nil { - return fmt.Errorf("failed to get attachment info: %w", err) + return fmt.Errorf("getting attachment info: %w", err) } outputPath := opts.outputFile @@ -67,21 +67,21 @@ func runDownload(attachmentID string, opts *downloadOptions) error { } } - reader, err := client.DownloadAttachment(context.Background(), attachmentID) + reader, err := client.DownloadAttachment(ctx, attachmentID) if err != nil { - return fmt.Errorf("failed to download attachment: %w", err) + return fmt.Errorf("downloading attachment: %w", err) } defer func() { _ = reader.Close() }() - outFile, err := os.Create(outputPath) + outFile, err := os.Create(outputPath) //nolint:gosec // CLI tool creates user-specified output file if err != nil { - return fmt.Errorf("failed to create output file: %w", err) + return fmt.Errorf("creating output file: %w", err) } defer func() { _ = outFile.Close() }() bytesWritten, err := io.Copy(outFile, reader) if err != nil { - return fmt.Errorf("failed to write file: %w", err) + return fmt.Errorf("writing file: %w", err) } v := opts.View() diff --git a/tools/cfl/internal/cmd/attachment/download_test.go b/tools/cfl/internal/cmd/attachment/download_test.go index aff0d52..130b5fc 100644 --- a/tools/cfl/internal/cmd/attachment/download_test.go +++ b/tools/cfl/internal/cmd/attachment/download_test.go @@ -2,14 +2,14 @@ package attachment import ( "bytes" + "context" "net/http" "net/http/httptest" "os" "path/filepath" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -18,6 +18,7 @@ import ( // TestSanitizeAttachmentFilename validates that filepath.Base correctly // sanitizes malicious filenames that could be used for path traversal attacks. func TestSanitizeAttachmentFilename(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -82,6 +83,7 @@ func TestSanitizeAttachmentFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := filepath.Base(tt.input) if result != tt.expected { t.Errorf("filepath.Base(%q) = %q, want %q", tt.input, result, tt.expected) @@ -97,6 +99,7 @@ func TestSanitizeAttachmentFilename(t *testing.T) { } func mockDownloadServer(t *testing.T) *httptest.Server { + t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/attachments/att123": @@ -148,16 +151,17 @@ func TestRunDownload_Success(t *testing.T) { Options: rootOpts, } - err := runDownload("att123", opts) - require.NoError(t, err) + err := runDownload(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) // Verify file was created - content, err := os.ReadFile(filepath.Join(tmpDir, "document.pdf")) - require.NoError(t, err) - assert.Equal(t, "fake pdf content", string(content)) + content, err := os.ReadFile(filepath.Join(tmpDir, "document.pdf")) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, "fake pdf content", string(content)) } func TestRunDownload_CustomOutputFile(t *testing.T) { + t.Parallel() server := mockDownloadServer(t) defer server.Close() @@ -173,13 +177,13 @@ func TestRunDownload_CustomOutputFile(t *testing.T) { outputFile: outputPath, } - err := runDownload("att123", opts) - require.NoError(t, err) + err := runDownload(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) // Verify file was created with custom name - content, err := os.ReadFile(outputPath) - require.NoError(t, err) - assert.Equal(t, "fake pdf content", string(content)) + content, err := os.ReadFile(outputPath) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, "fake pdf content", string(content)) } func TestRunDownload_FileExists_NoForce(t *testing.T) { @@ -193,8 +197,8 @@ func TestRunDownload_FileExists_NoForce(t *testing.T) { // Create existing file existingFile := filepath.Join(tmpDir, "document.pdf") - err := os.WriteFile(existingFile, []byte("existing content"), 0644) - require.NoError(t, err) + err := os.WriteFile(existingFile, []byte("existing content"), 0600) + testutil.RequireNoError(t, err) rootOpts := newDownloadTestRootOptions() client := api.NewClient(server.URL, "test@example.com", "token") @@ -205,14 +209,14 @@ func TestRunDownload_FileExists_NoForce(t *testing.T) { force: false, } - err = runDownload("att123", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "file already exists") - assert.Contains(t, err.Error(), "--force") + err = runDownload(context.Background(), "att123", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "file already exists") + testutil.Contains(t, err.Error(), "--force") // Verify original file was not overwritten - content, _ := os.ReadFile(existingFile) - assert.Equal(t, "existing content", string(content)) + content, _ := os.ReadFile(existingFile) //nolint:gosec // reading test fixture file + testutil.Equal(t, "existing content", string(content)) } func TestRunDownload_FileExists_WithForce(t *testing.T) { @@ -226,8 +230,8 @@ func TestRunDownload_FileExists_WithForce(t *testing.T) { // Create existing file existingFile := filepath.Join(tmpDir, "document.pdf") - err := os.WriteFile(existingFile, []byte("existing content"), 0644) - require.NoError(t, err) + err := os.WriteFile(existingFile, []byte("existing content"), 0600) + testutil.RequireNoError(t, err) rootOpts := newDownloadTestRootOptions() client := api.NewClient(server.URL, "test@example.com", "token") @@ -238,16 +242,17 @@ func TestRunDownload_FileExists_WithForce(t *testing.T) { force: true, } - err = runDownload("att123", opts) - require.NoError(t, err) + err = runDownload(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) // Verify file was overwritten - content, _ := os.ReadFile(existingFile) - assert.Equal(t, "fake pdf content", string(content)) + content, _ := os.ReadFile(existingFile) //nolint:gosec // reading test fixture file + testutil.Equal(t, "fake pdf content", string(content)) } func TestRunDownload_AttachmentNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Attachment not found"}`)) })) @@ -261,9 +266,9 @@ func TestRunDownload_AttachmentNotFound(t *testing.T) { Options: rootOpts, } - err := runDownload("nonexistent", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get attachment info") + err := runDownload(context.Background(), "nonexistent", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting attachment info") } func TestRunDownload_DownloadFailed(t *testing.T) { @@ -295,12 +300,13 @@ func TestRunDownload_DownloadFailed(t *testing.T) { Options: rootOpts, } - err := runDownload("att123", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to download attachment") + err := runDownload(context.Background(), "att123", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "downloading attachment") } func TestRunDownload_InvalidFilename(t *testing.T) { + t.Parallel() tests := []struct { name string filename string @@ -312,7 +318,8 @@ func TestRunDownload_InvalidFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "att123", @@ -330,9 +337,9 @@ func TestRunDownload_InvalidFilename(t *testing.T) { Options: rootOpts, } - err := runDownload("att123", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid attachment filename") + err := runDownload(context.Background(), "att123", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid attachment filename") }) } } @@ -369,14 +376,14 @@ func TestRunDownload_PathTraversalPrevented(t *testing.T) { Options: rootOpts, } - err := runDownload("att123", opts) - require.NoError(t, err) + err := runDownload(context.Background(), "att123", opts) + testutil.RequireNoError(t, err) // File should be saved as just "passwd" (the base name), not a path traversal _, err = os.Stat(filepath.Join(tmpDir, "passwd")) - assert.NoError(t, err, "file should be saved as 'passwd' in current directory") + testutil.NoError(t, err) // Should NOT have created file outside tmpDir _, err = os.Stat("/etc/passwd-test") - assert.True(t, os.IsNotExist(err) || err != nil, "should not write outside current directory") + testutil.True(t, os.IsNotExist(err) || err != nil, "should not write outside current directory") } diff --git a/tools/cfl/internal/cmd/attachment/list.go b/tools/cfl/internal/cmd/attachment/list.go index 9f8b94d..fcb7f02 100644 --- a/tools/cfl/internal/cmd/attachment/list.go +++ b/tools/cfl/internal/cmd/attachment/list.go @@ -37,8 +37,8 @@ func newListCmd(rootOpts *root.Options) *cobra.Command { # List unused (orphaned) attachments not referenced in page content cfl attachment list --page 12345 --unused`, - RunE: func(_ *cobra.Command, _ []string) error { - return runList(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + return runList(cmd.Context(), opts) }, } @@ -51,7 +51,7 @@ func newListCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runList(opts *listOptions) error { +func runList(ctx context.Context, opts *listOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -65,19 +65,19 @@ func runList(opts *listOptions) error { Limit: opts.limit, } - result, err := client.ListAttachments(context.Background(), opts.pageID, apiOpts) + result, err := client.ListAttachments(ctx, opts.pageID, apiOpts) if err != nil { - return fmt.Errorf("failed to list attachments: %w", err) + return fmt.Errorf("listing attachments: %w", err) } attachments := result.Results if opts.unused { - page, err := client.GetPage(context.Background(), opts.pageID, &api.GetPageOptions{ + page, err := client.GetPage(ctx, opts.pageID, &api.GetPageOptions{ BodyFormat: "storage", }) if err != nil { - return fmt.Errorf("failed to get page content: %w", err) + return fmt.Errorf("getting page content: %w", err) } pageContent := "" @@ -91,7 +91,7 @@ func runList(opts *listOptions) error { v := opts.View() headers := []string{"ID", "Title", "Media Type", "File Size"} - var rows [][]string + rows := make([][]string, 0, len(attachments)) for _, att := range attachments { size := formatFileSize(att.FileSize) rows = append(rows, []string{att.ID, att.Title, att.MediaType, size}) @@ -99,9 +99,9 @@ func runList(opts *listOptions) error { if len(attachments) == 0 && opts.Output != "json" { if opts.unused { - fmt.Println("No unused attachments found.") + _, _ = fmt.Fprintln(opts.Stderr, "No unused attachments found.") } else { - fmt.Println("No attachments found.") + _, _ = fmt.Fprintln(opts.Stderr, "No attachments found.") } return nil } diff --git a/tools/cfl/internal/cmd/attachment/list_test.go b/tools/cfl/internal/cmd/attachment/list_test.go index 13563ed..019428b 100644 --- a/tools/cfl/internal/cmd/attachment/list_test.go +++ b/tools/cfl/internal/cmd/attachment/list_test.go @@ -2,12 +2,12 @@ package attachment import ( "bytes" + "context" "net/http" "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -23,7 +23,8 @@ func newListTestRootOptions() *root.Options { } func TestRunList_Success(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [ @@ -44,12 +45,13 @@ func TestRunList_Success(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_Empty(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) })) @@ -65,12 +67,13 @@ func TestRunList_Empty(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_APIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) @@ -86,13 +89,14 @@ func TestRunList_APIError(t *testing.T) { limit: 25, } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to list attachments") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "listing attachments") } func TestRunList_JSONOutput(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [ @@ -113,11 +117,12 @@ func TestRunList_JSONOutput(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_InvalidOutputFormat(t *testing.T) { + t.Parallel() // Don't need a server - should fail before API call rootOpts := newListTestRootOptions() rootOpts.Output = "invalid" @@ -127,12 +132,13 @@ func TestRunList_InvalidOutputFormat(t *testing.T) { pageID: "12345", } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid output format") } func TestFormatFileSize(t *testing.T) { + t.Parallel() tests := []struct { bytes int64 expected string @@ -149,13 +155,15 @@ func TestFormatFileSize(t *testing.T) { for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { + t.Parallel() result := formatFileSize(tt.bytes) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, tt.expected, result) }) } } func TestIsAttachmentReferenced(t *testing.T) { + t.Parallel() tests := []struct { name string filename string @@ -202,13 +210,15 @@ func TestIsAttachmentReferenced(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := isAttachmentReferenced(tt.filename, tt.content) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, tt.expected, result) }) } } func TestFilterUnusedAttachments(t *testing.T) { + t.Parallel() attachments := []api.Attachment{ {ID: "att1", Title: "used-image.png"}, {ID: "att2", Title: "unused-doc.pdf"}, @@ -222,12 +232,13 @@ func TestFilterUnusedAttachments(t *testing.T) { unused := filterUnusedAttachments(attachments, content) - require.Len(t, unused, 1) - assert.Equal(t, "att2", unused[0].ID) - assert.Equal(t, "unused-doc.pdf", unused[0].Title) + testutil.Len(t, unused, 1) + testutil.Equal(t, "att2", unused[0].ID) + testutil.Equal(t, "unused-doc.pdf", unused[0].Title) } func TestFilterUnusedAttachments_AllUnused(t *testing.T) { + t.Parallel() attachments := []api.Attachment{ {ID: "att1", Title: "orphan1.png"}, {ID: "att2", Title: "orphan2.pdf"}, @@ -237,10 +248,11 @@ func TestFilterUnusedAttachments_AllUnused(t *testing.T) { unused := filterUnusedAttachments(attachments, content) - require.Len(t, unused, 2) + testutil.Len(t, unused, 2) } func TestFilterUnusedAttachments_NoneUnused(t *testing.T) { + t.Parallel() attachments := []api.Attachment{ {ID: "att1", Title: "used.png"}, } @@ -249,10 +261,11 @@ func TestFilterUnusedAttachments_NoneUnused(t *testing.T) { unused := filterUnusedAttachments(attachments, content) - assert.Empty(t, unused) + testutil.Empty(t, unused) } func TestRunList_UnusedFlag(t *testing.T) { + t.Parallel() requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ @@ -294,12 +307,13 @@ func TestRunList_UnusedFlag(t *testing.T) { unused: true, } - err := runList(opts) - require.NoError(t, err) - assert.Equal(t, 2, requestCount) // Both attachments and page content fetched + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, 2, requestCount) // Both attachments and page content fetched } func TestRunList_UnusedFlag_NoUnused(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/pages/12345/attachments": @@ -336,6 +350,6 @@ func TestRunList_UnusedFlag_NoUnused(t *testing.T) { unused: true, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } diff --git a/tools/cfl/internal/cmd/attachment/upload.go b/tools/cfl/internal/cmd/attachment/upload.go index 2e351b2..62300e5 100644 --- a/tools/cfl/internal/cmd/attachment/upload.go +++ b/tools/cfl/internal/cmd/attachment/upload.go @@ -30,8 +30,8 @@ func newUploadCmd(rootOpts *root.Options) *cobra.Command { # Upload with a comment (-m for message/comment) cfl attachment upload --page 12345 --file image.png -m "Screenshot"`, - RunE: func(_ *cobra.Command, _ []string) error { - return runUpload(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + return runUpload(cmd.Context(), opts) }, } @@ -45,7 +45,7 @@ func newUploadCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runUpload(opts *uploadOptions) error { +func runUpload(ctx context.Context, opts *uploadOptions) error { client, err := opts.APIClient() if err != nil { return err @@ -53,15 +53,15 @@ func runUpload(opts *uploadOptions) error { file, err := os.Open(opts.file) if err != nil { - return fmt.Errorf("failed to open file: %w", err) + return fmt.Errorf("opening file: %w", err) } defer func() { _ = file.Close() }() filename := filepath.Base(opts.file) - attachment, err := client.UploadAttachment(context.Background(), opts.pageID, filename, file, opts.comment) + attachment, err := client.UploadAttachment(ctx, opts.pageID, filename, file, opts.comment) if err != nil { - return fmt.Errorf("failed to upload attachment: %w", err) + return fmt.Errorf("uploading attachment: %w", err) } v := opts.View() diff --git a/tools/cfl/internal/cmd/attachment/upload_test.go b/tools/cfl/internal/cmd/attachment/upload_test.go index aa2c086..9f86c99 100644 --- a/tools/cfl/internal/cmd/attachment/upload_test.go +++ b/tools/cfl/internal/cmd/attachment/upload_test.go @@ -2,14 +2,14 @@ package attachment import ( "bytes" + "context" "net/http" "net/http/httptest" "os" "path/filepath" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -25,15 +25,16 @@ func newUploadTestRootOptions() *root.Options { } func TestRunUpload_Success(t *testing.T) { + t.Parallel() // Create temp file to upload tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "upload.txt") - err := os.WriteFile(testFile, []byte("test content"), 0644) - require.NoError(t, err) + err := os.WriteFile(testFile, []byte("test content"), 0600) + testutil.RequireNoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Contains(t, r.URL.Path, "/child/attachment") + testutil.Equal(t, "POST", r.Method) + testutil.Contains(t, r.URL.Path, "/child/attachment") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -57,20 +58,21 @@ func TestRunUpload_Success(t *testing.T) { file: testFile, } - err = runUpload(opts) - require.NoError(t, err) + err = runUpload(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunUpload_WithComment(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "upload.txt") - err := os.WriteFile(testFile, []byte("test content"), 0644) - require.NoError(t, err) + err := os.WriteFile(testFile, []byte("test content"), 0600) + testutil.RequireNoError(t, err) var receivedComment string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(10 << 20) - require.NoError(t, err) + testutil.RequireNoError(t, err) receivedComment = r.FormValue("comment") w.WriteHeader(http.StatusOK) @@ -96,12 +98,13 @@ func TestRunUpload_WithComment(t *testing.T) { comment: "My upload comment", } - err = runUpload(opts) - require.NoError(t, err) - assert.Equal(t, "My upload comment", receivedComment) + err = runUpload(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, "My upload comment", receivedComment) } func TestRunUpload_FileNotFound(t *testing.T) { + t.Parallel() rootOpts := newUploadTestRootOptions() client := api.NewClient("http://unused", "test@example.com", "token") rootOpts.SetAPIClient(client) @@ -112,18 +115,19 @@ func TestRunUpload_FileNotFound(t *testing.T) { file: "/nonexistent/file.txt", } - err := runUpload(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to open file") + err := runUpload(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "opening file") } func TestRunUpload_APIError(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "upload.txt") - err := os.WriteFile(testFile, []byte("test content"), 0644) - require.NoError(t, err) + err := os.WriteFile(testFile, []byte("test content"), 0600) + testutil.RequireNoError(t, err) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"message": "Permission denied"}`)) })) @@ -139,18 +143,19 @@ func TestRunUpload_APIError(t *testing.T) { file: testFile, } - err = runUpload(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to upload attachment") + err = runUpload(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "uploading attachment") } func TestRunUpload_JSONOutput(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "upload.txt") - err := os.WriteFile(testFile, []byte("test content"), 0644) - require.NoError(t, err) + err := os.WriteFile(testFile, []byte("test content"), 0600) + testutil.RequireNoError(t, err) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [{ @@ -174,6 +179,6 @@ func TestRunUpload_JSONOutput(t *testing.T) { file: testFile, } - err = runUpload(opts) - require.NoError(t, err) + err = runUpload(context.Background(), opts) + testutil.RequireNoError(t, err) } diff --git a/tools/cfl/internal/cmd/completion/completion_test.go b/tools/cfl/internal/cmd/completion/completion_test.go index 6558401..b22ad14 100644 --- a/tools/cfl/internal/cmd/completion/completion_test.go +++ b/tools/cfl/internal/cmd/completion/completion_test.go @@ -4,9 +4,8 @@ import ( "bytes" "testing" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" ) @@ -30,19 +29,21 @@ func createTestRootCmd() *cobra.Command { } func TestCompletionCommand(t *testing.T) { + t.Parallel() rootCmd := createTestRootCmd() // Find the completion command completionCmd, _, err := rootCmd.Find([]string{"completion"}) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "completion [bash|zsh|fish|powershell]", completionCmd.Use) - assert.NotEmpty(t, completionCmd.Short) - assert.NotEmpty(t, completionCmd.Long) - assert.Equal(t, []string{"bash", "zsh", "fish", "powershell"}, completionCmd.ValidArgs) + testutil.Equal(t, "completion [bash|zsh|fish|powershell]", completionCmd.Use) + testutil.NotEmpty(t, completionCmd.Short) + testutil.NotEmpty(t, completionCmd.Long) + testutil.Equal(t, []string{"bash", "zsh", "fish", "powershell"}, completionCmd.ValidArgs) } func TestBashCompletion(t *testing.T) { + t.Parallel() root := createTestRootCmd() buf := new(bytes.Buffer) @@ -50,15 +51,16 @@ func TestBashCompletion(t *testing.T) { root.SetArgs([]string{"completion", "bash"}) err := root.Execute() - require.NoError(t, err) + testutil.RequireNoError(t, err) output := buf.String() - assert.NotEmpty(t, output) + testutil.NotEmpty(t, output) // Bash completions should contain bash-specific markers - assert.Contains(t, output, "bash completion") + testutil.Contains(t, output, "bash completion") } func TestZshCompletion(t *testing.T) { + t.Parallel() root := createTestRootCmd() buf := new(bytes.Buffer) @@ -66,15 +68,16 @@ func TestZshCompletion(t *testing.T) { root.SetArgs([]string{"completion", "zsh"}) err := root.Execute() - require.NoError(t, err) + testutil.RequireNoError(t, err) output := buf.String() - assert.NotEmpty(t, output) + testutil.NotEmpty(t, output) // Zsh completions should contain zsh-specific markers - assert.Contains(t, output, "compdef") + testutil.Contains(t, output, "compdef") } func TestFishCompletion(t *testing.T) { + t.Parallel() root := createTestRootCmd() buf := new(bytes.Buffer) @@ -82,15 +85,16 @@ func TestFishCompletion(t *testing.T) { root.SetArgs([]string{"completion", "fish"}) err := root.Execute() - require.NoError(t, err) + testutil.RequireNoError(t, err) output := buf.String() - assert.NotEmpty(t, output) + testutil.NotEmpty(t, output) // Fish completions should contain fish-specific markers - assert.Contains(t, output, "complete -c") + testutil.Contains(t, output, "complete -c") } func TestPowerShellCompletion(t *testing.T) { + t.Parallel() root := createTestRootCmd() buf := new(bytes.Buffer) @@ -98,43 +102,46 @@ func TestPowerShellCompletion(t *testing.T) { root.SetArgs([]string{"completion", "powershell"}) err := root.Execute() - require.NoError(t, err) + testutil.RequireNoError(t, err) output := buf.String() - assert.NotEmpty(t, output) + testutil.NotEmpty(t, output) // PowerShell completions should contain PowerShell-specific markers - assert.Contains(t, output, "Register-ArgumentCompleter") + testutil.Contains(t, output, "Register-ArgumentCompleter") } func TestCompletionRequiresShellArg(t *testing.T) { + t.Parallel() root := createTestRootCmd() root.SetArgs([]string{"completion"}) root.SetErr(&bytes.Buffer{}) // Suppress error output err := root.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "accepts 1 arg(s)") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "accepts 1 arg(s)") } func TestCompletionRejectsInvalidShell(t *testing.T) { + t.Parallel() root := createTestRootCmd() root.SetArgs([]string{"completion", "invalid-shell"}) root.SetErr(&bytes.Buffer{}) // Suppress error output err := root.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid argument") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid argument") } func TestCompletionRejectsExtraArgs(t *testing.T) { + t.Parallel() root := createTestRootCmd() root.SetArgs([]string{"completion", "bash", "extra-arg"}) root.SetErr(&bytes.Buffer{}) // Suppress error output err := root.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "accepts 1 arg(s)") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "accepts 1 arg(s)") } diff --git a/tools/cfl/internal/cmd/configcmd/clear.go b/tools/cfl/internal/cmd/configcmd/clear.go index 0820aa6..bfc7ce7 100644 --- a/tools/cfl/internal/cmd/configcmd/clear.go +++ b/tools/cfl/internal/cmd/configcmd/clear.go @@ -29,9 +29,10 @@ func newClearCmd(opts *root.Options) *cobra.Command { cmd := &cobra.Command{ Use: "clear", Short: "Clear stored configuration", - Long: `Remove the cfl configuration file. + Long: `Remove the stored cfl configuration file. -Note: Environment variables (CFL_*, ATLASSIAN_*) will still be used if set.`, +This will delete ~/.config/cfl/config.yml. Environment variables (CFL_*, ATLASSIAN_*) +will continue to work even after clearing the config file.`, Example: ` # Clear configuration (with confirmation) cfl config clear @@ -52,14 +53,14 @@ func runClear(opts *clearOptions) error { // Check if config file exists if _, err := os.Stat(configPath); os.IsNotExist(err) { - fmt.Printf("No configuration file found at %s\n", configPath) + _, _ = fmt.Fprintf(opts.Stderr, "No configuration file found at %s\n", configPath) return nil } // Confirm unless --force if !opts.force { - fmt.Printf("This will remove: %s\n", configPath) - fmt.Print("Are you sure? [y/N]: ") + _, _ = fmt.Fprintf(opts.Stderr, "This will remove: %s\n", configPath) + _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") var response string _, err := fmt.Fscanln(opts.stdin, &response) @@ -69,20 +70,20 @@ func runClear(opts *clearOptions) error { response = strings.TrimSpace(strings.ToLower(response)) if response != "y" && response != "yes" { - fmt.Println("Cancelled.") + _, _ = fmt.Fprintln(opts.Stderr, "Cancelled.") return nil } } // Remove the file if err := os.Remove(configPath); err != nil { - return fmt.Errorf("failed to remove config file: %w", err) + return fmt.Errorf("removing config file: %w", err) } - fmt.Printf("Configuration file removed: %s\n", configPath) + _, _ = fmt.Fprintf(opts.Stderr, "Configuration file removed: %s\n", configPath) // Check for active environment variables - envVars := []string{} + var envVars []string if sharedconfig.GetEnvWithFallback("CFL_URL", "ATLASSIAN_URL") != "" { envVars = append(envVars, "URL") } @@ -94,10 +95,10 @@ func runClear(opts *clearOptions) error { } if len(envVars) > 0 { - fmt.Println() - fmt.Printf("Note: The following are still configured via environment variables: %s\n", + _, _ = fmt.Fprintln(opts.Stderr) + _, _ = fmt.Fprintf(opts.Stderr, "Note: The following are still configured via environment variables: %s\n", strings.Join(envVars, ", ")) - fmt.Println("These will continue to be used. Unset them if you want to fully clear configuration.") + _, _ = fmt.Fprintln(opts.Stderr, "These will continue to be used. Unset them if you want to fully clear configuration.") } return nil diff --git a/tools/cfl/internal/cmd/configcmd/clear_test.go b/tools/cfl/internal/cmd/configcmd/clear_test.go index 771152d..5144771 100644 --- a/tools/cfl/internal/cmd/configcmd/clear_test.go +++ b/tools/cfl/internal/cmd/configcmd/clear_test.go @@ -7,8 +7,7 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" ) @@ -32,7 +31,7 @@ func TestRunClear_FileNotFound(t *testing.T) { } err := runClear(opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestRunClear_WithForce(t *testing.T) { @@ -41,10 +40,10 @@ func TestRunClear_WithForce(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tempDir) configDir := filepath.Join(tempDir, "cfl") - require.NoError(t, os.MkdirAll(configDir, 0755)) + testutil.RequireNoError(t, os.MkdirAll(configDir, 0750)) configPath := filepath.Join(configDir, "config.yml") err := os.WriteFile(configPath, []byte("url: https://test.atlassian.net"), 0600) - require.NoError(t, err) + testutil.RequireNoError(t, err) rootOpts := &root.Options{ Output: "table", @@ -60,11 +59,11 @@ func TestRunClear_WithForce(t *testing.T) { } err = runClear(opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify file is deleted _, err = os.Stat(configPath) - assert.True(t, os.IsNotExist(err)) + testutil.True(t, os.IsNotExist(err)) } func TestRunClear_WithConfirmation(t *testing.T) { @@ -73,10 +72,10 @@ func TestRunClear_WithConfirmation(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tempDir) configDir := filepath.Join(tempDir, "cfl") - require.NoError(t, os.MkdirAll(configDir, 0755)) + testutil.RequireNoError(t, os.MkdirAll(configDir, 0750)) configPath := filepath.Join(configDir, "config.yml") err := os.WriteFile(configPath, []byte("url: https://test.atlassian.net"), 0600) - require.NoError(t, err) + testutil.RequireNoError(t, err) rootOpts := &root.Options{ Output: "table", @@ -92,11 +91,11 @@ func TestRunClear_WithConfirmation(t *testing.T) { } err = runClear(opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify file is deleted _, err = os.Stat(configPath) - assert.True(t, os.IsNotExist(err)) + testutil.True(t, os.IsNotExist(err)) } func TestRunClear_Cancelled(t *testing.T) { @@ -105,10 +104,10 @@ func TestRunClear_Cancelled(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tempDir) configDir := filepath.Join(tempDir, "cfl") - require.NoError(t, os.MkdirAll(configDir, 0755)) + testutil.RequireNoError(t, os.MkdirAll(configDir, 0750)) configPath := filepath.Join(configDir, "config.yml") err := os.WriteFile(configPath, []byte("url: https://test.atlassian.net"), 0600) - require.NoError(t, err) + testutil.RequireNoError(t, err) rootOpts := &root.Options{ Output: "table", @@ -124,9 +123,9 @@ func TestRunClear_Cancelled(t *testing.T) { } err = runClear(opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify file still exists _, err = os.Stat(configPath) - assert.NoError(t, err) + testutil.NoError(t, err) } diff --git a/tools/cfl/internal/cmd/configcmd/show.go b/tools/cfl/internal/cmd/configcmd/show.go index 99e4db5..d13ef8e 100644 --- a/tools/cfl/internal/cmd/configcmd/show.go +++ b/tools/cfl/internal/cmd/configcmd/show.go @@ -55,10 +55,10 @@ func runShow(opts *root.Options) error { v.RenderKeyValue("API Token", formatValueWithSource(maskToken(token), tokenSource)) v.RenderKeyValue("Default Space", formatValueWithSource(space, spaceSource)) - fmt.Println() - fmt.Printf("Config file: %s\n", configPath) + _, _ = fmt.Fprintln(opts.Stderr) + _, _ = fmt.Fprintf(opts.Stderr, "Config file: %s\n", configPath) if fileErr != nil { - fmt.Printf(" (file not found or unreadable)\n") + _, _ = fmt.Fprintf(opts.Stderr, " (file not found or unreadable)\n") } return nil diff --git a/tools/cfl/internal/cmd/configcmd/show_test.go b/tools/cfl/internal/cmd/configcmd/show_test.go index 30f8579..92a5f93 100644 --- a/tools/cfl/internal/cmd/configcmd/show_test.go +++ b/tools/cfl/internal/cmd/configcmd/show_test.go @@ -3,10 +3,11 @@ package configcmd import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestMaskToken(t *testing.T) { + t.Parallel() tests := []struct { name string token string @@ -41,13 +42,15 @@ func TestMaskToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := maskToken(tt.token) - assert.Equal(t, tt.want, got) + testutil.Equal(t, tt.want, got) }) } } func TestGetValueAndSource(t *testing.T) { + t.Parallel() tests := []struct { name string envValue string @@ -84,14 +87,16 @@ func TestGetValueAndSource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() gotValue, gotSource := getValueAndSource(tt.envValue, tt.fileValue, tt.envVarName) - assert.Equal(t, tt.wantValue, gotValue) - assert.Equal(t, tt.wantSource, gotSource) + testutil.Equal(t, tt.wantValue, gotValue) + testutil.Equal(t, tt.wantSource, gotSource) }) } } func TestFormatValueWithSource(t *testing.T) { + t.Parallel() tests := []struct { name string value string @@ -114,8 +119,9 @@ func TestFormatValueWithSource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := formatValueWithSource(tt.value, tt.source) - assert.Equal(t, tt.want, got) + testutil.Equal(t, tt.want, got) }) } } diff --git a/tools/cfl/internal/cmd/configcmd/test.go b/tools/cfl/internal/cmd/configcmd/test.go index dfd89f7..e31b44d 100644 --- a/tools/cfl/internal/cmd/configcmd/test.go +++ b/tools/cfl/internal/cmd/configcmd/test.go @@ -21,51 +21,51 @@ This verifies that: - You have permission to access the API`, Example: ` # Test current configuration cfl config test`, - RunE: func(_ *cobra.Command, _ []string) error { - return runTest(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + return runTest(cmd.Context(), opts) }, } } -func runTest(opts *root.Options) error { +func runTest(ctx context.Context, opts *root.Options) error { // Try to get the API client - this validates config client, err := opts.APIClient() if err != nil { return fmt.Errorf("configuration error: %w", err) } - fmt.Print("Testing connection... ") + _, _ = fmt.Fprint(opts.Stderr, "Testing connection... ") // Try to list spaces (limit 1) to verify connectivity - _, err = client.ListSpaces(context.Background(), nil) + _, err = client.ListSpaces(ctx, nil) if err != nil { - fmt.Println("failed!") - fmt.Println() - fmt.Println("Troubleshooting:") - fmt.Println(" - Verify your URL is correct (should include https://)") - fmt.Println(" - Check your email and API token") - fmt.Println(" - Ensure your API token hasn't expired") - fmt.Println(" - Verify you have permission to access Confluence") - fmt.Println() - fmt.Println("To regenerate an API token:") - fmt.Println(" https://id.atlassian.com/manage-profile/security/api-tokens") + _, _ = fmt.Fprintln(opts.Stderr, "failed!") + _, _ = fmt.Fprintln(opts.Stderr) + _, _ = fmt.Fprintln(opts.Stderr, "Troubleshooting:") + _, _ = fmt.Fprintln(opts.Stderr, " - Verify your URL is correct (should include https://)") + _, _ = fmt.Fprintln(opts.Stderr, " - Check your email and API token") + _, _ = fmt.Fprintln(opts.Stderr, " - Ensure your API token hasn't expired") + _, _ = fmt.Fprintln(opts.Stderr, " - Verify you have permission to access Confluence") + _, _ = fmt.Fprintln(opts.Stderr) + _, _ = fmt.Fprintln(opts.Stderr, "To regenerate an API token:") + _, _ = fmt.Fprintln(opts.Stderr, " https://id.atlassian.com/manage-profile/security/api-tokens") return fmt.Errorf("connection test failed: %w", err) } - fmt.Println("success!") - fmt.Println() + _, _ = fmt.Fprintln(opts.Stderr, "success!") + _, _ = fmt.Fprintln(opts.Stderr) // Get current user details - user, err := client.GetCurrentUser(context.Background()) + user, err := client.GetCurrentUser(ctx) if err != nil { // User details failed but connection worked - show basic success - fmt.Println("Your cfl configuration is working correctly.") + _, _ = fmt.Fprintln(opts.Stderr, "Your cfl configuration is working correctly.") return nil } - fmt.Println("Authentication successful") - fmt.Println("API access verified") - fmt.Println() + _, _ = fmt.Fprintln(opts.Stderr, "Authentication successful") + _, _ = fmt.Fprintln(opts.Stderr, "API access verified") + _, _ = fmt.Fprintln(opts.Stderr) // Display user info - try DisplayName first, fall back to PublicName displayName := user.DisplayName @@ -75,13 +75,13 @@ func runTest(opts *root.Options) error { if displayName != "" { if user.Email != "" { - fmt.Printf("Authenticated as: %s (%s)\n", displayName, user.Email) + _, _ = fmt.Fprintf(opts.Stderr, "Authenticated as: %s (%s)\n", displayName, user.Email) } else { - fmt.Printf("Authenticated as: %s\n", displayName) + _, _ = fmt.Fprintf(opts.Stderr, "Authenticated as: %s\n", displayName) } } if user.AccountID != "" { - fmt.Printf("Account ID: %s\n", user.AccountID) + _, _ = fmt.Fprintf(opts.Stderr, "Account ID: %s\n", user.AccountID) } return nil diff --git a/tools/cfl/internal/cmd/configcmd/test_test.go b/tools/cfl/internal/cmd/configcmd/test_test.go index 03629d2..f28bc00 100644 --- a/tools/cfl/internal/cmd/configcmd/test_test.go +++ b/tools/cfl/internal/cmd/configcmd/test_test.go @@ -2,13 +2,13 @@ package configcmd import ( "bytes" + "context" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -24,6 +24,7 @@ func newTestRootOptions() *root.Options { } func TestRunTest_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/user/current") { w.WriteHeader(http.StatusOK) @@ -40,14 +41,15 @@ func TestRunTest_Success(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - err := runTest(rootOpts) - require.NoError(t, err) + err := runTest(context.Background(), rootOpts) + testutil.RequireNoError(t, err) // Note: Output goes to real stdout via fmt.Print, not opts.Stdout // Just verifying no error is sufficient for this test } func TestRunTest_AuthFailure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"message": "Unauthorized"}`)) })) @@ -57,13 +59,14 @@ func TestRunTest_AuthFailure(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "bad-token") rootOpts.SetAPIClient(client) - err := runTest(rootOpts) - require.Error(t, err) - assert.Contains(t, err.Error(), "connection test failed") + err := runTest(context.Background(), rootOpts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "connection test failed") } func TestRunTest_ServerError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Server error"}`)) })) @@ -73,7 +76,7 @@ func TestRunTest_ServerError(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - err := runTest(rootOpts) - require.Error(t, err) - assert.Contains(t, err.Error(), "connection test failed") + err := runTest(context.Background(), rootOpts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "connection test failed") } diff --git a/tools/cfl/internal/cmd/init/init.go b/tools/cfl/internal/cmd/init/init.go index 98414bf..8bfe7c7 100644 --- a/tools/cfl/internal/cmd/init/init.go +++ b/tools/cfl/internal/cmd/init/init.go @@ -2,6 +2,7 @@ package init import ( + "context" "fmt" "net/http" "os" @@ -44,8 +45,8 @@ To generate an API token: # Pre-populate URL cfl init --url https://mycompany.atlassian.net`, - RunE: func(_ *cobra.Command, _ []string) error { - return runInit(url, email, noVerify) + RunE: func(cmd *cobra.Command, _ []string) error { + return runInit(cmd.Context(), url, email, noVerify) }, } @@ -56,7 +57,7 @@ To generate an API token: return cmd } -func runInit(prefillURL, prefillEmail string, noVerify bool) error { +func runInit(ctx context.Context, prefillURL, prefillEmail string, noVerify bool) error { configPath := config.DefaultConfigPath() // Load existing config for pre-population @@ -77,7 +78,7 @@ func runInit(prefillURL, prefillEmail string, noVerify bool) error { return err } if !overwrite { - fmt.Println("Initialization cancelled.") + _, _ = fmt.Fprintln(os.Stderr, "Initialization cancelled.") return nil } } @@ -167,12 +168,12 @@ func runInit(prefillURL, prefillEmail string, noVerify bool) error { // Verify connection unless skipped if !noVerify { - fmt.Print("Verifying connection... ") - if err := verifyConnection(cfg); err != nil { - fmt.Println("failed!") - return fmt.Errorf("connection verification failed: %w", err) + _, _ = fmt.Fprint(os.Stderr, "Verifying connection... ") + if err := verifyConnection(ctx, cfg); err != nil { + _, _ = fmt.Fprintln(os.Stderr, "failed!") + return fmt.Errorf("verifying connection: %w", err) } - fmt.Println("success!") + _, _ = fmt.Fprintln(os.Stderr, "success!") } // Save configuration @@ -180,18 +181,18 @@ func runInit(prefillURL, prefillEmail string, noVerify bool) error { return err } - fmt.Printf("\nConfiguration saved to %s\n", configPath) - fmt.Println("\nYou're all set! Try running:") - fmt.Println(" cfl space list") - fmt.Println(" cfl page list --space ") + _, _ = fmt.Fprintf(os.Stderr, "\nConfiguration saved to %s\n", configPath) + _, _ = fmt.Fprintln(os.Stderr, "\nYou're all set! Try running:") + _, _ = fmt.Fprintln(os.Stderr, " cfl space list") + _, _ = fmt.Fprintln(os.Stderr, " cfl page list --space ") return nil } -func verifyConnection(cfg *config.Config) error { +func verifyConnection(ctx context.Context, cfg *config.Config) error { client := &http.Client{Timeout: 10 * time.Second} - req, err := http.NewRequest("GET", cfg.URL+"/api/v2/spaces?limit=1", nil) + req, err := http.NewRequestWithContext(ctx, "GET", cfg.URL+"/api/v2/spaces?limit=1", nil) if err != nil { return err } diff --git a/tools/cfl/internal/cmd/init/init_test.go b/tools/cfl/internal/cmd/init/init_test.go index b1f0600..f40ba5c 100644 --- a/tools/cfl/internal/cmd/init/init_test.go +++ b/tools/cfl/internal/cmd/init/init_test.go @@ -2,32 +2,33 @@ package init import ( "bytes" + "context" "net/http" "net/http/httptest" "os" "path/filepath" "testing" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" "github.com/open-cli-collective/confluence-cli/internal/config" ) func TestVerifyConnection_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify the request - assert.Equal(t, "/api/v2/spaces", r.URL.Path) - assert.Equal(t, "1", r.URL.Query().Get("limit")) - assert.Equal(t, "application/json", r.Header.Get("Accept")) + testutil.Equal(t, "/api/v2/spaces", r.URL.Path) + testutil.Equal(t, "1", r.URL.Query().Get("limit")) + testutil.Equal(t, "application/json", r.Header.Get("Accept")) // Verify basic auth is present user, pass, ok := r.BasicAuth() - assert.True(t, ok, "basic auth should be present") - assert.Equal(t, "test@example.com", user) - assert.Equal(t, "test-token", pass) + testutil.True(t, ok, "basic auth should be present") + testutil.Equal(t, "test@example.com", user) + testutil.Equal(t, "test-token", pass) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -40,12 +41,13 @@ func TestVerifyConnection_Success(t *testing.T) { APIToken: "test-token", } - err := verifyConnection(cfg) - assert.NoError(t, err) + err := verifyConnection(context.Background(), cfg) + testutil.NoError(t, err) } func TestVerifyConnection_Unauthorized(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"message": "Unauthorized"}`)) })) @@ -57,14 +59,15 @@ func TestVerifyConnection_Unauthorized(t *testing.T) { APIToken: "wrong-token", } - err := verifyConnection(cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "authentication failed") - assert.Contains(t, err.Error(), "email and API token") + err := verifyConnection(context.Background(), cfg) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "authentication failed") + testutil.Contains(t, err.Error(), "email and API token") } func TestVerifyConnection_Forbidden(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) })) @@ -76,14 +79,15 @@ func TestVerifyConnection_Forbidden(t *testing.T) { APIToken: "token-no-perms", } - err := verifyConnection(cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "access denied") - assert.Contains(t, err.Error(), "permissions") + err := verifyConnection(context.Background(), cfg) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "access denied") + testutil.Contains(t, err.Error(), "permissions") } func TestVerifyConnection_ServerError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() @@ -94,24 +98,26 @@ func TestVerifyConnection_ServerError(t *testing.T) { APIToken: "test-token", } - err := verifyConnection(cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "unexpected status code: 500") + err := verifyConnection(context.Background(), cfg) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "unexpected status code: 500") } func TestVerifyConnection_NetworkError(t *testing.T) { + t.Parallel() cfg := &config.Config{ URL: "http://localhost:99999", // Non-existent server Email: "test@example.com", APIToken: "test-token", } - err := verifyConnection(cfg) - require.Error(t, err) + err := verifyConnection(context.Background(), cfg) + testutil.RequireError(t, err) // Should fail to connect } func TestVerifyConnection_StatusCodes(t *testing.T) { + t.Parallel() tests := []struct { name string statusCode int @@ -157,7 +163,8 @@ func TestVerifyConnection_StatusCodes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tt.statusCode) })) defer server.Close() @@ -168,18 +175,19 @@ func TestVerifyConnection_StatusCodes(t *testing.T) { APIToken: "test-token", } - err := verifyConnection(cfg) + err := verifyConnection(context.Background(), cfg) if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errContain) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), tt.errContain) } else { - assert.NoError(t, err) + testutil.NoError(t, err) } }) } } func TestConfigFilePermissions(t *testing.T) { + t.Parallel() // Create a temp directory tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.yml") @@ -192,19 +200,20 @@ func TestConfigFilePermissions(t *testing.T) { // Save the config err := cfg.Save(configPath) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Check the file permissions info, err := os.Stat(configPath) - require.NoError(t, err) + testutil.RequireNoError(t, err) // On Unix, permissions should be 0600 (user read/write only) // The exact mode includes the file type bits, so we mask with 0777 perm := info.Mode().Perm() - assert.Equal(t, os.FileMode(0600), perm, "config file should have 0600 permissions") + testutil.Equal(t, perm, os.FileMode(0600)) } func TestConfigFilePermissions_DirectoryCreation(t *testing.T) { + t.Parallel() // Create a temp directory with nested path tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "nested", "deeply", "config.yml") @@ -217,19 +226,20 @@ func TestConfigFilePermissions_DirectoryCreation(t *testing.T) { // Save should create the directory structure err := cfg.Save(configPath) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify file exists _, err = os.Stat(configPath) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify directory was created dirInfo, err := os.Stat(filepath.Dir(configPath)) - require.NoError(t, err) - assert.True(t, dirInfo.IsDir()) + testutil.RequireNoError(t, err) + testutil.True(t, dirInfo.IsDir()) } func TestInitCommand_Flags(t *testing.T) { + t.Parallel() // Create root command with init registered rootCmd := &cobra.Command{ Use: "cfl", @@ -247,23 +257,23 @@ func TestInitCommand_Flags(t *testing.T) { // Find the init command initCmd, _, err := rootCmd.Find([]string{"init"}) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify command structure - assert.Equal(t, "init", initCmd.Use) - assert.NotEmpty(t, initCmd.Short) - assert.NotEmpty(t, initCmd.Long) + testutil.Equal(t, "init", initCmd.Use) + testutil.NotEmpty(t, initCmd.Short) + testutil.NotEmpty(t, initCmd.Long) // Verify flags exist urlFlag := initCmd.Flags().Lookup("url") - require.NotNil(t, urlFlag) - assert.Equal(t, "", urlFlag.DefValue) + testutil.NotNil(t, urlFlag) + testutil.Equal(t, "", urlFlag.DefValue) emailFlag := initCmd.Flags().Lookup("email") - require.NotNil(t, emailFlag) - assert.Equal(t, "", emailFlag.DefValue) + testutil.NotNil(t, emailFlag) + testutil.Equal(t, "", emailFlag.DefValue) noVerifyFlag := initCmd.Flags().Lookup("no-verify") - require.NotNil(t, noVerifyFlag) - assert.Equal(t, "false", noVerifyFlag.DefValue) + testutil.NotNil(t, noVerifyFlag) + testutil.Equal(t, "false", noVerifyFlag.DefValue) } diff --git a/tools/cfl/internal/cmd/page/copy.go b/tools/cfl/internal/cmd/page/copy.go index cc4823c..7ba6522 100644 --- a/tools/cfl/internal/cmd/page/copy.go +++ b/tools/cfl/internal/cmd/page/copy.go @@ -39,8 +39,8 @@ func newCopyCmd(rootOpts *root.Options) *cobra.Command { # Copy without labels cfl page copy 12345 --title "Fresh Copy" --no-labels`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runCopy(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runCopy(cmd.Context(), args[0], opts) }, } @@ -54,7 +54,7 @@ func newCopyCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runCopy(pageID string, opts *copyOptions) error { +func runCopy(ctx context.Context, pageID string, opts *copyOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -67,13 +67,13 @@ func runCopy(pageID string, opts *copyOptions) error { destSpace := opts.space if destSpace == "" { // nil opts: body content is not needed, only SpaceID for determining destination - sourcePage, err := client.GetPage(context.Background(), pageID, nil) + sourcePage, err := client.GetPage(ctx, pageID, nil) if err != nil { - return fmt.Errorf("failed to get source page: %w", err) + return fmt.Errorf("getting source page: %w", err) } - space, err := client.GetSpace(context.Background(), sourcePage.SpaceID) + space, err := client.GetSpace(ctx, sourcePage.SpaceID) if err != nil { - return fmt.Errorf("failed to get space: %w", err) + return fmt.Errorf("getting space: %w", err) } destSpace = space.Key } @@ -88,9 +88,9 @@ func runCopy(pageID string, opts *copyOptions) error { CopyCustomContents: true, } - newPage, err := client.CopyPage(context.Background(), pageID, copyOpts) + newPage, err := client.CopyPage(ctx, pageID, copyOpts) if err != nil { - return fmt.Errorf("failed to copy page: %w", err) + return fmt.Errorf("copying page: %w", err) } v := opts.View() diff --git a/tools/cfl/internal/cmd/page/copy_test.go b/tools/cfl/internal/cmd/page/copy_test.go index 66cba62..8aba29b 100644 --- a/tools/cfl/internal/cmd/page/copy_test.go +++ b/tools/cfl/internal/cmd/page/copy_test.go @@ -2,20 +2,20 @@ package page import ( "bytes" + "context" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" ) // mockCopyServer creates a test server that handles page get and copy operations -func mockCopyServer(t *testing.T, getHandler, copyHandler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { +func mockCopyServer(_ *testing.T, getHandler, copyHandler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v2/pages/") { if getHandler != nil { @@ -57,9 +57,10 @@ func newTestRootOptions() *root.Options { } func TestRunCopy_Success(t *testing.T) { + t.Parallel() server := mockCopyServer(t, nil, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/rest/api/content/12345/copy", r.URL.Path) + testutil.Equal(t, "POST", r.Method) + testutil.Equal(t, "/rest/api/content/12345/copy", r.URL.Path) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "99999", @@ -81,11 +82,12 @@ func TestRunCopy_Success(t *testing.T) { space: "TEST", } - err := runCopy("12345", opts) - require.NoError(t, err) + err := runCopy(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunCopy_InfersSourceSpace(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -135,13 +137,14 @@ func TestRunCopy_InfersSourceSpace(t *testing.T) { space: "", // Not specified - should infer from source } - err := runCopy("12345", opts) - require.NoError(t, err) - assert.Equal(t, 3, callCount) // GetPage + GetSpace + CopyPage + err := runCopy(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, 3, callCount) // GetPage + GetSpace + CopyPage } func TestRunCopy_PageNotFound(t *testing.T) { - server := mockCopyServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := mockCopyServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) }) @@ -157,13 +160,14 @@ func TestRunCopy_PageNotFound(t *testing.T) { space: "TEST", } - err := runCopy("99999", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to copy page") + err := runCopy(context.Background(), "99999", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "copying page") } func TestRunCopy_JSONOutput(t *testing.T) { - server := mockCopyServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := mockCopyServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "99999", @@ -186,11 +190,12 @@ func TestRunCopy_JSONOutput(t *testing.T) { space: "TEST", } - err := runCopy("12345", opts) - require.NoError(t, err) + err := runCopy(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunCopy_InvalidOutputFormat(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() rootOpts.Output = "invalid" client := api.NewClient("http://unused", "user@example.com", "token") @@ -202,14 +207,15 @@ func TestRunCopy_InvalidOutputFormat(t *testing.T) { space: "TEST", } - err := runCopy("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") + err := runCopy(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid output format") } func TestRunCopy_GetSourcePageFails(t *testing.T) { + t.Parallel() server := mockCopyServer(t, - func(w http.ResponseWriter, r *http.Request) { + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) }, @@ -227,13 +233,14 @@ func TestRunCopy_GetSourcePageFails(t *testing.T) { space: "", // Empty - will try to get source page } - err := runCopy("invalid", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get source page") + err := runCopy(context.Background(), "invalid", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting source page") } func TestRunCopy_WithNoAttachments(t *testing.T) { - server := mockCopyServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := mockCopyServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "99999", @@ -256,12 +263,13 @@ func TestRunCopy_WithNoAttachments(t *testing.T) { noAttachments: true, } - err := runCopy("12345", opts) - require.NoError(t, err) + err := runCopy(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunCopy_WithNoLabels(t *testing.T) { - server := mockCopyServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := mockCopyServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "99999", @@ -284,12 +292,13 @@ func TestRunCopy_WithNoLabels(t *testing.T) { noLabels: true, } - err := runCopy("12345", opts) - require.NoError(t, err) + err := runCopy(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunCopy_PermissionDenied(t *testing.T) { - server := mockCopyServer(t, nil, func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := mockCopyServer(t, nil, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"message": "You do not have permission to copy this page"}`)) }) @@ -305,12 +314,13 @@ func TestRunCopy_PermissionDenied(t *testing.T) { space: "TEST", } - err := runCopy("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to copy page") + err := runCopy(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "copying page") } func TestRunCopy_GetSpaceFails(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v2/pages/"): @@ -342,7 +352,7 @@ func TestRunCopy_GetSpaceFails(t *testing.T) { space: "", // Empty - will try to get space } - err := runCopy("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get space") + err := runCopy(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting space") } diff --git a/tools/cfl/internal/cmd/page/create.go b/tools/cfl/internal/cmd/page/create.go index 0fe6a0e..d300919 100644 --- a/tools/cfl/internal/cmd/page/create.go +++ b/tools/cfl/internal/cmd/page/create.go @@ -86,7 +86,7 @@ Content format: opts.markdown = &useMd } opts.legacy, _ = cmd.Flags().GetBool("legacy") - return runCreate(opts) + return runCreate(cmd.Context(), opts) }, } @@ -104,12 +104,12 @@ Content format: return cmd } -func runCreate(opts *createOptions) error { +func runCreate(ctx context.Context, opts *createOptions) error { // Validate file exists before making any network calls so we fail // fast on bad input without needing config or API access. if opts.file != "" { if _, err := os.Stat(opts.file); err != nil { - return fmt.Errorf("failed to read file: %w", err) + return fmt.Errorf("reading file: %w", err) } } @@ -132,9 +132,9 @@ func runCreate(opts *createOptions) error { return err } - space, err := client.GetSpaceByKey(context.Background(), spaceKey) + space, err := client.GetSpaceByKey(ctx, spaceKey) if err != nil { - return fmt.Errorf("failed to find space '%s': %w", spaceKey, err) + return fmt.Errorf("finding space '%s': %w", spaceKey, err) } content, isMarkdown, err := getContent(opts) @@ -152,7 +152,7 @@ func runCreate(opts *createOptions) error { if isMarkdown { converted, err := md.ToConfluenceStorage([]byte(content)) if err != nil { - return fmt.Errorf("failed to convert markdown: %w", err) + return fmt.Errorf("converting markdown: %w", err) } content = converted } @@ -166,7 +166,7 @@ func runCreate(opts *createOptions) error { if isMarkdown { adfContent, err := md.ToADF([]byte(content)) if err != nil { - return fmt.Errorf("failed to convert markdown to ADF: %w", err) + return fmt.Errorf("converting markdown to ADF: %w", err) } content = adfContent } @@ -189,9 +189,9 @@ func runCreate(opts *createOptions) error { req.ParentID = opts.parent } - page, err := client.CreatePage(context.Background(), req) + page, err := client.CreatePage(ctx, req) if err != nil { - return fmt.Errorf("failed to create page: %w", err) + return fmt.Errorf("creating page: %w", err) } v := opts.View() @@ -229,7 +229,7 @@ func getContent(opts *createOptions) (string, bool, error) { if opts.file != "" { data, err := os.ReadFile(opts.file) if err != nil { - return "", false, fmt.Errorf("failed to read file: %w", err) + return "", false, fmt.Errorf("reading file: %w", err) } return string(data), useMarkdown(opts.file), nil } @@ -237,7 +237,7 @@ func getContent(opts *createOptions) (string, bool, error) { if opts.Stdin != nil && opts.Stdin != os.Stdin { data, err := io.ReadAll(opts.Stdin) if err != nil { - return "", false, fmt.Errorf("failed to read stdin: %w", err) + return "", false, fmt.Errorf("reading stdin: %w", err) } return string(data), useMarkdown(""), nil } @@ -246,7 +246,7 @@ func getContent(opts *createOptions) (string, bool, error) { if (stat.Mode() & os.ModeCharDevice) == 0 { data, err := io.ReadAll(os.Stdin) if err != nil { - return "", false, fmt.Errorf("failed to read stdin: %w", err) + return "", false, fmt.Errorf("reading stdin: %w", err) } return string(data), useMarkdown(""), nil } @@ -275,7 +275,7 @@ Enter your content here using markdown. tmpfile, err := os.CreateTemp("", "cfl-*"+ext) if err != nil { - return "", fmt.Errorf("failed to create temp file: %w", err) + return "", fmt.Errorf("creating temp file: %w", err) } defer func() { _ = os.Remove(tmpfile.Name()) }() @@ -292,7 +292,7 @@ Enter your content here using markdown. editor = "vi" } - cmd := exec.Command(editor, tmpfile.Name()) + cmd := exec.Command(editor, tmpfile.Name()) //nolint:gosec // launching user's editor is intentional CLI behavior cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -303,7 +303,7 @@ Enter your content here using markdown. data, err := os.ReadFile(tmpfile.Name()) if err != nil { - return "", fmt.Errorf("failed to read edited content: %w", err) + return "", fmt.Errorf("reading edited content: %w", err) } content := strings.TrimSpace(string(data)) diff --git a/tools/cfl/internal/cmd/page/create_test.go b/tools/cfl/internal/cmd/page/create_test.go index 6ad526e..22a0300 100644 --- a/tools/cfl/internal/cmd/page/create_test.go +++ b/tools/cfl/internal/cmd/page/create_test.go @@ -2,6 +2,7 @@ package page import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -11,8 +12,7 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -20,6 +20,7 @@ import ( // mockCreateServer creates a test server that handles GetSpaceByKey and CreatePage requests func mockCreateServer(t *testing.T, spaceKey, spaceID string, createStatus int) *httptest.Server { + t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces") && r.URL.Query().Get("keys") != "": @@ -58,11 +59,12 @@ func newCreateTestRootOptions() *root.Options { } func TestRunCreate_Success(t *testing.T) { + t.Parallel() // Create temp markdown file tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello\n\nWorld"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello\n\nWorld"), 0600) + testutil.RequireNoError(t, err) server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -78,18 +80,19 @@ func TestRunCreate_Success(t *testing.T) { file: mdFile, } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunCreate_HTMLFile_Legacy(t *testing.T) { + t.Parallel() // Create temp HTML file - should be treated as storage format in legacy mode tmpDir := t.TempDir() htmlFile := filepath.Join(tmpDir, "content.html") - err := os.WriteFile(htmlFile, []byte("

Hello World

"), 0644) - require.NoError(t, err) + err := os.WriteFile(htmlFile, []byte("

Hello World

"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -118,24 +121,25 @@ func TestRunCreate_HTMLFile_Legacy(t *testing.T) { legacy: true, // Use legacy mode for HTML files } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify HTML was not converted (should be passed as-is in storage format) - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Hello World

", content) + testutil.Equal(t, "

Hello World

", content) } func TestRunCreate_NoMarkdownFlag_Legacy(t *testing.T) { + t.Parallel() // Create temp file with markdown extension tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("

Raw XHTML

"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("

Raw XHTML

"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -166,21 +170,22 @@ func TestRunCreate_NoMarkdownFlag_Legacy(t *testing.T) { legacy: true, // Use legacy mode for storage format } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify content was not converted even though file has .md extension - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Raw XHTML

", content) + testutil.Equal(t, "

Raw XHTML

", content) } func TestRunCreate_MissingSpace(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello"), 0600) + testutil.RequireNoError(t, err) // Don't need server - should fail before API call rootOpts := newCreateTestRootOptions() @@ -194,18 +199,19 @@ func TestRunCreate_MissingSpace(t *testing.T) { file: mdFile, } - err = runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "space is required") + err = runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "space is required") } func TestRunCreate_SpaceNotFound(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello"), 0600) + testutil.RequireNoError(t, err) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Return empty results for space lookup w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -223,16 +229,17 @@ func TestRunCreate_SpaceNotFound(t *testing.T) { file: mdFile, } - err = runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to find space") + err = runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "finding space") } func TestRunCreate_CreateFailed(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello"), 0600) + testutil.RequireNoError(t, err) server := mockCreateServer(t, "DEV", "123456", http.StatusForbidden) defer server.Close() @@ -248,18 +255,19 @@ func TestRunCreate_CreateFailed(t *testing.T) { file: mdFile, } - err = runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create page") + err = runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "creating page") } func TestRunCreate_WithParent(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Child Page"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Child Page"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -288,18 +296,19 @@ func TestRunCreate_WithParent(t *testing.T) { file: mdFile, } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify parent ID was included in request - assert.Equal(t, "12345", receivedBody["parentId"]) + testutil.Equal(t, "12345", receivedBody["parentId"]) } func TestRunCreate_JSONOutput(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello"), 0600) + testutil.RequireNoError(t, err) server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -316,17 +325,18 @@ func TestRunCreate_JSONOutput(t *testing.T) { file: mdFile, } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunCreate_MarkdownConversion_Legacy(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello World\n\nThis is **bold** text."), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello World\n\nThis is **bold** text."), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -355,26 +365,27 @@ func TestRunCreate_MarkdownConversion_Legacy(t *testing.T) { legacy: true, // Use legacy mode to test storage format } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify markdown was converted to HTML storage format - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) // Should have HTML heading and strong tag from markdown conversion - assert.Contains(t, content, "bold") + testutil.Contains(t, content, "bold") } func TestRunCreate_MarkdownToADF(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Hello World\n\nThis is **bold** text."), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Hello World\n\nThis is **bold** text."), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -403,21 +414,22 @@ func TestRunCreate_MarkdownToADF(t *testing.T) { // Default: not legacy, uses ADF } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify ADF format was used (default) - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) content := adfMap["value"].(string) // Should be valid ADF JSON with heading and strong mark - assert.Contains(t, content, `"type":"doc"`) - assert.Contains(t, content, `"type":"heading"`) - assert.Contains(t, content, `"type":"strong"`) + testutil.Contains(t, content, `"type":"doc"`) + testutil.Contains(t, content, `"type":"heading"`) + testutil.Contains(t, content, `"type":"strong"`) } func TestRunCreate_FileReadError(t *testing.T) { + t.Parallel() server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -432,13 +444,14 @@ func TestRunCreate_FileReadError(t *testing.T) { file: "/nonexistent/file.md", } - err := runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read file") + err := runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "reading file") } func TestRunCreate_Stdin_ADF(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -466,21 +479,22 @@ func TestRunCreate_Stdin_ADF(t *testing.T) { title: "Test Page", } - err := runCreate(opts) - require.NoError(t, err) + err := runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify ADF format was used - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) content := adfMap["value"].(string) - assert.Contains(t, content, `"type":"doc"`) - assert.Contains(t, content, `"type":"heading"`) - assert.Contains(t, content, `"type":"strong"`) + testutil.Contains(t, content, `"type":"doc"`) + testutil.Contains(t, content, `"type":"heading"`) + testutil.Contains(t, content, `"type":"strong"`) } func TestRunCreate_Stdin_Legacy(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -509,20 +523,21 @@ func TestRunCreate_Stdin_Legacy(t *testing.T) { legacy: true, } - err := runCreate(opts) - require.NoError(t, err) + err := runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify storage format was used - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Contains(t, content, "bold") + testutil.Contains(t, content, "bold") } func TestRunCreate_Stdin_NoMarkdown_Legacy(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -553,19 +568,20 @@ func TestRunCreate_Stdin_NoMarkdown_Legacy(t *testing.T) { legacy: true, } - err := runCreate(opts) - require.NoError(t, err) + err := runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify raw content passed through without conversion - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Raw XHTML content

", content) + testutil.Equal(t, "

Raw XHTML content

", content) } func TestRunCreate_StorageFlag_Stdin(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -596,26 +612,27 @@ func TestRunCreate_StorageFlag_Stdin(t *testing.T) { markdown: &useMd, } - err := runCreate(opts) - require.NoError(t, err) + err := runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify storage format was used (not atlas_doc_format) - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) // Content should be passed through as-is - assert.Contains(t, content, `ac:structured-macro`) - assert.Nil(t, bodyMap["atlas_doc_format"]) + testutil.Contains(t, content, `ac:structured-macro`) + testutil.Nil(t, bodyMap["atlas_doc_format"]) } func TestRunCreate_StorageFlag_File(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() htmlFile := filepath.Join(tmpDir, "content.html") - err := os.WriteFile(htmlFile, []byte("

Direct storage XHTML

"), 0644) - require.NoError(t, err) + err := os.WriteFile(htmlFile, []byte("

Direct storage XHTML

"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -646,19 +663,20 @@ func TestRunCreate_StorageFlag_File(t *testing.T) { markdown: &useMd, } - err = runCreate(opts) - require.NoError(t, err) + err = runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify storage format was used without --legacy - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Direct storage XHTML

", content) - assert.Nil(t, bodyMap["atlas_doc_format"]) + testutil.Equal(t, "

Direct storage XHTML

", content) + testutil.Nil(t, bodyMap["atlas_doc_format"]) } func TestRunCreate_ComplexMarkdown_ADF(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"): @@ -698,21 +716,22 @@ func TestRunCreate_ComplexMarkdown_ADF(t *testing.T) { title: "Test Page", } - err := runCreate(opts) - require.NoError(t, err) + err := runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify ADF contains complex elements - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) content := adfMap["value"].(string) - assert.Contains(t, content, `"type":"table"`) - assert.Contains(t, content, `"type":"bulletList"`) - assert.Contains(t, content, `"type":"codeBlock"`) - assert.Contains(t, content, `"language":"go"`) + testutil.Contains(t, content, `"type":"table"`) + testutil.Contains(t, content, `"type":"bulletList"`) + testutil.Contains(t, content, `"type":"codeBlock"`) + testutil.Contains(t, content, `"language":"go"`) } func TestRunCreate_EmptyContentFromStdin(t *testing.T) { + t.Parallel() server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -727,12 +746,13 @@ func TestRunCreate_EmptyContentFromStdin(t *testing.T) { title: "Test Page", } - err := runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err := runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunCreate_WhitespaceOnlyFromStdin(t *testing.T) { + t.Parallel() server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -747,16 +767,17 @@ func TestRunCreate_WhitespaceOnlyFromStdin(t *testing.T) { title: "Test Page", } - err := runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err := runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunCreate_EmptyFile(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() emptyFile := filepath.Join(tmpDir, "empty.md") - err := os.WriteFile(emptyFile, []byte(""), 0644) - require.NoError(t, err) + err := os.WriteFile(emptyFile, []byte(""), 0600) + testutil.RequireNoError(t, err) server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -772,16 +793,17 @@ func TestRunCreate_EmptyFile(t *testing.T) { file: emptyFile, } - err = runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err = runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunCreate_WhitespaceOnlyFile(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() whitespaceFile := filepath.Join(tmpDir, "whitespace.md") - err := os.WriteFile(whitespaceFile, []byte(" \n\t\n "), 0644) - require.NoError(t, err) + err := os.WriteFile(whitespaceFile, []byte(" \n\t\n "), 0600) + testutil.RequireNoError(t, err) server := mockCreateServer(t, "DEV", "123456", http.StatusOK) defer server.Close() @@ -797,7 +819,7 @@ func TestRunCreate_WhitespaceOnlyFile(t *testing.T) { file: whitespaceFile, } - err = runCreate(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err = runCreate(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } diff --git a/tools/cfl/internal/cmd/page/delete.go b/tools/cfl/internal/cmd/page/delete.go index 2d75bad..d1a114f 100644 --- a/tools/cfl/internal/cmd/page/delete.go +++ b/tools/cfl/internal/cmd/page/delete.go @@ -29,8 +29,8 @@ func newDeleteCmd(rootOpts *root.Options) *cobra.Command { # Delete without confirmation cfl page delete 12345 --force`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runDelete(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(cmd.Context(), args[0], opts) }, } @@ -39,36 +39,36 @@ func newDeleteCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runDelete(pageID string, opts *deleteOptions) error { +func runDelete(ctx context.Context, pageID string, opts *deleteOptions) error { client, err := opts.APIClient() if err != nil { return err } // nil opts: body content is not needed, only title for the confirmation prompt - page, err := client.GetPage(context.Background(), pageID, nil) + page, err := client.GetPage(ctx, pageID, nil) if err != nil { - return fmt.Errorf("failed to get page: %w", err) + return fmt.Errorf("getting page: %w", err) } v := opts.View() if !opts.force { - fmt.Printf("About to delete page: %s (ID: %s)\n", page.Title, page.ID) - fmt.Print("Are you sure? [y/N]: ") + _, _ = fmt.Fprintf(opts.Stderr, "About to delete page: %s (ID: %s)\n", page.Title, page.ID) + _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") confirmed, err := prompt.Confirm(opts.Stdin) if err != nil { - return fmt.Errorf("failed to read confirmation: %w", err) + return fmt.Errorf("reading confirmation: %w", err) } if !confirmed { - fmt.Println("Deletion cancelled.") + _, _ = fmt.Fprintln(opts.Stderr, "Deletion cancelled.") return nil } } - if err := client.DeletePage(context.Background(), pageID); err != nil { - return fmt.Errorf("failed to delete page: %w", err) + if err := client.DeletePage(ctx, pageID); err != nil { + return fmt.Errorf("deleting page: %w", err) } if opts.Output == "json" { diff --git a/tools/cfl/internal/cmd/page/delete_test.go b/tools/cfl/internal/cmd/page/delete_test.go index 6e58f56..5ed4313 100644 --- a/tools/cfl/internal/cmd/page/delete_test.go +++ b/tools/cfl/internal/cmd/page/delete_test.go @@ -2,13 +2,13 @@ package page import ( "bytes" + "context" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -16,6 +16,7 @@ import ( // mockPageServer creates a test server that handles GetPage and DeletePage requests func mockPageServer(t *testing.T, pageID, title string, deleteStatus int) *httptest.Server { + t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"+pageID): @@ -46,6 +47,7 @@ func newDeleteTestRootOptions() *root.Options { } func TestRunDelete_ConfirmYes(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -59,11 +61,12 @@ func TestRunDelete_ConfirmYes(t *testing.T) { force: false, } - err := runDelete("12345", opts) - require.NoError(t, err) + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunDelete_ConfirmYesUppercase(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -77,11 +80,12 @@ func TestRunDelete_ConfirmYesUppercase(t *testing.T) { force: false, } - err := runDelete("12345", opts) - require.NoError(t, err) + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunDelete_ConfirmNo(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -95,11 +99,12 @@ func TestRunDelete_ConfirmNo(t *testing.T) { force: false, } - err := runDelete("12345", opts) - require.NoError(t, err) // Cancellation is not an error + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Cancellation is not an error } func TestRunDelete_ConfirmEmpty(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -113,11 +118,12 @@ func TestRunDelete_ConfirmEmpty(t *testing.T) { force: false, } - err := runDelete("12345", opts) - require.NoError(t, err) // Empty input should cancel + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Empty input should cancel } func TestRunDelete_ConfirmOther(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -131,11 +137,12 @@ func TestRunDelete_ConfirmOther(t *testing.T) { force: false, } - err := runDelete("12345", opts) - require.NoError(t, err) // Any non-y/Y input should cancel + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Any non-y/Y input should cancel } func TestRunDelete_Force(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -148,12 +155,13 @@ func TestRunDelete_Force(t *testing.T) { force: true, } - err := runDelete("12345", opts) - require.NoError(t, err) + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunDelete_PageNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) @@ -168,12 +176,13 @@ func TestRunDelete_PageNotFound(t *testing.T) { force: true, } - err := runDelete("99999", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get page") + err := runDelete(context.Background(), "99999", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting page") } func TestRunDelete_DeleteFailed(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusForbidden) defer server.Close() @@ -186,12 +195,13 @@ func TestRunDelete_DeleteFailed(t *testing.T) { force: true, } - err := runDelete("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to delete page") + err := runDelete(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "deleting page") } func TestRunDelete_JSONOutput(t *testing.T) { + t.Parallel() server := mockPageServer(t, "12345", "Test Page", http.StatusNoContent) defer server.Close() @@ -205,11 +215,12 @@ func TestRunDelete_JSONOutput(t *testing.T) { force: true, } - err := runDelete("12345", opts) - require.NoError(t, err) + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunDelete_ConfirmationInputs(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -226,6 +237,7 @@ func TestRunDelete_ConfirmationInputs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Track if delete was called deleteCalled := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -250,9 +262,9 @@ func TestRunDelete_ConfirmationInputs(t *testing.T) { force: false, } - err := runDelete("12345", opts) - require.NoError(t, err) - assert.Equal(t, tt.shouldProceed, deleteCalled, "delete should have been called: %v", tt.shouldProceed) + err := runDelete(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, deleteCalled, tt.shouldProceed) }) } } diff --git a/tools/cfl/internal/cmd/page/edit.go b/tools/cfl/internal/cmd/page/edit.go index 1274904..a3101ad 100644 --- a/tools/cfl/internal/cmd/page/edit.go +++ b/tools/cfl/internal/cmd/page/edit.go @@ -93,7 +93,7 @@ Content format: opts.markdown = &useMd } opts.legacy, _ = cmd.Flags().GetBool("legacy") - return runEdit(opts) + return runEdit(cmd.Context(), opts) }, } @@ -108,12 +108,12 @@ Content format: return cmd } -func runEdit(opts *editOptions) error { +func runEdit(ctx context.Context, opts *editOptions) error { // Validate file exists before making any network calls so we fail // fast on bad input without needing config or API access. if opts.file != "" { if _, err := os.Stat(opts.file); err != nil { - return fmt.Errorf("failed to read file: %w", err) + return fmt.Errorf("reading file: %w", err) } } @@ -127,9 +127,9 @@ func runEdit(opts *editOptions) error { return err } - existingPage, err := getPageWithBodyFallback(context.Background(), client, opts.pageID) + existingPage, err := getPageWithBodyFallback(ctx, client, opts.pageID) if err != nil { - return fmt.Errorf("failed to get page: %w", err) + return fmt.Errorf("getting page: %w", err) } newTitle := opts.title @@ -215,14 +215,14 @@ func runEdit(opts *editOptions) error { req.Body = existingPage.Body } - page, err := client.UpdatePage(context.Background(), opts.pageID, req) + page, err := client.UpdatePage(ctx, opts.pageID, req) if err != nil { - return fmt.Errorf("failed to update page: %w", err) + return fmt.Errorf("updating page: %w", err) } if opts.parent != "" { - if err := client.MovePage(context.Background(), opts.pageID, opts.parent); err != nil { - return fmt.Errorf("failed to move page to new parent: %w", err) + if err := client.MovePage(ctx, opts.pageID, opts.parent); err != nil { + return fmt.Errorf("moving page to new parent: %w", err) } } @@ -246,7 +246,7 @@ func convertEditContent(content string, isMarkdown, legacy bool) (string, error) if isMarkdown { converted, err := md.ToConfluenceStorage([]byte(content)) if err != nil { - return "", fmt.Errorf("failed to convert markdown: %w", err) + return "", fmt.Errorf("converting markdown: %w", err) } return converted, nil } @@ -256,7 +256,7 @@ func convertEditContent(content string, isMarkdown, legacy bool) (string, error) if isMarkdown { adfContent, err := md.ToADF([]byte(content)) if err != nil { - return "", fmt.Errorf("failed to convert markdown to ADF: %w", err) + return "", fmt.Errorf("converting markdown to ADF: %w", err) } return adfContent, nil } @@ -284,7 +284,7 @@ func getEditContent(opts *editOptions, existingPage *api.Page) (string, bool, er if opts.file != "" { data, err := os.ReadFile(opts.file) if err != nil { - return "", false, fmt.Errorf("failed to read file: %w", err) + return "", false, fmt.Errorf("reading file: %w", err) } return string(data), useMarkdown(opts.file), nil } @@ -292,7 +292,7 @@ func getEditContent(opts *editOptions, existingPage *api.Page) (string, bool, er if opts.Stdin != nil && opts.Stdin != os.Stdin { data, err := io.ReadAll(opts.Stdin) if err != nil { - return "", false, fmt.Errorf("failed to read stdin: %w", err) + return "", false, fmt.Errorf("reading stdin: %w", err) } return string(data), useMarkdown(""), nil } @@ -301,7 +301,7 @@ func getEditContent(opts *editOptions, existingPage *api.Page) (string, bool, er if (stat.Mode() & os.ModeCharDevice) == 0 { data, err := io.ReadAll(os.Stdin) if err != nil { - return "", false, fmt.Errorf("failed to read stdin: %w", err) + return "", false, fmt.Errorf("reading stdin: %w", err) } return string(data), useMarkdown(""), nil } @@ -335,7 +335,7 @@ func openEditorForEdit(existingPage *api.Page, isMarkdown bool) (string, error) tmpfile, err := os.CreateTemp("", "cfl-edit-*"+ext) if err != nil { - return "", fmt.Errorf("failed to create temp file: %w", err) + return "", fmt.Errorf("creating temp file: %w", err) } defer func() { _ = os.Remove(tmpfile.Name()) }() @@ -352,7 +352,7 @@ func openEditorForEdit(existingPage *api.Page, isMarkdown bool) (string, error) editor = "vi" } - cmd := exec.Command(editor, tmpfile.Name()) + cmd := exec.Command(editor, tmpfile.Name()) //nolint:gosec // launching user's editor is intentional CLI behavior cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -363,7 +363,7 @@ func openEditorForEdit(existingPage *api.Page, isMarkdown bool) (string, error) data, err := os.ReadFile(tmpfile.Name()) if err != nil { - return "", fmt.Errorf("failed to read edited content: %w", err) + return "", fmt.Errorf("reading edited content: %w", err) } content := strings.TrimSpace(string(data)) diff --git a/tools/cfl/internal/cmd/page/edit_test.go b/tools/cfl/internal/cmd/page/edit_test.go index 2c2defc..aecce35 100644 --- a/tools/cfl/internal/cmd/page/edit_test.go +++ b/tools/cfl/internal/cmd/page/edit_test.go @@ -2,6 +2,7 @@ package page import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -11,8 +12,7 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -28,16 +28,17 @@ func newEditTestRootOptions() *root.Options { } func TestRunEdit_Success(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Updated Content\n\nNew text here."), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Updated Content\n\nNew text here."), 0600) + testutil.RequireNoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 5}, @@ -46,7 +47,7 @@ func TestRunEdit_Success(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 6}, @@ -68,17 +69,18 @@ func TestRunEdit_Success(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunEdit_TitleOnly(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Old Title", "version": {"number": 3}, @@ -87,9 +89,9 @@ func TestRunEdit_TitleOnly(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/pages/12345"): body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "New Title", "version": {"number": 4}, @@ -116,24 +118,25 @@ func TestRunEdit_TitleOnly(t *testing.T) { // we need to provide a file to avoid the editor path. tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("

Keep this

"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("

Keep this

"), 0600) + testutil.RequireNoError(t, err) useMd := false opts.file = mdFile opts.markdown = &useMd - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify title was changed - assert.Equal(t, "New Title", receivedBody["title"]) + testutil.Equal(t, "New Title", receivedBody["title"]) } func TestRunEdit_PageNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"message": "Page not found"}`)) + _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) defer server.Close() @@ -147,22 +150,23 @@ func TestRunEdit_PageNotFound(t *testing.T) { title: "New Title", } - err := runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get page") + err := runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting page") } func TestRunEdit_UpdateFailed(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# New Content"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# New Content"), 0600) + testutil.RequireNoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -171,7 +175,7 @@ func TestRunEdit_UpdateFailed(t *testing.T) { }`)) case "PUT": w.WriteHeader(http.StatusForbidden) - w.Write([]byte(`{"message": "Permission denied"}`)) + _, _ = w.Write([]byte(`{"message": "Permission denied"}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -188,23 +192,24 @@ func TestRunEdit_UpdateFailed(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to update page") + err = runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "updating page") } func TestRunEdit_VersionIncrement(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Updated"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Updated"), 0600) + testutil.RequireNoError(t, err) var receivedVersion int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 7}, @@ -213,13 +218,13 @@ func TestRunEdit_VersionIncrement(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - var req map[string]interface{} - json.Unmarshal(body, &req) - if v, ok := req["version"].(map[string]interface{}); ok { + var req map[string]any + _ = json.Unmarshal(body, &req) + if v, ok := req["version"].(map[string]any); ok { receivedVersion = int(v["number"].(float64)) } w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 8}, @@ -241,25 +246,26 @@ func TestRunEdit_VersionIncrement(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify version was incremented from 7 to 8 - assert.Equal(t, 8, receivedVersion) + testutil.Equal(t, 8, receivedVersion) } func TestRunEdit_HTMLFile(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() htmlFile := filepath.Join(tmpDir, "content.html") - err := os.WriteFile(htmlFile, []byte("

Direct HTML

"), 0644) - require.NoError(t, err) + err := os.WriteFile(htmlFile, []byte("

Direct HTML

"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -268,9 +274,9 @@ func TestRunEdit_HTMLFile(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -294,28 +300,29 @@ func TestRunEdit_HTMLFile(t *testing.T) { } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify HTML was not converted (storage format in legacy mode) - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Direct HTML

", content) + testutil.Equal(t, "

Direct HTML

", content) } func TestRunEdit_NoMarkdownFlag(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("

Raw XHTML in .md file

"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("

Raw XHTML in .md file

"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -324,9 +331,9 @@ func TestRunEdit_NoMarkdownFlag(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -351,28 +358,29 @@ func TestRunEdit_NoMarkdownFlag(t *testing.T) { legacy: true, // Use legacy mode for storage format } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify content was not converted (storage format in legacy mode) - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Raw XHTML in .md file

", content) + testutil.Equal(t, "

Raw XHTML in .md file

", content) } func TestRunEdit_MarkdownToADF(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Updated\n\nNew **bold** text."), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Updated\n\nNew **bold** text."), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -381,9 +389,9 @@ func TestRunEdit_MarkdownToADF(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -407,31 +415,32 @@ func TestRunEdit_MarkdownToADF(t *testing.T) { // Default: not legacy, uses ADF } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify ADF format was used (default) - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) content := adfMap["value"].(string) // Should be valid ADF JSON - assert.Contains(t, content, `"type":"doc"`) - assert.Contains(t, content, `"type":"heading"`) - assert.Contains(t, content, `"type":"strong"`) + testutil.Contains(t, content, `"type":"doc"`) + testutil.Contains(t, content, `"type":"heading"`) + testutil.Contains(t, content, `"type":"strong"`) } func TestRunEdit_JSONOutput(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Updated"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Updated"), 0600) + testutil.RequireNoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -440,7 +449,7 @@ func TestRunEdit_JSONOutput(t *testing.T) { }`)) case "PUT": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -462,14 +471,15 @@ func TestRunEdit_JSONOutput(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunEdit_FileReadError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -489,18 +499,19 @@ func TestRunEdit_FileReadError(t *testing.T) { file: "/nonexistent/file.md", } - err := runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read file") + err := runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "reading file") } func TestRunEdit_Stdin_ADF(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -509,9 +520,9 @@ func TestRunEdit_Stdin_ADF(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -532,26 +543,27 @@ func TestRunEdit_Stdin_ADF(t *testing.T) { pageID: "12345", } - err := runEdit(opts) - require.NoError(t, err) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify ADF format was used - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) content := adfMap["value"].(string) - assert.Contains(t, content, `"type":"doc"`) - assert.Contains(t, content, `"type":"heading"`) - assert.Contains(t, content, `"type":"strong"`) + testutil.Contains(t, content, `"type":"doc"`) + testutil.Contains(t, content, `"type":"heading"`) + testutil.Contains(t, content, `"type":"strong"`) } func TestRunEdit_Stdin_Legacy(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -560,9 +572,9 @@ func TestRunEdit_Stdin_Legacy(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -584,25 +596,26 @@ func TestRunEdit_Stdin_Legacy(t *testing.T) { legacy: true, } - err := runEdit(opts) - require.NoError(t, err) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify storage format was used - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Contains(t, content, "bold") + testutil.Contains(t, content, "bold") } func TestRunEdit_TitleAndContent(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Old Title", "version": {"number": 1}, @@ -611,9 +624,9 @@ func TestRunEdit_TitleAndContent(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "New Title", "version": {"number": 2}, @@ -627,8 +640,8 @@ func TestRunEdit_TitleAndContent(t *testing.T) { tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# New Content\n\nUpdated text here."), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# New Content\n\nUpdated text here."), 0600) + testutil.RequireNoError(t, err) rootOpts := newEditTestRootOptions() client := api.NewClient(server.URL, "test@example.com", "token") @@ -640,23 +653,24 @@ func TestRunEdit_TitleAndContent(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify both title and content were updated - assert.Equal(t, "New Title", receivedBody["title"]) - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) - assert.NotNil(t, adfMap["value"]) + testutil.Equal(t, "New Title", receivedBody["title"]) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) + testutil.NotNil(t, adfMap["value"]) } func TestRunEdit_ComplexMarkdown_ADF(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -665,9 +679,9 @@ func TestRunEdit_ComplexMarkdown_ADF(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -693,8 +707,8 @@ func TestRunEdit_ComplexMarkdown_ADF(t *testing.T) { tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "complex.md") - err := os.WriteFile(mdFile, []byte(complexMarkdown), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte(complexMarkdown), 0600) + testutil.RequireNoError(t, err) rootOpts := newEditTestRootOptions() client := api.NewClient(server.URL, "test@example.com", "token") @@ -705,27 +719,28 @@ func TestRunEdit_ComplexMarkdown_ADF(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify ADF contains complex elements - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) content := adfMap["value"].(string) - assert.Contains(t, content, `"type":"table"`) - assert.Contains(t, content, `"type":"bulletList"`) - assert.Contains(t, content, `"type":"codeBlock"`) - assert.Contains(t, content, `"language":"go"`) + testutil.Contains(t, content, `"type":"table"`) + testutil.Contains(t, content, `"type":"bulletList"`) + testutil.Contains(t, content, `"type":"codeBlock"`) + testutil.Contains(t, content, `"language":"go"`) } func TestRunEdit_MoveToParent(t *testing.T) { + t.Parallel() moveCalled := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 1}, @@ -734,7 +749,7 @@ func TestRunEdit_MoveToParent(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 2}, @@ -743,7 +758,7 @@ func TestRunEdit_MoveToParent(t *testing.T) { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move/append/67890"): moveCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -761,19 +776,20 @@ func TestRunEdit_MoveToParent(t *testing.T) { parent: "67890", } - err := runEdit(opts) - require.NoError(t, err) - assert.True(t, moveCalled, "MovePage should have been called") + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.True(t, moveCalled, "MovePage should have been called") } func TestRunEdit_MoveAndRename(t *testing.T) { + t.Parallel() var receivedTitle string moveCalled := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Old Title", "version": {"number": 1}, @@ -782,11 +798,11 @@ func TestRunEdit_MoveAndRename(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): body, _ := io.ReadAll(r.Body) - var req map[string]interface{} - json.Unmarshal(body, &req) + var req map[string]any + _ = json.Unmarshal(body, &req) receivedTitle = req["title"].(string) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "New Title", "version": {"number": 2}, @@ -795,7 +811,7 @@ func TestRunEdit_MoveAndRename(t *testing.T) { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move/append/67890"): moveCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -813,18 +829,19 @@ func TestRunEdit_MoveAndRename(t *testing.T) { parent: "67890", } - err := runEdit(opts) - require.NoError(t, err) - assert.True(t, moveCalled, "MovePage should have been called") - assert.Equal(t, "New Title", receivedTitle) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.True(t, moveCalled, "MovePage should have been called") + testutil.Equal(t, "New Title", receivedTitle) } func TestRunEdit_MoveFailed(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 1}, @@ -833,7 +850,7 @@ func TestRunEdit_MoveFailed(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 2}, @@ -841,7 +858,7 @@ func TestRunEdit_MoveFailed(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move"): w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"message": "Target page not found"}`)) + _, _ = w.Write([]byte(`{"message": "Target page not found"}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -860,19 +877,20 @@ func TestRunEdit_MoveFailed(t *testing.T) { } - err := runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to move page to new parent") + err := runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "moving page to new parent") } func TestRunEdit_MoveWithContent(t *testing.T) { + t.Parallel() moveCalled := false - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 1}, @@ -881,9 +899,9 @@ func TestRunEdit_MoveWithContent(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 2}, @@ -892,7 +910,7 @@ func TestRunEdit_MoveWithContent(t *testing.T) { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move/append/67890"): moveCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -909,21 +927,22 @@ func TestRunEdit_MoveWithContent(t *testing.T) { parent: "67890", } - err := runEdit(opts) - require.NoError(t, err) - assert.True(t, moveCalled, "MovePage should have been called") + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.True(t, moveCalled, "MovePage should have been called") // Verify content was also updated - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) - assert.NotNil(t, adfMap["value"]) + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) + testutil.NotNil(t, adfMap["value"]) } func TestRunEdit_EmptyContentFromStdin(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -945,16 +964,17 @@ func TestRunEdit_EmptyContentFromStdin(t *testing.T) { pageID: "12345", } - err := runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err := runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunEdit_WhitespaceOnlyFromStdin(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -976,21 +996,22 @@ func TestRunEdit_WhitespaceOnlyFromStdin(t *testing.T) { pageID: "12345", } - err := runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err := runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunEdit_EmptyFile(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() emptyFile := filepath.Join(tmpDir, "empty.md") - err := os.WriteFile(emptyFile, []byte(""), 0644) - require.NoError(t, err) + err := os.WriteFile(emptyFile, []byte(""), 0600) + testutil.RequireNoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -1013,21 +1034,22 @@ func TestRunEdit_EmptyFile(t *testing.T) { file: emptyFile, } - err = runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err = runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunEdit_WhitespaceOnlyFile(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() whitespaceFile := filepath.Join(tmpDir, "whitespace.md") - err := os.WriteFile(whitespaceFile, []byte(" \n\t\n "), 0644) - require.NoError(t, err) + err := os.WriteFile(whitespaceFile, []byte(" \n\t\n "), 0600) + testutil.RequireNoError(t, err) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -1050,19 +1072,20 @@ func TestRunEdit_WhitespaceOnlyFile(t *testing.T) { file: whitespaceFile, } - err = runEdit(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "page content cannot be empty") + err = runEdit(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "page content cannot be empty") } func TestRunEdit_TitleOnlyUpdate_NoContentValidation(t *testing.T) { + t.Parallel() // When updating title only (with file providing content), validation should pass updateCalled := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Old Title", "version": {"number": 1}, @@ -1072,7 +1095,7 @@ func TestRunEdit_TitleOnlyUpdate_NoContentValidation(t *testing.T) { case "PUT": updateCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "New Title", "version": {"number": 2}, @@ -1091,8 +1114,8 @@ func TestRunEdit_TitleOnlyUpdate_NoContentValidation(t *testing.T) { // Provide a file with valid content to avoid editor tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Valid Content"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Valid Content"), 0600) + testutil.RequireNoError(t, err) rootOpts.Stdin = nil opts := &editOptions{ @@ -1102,12 +1125,13 @@ func TestRunEdit_TitleOnlyUpdate_NoContentValidation(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) - assert.True(t, updateCalled, "Update should have been called") + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.True(t, updateCalled, "Update should have been called") } func TestRunEdit_MoveOnly_NoEditorOpened(t *testing.T) { + t.Parallel() // Test: cfl page edit 12345 --parent 67890 // Verifies: page is moved without content change, no editor opened moveCalled := false @@ -1116,7 +1140,7 @@ func TestRunEdit_MoveOnly_NoEditorOpened(t *testing.T) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 1}, @@ -1126,7 +1150,7 @@ func TestRunEdit_MoveOnly_NoEditorOpened(t *testing.T) { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): updateCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 2}, @@ -1135,7 +1159,7 @@ func TestRunEdit_MoveOnly_NoEditorOpened(t *testing.T) { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move/append/67890"): moveCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -1152,22 +1176,23 @@ func TestRunEdit_MoveOnly_NoEditorOpened(t *testing.T) { parent: "67890", } - err := runEdit(opts) - require.NoError(t, err) - assert.True(t, updateCalled, "UpdatePage should have been called") - assert.True(t, moveCalled, "MovePage should have been called") + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.True(t, updateCalled, "UpdatePage should have been called") + testutil.True(t, moveCalled, "MovePage should have been called") } func TestRunEdit_MoveWithTitleOnly_NoEditorOpened(t *testing.T) { + t.Parallel() // Test: cfl page edit 12345 --parent 67890 --title "New Title" // Verifies: page is moved and title updated, body preserved, no editor opened moveCalled := false - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Old Title", "version": {"number": 1}, @@ -1176,9 +1201,9 @@ func TestRunEdit_MoveWithTitleOnly_NoEditorOpened(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "New Title", "version": {"number": 2}, @@ -1187,7 +1212,7 @@ func TestRunEdit_MoveWithTitleOnly_NoEditorOpened(t *testing.T) { case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move/append/67890"): moveCalled = true w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -1205,19 +1230,20 @@ func TestRunEdit_MoveWithTitleOnly_NoEditorOpened(t *testing.T) { parent: "67890", } - err := runEdit(opts) - require.NoError(t, err) - assert.True(t, moveCalled, "MovePage should have been called") - assert.Equal(t, "New Title", receivedBody["title"]) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.True(t, moveCalled, "MovePage should have been called") + testutil.Equal(t, "New Title", receivedBody["title"]) } func TestRunEdit_StorageFlag_Stdin(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -1226,9 +1252,9 @@ func TestRunEdit_StorageFlag_Stdin(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -1252,34 +1278,35 @@ func TestRunEdit_StorageFlag_Stdin(t *testing.T) { markdown: &useMd, } - err := runEdit(opts) - require.NoError(t, err) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify storage format was used (not atlas_doc_format) - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) // Content should be passed through as-is, preserving Confluence-specific markup - assert.Contains(t, content, `ac:structured-macro`) - assert.Contains(t, content, `ri:user`) + testutil.Contains(t, content, `ac:structured-macro`) + testutil.Contains(t, content, `ri:user`) // Should NOT have atlas_doc_format - assert.Nil(t, bodyMap["atlas_doc_format"]) + testutil.Nil(t, bodyMap["atlas_doc_format"]) } func TestRunEdit_StorageFlag_File(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() htmlFile := filepath.Join(tmpDir, "content.html") - err := os.WriteFile(htmlFile, []byte("

Direct storage XHTML

"), 0644) - require.NoError(t, err) + err := os.WriteFile(htmlFile, []byte("

Direct storage XHTML

"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 1}, @@ -1288,9 +1315,9 @@ func TestRunEdit_StorageFlag_File(t *testing.T) { }`)) case "PUT": body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test", "version": {"number": 2}, @@ -1315,28 +1342,29 @@ func TestRunEdit_StorageFlag_File(t *testing.T) { markdown: &useMd, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify storage format was used without --legacy - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) content := storageMap["value"].(string) - assert.Equal(t, "

Direct storage XHTML

", content) + testutil.Equal(t, "

Direct storage XHTML

", content) // Should NOT have atlas_doc_format - assert.Nil(t, bodyMap["atlas_doc_format"]) + testutil.Nil(t, bodyMap["atlas_doc_format"]) } func TestRunEdit_MoveOnly_BodyPreserved(t *testing.T) { + t.Parallel() // Test: move-only operation preserves original body exactly // Verifies: received body in PUT request matches original page body - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 1}, @@ -1345,9 +1373,9 @@ func TestRunEdit_MoveOnly_BodyPreserved(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/api/v2/pages/12345"): body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "Test Page", "version": {"number": 2}, @@ -1355,7 +1383,7 @@ func TestRunEdit_MoveOnly_BodyPreserved(t *testing.T) { }`)) case r.Method == "PUT" && strings.Contains(r.URL.Path, "/rest/api/content/12345/move/append/67890"): w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -1372,17 +1400,18 @@ func TestRunEdit_MoveOnly_BodyPreserved(t *testing.T) { parent: "67890", } - err := runEdit(opts) - require.NoError(t, err) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify body was preserved from original page - bodyMap := receivedBody["body"].(map[string]interface{}) - storageMap := bodyMap["storage"].(map[string]interface{}) - assert.Equal(t, "

Original content that must be preserved

", storageMap["value"]) + bodyMap := receivedBody["body"].(map[string]any) + storageMap := bodyMap["storage"].(map[string]any) + testutil.Equal(t, "

Original content that must be preserved

", storageMap["value"]) } func TestRunEdit_ADFPage_TitleOnly_PreservesBody(t *testing.T) { - var receivedBody map[string]interface{} + t.Parallel() + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/12345"): @@ -1390,7 +1419,7 @@ func TestRunEdit_ADFPage_TitleOnly_PreservesBody(t *testing.T) { case "storage": // Storage returns empty for this ADF page w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "ADF Page", "version": {"number": 3}, @@ -1400,7 +1429,7 @@ func TestRunEdit_ADFPage_TitleOnly_PreservesBody(t *testing.T) { case "atlas_doc_format": // ADF has content w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "ADF Page", "version": {"number": 3}, @@ -1410,9 +1439,9 @@ func TestRunEdit_ADFPage_TitleOnly_PreservesBody(t *testing.T) { } case r.Method == "PUT" && strings.Contains(r.URL.Path, "/pages/12345"): body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "New Title", "version": {"number": 4}, @@ -1435,29 +1464,30 @@ func TestRunEdit_ADFPage_TitleOnly_PreservesBody(t *testing.T) { title: "New Title", } - err := runEdit(opts) - require.NoError(t, err) + err := runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // Verify body was preserved as ADF (not storage) - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) - assert.Contains(t, adfMap["value"], "ADF body") + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) + testutil.Contains(t, adfMap["value"].(string), "ADF body") } func TestRunEdit_ADFPage_NewContent(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("# Updated Content"), 0644) - require.NoError(t, err) + err := os.WriteFile(mdFile, []byte("# Updated Content"), 0600) + testutil.RequireNoError(t, err) - var receivedBody map[string]interface{} + var receivedBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/12345"): switch r.URL.Query().Get("body-format") { case "storage": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "ADF Page", "version": {"number": 2}, @@ -1466,7 +1496,7 @@ func TestRunEdit_ADFPage_NewContent(t *testing.T) { }`)) case "atlas_doc_format": w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "ADF Page", "version": {"number": 2}, @@ -1476,9 +1506,9 @@ func TestRunEdit_ADFPage_NewContent(t *testing.T) { } case r.Method == "PUT" && strings.Contains(r.URL.Path, "/pages/12345"): body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &receivedBody) + _ = json.Unmarshal(body, &receivedBody) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{ + _, _ = w.Write([]byte(`{ "id": "12345", "title": "ADF Page", "version": {"number": 3}, @@ -1501,11 +1531,11 @@ func TestRunEdit_ADFPage_NewContent(t *testing.T) { file: mdFile, } - err = runEdit(opts) - require.NoError(t, err) + err = runEdit(context.Background(), opts) + testutil.RequireNoError(t, err) // New content should be submitted as ADF (default path) - bodyMap := receivedBody["body"].(map[string]interface{}) - adfMap := bodyMap["atlas_doc_format"].(map[string]interface{}) - assert.Contains(t, adfMap["value"], "Updated Content") + bodyMap := receivedBody["body"].(map[string]any) + adfMap := bodyMap["atlas_doc_format"].(map[string]any) + testutil.Contains(t, adfMap["value"].(string), "Updated Content") } diff --git a/tools/cfl/internal/cmd/page/fetch_test.go b/tools/cfl/internal/cmd/page/fetch_test.go index c5b9168..a307626 100644 --- a/tools/cfl/internal/cmd/page/fetch_test.go +++ b/tools/cfl/internal/cmd/page/fetch_test.go @@ -7,17 +7,17 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" ) func TestGetPageWithBodyFallback_StorageHasContent(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ - assert.Equal(t, "storage", r.URL.Query().Get("body-format"), "should only request storage") + testutil.Equal(t, "storage", r.URL.Query().Get("body-format")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -31,12 +31,13 @@ func TestGetPageWithBodyFallback_StorageHasContent(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") - require.NoError(t, err) - assert.Equal(t, 1, callCount, "should not make a second call when storage has content") - assert.True(t, hasStorageContent(page)) + testutil.RequireNoError(t, err) + testutil.Equal(t, 1, callCount) + testutil.True(t, hasStorageContent(page)) } func TestGetPageWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -65,12 +66,13 @@ func TestGetPageWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") - require.NoError(t, err) - assert.Equal(t, 2, callCount, "should make fallback call when storage is empty") - assert.True(t, hasADFContent(page)) + testutil.RequireNoError(t, err) + testutil.Equal(t, 2, callCount) + testutil.True(t, hasADFContent(page)) } func TestGetPageWithBodyFallback_NullBody_FallsBackToADF(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -99,13 +101,14 @@ func TestGetPageWithBodyFallback_NullBody_FallsBackToADF(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") - require.NoError(t, err) - assert.Equal(t, 2, callCount) - assert.True(t, hasADFContent(page)) + testutil.RequireNoError(t, err) + testutil.Equal(t, 2, callCount) + testutil.True(t, hasADFContent(page)) } func TestGetPageWithBodyFallback_BothEmpty(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -119,13 +122,14 @@ func TestGetPageWithBodyFallback_BothEmpty(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") - require.NoError(t, err) - assert.False(t, hasStorageContent(page)) - assert.False(t, hasADFContent(page)) + testutil.RequireNoError(t, err) + testutil.False(t, hasStorageContent(page)) + testutil.False(t, hasADFContent(page)) } func TestGetPageWithBodyFallback_GetPageError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) @@ -133,10 +137,11 @@ func TestGetPageWithBodyFallback_GetPageError(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") _, err := getPageWithBodyFallback(context.Background(), client, "99999") - require.Error(t, err) + testutil.RequireError(t, err) } func TestGetPageWithBodyFallback_ADFFallbackFails_GracefulDegradation(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -160,12 +165,13 @@ func TestGetPageWithBodyFallback_ADFFallbackFails_GracefulDegradation(t *testing client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") - require.NoError(t, err, "should not error even if ADF fallback fails") - assert.False(t, hasStorageContent(page)) - assert.False(t, hasADFContent(page)) + testutil.RequireNoError(t, err) + testutil.False(t, hasStorageContent(page)) + testutil.False(t, hasADFContent(page)) } func TestHasStorageContent(t *testing.T) { + t.Parallel() tests := []struct { name string page *api.Page @@ -179,12 +185,14 @@ func TestHasStorageContent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, hasStorageContent(tt.page)) + t.Parallel() + testutil.Equal(t, tt.expected, hasStorageContent(tt.page)) }) } } func TestHasADFContent(t *testing.T) { + t.Parallel() tests := []struct { name string page *api.Page @@ -198,7 +206,8 @@ func TestHasADFContent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, hasADFContent(tt.page)) + t.Parallel() + testutil.Equal(t, tt.expected, hasADFContent(tt.page)) }) } } diff --git a/tools/cfl/internal/cmd/page/list.go b/tools/cfl/internal/cmd/page/list.go index 463e281..5326d8b 100644 --- a/tools/cfl/internal/cmd/page/list.go +++ b/tools/cfl/internal/cmd/page/list.go @@ -40,8 +40,8 @@ page content.`, # Output as JSON cfl page list -s DEV -o json`, - RunE: func(_ *cobra.Command, _ []string) error { - return runList(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + return runList(cmd.Context(), opts) }, } @@ -60,7 +60,7 @@ var validStatuses = map[string]bool{ "deleted": true, } -func runList(opts *listOptions) error { +func runList(ctx context.Context, opts *listOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -77,7 +77,7 @@ func runList(opts *listOptions) error { if opts.limit == 0 { if opts.Output == "json" { - return v.JSON([]interface{}{}) + return v.JSON([]any{}) } v.RenderText("No pages found.") return nil @@ -102,9 +102,9 @@ func runList(opts *listOptions) error { return err } - space, err := client.GetSpaceByKey(context.Background(), spaceKey) + space, err := client.GetSpaceByKey(ctx, spaceKey) if err != nil { - return fmt.Errorf("failed to find space '%s': %w", spaceKey, err) + return fmt.Errorf("finding space '%s': %w", spaceKey, err) } apiOpts := &api.ListPagesOptions{ @@ -112,9 +112,9 @@ func runList(opts *listOptions) error { Status: opts.status, } - result, err := client.ListPages(context.Background(), space.ID, apiOpts) + result, err := client.ListPages(ctx, space.ID, apiOpts) if err != nil { - return fmt.Errorf("failed to list pages: %w", err) + return fmt.Errorf("listing pages: %w", err) } if len(result.Results) == 0 { @@ -123,7 +123,7 @@ func runList(opts *listOptions) error { } headers := []string{"ID", "TITLE", "STATUS", "VERSION"} - var rows [][]string + rows := make([][]string, 0, len(result.Results)) for _, page := range result.Results { version := "" diff --git a/tools/cfl/internal/cmd/page/list_test.go b/tools/cfl/internal/cmd/page/list_test.go index c39bcb2..6976add 100644 --- a/tools/cfl/internal/cmd/page/list_test.go +++ b/tools/cfl/internal/cmd/page/list_test.go @@ -2,13 +2,13 @@ package page import ( "bytes" + "context" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -17,6 +17,7 @@ import ( // mockListServer creates a test server for page list operations // It handles both GetSpaceByKey and ListPages endpoints func mockListServer(t *testing.T, spaceKey, spaceID string, pages string) *httptest.Server { + t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces") && r.URL.Query().Get("keys") != "": @@ -46,6 +47,7 @@ func newListPageTestRootOptions() *root.Options { } func TestRunList_PageList_Success(t *testing.T) { + t.Parallel() server := mockListServer(t, "DEV", "123456", `{ "results": [ {"id": "11111", "title": "Page One", "status": "current", "version": {"number": 1}}, @@ -65,11 +67,12 @@ func TestRunList_PageList_Success(t *testing.T) { status: "current", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_EmptyResults(t *testing.T) { + t.Parallel() server := mockListServer(t, "DEV", "123456", `{"results": []}`) defer server.Close() @@ -84,11 +87,12 @@ func TestRunList_PageList_EmptyResults(t *testing.T) { status: "current", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_JSONOutput(t *testing.T) { + t.Parallel() server := mockListServer(t, "DEV", "123456", `{ "results": [ {"id": "11111", "title": "Page One", "status": "current", "version": {"number": 1}} @@ -108,11 +112,12 @@ func TestRunList_PageList_JSONOutput(t *testing.T) { status: "current", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_InvalidOutputFormat(t *testing.T) { + t.Parallel() rootOpts := newListPageTestRootOptions() rootOpts.Output = "invalid" @@ -122,12 +127,13 @@ func TestRunList_PageList_InvalidOutputFormat(t *testing.T) { limit: 25, } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid output format") } func TestRunList_PageList_NegativeLimit(t *testing.T) { + t.Parallel() rootOpts := newListPageTestRootOptions() opts := &listOptions{ @@ -137,12 +143,13 @@ func TestRunList_PageList_NegativeLimit(t *testing.T) { status: "current", } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid limit") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid limit") } func TestRunList_PageList_ZeroLimit(t *testing.T) { + t.Parallel() rootOpts := newListPageTestRootOptions() opts := &listOptions{ @@ -153,13 +160,14 @@ func TestRunList_PageList_ZeroLimit(t *testing.T) { } // Zero limit should return empty without making API call - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_MissingSpace(t *testing.T) { + t.Parallel() // Create a mock client to avoid config loading - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() @@ -175,13 +183,14 @@ func TestRunList_PageList_MissingSpace(t *testing.T) { status: "current", } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "space is required") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "space is required") } func TestRunList_PageList_SpaceNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Return empty results for space lookup w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -199,12 +208,13 @@ func TestRunList_PageList_SpaceNotFound(t *testing.T) { status: "current", } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to find space") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "finding space") } func TestRunList_PageList_NullVersion(t *testing.T) { + t.Parallel() server := mockListServer(t, "DEV", "123456", `{ "results": [ {"id": "11111", "title": "Page Without Version", "status": "current", "version": null} @@ -223,11 +233,12 @@ func TestRunList_PageList_NullVersion(t *testing.T) { status: "current", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_HasMore(t *testing.T) { + t.Parallel() server := mockListServer(t, "DEV", "123456", `{ "results": [ {"id": "11111", "title": "Page One", "status": "current", "version": {"number": 1}} @@ -247,11 +258,12 @@ func TestRunList_PageList_HasMore(t *testing.T) { status: "current", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_LongTitle(t *testing.T) { + t.Parallel() longTitle := strings.Repeat("A", 100) server := mockListServer(t, "DEV", "123456", `{ "results": [ @@ -271,14 +283,15 @@ func TestRunList_PageList_LongTitle(t *testing.T) { status: "current", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_StatusFilter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/pages") { - assert.Equal(t, "archived", r.URL.Query().Get("status")) + testutil.Equal(t, "archived", r.URL.Query().Get("status")) } if r.URL.Query().Get("keys") != "" { w.WriteHeader(http.StatusOK) @@ -301,11 +314,12 @@ func TestRunList_PageList_StatusFilter(t *testing.T) { status: "archived", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_PageList_InvalidStatus(t *testing.T) { + t.Parallel() rootOpts := newListPageTestRootOptions() opts := &listOptions{ @@ -315,16 +329,17 @@ func TestRunList_PageList_InvalidStatus(t *testing.T) { status: "draft", } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid status") - assert.Contains(t, err.Error(), "draft") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid status") + testutil.Contains(t, err.Error(), "draft") } func TestRunList_PageList_TrashedStatus(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/pages") { - assert.Equal(t, "trashed", r.URL.Query().Get("status")) + testutil.Equal(t, "trashed", r.URL.Query().Get("status")) } if r.URL.Query().Get("keys") != "" { w.WriteHeader(http.StatusOK) @@ -347,6 +362,6 @@ func TestRunList_PageList_TrashedStatus(t *testing.T) { status: "trashed", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } diff --git a/tools/cfl/internal/cmd/page/view.go b/tools/cfl/internal/cmd/page/view.go index 69c7760..be9fef4 100644 --- a/tools/cfl/internal/cmd/page/view.go +++ b/tools/cfl/internal/cmd/page/view.go @@ -62,8 +62,8 @@ JSON output (--output json) always includes the full body.`, # Pipe markdown content to edit cfl page view 12345 --content-only | cfl page edit 12345 --legacy`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runView(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runView(cmd.Context(), args[0], opts) }, } @@ -76,7 +76,7 @@ JSON output (--output json) always includes the full body.`, return cmd } -func runView(pageID string, opts *viewOptions) error { +func runView(ctx context.Context, pageID string, opts *viewOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -102,17 +102,17 @@ func runView(pageID string, opts *viewOptions) error { // --web only needs page links, not body content if opts.web { - page, err := client.GetPage(context.Background(), pageID, nil) + page, err := client.GetPage(ctx, pageID, nil) if err != nil { - return fmt.Errorf("failed to get page: %w", err) + return fmt.Errorf("getting page: %w", err) } url := cfg.URL + page.Links.WebUI return openBrowser(url) } - page, err := getPageWithBodyFallback(context.Background(), client, pageID) + page, err := getPageWithBodyFallback(ctx, client, pageID) if err != nil { - return fmt.Errorf("failed to get page: %w", err) + return fmt.Errorf("getting page: %w", err) } v := opts.View() @@ -120,7 +120,7 @@ func runView(pageID string, opts *viewOptions) error { // Look up space key for display spaceKey := "" if page.SpaceID != "" { - space, err := client.GetSpace(context.Background(), page.SpaceID) + space, err := client.GetSpace(ctx, page.SpaceID) if err == nil && space != nil { spaceKey = space.Key } diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index 7803c69..c128f89 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -2,14 +2,14 @@ package page import ( "bytes" + "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -25,10 +25,11 @@ func newViewTestRootOptions() *root.Options { } func TestRunView_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.URL.Path, "/pages/12345") - assert.Equal(t, "GET", r.Method) - assert.Equal(t, "storage", r.URL.Query().Get("body-format"), "must request body-format=storage to get page content") + testutil.Contains(t, r.URL.Path, "/pages/12345") + testutil.Equal(t, "GET", r.Method) + testutil.Equal(t, "storage", r.URL.Query().Get("body-format")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -49,16 +50,17 @@ func TestRunView_Success(t *testing.T) { Options: rootOpts, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) stdout := rootOpts.Stdout.(*bytes.Buffer) - assert.Contains(t, stdout.String(), "Hello", "should render storage content") - assert.Contains(t, stdout.String(), "World", "should render bold text") + testutil.Contains(t, stdout.String(), "Hello") + testutil.Contains(t, stdout.String(), "World") } func TestRunView_RawFormat(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -79,15 +81,16 @@ func TestRunView_RawFormat(t *testing.T) { raw: true, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) stdout := rootOpts.Stdout.(*bytes.Buffer) - assert.Contains(t, stdout.String(), "

Raw HTML Content

", "should output raw storage HTML") + testutil.Contains(t, stdout.String(), "

Raw HTML Content

") } func TestRunView_JSONOutput(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -108,12 +111,13 @@ func TestRunView_JSONOutput(t *testing.T) { Options: rootOpts, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunView_PageNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Page not found"}`)) })) @@ -127,13 +131,14 @@ func TestRunView_PageNotFound(t *testing.T) { Options: rootOpts, } - err := runView("99999", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get page") + err := runView(context.Background(), "99999", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "getting page") } func TestRunView_EmptyContent(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -152,11 +157,12 @@ func TestRunView_EmptyContent(t *testing.T) { Options: rootOpts, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunView_InvalidOutputFormat(t *testing.T) { + t.Parallel() rootOpts := newViewTestRootOptions() rootOpts.Output = "invalid" @@ -164,13 +170,14 @@ func TestRunView_InvalidOutputFormat(t *testing.T) { Options: rootOpts, } - err := runView("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") + err := runView(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid output format") } func TestRunView_ShowMacros(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -191,12 +198,13 @@ func TestRunView_ShowMacros(t *testing.T) { showMacros: true, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestRunView_ContentOnly(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -217,13 +225,14 @@ func TestRunView_ContentOnly(t *testing.T) { contentOnly: true, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Output should only contain markdown content, no Title:/ID:/Version: headers } func TestRunView_ContentOnly_Raw(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -245,13 +254,14 @@ func TestRunView_ContentOnly_Raw(t *testing.T) { raw: true, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Output should only contain raw XHTML, no Title:/ID:/Version: headers } func TestRunView_ContentOnly_ShowMacros(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -273,12 +283,13 @@ func TestRunView_ContentOnly_ShowMacros(t *testing.T) { showMacros: true, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Output should contain markdown with [TOC] macro placeholder } func TestRunView_ContentOnly_JSON_Error(t *testing.T) { + t.Parallel() rootOpts := newViewTestRootOptions() rootOpts.Output = "json" @@ -287,12 +298,13 @@ func TestRunView_ContentOnly_JSON_Error(t *testing.T) { contentOnly: true, } - err := runView("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "--content-only is incompatible with --output json") + err := runView(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--content-only is incompatible with --output json") } func TestRunView_ContentOnly_Web_Error(t *testing.T) { + t.Parallel() rootOpts := newViewTestRootOptions() opts := &viewOptions{ @@ -301,13 +313,14 @@ func TestRunView_ContentOnly_Web_Error(t *testing.T) { web: true, } - err := runView("12345", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "--content-only is incompatible with --web") + err := runView(context.Background(), "12345", opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--content-only is incompatible with --web") } func TestRunView_ContentOnly_EmptyBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -327,18 +340,19 @@ func TestRunView_ContentOnly_EmptyBody(t *testing.T) { contentOnly: true, } - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Output should be "(No content)" without metadata headers } func TestRunView_WithSpaceKey(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ if callCount == 1 { // First call: GetPage - assert.Contains(t, r.URL.Path, "/pages/12345") + testutil.Contains(t, r.URL.Path, "/pages/12345") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "12345", @@ -350,7 +364,7 @@ func TestRunView_WithSpaceKey(t *testing.T) { }`)) } else { // Second call: GetSpace - assert.Contains(t, r.URL.Path, "/spaces/98765") + testutil.Contains(t, r.URL.Path, "/spaces/98765") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "id": "98765", @@ -370,14 +384,15 @@ func TestRunView_WithSpaceKey(t *testing.T) { Options: rootOpts, } - err := runView("12345", opts) - require.NoError(t, err) - assert.Equal(t, 2, callCount, "should call both GetPage and GetSpace") + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, 2, callCount) } func TestRunView_SpaceLookupFails_Graceful(t *testing.T) { + t.Parallel() callCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { callCount++ if callCount == 1 { // First call: GetPage @@ -407,11 +422,12 @@ func TestRunView_SpaceLookupFails_Graceful(t *testing.T) { } // Should succeed even if space lookup fails - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) } func TestEnrichPageWithSpaceKey(t *testing.T) { + t.Parallel() page := &api.Page{ ID: "12345", Title: "Test Page", @@ -420,49 +436,56 @@ func TestEnrichPageWithSpaceKey(t *testing.T) { enriched := enrichPageWithSpaceKey(page, "DEV") - assert.Equal(t, "12345", enriched.ID) - assert.Equal(t, "Test Page", enriched.Title) - assert.Equal(t, "DEV", enriched.SpaceKey) + testutil.Equal(t, "12345", enriched.ID) + testutil.Equal(t, "Test Page", enriched.Title) + testutil.Equal(t, "DEV", enriched.SpaceKey) } func TestTruncateContent(t *testing.T) { + t.Parallel() t.Run("short content is not truncated", func(t *testing.T) { + t.Parallel() opts := &viewOptions{} result := truncateContent("short", opts) - assert.Equal(t, "short", result) + testutil.Equal(t, "short", result) }) t.Run("long content is truncated by default", func(t *testing.T) { + t.Parallel() opts := &viewOptions{} long := strings.Repeat("x", maxViewChars+100) result := truncateContent(long, opts) - assert.Len(t, strings.SplitN(result, "\n\n... [truncated", 2)[0], maxViewChars) - assert.Contains(t, result, fmt.Sprintf("... [truncated at %d chars, use --full for complete text]", maxViewChars)) + testutil.Len(t, strings.SplitN(result, "\n\n... [truncated", 2)[0], maxViewChars) + testutil.Contains(t, result, fmt.Sprintf("... [truncated at %d chars, use --full for complete text]", maxViewChars)) }) t.Run("--full bypasses truncation", func(t *testing.T) { + t.Parallel() opts := &viewOptions{full: true} long := strings.Repeat("x", maxViewChars+100) result := truncateContent(long, opts) - assert.Equal(t, long, result) + testutil.Equal(t, long, result) }) t.Run("--content-only implies full", func(t *testing.T) { + t.Parallel() opts := &viewOptions{contentOnly: true} long := strings.Repeat("x", maxViewChars+100) result := truncateContent(long, opts) - assert.Equal(t, long, result) + testutil.Equal(t, long, result) }) t.Run("content at exact limit is not truncated", func(t *testing.T) { + t.Parallel() opts := &viewOptions{} exact := strings.Repeat("x", maxViewChars) result := truncateContent(exact, opts) - assert.Equal(t, exact, result) + testutil.Equal(t, exact, result) }) } func TestRunView_ADFPage_FallbackToAtlasDocFormat(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -507,17 +530,18 @@ func TestRunView_ADFPage_FallbackToAtlasDocFormat(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts} - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Should make 3 calls: storage (empty), atlas_doc_format (has content), GetSpace - assert.Equal(t, 3, callCount, "should fallback to atlas_doc_format when storage is empty") + testutil.Equal(t, 3, callCount) stdout := rootOpts.Stdout.(*bytes.Buffer) - assert.Contains(t, stdout.String(), "Hello ADF", "should render ADF content as markdown") + testutil.Contains(t, stdout.String(), "Hello ADF") } func TestRunView_ADFPage_RawFormat(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/pages/12345") { switch r.URL.Query().Get("body-format") { @@ -558,15 +582,16 @@ func TestRunView_ADFPage_RawFormat(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts, raw: true} - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) stdout := rootOpts.Stdout.(*bytes.Buffer) - assert.Contains(t, stdout.String(), "Raw ADF", "should output raw ADF JSON") - assert.Contains(t, stdout.String(), `"type"`, "should contain ADF JSON structure") + testutil.Contains(t, stdout.String(), "Raw ADF") + testutil.Contains(t, stdout.String(), `"type"`) } func TestRunView_StoragePage_NoFallback(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -596,17 +621,18 @@ func TestRunView_StoragePage_NoFallback(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts} - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) // Should only make 2 calls: GetPage (storage has content) + GetSpace, no fallback - assert.Equal(t, 2, callCount, "should not fallback when storage has content") + testutil.Equal(t, 2, callCount) stdout := rootOpts.Stdout.(*bytes.Buffer) - assert.Contains(t, stdout.String(), "Has content", "should render storage content as markdown") + testutil.Contains(t, stdout.String(), "Has content") } func TestRunView_ADFPage_NullBody(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/pages/12345") { switch r.URL.Query().Get("body-format") { @@ -642,9 +668,9 @@ func TestRunView_ADFPage_NullBody(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts} - err := runView("12345", opts) - require.NoError(t, err) + err := runView(context.Background(), "12345", opts) + testutil.RequireNoError(t, err) stdout := rootOpts.Stdout.(*bytes.Buffer) - assert.Contains(t, stdout.String(), "(No content)", "should display no content message") + testutil.Contains(t, stdout.String(), "(No content)") } diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index f849dbb..7eddb1d 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -52,7 +52,7 @@ func (o *Options) Config() (*config.Config, error) { } cfg, err := config.LoadWithEnv(config.DefaultConfigPath()) if err != nil { - return nil, fmt.Errorf("failed to load config: %w (run 'cfl init' to configure)", err) + return nil, fmt.Errorf("loading config: %w (run 'cfl init' to configure)", err) } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w (run 'cfl init' to configure)", err) diff --git a/tools/cfl/internal/cmd/search/search.go b/tools/cfl/internal/cmd/search/search.go index e9edb38..41168f0 100644 --- a/tools/cfl/internal/cmd/search/search.go +++ b/tools/cfl/internal/cmd/search/search.go @@ -78,11 +78,11 @@ convenient flags for common filters, or provide raw CQL for advanced queries.`, # Output as JSON for scripting cfl search "config" -o json`, Args: cobra.MaximumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.query = args[0] } - return runSearch(opts) + return runSearch(cmd.Context(), opts) }, } @@ -99,7 +99,7 @@ convenient flags for common filters, or provide raw CQL for advanced queries.`, return cmd } -func runSearch(opts *searchOptions) error { +func runSearch(ctx context.Context, opts *searchOptions) error { // Validate output format if err := view.ValidateFormat(opts.Output); err != nil { return err @@ -126,7 +126,7 @@ func runSearch(opts *searchOptions) error { // Handle limit 0 - return empty if opts.limit == 0 { if opts.Output == "json" { - return v.JSON([]interface{}{}) + return v.JSON([]any{}) } v.RenderText("No results.") return nil @@ -160,7 +160,7 @@ func runSearch(opts *searchOptions) error { Limit: opts.limit, } - result, err := client.Search(context.Background(), apiOpts) + result, err := client.Search(ctx, apiOpts) if err != nil { return fmt.Errorf("search failed: %w", err) } @@ -172,7 +172,7 @@ func runSearch(opts *searchOptions) error { // Render results headers := []string{"ID", "TYPE", "SPACE KEY", "TITLE"} - var rows [][]string + rows := make([][]string, 0, len(result.Results)) for _, r := range result.Results { spaceKey := extractSpaceKey(r.ResultGlobalContainer.DisplayURL) diff --git a/tools/cfl/internal/cmd/search/search_test.go b/tools/cfl/internal/cmd/search/search_test.go index ba056c5..e6a72eb 100644 --- a/tools/cfl/internal/cmd/search/search_test.go +++ b/tools/cfl/internal/cmd/search/search_test.go @@ -2,13 +2,13 @@ package search import ( "bytes" + "context" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -16,6 +16,7 @@ import ( // mockSearchServer creates a test server for search operations func mockSearchServer(t *testing.T, response string) *httptest.Server { + t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" && strings.Contains(r.URL.Path, "/rest/api/search") { w.WriteHeader(http.StatusOK) @@ -37,6 +38,7 @@ func newTestRootOptions() *root.Options { } func TestRunSearch_Success(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{ "results": [ { @@ -64,11 +66,12 @@ func TestRunSearch_Success(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_EmptyResults(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{ "results": [], "start": 0, @@ -87,11 +90,12 @@ func TestRunSearch_EmptyResults(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_JSONOutput(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{ "results": [ { @@ -116,11 +120,12 @@ func TestRunSearch_JSONOutput(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_PlainOutput(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{ "results": [ { @@ -145,11 +150,12 @@ func TestRunSearch_PlainOutput(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_InvalidOutputFormat(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() rootOpts.Output = "invalid" @@ -159,12 +165,13 @@ func TestRunSearch_InvalidOutputFormat(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") + err := runSearch(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid output format") } func TestRunSearch_InvalidType(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &searchOptions{ @@ -174,17 +181,19 @@ func TestRunSearch_InvalidType(t *testing.T) { contentType: "invalid", } - err := runSearch(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid type") - assert.Contains(t, err.Error(), "invalid") + err := runSearch(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid type") + testutil.Contains(t, err.Error(), "invalid") } func TestRunSearch_ValidTypes(t *testing.T) { + t.Parallel() validTypes := []string{"page", "blogpost", "attachment", "comment"} for _, contentType := range validTypes { t.Run(contentType, func(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{"results": [], "totalSize": 0}`) defer server.Close() @@ -199,13 +208,14 @@ func TestRunSearch_ValidTypes(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) }) } } func TestRunSearch_NoQuery(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &searchOptions{ @@ -213,12 +223,13 @@ func TestRunSearch_NoQuery(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "search requires a query") + err := runSearch(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "search requires a query") } func TestRunSearch_NegativeLimit(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &searchOptions{ @@ -227,12 +238,13 @@ func TestRunSearch_NegativeLimit(t *testing.T) { limit: -1, } - err := runSearch(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid limit") + err := runSearch(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid limit") } func TestRunSearch_ZeroLimit(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &searchOptions{ @@ -242,14 +254,15 @@ func TestRunSearch_ZeroLimit(t *testing.T) { } // Zero limit should return empty without making API call - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_WithSpaceFilter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") - assert.Contains(t, cql, `space = "DEV"`) + testutil.Contains(t, cql, `space = "DEV"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -267,14 +280,15 @@ func TestRunSearch_WithSpaceFilter(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_WithTypeFilter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") - assert.Contains(t, cql, `type = "page"`) + testutil.Contains(t, cql, `type = "page"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -292,14 +306,15 @@ func TestRunSearch_WithTypeFilter(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_WithTitleFilter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") - assert.Contains(t, cql, `title ~ "Getting Started"`) + testutil.Contains(t, cql, `title ~ "Getting Started"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -316,14 +331,15 @@ func TestRunSearch_WithTitleFilter(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_WithLabelFilter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") - assert.Contains(t, cql, `label = "documentation"`) + testutil.Contains(t, cql, `label = "documentation"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -340,15 +356,16 @@ func TestRunSearch_WithLabelFilter(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_WithRawCQL(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") // Raw CQL should be used as-is - assert.Equal(t, `type=page AND lastModified > now("-7d")`, cql) + testutil.Equal(t, `type=page AND lastModified > now("-7d")`, cql) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -365,17 +382,18 @@ func TestRunSearch_WithRawCQL(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_CombinedFilters(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") - assert.Contains(t, cql, `text ~ "kubernetes"`) - assert.Contains(t, cql, `space = "DEV"`) - assert.Contains(t, cql, `type = "page"`) - assert.Contains(t, cql, `label = "infrastructure"`) + testutil.Contains(t, cql, `text ~ "kubernetes"`) + testutil.Contains(t, cql, `space = "DEV"`) + testutil.Contains(t, cql, `type = "page"`) + testutil.Contains(t, cql, `label = "infrastructure"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -395,12 +413,13 @@ func TestRunSearch_CombinedFilters(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_APIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"message": "Invalid CQL query"}`)) })) @@ -416,12 +435,13 @@ func TestRunSearch_APIError(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "search failed") + err := runSearch(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "search failed") } func TestRunSearch_HasMore(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{ "results": [ { @@ -445,11 +465,12 @@ func TestRunSearch_HasMore(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_LongTitle(t *testing.T) { + t.Parallel() longTitle := strings.Repeat("A", 100) server := mockSearchServer(t, `{ "results": [ @@ -474,11 +495,12 @@ func TestRunSearch_LongTitle(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_SpaceOnlyFilter(t *testing.T) { + t.Parallel() // Space-only filter should work (no query required) server := mockSearchServer(t, `{"results": [], "totalSize": 0}`) defer server.Close() @@ -493,14 +515,15 @@ func TestRunSearch_SpaceOnlyFilter(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunSearch_LimitParameter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { limit := r.URL.Query().Get("limit") - assert.Equal(t, "50", limit) + testutil.Equal(t, "50", limit) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -517,11 +540,12 @@ func TestRunSearch_LimitParameter(t *testing.T) { limit: 50, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestExtractSpaceKey(t *testing.T) { + t.Parallel() tests := []struct { name string displayURL string @@ -566,13 +590,15 @@ func TestExtractSpaceKey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := extractSpaceKey(tt.displayURL) - assert.Equal(t, tt.want, got) + testutil.Equal(t, tt.want, got) }) } } func TestRunSearch_DisplaysSpaceKey(t *testing.T) { + t.Parallel() server := mockSearchServer(t, `{ "results": [ { @@ -598,7 +624,7 @@ func TestRunSearch_DisplaysSpaceKey(t *testing.T) { limit: 25, } - err := runSearch(opts) - require.NoError(t, err) + err := runSearch(context.Background(), opts) + testutil.RequireNoError(t, err) // The output should contain the space key "DEV" extracted from displayUrl } diff --git a/tools/cfl/internal/cmd/space/create.go b/tools/cfl/internal/cmd/space/create.go index a9196c3..f643a8e 100644 --- a/tools/cfl/internal/cmd/space/create.go +++ b/tools/cfl/internal/cmd/space/create.go @@ -32,8 +32,8 @@ func newCreateCmd(rootOpts *root.Options) *cobra.Command { # Create with description cfl space create --key DEV --name "Development" --description "Development team space"`, - RunE: func(_ *cobra.Command, _ []string) error { - return runCreate(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + return runCreate(cmd.Context(), opts) }, } @@ -48,7 +48,7 @@ func newCreateCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runCreate(opts *createOptions) error { +func runCreate(ctx context.Context, opts *createOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -75,9 +75,9 @@ func runCreate(opts *createOptions) error { } } - space, err := client.CreateSpace(context.Background(), req) + space, err := client.CreateSpace(ctx, req) if err != nil { - return fmt.Errorf("failed to create space: %w", err) + return fmt.Errorf("creating space: %w", err) } v := opts.View() diff --git a/tools/cfl/internal/cmd/space/delete.go b/tools/cfl/internal/cmd/space/delete.go index d344f79..ae789e8 100644 --- a/tools/cfl/internal/cmd/space/delete.go +++ b/tools/cfl/internal/cmd/space/delete.go @@ -30,8 +30,8 @@ func newDeleteCmd(rootOpts *root.Options) *cobra.Command { # Delete without confirmation cfl space delete TEST --force`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runDelete(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(cmd.Context(), args[0], opts) }, } @@ -40,7 +40,7 @@ func newDeleteCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runDelete(spaceKey string, opts *deleteOptions) error { +func runDelete(ctx context.Context, spaceKey string, opts *deleteOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -50,29 +50,29 @@ func runDelete(spaceKey string, opts *deleteOptions) error { return err } - space, err := client.GetSpaceByKey(context.Background(), spaceKey) + space, err := client.GetSpaceByKey(ctx, spaceKey) if err != nil { - return fmt.Errorf("failed to get space: %w", err) + return fmt.Errorf("getting space: %w", err) } v := opts.View() if !opts.force { - fmt.Printf("About to delete space: %s (%s)\n", space.Name, space.Key) - fmt.Print("Are you sure? [y/N]: ") + _, _ = fmt.Fprintf(opts.Stderr, "About to delete space: %s (%s)\n", space.Name, space.Key) + _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") confirmed, err := prompt.Confirm(opts.Stdin) if err != nil { - return fmt.Errorf("failed to read confirmation: %w", err) + return fmt.Errorf("reading confirmation: %w", err) } if !confirmed { - fmt.Println("Deletion cancelled.") + _, _ = fmt.Fprintln(opts.Stderr, "Deletion cancelled.") return nil } } - if err := client.DeleteSpace(context.Background(), spaceKey); err != nil { - return fmt.Errorf("failed to delete space: %w", err) + if err := client.DeleteSpace(ctx, spaceKey); err != nil { + return fmt.Errorf("deleting space: %w", err) } if opts.Output == "json" { diff --git a/tools/cfl/internal/cmd/space/list.go b/tools/cfl/internal/cmd/space/list.go index f17c13e..fa7eb0d 100644 --- a/tools/cfl/internal/cmd/space/list.go +++ b/tools/cfl/internal/cmd/space/list.go @@ -39,8 +39,8 @@ func newListCmd(rootOpts *root.Options) *cobra.Command { # Paginate through results cfl space list --cursor "eyJpZCI6MTIzfQ=="`, - RunE: func(_ *cobra.Command, _ []string) error { - return runList(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + return runList(cmd.Context(), opts) }, } @@ -51,7 +51,7 @@ func newListCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runList(opts *listOptions) error { +func runList(ctx context.Context, opts *listOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -64,7 +64,7 @@ func runList(opts *listOptions) error { if opts.limit == 0 { if opts.Output == "json" { - return v.JSON([]interface{}{}) + return v.JSON([]any{}) } v.RenderText("No spaces found.") return nil @@ -81,9 +81,9 @@ func runList(opts *listOptions) error { Cursor: opts.cursor, } - result, err := client.ListSpaces(context.Background(), apiOpts) + result, err := client.ListSpaces(ctx, apiOpts) if err != nil { - return fmt.Errorf("failed to list spaces: %w", err) + return fmt.Errorf("listing spaces: %w", err) } if len(result.Results) == 0 { @@ -92,7 +92,7 @@ func runList(opts *listOptions) error { } headers := []string{"KEY", "NAME", "TYPE", "DESCRIPTION"} - var rows [][]string + rows := make([][]string, 0, len(result.Results)) for _, space := range result.Results { desc := "" diff --git a/tools/cfl/internal/cmd/space/list_test.go b/tools/cfl/internal/cmd/space/list_test.go index e31edb7..6987811 100644 --- a/tools/cfl/internal/cmd/space/list_test.go +++ b/tools/cfl/internal/cmd/space/list_test.go @@ -2,12 +2,12 @@ package space import ( "bytes" + "context" "net/http" "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -23,9 +23,10 @@ func newTestRootOptions() *root.Options { } func TestRunList_Success(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "GET", r.Method) - assert.Contains(t, r.URL.Path, "/spaces") + testutil.Equal(t, "GET", r.Method) + testutil.Contains(t, r.URL.Path, "/spaces") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -58,12 +59,13 @@ func TestRunList_Success(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_EmptyResults(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) })) @@ -78,12 +80,13 @@ func TestRunList_EmptyResults(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_JSONOutput(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [ @@ -103,11 +106,12 @@ func TestRunList_JSONOutput(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_InvalidOutputFormat(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() rootOpts.Output = "invalid" @@ -116,12 +120,13 @@ func TestRunList_InvalidOutputFormat(t *testing.T) { limit: 25, } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid output format") } func TestRunList_NegativeLimit(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &listOptions{ @@ -129,12 +134,13 @@ func TestRunList_NegativeLimit(t *testing.T) { limit: -1, } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid limit") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid limit") } func TestRunList_ZeroLimit(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &listOptions{ @@ -143,11 +149,12 @@ func TestRunList_ZeroLimit(t *testing.T) { } // Zero limit should return empty without making API call - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_ZeroLimitJSON(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() rootOpts.Output = "json" @@ -157,13 +164,14 @@ func TestRunList_ZeroLimitJSON(t *testing.T) { } // Zero limit should return empty JSON array without making API call - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_WithTypeFilter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "global", r.URL.Query().Get("type")) + testutil.Equal(t, "global", r.URL.Query().Get("type")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -184,13 +192,14 @@ func TestRunList_WithTypeFilter(t *testing.T) { spaceType: "global", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_WithLimitParameter(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "50", r.URL.Query().Get("limit")) + testutil.Equal(t, "50", r.URL.Query().Get("limit")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -206,12 +215,13 @@ func TestRunList_WithLimitParameter(t *testing.T) { limit: 50, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_APIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"message": "Authentication required"}`)) })) @@ -226,13 +236,14 @@ func TestRunList_APIError(t *testing.T) { limit: 25, } - err := runList(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to list spaces") + err := runList(context.Background(), opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "listing spaces") } func TestRunList_HasMore(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [ @@ -252,12 +263,13 @@ func TestRunList_HasMore(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_NullDescription(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [ @@ -276,13 +288,14 @@ func TestRunList_NullDescription(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_WithCursor(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "abc123", r.URL.Query().Get("cursor")) + testutil.Equal(t, "abc123", r.URL.Query().Get("cursor")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -303,12 +316,13 @@ func TestRunList_WithCursor(t *testing.T) { cursor: "abc123", } - err := runList(opts) - require.NoError(t, err) + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) } func TestRunList_DisplaysNextCursor(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ "results": [ @@ -330,13 +344,14 @@ func TestRunList_DisplaysNextCursor(t *testing.T) { limit: 25, } - err := runList(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "nextPageCursor123") - assert.Contains(t, stderr.String(), "--cursor") + err := runList(context.Background(), opts) + testutil.RequireNoError(t, err) + testutil.Contains(t, stderr.String(), "nextPageCursor123") + testutil.Contains(t, stderr.String(), "--cursor") } func TestExtractCursor(t *testing.T) { + t.Parallel() tests := []struct { name string nextLink string @@ -366,8 +381,9 @@ func TestExtractCursor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := extractCursor(tt.nextLink) - assert.Equal(t, tt.want, got) + testutil.Equal(t, tt.want, got) }) } } diff --git a/tools/cfl/internal/cmd/space/space_test.go b/tools/cfl/internal/cmd/space/space_test.go index 7877d60..9e013bc 100644 --- a/tools/cfl/internal/cmd/space/space_test.go +++ b/tools/cfl/internal/cmd/space/space_test.go @@ -2,14 +2,14 @@ package space import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" @@ -41,10 +41,11 @@ const v1SpaceUpdateResponse = `{ // --- View tests --- func TestRunView_Table(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "GET", r.Method) - assert.Contains(t, r.URL.Path, "/spaces") - assert.Equal(t, "TEST", r.URL.Query().Get("keys")) + testutil.Equal(t, "GET", r.Method) + testutil.Contains(t, r.URL.Path, "/spaces") + testutil.Equal(t, "TEST", r.URL.Query().Get("keys")) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(spaceListResponse)) @@ -62,17 +63,18 @@ func TestRunView_Table(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts} - err := runView("TEST", opts) + err := runView(context.Background(), "TEST", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) output := stdout.String() - assert.Contains(t, output, "TEST") - assert.Contains(t, output, "Test Space") - assert.Contains(t, output, "global") - assert.Contains(t, output, "A test space") + testutil.Contains(t, output, "TEST") + testutil.Contains(t, output, "Test Space") + testutil.Contains(t, output, "global") + testutil.Contains(t, output, "A test space") } func TestRunView_JSON(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(spaceListResponse)) @@ -90,16 +92,17 @@ func TestRunView_JSON(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts} - err := runView("TEST", opts) + err := runView(context.Background(), "TEST", opts) - require.NoError(t, err) - var result map[string]interface{} + testutil.RequireNoError(t, err) + var result map[string]any err = json.Unmarshal(stdout.Bytes(), &result) - require.NoError(t, err) - assert.Equal(t, "TEST", result["key"]) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", result["key"]) } func TestRunView_NotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -111,25 +114,26 @@ func TestRunView_NotFound(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{Options: rootOpts} - err := runView("NONEXISTENT", opts) + err := runView(context.Background(), "NONEXISTENT", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "not found") } // --- Create tests --- func TestRunCreate(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v2/spaces", r.URL.Path) + testutil.Equal(t, "POST", r.Method) + testutil.Equal(t, "/api/v2/spaces", r.URL.Path) var req api.CreateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.Equal(t, "TEST", req.Key) - assert.Equal(t, "Test Space", req.Name) - assert.Equal(t, "global", req.Type) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", req.Key) + testutil.Equal(t, "Test Space", req.Name) + testutil.Equal(t, "global", req.Type) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -160,16 +164,17 @@ func TestRunCreate(t *testing.T) { spaceType: "global", } - err := runCreate(opts) + err := runCreate(context.Background(), opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) output := stdout.String() - assert.Contains(t, output, "Created space") - assert.Contains(t, output, "Test Space") - assert.Contains(t, output, "TEST") + testutil.Contains(t, output, "Created space") + testutil.Contains(t, output, "Test Space") + testutil.Contains(t, output, "TEST") } func TestRunCreate_JSON(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -199,22 +204,23 @@ func TestRunCreate_JSON(t *testing.T) { spaceType: "global", } - err := runCreate(opts) + err := runCreate(context.Background(), opts) - require.NoError(t, err) - var result map[string]interface{} + testutil.RequireNoError(t, err) + var result map[string]any err = json.Unmarshal(stdout.Bytes(), &result) - require.NoError(t, err) - assert.Equal(t, "TEST", result["key"]) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", result["key"]) } func TestRunCreate_WithDescription(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req api.CreateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.NotNil(t, req.Description) - assert.Equal(t, "A test space", req.Description.Plain.Value) + testutil.RequireNoError(t, err) + testutil.NotNil(t, req.Description) + testutil.Equal(t, "A test space", req.Description.Plain.Value) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{ @@ -239,22 +245,23 @@ func TestRunCreate_WithDescription(t *testing.T) { spaceType: "global", } - err := runCreate(opts) - require.NoError(t, err) + err := runCreate(context.Background(), opts) + testutil.RequireNoError(t, err) } // --- Update tests --- func TestRunUpdate(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - assert.Equal(t, "/rest/api/space/TEST", r.URL.Path) + testutil.Equal(t, "PUT", r.Method) + testutil.Equal(t, "/rest/api/space/TEST", r.URL.Path) var req api.UpdateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.Equal(t, "TEST", req.Key) - assert.Equal(t, "Updated Name", req.Name) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", req.Key) + testutil.Equal(t, "Updated Name", req.Name) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(v1SpaceUpdateResponse)) @@ -276,15 +283,16 @@ func TestRunUpdate(t *testing.T) { name: "Updated Name", } - err := runUpdate("TEST", opts) + err := runUpdate(context.Background(), "TEST", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) output := stdout.String() - assert.Contains(t, output, "Updated space") - assert.Contains(t, output, "Updated Name") + testutil.Contains(t, output, "Updated space") + testutil.Contains(t, output, "Updated Name") } func TestRunUpdate_JSON(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(v1SpaceUpdateResponse)) @@ -306,36 +314,38 @@ func TestRunUpdate_JSON(t *testing.T) { name: "Updated Name", } - err := runUpdate("TEST", opts) + err := runUpdate(context.Background(), "TEST", opts) - require.NoError(t, err) - var result map[string]interface{} + testutil.RequireNoError(t, err) + var result map[string]any err = json.Unmarshal(stdout.Bytes(), &result) - require.NoError(t, err) - assert.Equal(t, "TEST", result["key"]) + testutil.RequireNoError(t, err) + testutil.Equal(t, "TEST", result["key"]) } func TestRunUpdate_NoFlags(t *testing.T) { + t.Parallel() rootOpts := newTestRootOptions() opts := &updateOptions{ Options: rootOpts, } - err := runUpdate("TEST", opts) + err := runUpdate(context.Background(), "TEST", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "at least one of --name or --description is required") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "at least one of --name or --description is required") } func TestRunUpdate_WithDescription(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req api.UpdateSpaceRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.NotNil(t, req.Description) - assert.Equal(t, "New description", req.Description.Plain.Value) - assert.Equal(t, "plain", req.Description.Plain.Representation) + testutil.RequireNoError(t, err) + testutil.NotNil(t, req.Description) + testutil.Equal(t, "New description", req.Description.Plain.Value) + testutil.Equal(t, "plain", req.Description.Plain.Representation) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(v1SpaceUpdateResponse)) @@ -351,26 +361,27 @@ func TestRunUpdate_WithDescription(t *testing.T) { description: "New description", } - err := runUpdate("TEST", opts) - require.NoError(t, err) + err := runUpdate(context.Background(), "TEST", opts) + testutil.RequireNoError(t, err) } // --- Delete tests --- func TestRunDelete_Force(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ if callCount == 1 { // GetSpaceByKey call - assert.Equal(t, "GET", r.Method) + testutil.Equal(t, "GET", r.Method) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(spaceListResponse)) return } // DeleteSpace call - assert.Equal(t, "DELETE", r.Method) - assert.Equal(t, "/rest/api/space/TEST", r.URL.Path) + testutil.Equal(t, "DELETE", r.Method) + testutil.Equal(t, "/rest/api/space/TEST", r.URL.Path) w.WriteHeader(http.StatusAccepted) })) defer server.Close() @@ -390,17 +401,18 @@ func TestRunDelete_Force(t *testing.T) { force: true, } - err := runDelete("TEST", opts) + err := runDelete(context.Background(), "TEST", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) output := stdout.String() - assert.Contains(t, output, "Deleted space") - assert.Contains(t, output, "Test Space") + testutil.Contains(t, output, "Deleted space") + testutil.Contains(t, output, "Test Space") } func TestRunDelete_Force_JSON(t *testing.T) { + t.Parallel() callCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { callCount++ if callCount == 1 { w.WriteHeader(http.StatusOK) @@ -426,18 +438,19 @@ func TestRunDelete_Force_JSON(t *testing.T) { force: true, } - err := runDelete("TEST", opts) + err := runDelete(context.Background(), "TEST", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) var result map[string]string err = json.Unmarshal(stdout.Bytes(), &result) - require.NoError(t, err) - assert.Equal(t, "deleted", result["status"]) - assert.Equal(t, "TEST", result["space_key"]) - assert.Equal(t, "Test Space", result["name"]) + testutil.RequireNoError(t, err) + testutil.Equal(t, "deleted", result["status"]) + testutil.Equal(t, "TEST", result["space_key"]) + testutil.Equal(t, "Test Space", result["name"]) } func TestRunDelete_NoForce_Declined(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(spaceListResponse)) @@ -454,12 +467,13 @@ func TestRunDelete_NoForce_Declined(t *testing.T) { force: false, } - err := runDelete("TEST", opts) + err := runDelete(context.Background(), "TEST", opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) } func TestRunDelete_NoForce_Accepted(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { callCount++ @@ -482,13 +496,14 @@ func TestRunDelete_NoForce_Accepted(t *testing.T) { force: false, } - err := runDelete("TEST", opts) + err := runDelete(context.Background(), "TEST", opts) - require.NoError(t, err) - assert.Equal(t, 2, callCount) + testutil.RequireNoError(t, err) + testutil.Equal(t, 2, callCount) } func TestRunDelete_NotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": []}`)) @@ -504,8 +519,8 @@ func TestRunDelete_NotFound(t *testing.T) { force: true, } - err := runDelete("NONEXISTENT", opts) + err := runDelete(context.Background(), "NONEXISTENT", opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "not found") } diff --git a/tools/cfl/internal/cmd/space/update.go b/tools/cfl/internal/cmd/space/update.go index 65165dc..7a8a3df 100644 --- a/tools/cfl/internal/cmd/space/update.go +++ b/tools/cfl/internal/cmd/space/update.go @@ -34,8 +34,8 @@ func newUpdateCmd(rootOpts *root.Options) *cobra.Command { # Update both cfl space update DEV --name "Development Team" --description "Updated description"`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runUpdate(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(cmd.Context(), args[0], opts) }, } @@ -45,7 +45,7 @@ func newUpdateCmd(rootOpts *root.Options) *cobra.Command { return cmd } -func runUpdate(spaceKey string, opts *updateOptions) error { +func runUpdate(ctx context.Context, spaceKey string, opts *updateOptions) error { if opts.name == "" && opts.description == "" { return fmt.Errorf("at least one of --name or --description is required") } @@ -76,9 +76,9 @@ func runUpdate(spaceKey string, opts *updateOptions) error { } } - space, err := client.UpdateSpace(context.Background(), spaceKey, req) + space, err := client.UpdateSpace(ctx, spaceKey, req) if err != nil { - return fmt.Errorf("failed to update space: %w", err) + return fmt.Errorf("updating space: %w", err) } v := opts.View() diff --git a/tools/cfl/internal/cmd/space/view.go b/tools/cfl/internal/cmd/space/view.go index 43f7343..5530662 100644 --- a/tools/cfl/internal/cmd/space/view.go +++ b/tools/cfl/internal/cmd/space/view.go @@ -29,15 +29,15 @@ func newViewCmd(rootOpts *root.Options) *cobra.Command { # View as JSON cfl space view DEV -o json`, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return runView(args[0], opts) + RunE: func(cmd *cobra.Command, args []string) error { + return runView(cmd.Context(), args[0], opts) }, } return cmd } -func runView(spaceKey string, opts *viewOptions) error { +func runView(ctx context.Context, spaceKey string, opts *viewOptions) error { if err := view.ValidateFormat(opts.Output); err != nil { return err } @@ -47,9 +47,9 @@ func runView(spaceKey string, opts *viewOptions) error { return err } - space, err := client.GetSpaceByKey(context.Background(), spaceKey) + space, err := client.GetSpaceByKey(ctx, spaceKey) if err != nil { - return fmt.Errorf("failed to get space: %w", err) + return fmt.Errorf("getting space: %w", err) } v := opts.View() diff --git a/tools/cfl/internal/config/config.go b/tools/cfl/internal/config/config.go index aa9492c..b7b58e7 100644 --- a/tools/cfl/internal/config/config.go +++ b/tools/cfl/internal/config/config.go @@ -87,18 +87,18 @@ func DefaultConfigPath() string { func (c *Config) Save(path string) error { // Create directory if it doesn't exist dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("creating config directory: %w", err) } data, err := yaml.Marshal(c) if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) + return fmt.Errorf("marshaling config: %w", err) } // Write with restricted permissions (user read/write only) if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) + return fmt.Errorf("writing config file: %w", err) } return nil @@ -106,14 +106,14 @@ func (c *Config) Save(path string) error { // Load reads the configuration from the specified path. func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // reading config file by path if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) + return nil, fmt.Errorf("reading config file: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) + return nil, fmt.Errorf("parsing config file: %w", err) } return &cfg, nil diff --git a/tools/cfl/internal/config/config_test.go b/tools/cfl/internal/config/config_test.go index 76b4e76..0bb16a5 100644 --- a/tools/cfl/internal/config/config_test.go +++ b/tools/cfl/internal/config/config_test.go @@ -7,11 +7,11 @@ import ( "testing" sharedconfig "github.com/open-cli-collective/atlassian-go/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestConfig_Validate(t *testing.T) { + t.Parallel() tests := []struct { name string config Config @@ -68,18 +68,20 @@ func TestConfig_Validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() err := tt.config.Validate() if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errMsg) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), tt.errMsg) } else { - assert.NoError(t, err) + testutil.NoError(t, err) } }) } } func TestConfig_NormalizeURL(t *testing.T) { + t.Parallel() tests := []struct { name string inputURL string @@ -109,14 +111,16 @@ func TestConfig_NormalizeURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() cfg := Config{URL: tt.inputURL} cfg.NormalizeURL() - assert.Equal(t, tt.expected, cfg.URL) + testutil.Equal(t, tt.expected, cfg.URL) }) } } func TestConfig_LoadFromEnv(t *testing.T) { + t.Parallel() // Save original env vars origURL := os.Getenv("CFL_URL") origEmail := os.Getenv("CFL_EMAIL") @@ -132,6 +136,7 @@ func TestConfig_LoadFromEnv(t *testing.T) { }() t.Run("loads all env vars", func(t *testing.T) { + t.Parallel() _ = os.Setenv("CFL_URL", "https://env.atlassian.net") _ = os.Setenv("CFL_EMAIL", "env@example.com") _ = os.Setenv("CFL_API_TOKEN", "env-token") @@ -140,13 +145,14 @@ func TestConfig_LoadFromEnv(t *testing.T) { cfg := &Config{} cfg.LoadFromEnv() - assert.Equal(t, "https://env.atlassian.net", cfg.URL) - assert.Equal(t, "env@example.com", cfg.Email) - assert.Equal(t, "env-token", cfg.APIToken) - assert.Equal(t, "ENV", cfg.DefaultSpace) + testutil.Equal(t, "https://env.atlassian.net", cfg.URL) + testutil.Equal(t, "env@example.com", cfg.Email) + testutil.Equal(t, "env-token", cfg.APIToken) + testutil.Equal(t, "ENV", cfg.DefaultSpace) }) t.Run("env vars override existing values", func(t *testing.T) { + t.Parallel() _ = os.Setenv("CFL_URL", "https://override.atlassian.net") _ = os.Setenv("CFL_EMAIL", "") _ = os.Setenv("CFL_API_TOKEN", "") @@ -159,25 +165,27 @@ func TestConfig_LoadFromEnv(t *testing.T) { cfg.LoadFromEnv() // URL should be overridden - assert.Equal(t, "https://override.atlassian.net", cfg.URL) + testutil.Equal(t, "https://override.atlassian.net", cfg.URL) // Email should remain (empty env var doesn't override) - assert.Equal(t, "original@example.com", cfg.Email) + testutil.Equal(t, "original@example.com", cfg.Email) }) } func TestDefaultConfigPath(t *testing.T) { + t.Parallel() path := DefaultConfigPath() // Should be under home directory home, err := os.UserHomeDir() - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.True(t, strings.HasPrefix(path, home)) - assert.Contains(t, path, "cfl") - assert.True(t, filepath.Ext(path) == ".yml" || filepath.Ext(path) == ".yaml") + testutil.True(t, strings.HasPrefix(path, home)) + testutil.Contains(t, path, "cfl") + testutil.True(t, filepath.Ext(path) == ".yml" || filepath.Ext(path) == ".yaml") } func TestConfig_Save_and_Load(t *testing.T) { + t.Parallel() // Create a temp directory for the test tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.yml") @@ -192,22 +200,23 @@ func TestConfig_Save_and_Load(t *testing.T) { // Save err := original.Save(configPath) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Load loaded, err := Load(configPath) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, original.URL, loaded.URL) - assert.Equal(t, original.Email, loaded.Email) - assert.Equal(t, original.APIToken, loaded.APIToken) - assert.Equal(t, original.DefaultSpace, loaded.DefaultSpace) - assert.Equal(t, original.OutputFormat, loaded.OutputFormat) + testutil.Equal(t, original.URL, loaded.URL) + testutil.Equal(t, original.Email, loaded.Email) + testutil.Equal(t, original.APIToken, loaded.APIToken) + testutil.Equal(t, original.DefaultSpace, loaded.DefaultSpace) + testutil.Equal(t, original.OutputFormat, loaded.OutputFormat) } func TestLoad_FileNotFound(t *testing.T) { + t.Parallel() _, err := Load("/nonexistent/path/config.yml") - require.Error(t, err) + testutil.RequireError(t, err) } func TestConfig_LoadFromEnv_AtlassianFallback(t *testing.T) { @@ -225,35 +234,35 @@ func TestConfig_LoadFromEnv_AtlassianFallback(t *testing.T) { clearEnvVars() defer clearEnvVars() - os.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net") - os.Setenv("ATLASSIAN_EMAIL", "shared@example.com") - os.Setenv("ATLASSIAN_API_TOKEN", "shared-token") + t.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net") + t.Setenv("ATLASSIAN_EMAIL", "shared@example.com") + t.Setenv("ATLASSIAN_API_TOKEN", "shared-token") cfg := &Config{} cfg.LoadFromEnv() - assert.Equal(t, "https://shared.atlassian.net", cfg.URL) - assert.Equal(t, "shared@example.com", cfg.Email) - assert.Equal(t, "shared-token", cfg.APIToken) + testutil.Equal(t, "https://shared.atlassian.net", cfg.URL) + testutil.Equal(t, "shared@example.com", cfg.Email) + testutil.Equal(t, "shared-token", cfg.APIToken) }) t.Run("CFL_* takes precedence over ATLASSIAN_*", func(t *testing.T) { clearEnvVars() defer clearEnvVars() - os.Setenv("CFL_URL", "https://cfl.atlassian.net") - os.Setenv("CFL_EMAIL", "cfl@example.com") - os.Setenv("CFL_API_TOKEN", "cfl-token") - os.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net") - os.Setenv("ATLASSIAN_EMAIL", "shared@example.com") - os.Setenv("ATLASSIAN_API_TOKEN", "shared-token") + t.Setenv("CFL_URL", "https://cfl.atlassian.net") + t.Setenv("CFL_EMAIL", "cfl@example.com") + t.Setenv("CFL_API_TOKEN", "cfl-token") + t.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net") + t.Setenv("ATLASSIAN_EMAIL", "shared@example.com") + t.Setenv("ATLASSIAN_API_TOKEN", "shared-token") cfg := &Config{} cfg.LoadFromEnv() - assert.Equal(t, "https://cfl.atlassian.net", cfg.URL) - assert.Equal(t, "cfl@example.com", cfg.Email) - assert.Equal(t, "cfl-token", cfg.APIToken) + testutil.Equal(t, "https://cfl.atlassian.net", cfg.URL) + testutil.Equal(t, "cfl@example.com", cfg.Email) + testutil.Equal(t, "cfl-token", cfg.APIToken) }) t.Run("mixed CFL_* and ATLASSIAN_*", func(t *testing.T) { @@ -261,16 +270,16 @@ func TestConfig_LoadFromEnv_AtlassianFallback(t *testing.T) { defer clearEnvVars() // Only URL is CFL-specific, rest use shared - os.Setenv("CFL_URL", "https://cfl.atlassian.net") - os.Setenv("ATLASSIAN_EMAIL", "shared@example.com") - os.Setenv("ATLASSIAN_API_TOKEN", "shared-token") + t.Setenv("CFL_URL", "https://cfl.atlassian.net") + t.Setenv("ATLASSIAN_EMAIL", "shared@example.com") + t.Setenv("ATLASSIAN_API_TOKEN", "shared-token") cfg := &Config{} cfg.LoadFromEnv() - assert.Equal(t, "https://cfl.atlassian.net", cfg.URL) - assert.Equal(t, "shared@example.com", cfg.Email) - assert.Equal(t, "shared-token", cfg.APIToken) + testutil.Equal(t, "https://cfl.atlassian.net", cfg.URL) + testutil.Equal(t, "shared@example.com", cfg.Email) + testutil.Equal(t, "shared-token", cfg.APIToken) }) } @@ -283,20 +292,20 @@ func TestGetEnvWithFallback(t *testing.T) { }() t.Run("returns primary when set", func(t *testing.T) { - os.Setenv("TEST_PRIMARY", "primary-value") - os.Setenv("TEST_FALLBACK", "fallback-value") - assert.Equal(t, "primary-value", sharedconfig.GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) + t.Setenv("TEST_PRIMARY", "primary-value") + t.Setenv("TEST_FALLBACK", "fallback-value") + testutil.Equal(t, "primary-value", sharedconfig.GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) }) t.Run("returns fallback when primary empty", func(t *testing.T) { os.Unsetenv("TEST_PRIMARY") - os.Setenv("TEST_FALLBACK", "fallback-value") - assert.Equal(t, "fallback-value", sharedconfig.GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) + t.Setenv("TEST_FALLBACK", "fallback-value") + testutil.Equal(t, "fallback-value", sharedconfig.GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) }) t.Run("returns empty when both empty", func(t *testing.T) { os.Unsetenv("TEST_PRIMARY") os.Unsetenv("TEST_FALLBACK") - assert.Equal(t, "", sharedconfig.GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) + testutil.Equal(t, "", sharedconfig.GetEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) }) } diff --git a/tools/cfl/pkg/md/codeprotect.go b/tools/cfl/pkg/md/codeprotect.go index feee600..9bd6d2f 100644 --- a/tools/cfl/pkg/md/codeprotect.go +++ b/tools/cfl/pkg/md/codeprotect.go @@ -1,9 +1,4 @@ -// codeprotect.go provides utilities for protecting code regions (fenced code -// blocks and inline code spans) from preprocessing transformations. -// -// The protect/restore cycle allows regex-based preprocessors (wiki-links, -// macros) to skip content inside code regions without needing markdown-aware -// parsing. +// Package md provides bidirectional Markdown-Confluence conversion. package md import ( diff --git a/tools/cfl/pkg/md/codeprotect_test.go b/tools/cfl/pkg/md/codeprotect_test.go index ac0c99a..1cceaa4 100644 --- a/tools/cfl/pkg/md/codeprotect_test.go +++ b/tools/cfl/pkg/md/codeprotect_test.go @@ -3,10 +3,11 @@ package md import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestProtectCodeRegions_FencedBlock(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -18,11 +19,11 @@ func TestProtectCodeRegions_FencedBlock(t *testing.T) { input: "before\n```\n[[My Page]]\n```\nafter", expectedRegion: 1, checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.Contains(t, output, "before\n") - assert.Contains(t, output, "after") - assert.NotContains(t, output, "[[My Page]]") - assert.Contains(t, regions[0].content, "[[My Page]]") - assert.Contains(t, regions[0].content, "```") + testutil.Contains(t, output, "before\n") + testutil.Contains(t, output, "after") + testutil.NotContains(t, output, "[[My Page]]") + testutil.Contains(t, regions[0].content, "[[My Page]]") + testutil.Contains(t, regions[0].content, "```") }, }, { @@ -30,8 +31,8 @@ func TestProtectCodeRegions_FencedBlock(t *testing.T) { input: "before\n~~~\n[[My Page]]\n~~~\nafter", expectedRegion: 1, checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.NotContains(t, output, "[[My Page]]") - assert.Contains(t, regions[0].content, "[[My Page]]") + testutil.NotContains(t, output, "[[My Page]]") + testutil.Contains(t, regions[0].content, "[[My Page]]") }, }, { @@ -39,41 +40,43 @@ func TestProtectCodeRegions_FencedBlock(t *testing.T) { input: "before\n```markdown\nUse [[Page Title]] syntax\n```\nafter", expectedRegion: 1, checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.NotContains(t, output, "[[Page Title]]") - assert.Contains(t, regions[0].content, "[[Page Title]]") - assert.Contains(t, regions[0].content, "```markdown") + testutil.NotContains(t, output, "[[Page Title]]") + testutil.Contains(t, regions[0].content, "[[Page Title]]") + testutil.Contains(t, regions[0].content, "```markdown") }, }, { name: "no code block", input: "See [[My Page]] for details", expectedRegion: 0, - checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.Equal(t, "See [[My Page]] for details", output) + checkOutput: func(t *testing.T, output string, _ []codeRegion) { + testutil.Equal(t, "See [[My Page]] for details", output) }, }, { name: "multiple code blocks", input: "```\n[[A]]\n```\ntext\n```\n[[B]]\n```", expectedRegion: 2, - checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.Contains(t, output, "text") - assert.NotContains(t, output, "[[A]]") - assert.NotContains(t, output, "[[B]]") + checkOutput: func(t *testing.T, output string, _ []codeRegion) { + testutil.Contains(t, output, "text") + testutil.NotContains(t, output, "[[A]]") + testutil.NotContains(t, output, "[[B]]") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() output, regions := protectCodeRegions([]byte(tt.input)) - assert.Equal(t, tt.expectedRegion, len(regions)) + testutil.Equal(t, tt.expectedRegion, len(regions)) tt.checkOutput(t, string(output), regions) }) } } func TestProtectCodeRegions_InlineCode(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -85,10 +88,10 @@ func TestProtectCodeRegions_InlineCode(t *testing.T) { input: "Use `[[My Page]]` for links", expectedRegion: 1, checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.NotContains(t, output, "[[My Page]]") - assert.Contains(t, output, "Use ") - assert.Contains(t, output, " for links") - assert.Equal(t, "`[[My Page]]`", regions[0].content) + testutil.NotContains(t, output, "[[My Page]]") + testutil.Contains(t, output, "Use ") + testutil.Contains(t, output, " for links") + testutil.Equal(t, "`[[My Page]]`", regions[0].content) }, }, { @@ -96,16 +99,16 @@ func TestProtectCodeRegions_InlineCode(t *testing.T) { input: "Use ``[[My Page]]`` for links", expectedRegion: 1, checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.NotContains(t, output, "[[My Page]]") - assert.Equal(t, "``[[My Page]]``", regions[0].content) + testutil.NotContains(t, output, "[[My Page]]") + testutil.Equal(t, "``[[My Page]]``", regions[0].content) }, }, { name: "no inline code", input: "See [[My Page]] here", expectedRegion: 0, - checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.Equal(t, "See [[My Page]] here", output) + checkOutput: func(t *testing.T, output string, _ []codeRegion) { + testutil.Equal(t, "See [[My Page]] here", output) }, }, { @@ -113,39 +116,42 @@ func TestProtectCodeRegions_InlineCode(t *testing.T) { input: "Use `[[syntax]]` to link to [[Real Page]]", expectedRegion: 1, checkOutput: func(t *testing.T, output string, regions []codeRegion) { - assert.Contains(t, output, "[[Real Page]]") - assert.NotContains(t, output, "`[[syntax]]`") - assert.Equal(t, "`[[syntax]]`", regions[0].content) + testutil.Contains(t, output, "[[Real Page]]") + testutil.NotContains(t, output, "`[[syntax]]`") + testutil.Equal(t, "`[[syntax]]`", regions[0].content) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() output, regions := protectCodeRegions([]byte(tt.input)) - assert.Equal(t, tt.expectedRegion, len(regions)) + testutil.Equal(t, tt.expectedRegion, len(regions)) tt.checkOutput(t, string(output), regions) }) } } func TestProtectCodeRegions_Mixed(t *testing.T) { + t.Parallel() input := "See [[Page A]] here.\n\n```\n[[Page B]] in code\n```\n\nAlso `[[Page C]]` inline.\n\nAnd [[Page D]] at end." output, regions := protectCodeRegions([]byte(input)) outputStr := string(output) // Code regions should be protected - assert.Equal(t, 2, len(regions)) - assert.NotContains(t, outputStr, "[[Page B]]") - assert.NotContains(t, outputStr, "[[Page C]]") + testutil.Equal(t, 2, len(regions)) + testutil.NotContains(t, outputStr, "[[Page B]]") + testutil.NotContains(t, outputStr, "[[Page C]]") // Non-code wiki links should remain - assert.Contains(t, outputStr, "[[Page A]]") - assert.Contains(t, outputStr, "[[Page D]]") + testutil.Contains(t, outputStr, "[[Page A]]") + testutil.Contains(t, outputStr, "[[Page D]]") } func TestRestoreCodeRegions(t *testing.T) { + t.Parallel() // Simulate a protect → transform → restore cycle input := "before\n```\n[[My Page]]\n```\nafter [[Link]]" @@ -153,26 +159,28 @@ func TestRestoreCodeRegions(t *testing.T) { // Simulate wiki-link replacement on the non-code parts protectedStr := string(protected) - assert.Contains(t, protectedStr, "[[Link]]") + testutil.Contains(t, protectedStr, "[[Link]]") // Restore restored := restoreCodeRegions(protected, regions) - assert.Equal(t, input, string(restored)) + testutil.Equal(t, input, string(restored)) } func TestProtectCodeRegions_UnclosedFence(t *testing.T) { + t.Parallel() // Unclosed fence should protect to end of input input := "before\n```\n[[My Page]]\nno closing fence" output, regions := protectCodeRegions([]byte(input)) - assert.Equal(t, 1, len(regions)) - assert.Contains(t, string(output), "before\n") - assert.NotContains(t, string(output), "[[My Page]]") + testutil.Equal(t, 1, len(regions)) + testutil.Contains(t, string(output), "before\n") + testutil.NotContains(t, string(output), "[[My Page]]") } func TestProtectCodeRegions_UnmatchedBacktick(t *testing.T) { + t.Parallel() // Unmatched backtick should not swallow content input := "text `unclosed [[My Page]]" output, regions := protectCodeRegions([]byte(input)) - assert.Equal(t, 0, len(regions)) - assert.Equal(t, input, string(output)) + testutil.Equal(t, 0, len(regions)) + testutil.Equal(t, input, string(output)) } diff --git a/tools/cfl/pkg/md/converter.go b/tools/cfl/pkg/md/converter.go index a6e05e8..a1472a3 100644 --- a/tools/cfl/pkg/md/converter.go +++ b/tools/cfl/pkg/md/converter.go @@ -1,4 +1,3 @@ -// Package md provides markdown conversion utilities for Confluence. package md import ( @@ -153,13 +152,13 @@ func postprocessMacros(html string, macros map[int]string) string { // The inner macro placeholder ends up embedded in the outer macro's XML. for _, id := range ids { macroXML := macros[id] - for _, innerId := range ids { - if innerId >= id { + for _, innerID := range ids { + if innerID >= id { continue // Only replace placeholders from earlier (inner) macros } - placeholder := FormatPlaceholder(innerId) + placeholder := FormatPlaceholder(innerID) if strings.Contains(macroXML, placeholder) { - macros[id] = strings.Replace(macroXML, placeholder, macros[innerId], 1) + macros[id] = strings.Replace(macroXML, placeholder, macros[innerID], 1) macroXML = macros[id] } } diff --git a/tools/cfl/pkg/md/converter_test.go b/tools/cfl/pkg/md/converter_test.go index 26a1800..f69f230 100644 --- a/tools/cfl/pkg/md/converter_test.go +++ b/tools/cfl/pkg/md/converter_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestToConfluenceStorage(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -113,14 +113,16 @@ func TestToConfluenceStorage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToConfluenceStorage([]byte(tt.input)) - require.NoError(t, err) - assert.Equal(t, tt.expected, result) + testutil.RequireNoError(t, err) + testutil.Equal(t, tt.expected, result) }) } } func TestToConfluenceStorage_ComplexDocument(t *testing.T) { + t.Parallel() input := `# Project README This is the **introduction** to the project. @@ -143,20 +145,21 @@ For more info, see [the docs](https://example.com). ` result, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify key elements are present - assert.Contains(t, result, "

Project README

") - assert.Contains(t, result, "introduction") - assert.Contains(t, result, "

Features

") - assert.Contains(t, result, "
  • Feature one
  • ") - assert.Contains(t, result, "

    Code Example

    ") - assert.Contains(t, result, "language-go") - assert.Contains(t, result, "fmt.Println") - assert.Contains(t, result, `the docs`) + testutil.Contains(t, result, "

    Project README

    ") + testutil.Contains(t, result, "introduction") + testutil.Contains(t, result, "

    Features

    ") + testutil.Contains(t, result, "
  • Feature one
  • ") + testutil.Contains(t, result, "

    Code Example

    ") + testutil.Contains(t, result, "language-go") + testutil.Contains(t, result, "fmt.Println") + testutil.Contains(t, result, `the docs`) } func TestToConfluenceStorage_TOCMacro(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -219,16 +222,18 @@ func TestToConfluenceStorage_TOCMacro(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToConfluenceStorage([]byte(tt.input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) for _, expected := range tt.contains { - assert.Contains(t, result, expected, "should contain: %s", expected) + testutil.Contains(t, result, expected) } }) } } func TestToConfluenceStorage_TOCMixedWithContent(t *testing.T) { + t.Parallel() input := `[TOC maxLevel=3] # Heading 1 @@ -240,20 +245,21 @@ Some content here. More content. ` result, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify TOC macro is present - assert.Contains(t, result, ``) - assert.Contains(t, result, `3`) - assert.Contains(t, result, ``) + testutil.Contains(t, result, ``) + testutil.Contains(t, result, `3`) + testutil.Contains(t, result, ``) // Verify other content is preserved - assert.Contains(t, result, "

    Heading 1

    ") - assert.Contains(t, result, "Some content here.") - assert.Contains(t, result, "

    Heading 2

    ") + testutil.Contains(t, result, "

    Heading 1

    ") + testutil.Contains(t, result, "Some content here.") + testutil.Contains(t, result, "

    Heading 2

    ") } func TestToConfluenceStorage_TOCRoundtrip(t *testing.T) { + t.Parallel() // Test that TOC can survive a roundtrip conversion // Start with Confluence storage format with TOC originalXHTML := `

    Before

    @@ -267,24 +273,25 @@ func TestToConfluenceStorage_TOCRoundtrip(t *testing.T) { // Convert to markdown with ShowMacros opts := ConvertOptions{ShowMacros: true} markdown, err := FromConfluenceStorageWithOptions(originalXHTML, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify markdown has TOC placeholder with params - assert.Contains(t, markdown, "[TOC") - assert.Contains(t, markdown, "maxLevel=3") - assert.Contains(t, markdown, "minLevel=1") + testutil.Contains(t, markdown, "[TOC") + testutil.Contains(t, markdown, "maxLevel=3") + testutil.Contains(t, markdown, "minLevel=1") // Convert back to storage format resultXHTML, err := ToConfluenceStorage([]byte(markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify TOC macro is restored - assert.Contains(t, resultXHTML, ``) - assert.Contains(t, resultXHTML, `3`) - assert.Contains(t, resultXHTML, `1`) + testutil.Contains(t, resultXHTML, ``) + testutil.Contains(t, resultXHTML, `3`) + testutil.Contains(t, resultXHTML, `1`) } func TestParseKeyValueParams(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -324,13 +331,15 @@ func TestParseKeyValueParams(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := parseKeyValueParams(tt.input) - assert.Equal(t, tt.expected, result) + testutil.Equal(t, tt.expected, result) }) } } func TestToConfluenceStorage_PanelMacros(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -429,16 +438,18 @@ func TestToConfluenceStorage_PanelMacros(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToConfluenceStorage([]byte(tt.input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) for _, expected := range tt.contains { - assert.Contains(t, result, expected, "should contain: %s", expected) + testutil.Contains(t, result, expected) } }) } } func TestToConfluenceStorage_PanelMixedWithContent(t *testing.T) { + t.Parallel() input := `# Heading Some intro text. @@ -450,19 +461,20 @@ This is a warning. More text after. ` result, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify all parts are present - assert.Contains(t, result, "

    Heading

    ") - assert.Contains(t, result, "Some intro text.") - assert.Contains(t, result, ``) - assert.Contains(t, result, `Important`) - assert.Contains(t, result, "This is a warning.") - assert.Contains(t, result, "") - assert.Contains(t, result, "More text after.") + testutil.Contains(t, result, "

    Heading

    ") + testutil.Contains(t, result, "Some intro text.") + testutil.Contains(t, result, ``) + testutil.Contains(t, result, `Important`) + testutil.Contains(t, result, "This is a warning.") + testutil.Contains(t, result, "") + testutil.Contains(t, result, "More text after.") } func TestToConfluenceStorage_PanelRoundtrip(t *testing.T) { + t.Parallel() // Test that panel can survive a roundtrip conversion // Use a simple title without spaces to avoid quoting complexity originalXHTML := `

    Before

    @@ -475,46 +487,48 @@ func TestToConfluenceStorage_PanelRoundtrip(t *testing.T) { // Convert to markdown with ShowMacros opts := ConvertOptions{ShowMacros: true} markdown, err := FromConfluenceStorageWithOptions(originalXHTML, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify markdown has panel placeholder (brackets may be escaped by markdown converter) - assert.Contains(t, markdown, "INFO") - assert.Contains(t, markdown, "title=Important") - assert.Contains(t, markdown, "Panel content") + testutil.Contains(t, markdown, "INFO") + testutil.Contains(t, markdown, "title=Important") + testutil.Contains(t, markdown, "Panel content") // Convert back to storage format resultXHTML, err := ToConfluenceStorage([]byte(markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify panel macro is restored - assert.Contains(t, resultXHTML, ``) - assert.Contains(t, resultXHTML, `Important`) - assert.Contains(t, resultXHTML, ``) - assert.Contains(t, resultXHTML, `Panel content`) - assert.Contains(t, resultXHTML, ``) + testutil.Contains(t, resultXHTML, ``) + testutil.Contains(t, resultXHTML, `Important`) + testutil.Contains(t, resultXHTML, ``) + testutil.Contains(t, resultXHTML, `Panel content`) + testutil.Contains(t, resultXHTML, ``) } func TestToConfluenceStorage_NestedMacros(t *testing.T) { + t.Parallel() // Test nested TOC inside INFO panel input := `[INFO] Check out the table of contents: [TOC] [/INFO]` result, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // The result should have both the panel macro and TOC macro - assert.Contains(t, result, ` infoEnd := strings.LastIndex(result, ``) - assert.True(t, infoStart < richTextStart, "INFO should start before rich-text-body") - assert.True(t, richTextStart < tocStart, "rich-text-body should start before TOC") - assert.True(t, tocStart < richTextEnd, "TOC should be before rich-text-body end") - assert.True(t, richTextEnd < infoEnd, "rich-text-body should end before INFO") + testutil.True(t, infoStart < richTextStart, "INFO should start before rich-text-body") + testutil.True(t, richTextStart < tocStart, "rich-text-body should start before TOC") + testutil.True(t, tocStart < richTextEnd, "TOC should be before rich-text-body end") + testutil.True(t, richTextEnd < infoEnd, "rich-text-body should end before INFO") } // TestToConfluenceStorage_MacrosInCodeBlock verifies that bracket macros inside fenced // code blocks are preserved as literal text and not expanded to Confluence XML. func TestToConfluenceStorage_MacrosInCodeBlock(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -699,13 +718,14 @@ func TestToConfluenceStorage_MacrosInCodeBlock(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToConfluenceStorage([]byte(tt.input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) for _, s := range tt.contains { - assert.Contains(t, result, s, "should contain: %s", s) + testutil.Contains(t, result, s) } for _, s := range tt.notContains { - assert.NotContains(t, result, s, "should not contain: %s", s) + testutil.NotContains(t, result, s) } }) } @@ -715,6 +735,7 @@ func TestToConfluenceStorage_MacrosInCodeBlock(t *testing.T) { // conversion is deterministic. This catches issues with non-deterministic map iteration // order in Go which previously caused flaky test failures. See issue #68. func TestToConfluenceStorage_DeterministicNestedMacros(t *testing.T) { + t.Parallel() input := `[INFO]Outer [WARNING]Inner [TOC] @@ -726,10 +747,10 @@ More outer // Run 100 times to catch non-deterministic behavior for i := 0; i < 100; i++ { result, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err, "iteration %d", i) - assert.Contains(t, result, `ac:name="toc"`, "iteration %d: TOC macro missing", i) - assert.Contains(t, result, `ac:name="warning"`, "iteration %d: WARNING macro missing", i) - assert.Contains(t, result, `ac:name="info"`, "iteration %d: INFO macro missing", i) - assert.NotContains(t, result, "CFMACRO", "iteration %d: unresolved placeholder", i) + testutil.RequireNoError(t, err) + testutil.Contains(t, result, `ac:name="toc"`) + testutil.Contains(t, result, `ac:name="warning"`) + testutil.Contains(t, result, `ac:name="info"`) + testutil.NotContains(t, result, "CFMACRO") } } diff --git a/tools/cfl/pkg/md/from_adf.go b/tools/cfl/pkg/md/from_adf.go index b34d28f..3284116 100644 --- a/tools/cfl/pkg/md/from_adf.go +++ b/tools/cfl/pkg/md/from_adf.go @@ -18,7 +18,7 @@ func FromADF(adfJSON string) (string, error) { var doc adf.Document if err := json.Unmarshal([]byte(adfJSON), &doc); err != nil { - return "", fmt.Errorf("failed to parse ADF JSON: %w", err) + return "", fmt.Errorf("parsing ADF JSON: %w", err) } var sb strings.Builder @@ -104,7 +104,7 @@ func renderHeading(sb *strings.Builder, node *adf.Node) { sb.WriteString("\n") } -func renderParagraph(sb *strings.Builder, node *adf.Node, depth int) { +func renderParagraph(sb *strings.Builder, node *adf.Node, _ int) { renderInlineNodes(sb, node.Content) sb.WriteString("\n") } @@ -405,7 +405,7 @@ func extractExtensionParams(node *adf.Node) string { return "" } - paramMap, ok := params.(map[string]interface{}) + paramMap, ok := params.(map[string]any) if !ok { return "" } @@ -416,7 +416,7 @@ func extractExtensionParams(node *adf.Node) string { if name == "macroMetadata" { continue } - if m, ok := entry.(map[string]interface{}); ok { + if m, ok := entry.(map[string]any); ok { if v, ok := m["value"]; ok { parts = append(parts, fmt.Sprintf("%s=%v", name, v)) } diff --git a/tools/cfl/pkg/md/from_adf_test.go b/tools/cfl/pkg/md/from_adf_test.go index 50bd3d9..a31fdda 100644 --- a/tools/cfl/pkg/md/from_adf_test.go +++ b/tools/cfl/pkg/md/from_adf_test.go @@ -4,37 +4,41 @@ import ( "encoding/json" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestFromADF_EmptyInput(t *testing.T) { + t.Parallel() result, err := FromADF("") - require.NoError(t, err) - assert.Equal(t, "", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "", result) } func TestFromADF_EmptyDocument(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "", result) } func TestFromADF_InvalidJSON(t *testing.T) { + t.Parallel() _, err := FromADF("{invalid") - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse ADF JSON") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "parsing ADF JSON") } func TestFromADF_Paragraph(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"Hello world"}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "Hello world\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Hello world\n", result) } func TestFromADF_Headings(t *testing.T) { + t.Parallel() tests := []struct { name string level int @@ -51,50 +55,57 @@ func TestFromADF_Headings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() input := adfDoc(adfHeading(tt.level, tt.text)) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, tt.expected, result) + testutil.RequireNoError(t, err) + testutil.Equal(t, tt.expected, result) }) } } func TestFromADF_Bold(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(adfMarkedText("bold text", "strong"))) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "**bold text**\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "**bold text**\n", result) } func TestFromADF_Italic(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(adfMarkedText("italic text", "em"))) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "*italic text*\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "*italic text*\n", result) } func TestFromADF_InlineCode(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(adfMarkedText("fmt.Println()", "code"))) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "`fmt.Println()`\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "`fmt.Println()`\n", result) } func TestFromADF_Strikethrough(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(adfMarkedText("deleted", "strike"))) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "~~deleted~~\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "~~deleted~~\n", result) } func TestFromADF_Link(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(`{"type":"text","text":"click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]}`)) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "[click here](https://example.com)\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "[click here](https://example.com)\n", result) } func TestFromADF_MixedInline(t *testing.T) { + t.Parallel() input := adfDoc(adfPara( `{"type":"text","text":"Hello "}`, adfMarkedText("world", "strong"), @@ -102,186 +113,208 @@ func TestFromADF_MixedInline(t *testing.T) { adfMarkedText("code", "code"), )) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "Hello **world** and `code`\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Hello **world** and `code`\n", result) } func TestFromADF_BulletList(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item one"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item two"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item three"}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "- Item one\n- Item two\n- Item three\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "- Item one\n- Item two\n- Item three\n", result) } func TestFromADF_OrderedList(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Second"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Third"}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "1. First\n2. Second\n3. Third\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "1. First\n2. Second\n3. Third\n", result) } func TestFromADF_NestedList(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Outer"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Inner"}]}]}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "- Outer") - assert.Contains(t, result, " - Inner") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "- Outer") + testutil.Contains(t, result, " - Inner") } func TestFromADF_CodeBlock_NoLanguage(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"codeBlock","content":[{"type":"text","text":"hello world"}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "```\nhello world\n```\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "```\nhello world\n```\n", result) } func TestFromADF_CodeBlock_WithLanguage(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println(\"hello\")"}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "```go\nfmt.Println(\"hello\")\n```\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "```go\nfmt.Println(\"hello\")\n```\n", result) } func TestFromADF_Blockquote(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted text"}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "> Quoted text\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "> Quoted text\n", result) } func TestFromADF_HorizontalRule(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"Above"}]},{"type":"rule"},{"type":"paragraph","content":[{"type":"text","text":"Below"}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "Above") - assert.Contains(t, result, "---") - assert.Contains(t, result, "Below") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "Above") + testutil.Contains(t, result, "---") + testutil.Contains(t, result, "Below") } func TestFromADF_Table(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"table","attrs":{"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Value"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"A"}]}]},{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"1"}]}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "| Name") - assert.Contains(t, result, "| A") - assert.Contains(t, result, "---") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "| Name") + testutil.Contains(t, result, "| A") + testutil.Contains(t, result, "---") } func TestFromADF_HardBreak(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(`{"type":"text","text":"Line one"}`, `{"type":"hardBreak"}`, `{"type":"text","text":"Line two"}`)) result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "Line one \nLine two\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Line one \nLine two\n", result) } func TestFromADF_Extension_TOC(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default"}}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "[TOC]\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "[TOC]\n", result) } func TestFromADF_Extension_TOC_WithParams(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","parameters":{"maxLevel":{"value":"3"}},"layout":"default"}}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Equal(t, "[TOC maxLevel=3]\n", result) + testutil.RequireNoError(t, err) + testutil.Equal(t, "[TOC maxLevel=3]\n", result) } func TestFromADF_Panel_Info(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"panel","attrs":{"panelType":"info"},"content":[{"type":"paragraph","content":[{"type":"text","text":"Important info"}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "[INFO]") - assert.Contains(t, result, "Important info") - assert.Contains(t, result, "[/INFO]") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "[INFO]") + testutil.Contains(t, result, "Important info") + testutil.Contains(t, result, "[/INFO]") } func TestFromADF_Panel_Warning(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"panel","attrs":{"panelType":"warning"},"content":[{"type":"paragraph","content":[{"type":"text","text":"Be careful"}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "[WARNING]") - assert.Contains(t, result, "Be careful") - assert.Contains(t, result, "[/WARNING]") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "[WARNING]") + testutil.Contains(t, result, "Be careful") + testutil.Contains(t, result, "[/WARNING]") } func TestFromADF_BodiedExtension_Expand(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"expand","parameters":{"title":{"value":"Click me"}},"layout":"default"},"content":[{"type":"paragraph","content":[{"type":"text","text":"Hidden content"}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "[EXPAND title=Click me]") - assert.Contains(t, result, "Hidden content") - assert.Contains(t, result, "[/EXPAND]") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "[EXPAND title=Click me]") + testutil.Contains(t, result, "Hidden content") + testutil.Contains(t, result, "[/EXPAND]") } func TestFromADF_EmptyParagraph(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"paragraph"}]}` result, err := FromADF(input) - require.NoError(t, err) + testutil.RequireNoError(t, err) // An empty paragraph produces just a newline, which gets trimmed to empty. - assert.Equal(t, "", result) + testutil.Equal(t, "", result) } func TestFromADF_MultipleBlocks(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]},{"type":"paragraph","content":[{"type":"text","text":"Some text"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item"}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "# Title") - assert.Contains(t, result, "Some text") - assert.Contains(t, result, "- Item") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "# Title") + testutil.Contains(t, result, "Some text") + testutil.Contains(t, result, "- Item") } func TestFromADF_UnknownNodeFallback(t *testing.T) { + t.Parallel() input := `{"type":"doc","version":1,"content":[{"type":"customWidget","text":"fallback text"}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "fallback text") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "fallback text") } func TestFromADF_InlineCard(t *testing.T) { + t.Parallel() input := adfDoc(adfPara(`{"type":"inlineCard","attrs":{"url":"https://example.com/page"}}`)) result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "https://example.com/page") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "https://example.com/page") } func TestFromADF_ListItem_ContinuationParagraph(t *testing.T) { + t.Parallel() // List item with two paragraphs: first gets bullet prefix, second gets indent only. input := `{"type":"doc","version":1,"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First para"}]},{"type":"paragraph","content":[{"type":"text","text":"Second para"}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "- First para") - assert.Contains(t, result, " Second para") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "- First para") + testutil.Contains(t, result, " Second para") // Second paragraph should NOT have a bullet prefix. - assert.NotContains(t, result, "- Second para") + testutil.NotContains(t, result, "- Second para") } func TestFromADF_ListItem_NestedOrderedList(t *testing.T) { + t.Parallel() // Bullet list item containing a nested ordered list. input := `{"type":"doc","version":1,"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Outer"}]},{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Inner"}]}]}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "- Outer") - assert.Contains(t, result, " 1. Inner") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "- Outer") + testutil.Contains(t, result, " 1. Inner") } func TestFromADF_ListItem_WithCodeBlock(t *testing.T) { + t.Parallel() // List item containing a paragraph followed by a code block. input := `{"type":"doc","version":1,"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Item with code"}]},{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "- Item with code") - assert.Contains(t, result, "```go") - assert.Contains(t, result, "fmt.Println()") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "- Item with code") + testutil.Contains(t, result, "```go") + testutil.Contains(t, result, "fmt.Println()") } func TestFromADF_ListItem_DefaultChild(t *testing.T) { + t.Parallel() // List item containing a blockquote (falls through to the default case). input := `{"type":"doc","version":1,"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted"}]}]}]}]}]}` result, err := FromADF(input) - require.NoError(t, err) - assert.Contains(t, result, "- > Quoted") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "- > Quoted") } // Test helpers for building ADF JSON strings. diff --git a/tools/cfl/pkg/md/from_html.go b/tools/cfl/pkg/md/from_html.go index 25f30ea..ef66655 100644 --- a/tools/cfl/pkg/md/from_html.go +++ b/tools/cfl/pkg/md/from_html.go @@ -176,7 +176,7 @@ func (t *macroIDTracker) addMacrosWithPlaceholders(node *MacroNode, output *stri } // renderMacroToPlaceholders creates a macroPlaceholder from a MacroNode. -func renderMacroToPlaceholders(node *MacroNode, id int) macroPlaceholder { +func renderMacroToPlaceholders(node *MacroNode, _ int) macroPlaceholder { macroType, _ := LookupMacro(node.Name) openTag := RenderMacroToBracketOpen(node) diff --git a/tools/cfl/pkg/md/from_html_test.go b/tools/cfl/pkg/md/from_html_test.go index 2c603a0..5a49e32 100644 --- a/tools/cfl/pkg/md/from_html_test.go +++ b/tools/cfl/pkg/md/from_html_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestFromConfluenceStorage(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -93,14 +93,16 @@ func TestFromConfluenceStorage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := FromConfluenceStorage(tt.input) - require.NoError(t, err) - assert.Equal(t, tt.expected, result) + testutil.RequireNoError(t, err) + testutil.Equal(t, tt.expected, result) }) } } func TestFromConfluenceStorage_ConfluenceCodeMacro(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -159,16 +161,18 @@ func TestFromConfluenceStorage_ConfluenceCodeMacro(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := FromConfluenceStorage(tt.input) - require.NoError(t, err) + testutil.RequireNoError(t, err) for _, expected := range tt.contains { - assert.Contains(t, result, expected, "should contain: %s", expected) + testutil.Contains(t, result, expected) } }) } } func TestFromConfluenceStorage_Tables(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -215,16 +219,18 @@ func TestFromConfluenceStorage_Tables(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := FromConfluenceStorage(tt.input) - require.NoError(t, err) + testutil.RequireNoError(t, err) for _, expected := range tt.contains { - assert.Contains(t, result, expected, "should contain: %s", expected) + testutil.Contains(t, result, expected) } }) } } func TestFromConfluenceStorage_NonCodeMacrosStripped(t *testing.T) { + t.Parallel() // Non-code macros should still be stripped input := `

    Before

    @@ -233,14 +239,15 @@ func TestFromConfluenceStorage_NonCodeMacrosStripped(t *testing.T) {

    After

    ` result, err := FromConfluenceStorage(input) - require.NoError(t, err) - assert.Contains(t, result, "Before") - assert.Contains(t, result, "After") - assert.NotContains(t, result, "toc") - assert.NotContains(t, result, "maxLevel") + testutil.RequireNoError(t, err) + testutil.Contains(t, result, "Before") + testutil.Contains(t, result, "After") + testutil.NotContains(t, result, "toc") + testutil.NotContains(t, result, "maxLevel") } func TestFromConfluenceStorage_TOCWithShowMacros(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -286,15 +293,17 @@ func TestFromConfluenceStorage_TOCWithShowMacros(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(tt.input, opts) - require.NoError(t, err) - assert.Contains(t, result, tt.expected) + testutil.RequireNoError(t, err) + testutil.Contains(t, result, tt.expected) }) } } func TestFromConfluenceStorage_PanelMacrosWithShowMacros(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -365,17 +374,19 @@ func TestFromConfluenceStorage_PanelMacrosWithShowMacros(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(tt.input, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) for _, expected := range tt.contains { - assert.Contains(t, result, expected, "should contain: %s", expected) + testutil.Contains(t, result, expected) } }) } } func TestFromConfluenceStorage_ComplexDocument(t *testing.T) { + t.Parallel() input := `

    Project README

    This is the introduction to the project.

    Features

    @@ -391,20 +402,21 @@ func TestFromConfluenceStorage_ComplexDocument(t *testing.T) {

    For more info, see the docs.

    ` result, err := FromConfluenceStorage(input) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify key elements are present - assert.Contains(t, result, "# Project README") - assert.Contains(t, result, "**introduction**") - assert.Contains(t, result, "## Features") - assert.Contains(t, result, "- Feature one") - assert.Contains(t, result, "## Code Example") - assert.Contains(t, result, "```") - assert.Contains(t, result, "fmt.Println") - assert.Contains(t, result, "[the docs](https://example.com)") + testutil.Contains(t, result, "# Project README") + testutil.Contains(t, result, "**introduction**") + testutil.Contains(t, result, "## Features") + testutil.Contains(t, result, "- Feature one") + testutil.Contains(t, result, "## Code Example") + testutil.Contains(t, result, "```") + testutil.Contains(t, result, "fmt.Println") + testutil.Contains(t, result, "[the docs](https://example.com)") } func TestFromConfluenceStorage_NestedMacros(t *testing.T) { + t.Parallel() // Test nested TOC inside INFO panel input := ` @@ -418,22 +430,23 @@ func TestFromConfluenceStorage_NestedMacros(t *testing.T) { opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(input, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have both INFO panel and nested TOC - assert.Contains(t, result, "[INFO]") - assert.Contains(t, result, "[/INFO]") - assert.Contains(t, result, "[TOC maxLevel=2]") - assert.Contains(t, result, "# Title") + testutil.Contains(t, result, "[INFO]") + testutil.Contains(t, result, "[/INFO]") + testutil.Contains(t, result, "[TOC maxLevel=2]") + testutil.Contains(t, result, "# Title") // INFO should not have TOC's parameters - assert.NotContains(t, result, "[INFO maxLevel=2]") + testutil.NotContains(t, result, "[INFO maxLevel=2]") } // TestXHTMLToMD_NestedMacroPositionPreserved verifies that when converting XHTML back to // Markdown, nested macros appear at their original position in the body content (not // appended to the end). func TestXHTMLToMD_NestedMacroPositionPreserved(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -490,13 +503,14 @@ func TestXHTMLToMD_NestedMacroPositionPreserved(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(tt.input, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify all expected strings are present for _, expected := range tt.verifyOrder { - assert.Contains(t, result, expected, "should contain: %s", expected) + testutil.Contains(t, result, expected) } // Verify order: each string should come after the previous one @@ -514,9 +528,9 @@ func TestXHTMLToMD_NestedMacroPositionPreserved(t *testing.T) { } // No placeholders should remain - assert.NotContains(t, result, "CFXMLCHILD", "XML child placeholders should be replaced") - assert.NotContains(t, result, "CFMACROOPEN", "Macro placeholders should be replaced") - assert.NotContains(t, result, "CFMACROCLOSE", "Macro placeholders should be replaced") + testutil.NotContains(t, result, "CFXMLCHILD") + testutil.NotContains(t, result, "CFMACROOPEN") + testutil.NotContains(t, result, "CFMACROCLOSE") }) } } @@ -529,6 +543,7 @@ func findStringIndex(s, substr string) int { // TestXHTMLToMD_NestedMacroOrderPreserved verifies exact ordering of content and nested // macros using index comparisons. func TestXHTMLToMD_NestedMacroOrderPreserved(t *testing.T) { + t.Parallel() input := `

    Before

    @@ -539,24 +554,25 @@ func TestXHTMLToMD_NestedMacroOrderPreserved(t *testing.T) { opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(input, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) beforeIdx := strings.Index(result, "Before") tocIdx := strings.Index(result, "[TOC]") afterIdx := strings.Index(result, "After") - assert.True(t, beforeIdx >= 0, "Before should be found") - assert.True(t, tocIdx >= 0, "[TOC] should be found") - assert.True(t, afterIdx >= 0, "After should be found") + testutil.True(t, beforeIdx >= 0, "Before should be found") + testutil.True(t, tocIdx >= 0, "[TOC] should be found") + testutil.True(t, afterIdx >= 0, "After should be found") - assert.True(t, beforeIdx < tocIdx, "Before should come before [TOC]") - assert.True(t, tocIdx < afterIdx, "[TOC] should come before After") + testutil.True(t, beforeIdx < tocIdx, "Before should come before [TOC]") + testutil.True(t, tocIdx < afterIdx, "[TOC] should come before After") } // TestFromConfluenceStorage_NestedMacroInParagraph tests the exact bug scenario from issue #56. // When a self-closing nested macro is wrapped in a

    tag, the parser should correctly // identify both macros and their nesting relationship. func TestFromConfluenceStorage_NestedMacroInParagraph(t *testing.T) { + t.Parallel() // This is the exact XHTML structure from issue #56 input := ` @@ -567,30 +583,31 @@ func TestFromConfluenceStorage_NestedMacroInParagraph(t *testing.T) { opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(input, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should contain both INFO and TOC macros without "unclosed macro" warning - assert.Contains(t, result, "[INFO]", "should contain [INFO] macro") - assert.Contains(t, result, "[TOC]", "should contain [TOC] macro") - assert.Contains(t, result, "[/INFO]", "should contain [/INFO] close tag") - assert.Contains(t, result, "# Header 1", "should contain header") + testutil.Contains(t, result, "[INFO]") + testutil.Contains(t, result, "[TOC]") + testutil.Contains(t, result, "[/INFO]") + testutil.Contains(t, result, "# Header 1") // TOC should be inside INFO (between [INFO] and [/INFO]) infoStart := strings.Index(result, "[INFO]") tocPos := strings.Index(result, "[TOC]") infoEnd := strings.Index(result, "[/INFO]") - assert.True(t, infoStart >= 0, "[INFO] should be found") - assert.True(t, tocPos >= 0, "[TOC] should be found") - assert.True(t, infoEnd >= 0, "[/INFO] should be found") + testutil.True(t, infoStart >= 0, "[INFO] should be found") + testutil.True(t, tocPos >= 0, "[TOC] should be found") + testutil.True(t, infoEnd >= 0, "[/INFO] should be found") - assert.True(t, infoStart < tocPos, "[INFO] should come before [TOC]") - assert.True(t, tocPos < infoEnd, "[TOC] should come before [/INFO]") + testutil.True(t, infoStart < tocPos, "[INFO] should come before [TOC]") + testutil.True(t, tocPos < infoEnd, "[TOC] should come before [/INFO]") } // TestFromConfluenceStorage_MultipleSelfClosingNestedMacros tests multiple self-closing // macros nested inside a body macro. func TestFromConfluenceStorage_MultipleSelfClosingNestedMacros(t *testing.T) { + t.Parallel() input := `

    First paragraph

    @@ -603,15 +620,15 @@ func TestFromConfluenceStorage_MultipleSelfClosingNestedMacros(t *testing.T) { opts := ConvertOptions{ShowMacros: true} result, err := FromConfluenceStorageWithOptions(input, opts) - require.NoError(t, err) + testutil.RequireNoError(t, err) // All macros should be present - assert.Contains(t, result, "[INFO]") - assert.Contains(t, result, "[TOC]") - assert.Contains(t, result, "[/INFO]") + testutil.Contains(t, result, "[INFO]") + testutil.Contains(t, result, "[TOC]") + testutil.Contains(t, result, "[/INFO]") // Content should be preserved - assert.Contains(t, result, "First paragraph") - assert.Contains(t, result, "Middle text") - assert.Contains(t, result, "Last paragraph") + testutil.Contains(t, result, "First paragraph") + testutil.Contains(t, result, "Middle text") + testutil.Contains(t, result, "Last paragraph") } diff --git a/tools/cfl/pkg/md/macro.go b/tools/cfl/pkg/md/macro.go index c236a69..8991691 100644 --- a/tools/cfl/pkg/md/macro.go +++ b/tools/cfl/pkg/md/macro.go @@ -1,4 +1,3 @@ -// macro.go defines the core data structures for macro parsing. package md import "strings" @@ -14,6 +13,7 @@ type MacroNode struct { // BodyType indicates how a macro's body content should be handled. type BodyType string +// BodyType constants define how a macro's body content is handled. const ( BodyTypeNone BodyType = "" // no body (e.g., TOC) BodyTypeRichText BodyType = "rich-text" // HTML content (e.g., panels) diff --git a/tools/cfl/pkg/md/macro_test.go b/tools/cfl/pkg/md/macro_test.go index 01b270f..54c7b54 100644 --- a/tools/cfl/pkg/md/macro_test.go +++ b/tools/cfl/pkg/md/macro_test.go @@ -1,24 +1,28 @@ package md import ( + "fmt" "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestMacroRegistry_ContainsExpectedMacros(t *testing.T) { + t.Parallel() expectedMacros := []string{"toc", "info", "warning", "note", "tip", "expand", "code"} for _, name := range expectedMacros { t.Run(name, func(t *testing.T) { + t.Parallel() mt, ok := MacroRegistry[name] - assert.True(t, ok, "MacroRegistry should contain %q", name) - assert.Equal(t, name, mt.Name) + testutil.True(t, ok, fmt.Sprintf("MacroRegistry should contain %q", name)) + testutil.Equal(t, name, mt.Name) }) } } func TestLookupMacro_CaseInsensitive(t *testing.T) { + t.Parallel() tests := []struct { input string expected string @@ -35,16 +39,18 @@ func TestLookupMacro_CaseInsensitive(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { + t.Parallel() mt, ok := LookupMacro(tt.input) - assert.Equal(t, tt.found, ok) + testutil.Equal(t, tt.found, ok) if tt.found { - assert.Equal(t, tt.expected, mt.Name) + testutil.Equal(t, tt.expected, mt.Name) } }) } } func TestMacroType_BodyConfiguration(t *testing.T) { + t.Parallel() tests := []struct { name string hasBody bool @@ -61,15 +67,17 @@ func TestMacroType_BodyConfiguration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() mt, ok := MacroRegistry[tt.name] - assert.True(t, ok) - assert.Equal(t, tt.hasBody, mt.HasBody) - assert.Equal(t, tt.bodyType, mt.BodyType) + testutil.True(t, ok) + testutil.Equal(t, tt.hasBody, mt.HasBody) + testutil.Equal(t, tt.bodyType, mt.BodyType) }) } } func TestMacroNode_Construction(t *testing.T) { + t.Parallel() // Test basic construction node := &MacroNode{ Name: "info", @@ -78,13 +86,14 @@ func TestMacroNode_Construction(t *testing.T) { Children: nil, } - assert.Equal(t, "info", node.Name) - assert.Equal(t, "Important", node.Parameters["title"]) - assert.Equal(t, "This is the content", node.Body) - assert.Nil(t, node.Children) + testutil.Equal(t, "info", node.Name) + testutil.Equal(t, "Important", node.Parameters["title"]) + testutil.Equal(t, "This is the content", node.Body) + testutil.Nil(t, node.Children) } func TestMacroNode_WithChildren(t *testing.T) { + t.Parallel() // Test nested structure child := &MacroNode{ Name: "code", @@ -99,8 +108,8 @@ func TestMacroNode_WithChildren(t *testing.T) { Children: []*MacroNode{child}, } - assert.Equal(t, "expand", parent.Name) - assert.Len(t, parent.Children, 1) - assert.Equal(t, "code", parent.Children[0].Name) - assert.Equal(t, "go", parent.Children[0].Parameters["language"]) + testutil.Equal(t, "expand", parent.Name) + testutil.Len(t, parent.Children, 1) + testutil.Equal(t, "code", parent.Children[0].Name) + testutil.Equal(t, "go", parent.Children[0].Parameters["language"]) } diff --git a/tools/cfl/pkg/md/parser.go b/tools/cfl/pkg/md/parser.go index 00bccac..eb81a7e 100644 --- a/tools/cfl/pkg/md/parser.go +++ b/tools/cfl/pkg/md/parser.go @@ -1,14 +1,11 @@ -// parser.go defines shared types for macro parsing in both directions. package md -import ( - "fmt" - "log" -) +import "fmt" // SegmentType indicates whether a segment is text or a macro. type SegmentType int +// SegmentType constants classify parts of a parsed macro stream. const ( SegmentText SegmentType = iota // plain text/HTML content SegmentMacro // parsed macro node @@ -53,10 +50,9 @@ func (pr *ParseResult) AddMacroSegment(macro *MacroNode) { } // AddWarning logs a warning and stores it in the result. -func (pr *ParseResult) AddWarning(format string, args ...interface{}) { +func (pr *ParseResult) AddWarning(format string, args ...any) { msg := fmt.Sprintf(format, args...) pr.Warnings = append(pr.Warnings, msg) - log.Printf("WARN: "+format, args...) } // GetMacros returns all MacroNodes from the parse result. diff --git a/tools/cfl/pkg/md/parser_bracket.go b/tools/cfl/pkg/md/parser_bracket.go index 2a83f78..5c5889d 100644 --- a/tools/cfl/pkg/md/parser_bracket.go +++ b/tools/cfl/pkg/md/parser_bracket.go @@ -1,4 +1,3 @@ -// parser_bracket.go parses bracket syntax [MACRO]...[/MACRO] into MacroNode trees. package md import ( diff --git a/tools/cfl/pkg/md/parser_test.go b/tools/cfl/pkg/md/parser_test.go index 43899d4..05d33e9 100644 --- a/tools/cfl/pkg/md/parser_test.go +++ b/tools/cfl/pkg/md/parser_test.go @@ -3,77 +3,84 @@ package md import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) // ==================== Bracket Parser Tests ==================== func TestParseBracketMacros_EmptyInput(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("") - require.NoError(t, err) - assert.Empty(t, result.Segments) + testutil.RequireNoError(t, err) + testutil.Empty(t, result.Segments) } func TestParseBracketMacros_PlainText(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("Hello world") - require.NoError(t, err) - require.Len(t, result.Segments, 1) - assert.Equal(t, SegmentText, result.Segments[0].Type) - assert.Equal(t, "Hello world", result.Segments[0].Text) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) + testutil.Equal(t, SegmentText, result.Segments[0].Type) + testutil.Equal(t, "Hello world", result.Segments[0].Text) } func TestParseBracketMacros_SimpleTOC(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[TOC]") - require.NoError(t, err) - require.Len(t, result.Segments, 1) - assert.Equal(t, SegmentMacro, result.Segments[0].Type) - assert.Equal(t, "toc", result.Segments[0].Macro.Name) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) + testutil.Equal(t, SegmentMacro, result.Segments[0].Type) + testutil.Equal(t, "toc", result.Segments[0].Macro.Name) } func TestParseBracketMacros_TOCWithParams(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[TOC maxLevel=3 minLevel=1]") - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "toc", macro.Name) - assert.Equal(t, "3", macro.Parameters["maxLevel"]) - assert.Equal(t, "1", macro.Parameters["minLevel"]) + testutil.Equal(t, "toc", macro.Name) + testutil.Equal(t, "3", macro.Parameters["maxLevel"]) + testutil.Equal(t, "1", macro.Parameters["minLevel"]) } func TestParseBracketMacros_PanelWithBody(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[INFO]Content here[/INFO]") - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "info", macro.Name) - assert.Equal(t, "Content here", macro.Body) + testutil.Equal(t, "info", macro.Name) + testutil.Equal(t, "Content here", macro.Body) } func TestParseBracketMacros_PanelWithTitleAndBody(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros(`[WARNING title="Watch Out"]Be careful![/WARNING]`) - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "warning", macro.Name) - assert.Equal(t, "Watch Out", macro.Parameters["title"]) - assert.Equal(t, "Be careful!", macro.Body) + testutil.Equal(t, "warning", macro.Name) + testutil.Equal(t, "Watch Out", macro.Parameters["title"]) + testutil.Equal(t, "Be careful!", macro.Body) } func TestParseBracketMacros_NestedMacros(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[INFO]Before [TOC] after[/INFO]") - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "info", macro.Name) + testutil.Equal(t, "info", macro.Name) // TOC should be a child - require.Len(t, macro.Children, 1) - assert.Equal(t, "toc", macro.Children[0].Name) + testutil.Len(t, macro.Children, 1) + testutil.Equal(t, "toc", macro.Children[0].Name) } func TestParseBracketMacros_MultipleMacros(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("Before [TOC] middle [INFO]content[/INFO] after") - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have: text, macro, text, macro, text textCount := 0 @@ -85,15 +92,16 @@ func TestParseBracketMacros_MultipleMacros(t *testing.T) { macroCount++ } } - assert.Equal(t, 2, macroCount, "should have 2 macros") - assert.GreaterOrEqual(t, textCount, 2, "should have at least 2 text segments") + testutil.Equal(t, 2, macroCount) + testutil.GreaterOrEqual(t, textCount, 2) } func TestParseBracketMacros_UnknownMacro(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[UNKNOWN]content[/UNKNOWN]") - require.NoError(t, err) + testutil.RequireNoError(t, err) // Unknown macro should be treated as text - assert.GreaterOrEqual(t, len(result.Warnings), 1, "should have warning") + testutil.GreaterOrEqual(t, len(result.Warnings), 1) // Content should be in text segments hasText := false for _, seg := range result.Segments { @@ -101,19 +109,21 @@ func TestParseBracketMacros_UnknownMacro(t *testing.T) { hasText = true } } - assert.True(t, hasText, "unknown macro should be preserved as text") + testutil.True(t, hasText, "unknown macro should be preserved as text") } func TestParseBracketMacros_MismatchedClose(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[INFO]content[/WARNING]more[/INFO]") - require.NoError(t, err) - assert.GreaterOrEqual(t, len(result.Warnings), 1, "should have warning for mismatch") + testutil.RequireNoError(t, err) + testutil.GreaterOrEqual(t, len(result.Warnings), 1) } func TestParseBracketMacros_UnclosedMacro(t *testing.T) { + t.Parallel() result, err := ParseBracketMacros("[INFO]content without close") - require.NoError(t, err) - assert.GreaterOrEqual(t, len(result.Warnings), 1, "should have warning for unclosed") + testutil.RequireNoError(t, err) + testutil.GreaterOrEqual(t, len(result.Warnings), 1) // Should be treated as text hasText := false for _, seg := range result.Segments { @@ -121,81 +131,89 @@ func TestParseBracketMacros_UnclosedMacro(t *testing.T) { hasText = true } } - assert.True(t, hasText, "unclosed macro should be preserved as text") + testutil.True(t, hasText, "unclosed macro should be preserved as text") } // ==================== XML Parser Tests ==================== func TestParseConfluenceXML_EmptyInput(t *testing.T) { + t.Parallel() result, err := ParseConfluenceXML("") - require.NoError(t, err) - assert.Empty(t, result.Segments) + testutil.RequireNoError(t, err) + testutil.Empty(t, result.Segments) } func TestParseConfluenceXML_PlainHTML(t *testing.T) { + t.Parallel() result, err := ParseConfluenceXML("

    Hello world

    ") - require.NoError(t, err) - require.Len(t, result.Segments, 1) - assert.Equal(t, SegmentText, result.Segments[0].Type) - assert.Equal(t, "

    Hello world

    ", result.Segments[0].Text) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) + testutil.Equal(t, SegmentText, result.Segments[0].Type) + testutil.Equal(t, "

    Hello world

    ", result.Segments[0].Text) } func TestParseConfluenceXML_SimpleTOC(t *testing.T) { + t.Parallel() input := `` result, err := ParseConfluenceXML(input) - require.NoError(t, err) - require.Len(t, result.Segments, 1) - assert.Equal(t, SegmentMacro, result.Segments[0].Type) - assert.Equal(t, "toc", result.Segments[0].Macro.Name) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) + testutil.Equal(t, SegmentMacro, result.Segments[0].Type) + testutil.Equal(t, "toc", result.Segments[0].Macro.Name) } func TestParseConfluenceXML_TOCWithParams(t *testing.T) { + t.Parallel() input := `31` result, err := ParseConfluenceXML(input) - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "toc", macro.Name) - assert.Equal(t, "3", macro.Parameters["maxLevel"]) - assert.Equal(t, "1", macro.Parameters["minLevel"]) + testutil.Equal(t, "toc", macro.Name) + testutil.Equal(t, "3", macro.Parameters["maxLevel"]) + testutil.Equal(t, "1", macro.Parameters["minLevel"]) } func TestParseConfluenceXML_PanelWithBody(t *testing.T) { + t.Parallel() input := `

    Content

    ` result, err := ParseConfluenceXML(input) - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "info", macro.Name) - assert.Contains(t, macro.Body, "Content") + testutil.Equal(t, "info", macro.Name) + testutil.Contains(t, macro.Body, "Content") } func TestParseConfluenceXML_CodeWithCDATA(t *testing.T) { + t.Parallel() input := `python` result, err := ParseConfluenceXML(input) - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "code", macro.Name) - assert.Equal(t, "python", macro.Parameters["language"]) - assert.Contains(t, macro.Body, `print("Hello")`) + testutil.Equal(t, "code", macro.Name) + testutil.Equal(t, "python", macro.Parameters["language"]) + testutil.Contains(t, macro.Body, `print("Hello")`) } func TestParseConfluenceXML_NestedMacros(t *testing.T) { + t.Parallel() input := `` result, err := ParseConfluenceXML(input) - require.NoError(t, err) - require.Len(t, result.Segments, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, result.Segments, 1) macro := result.Segments[0].Macro - assert.Equal(t, "info", macro.Name) - require.Len(t, macro.Children, 1) - assert.Equal(t, "toc", macro.Children[0].Name) + testutil.Equal(t, "info", macro.Name) + testutil.Len(t, macro.Children, 1) + testutil.Equal(t, "toc", macro.Children[0].Name) } func TestParseConfluenceXML_WithSurroundingHTML(t *testing.T) { + t.Parallel() input := `

    Title

    Content

    ` result, err := ParseConfluenceXML(input) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have text, macro, text textCount := 0 @@ -207,13 +225,14 @@ func TestParseConfluenceXML_WithSurroundingHTML(t *testing.T) { macroCount++ } } - assert.Equal(t, 1, macroCount) - assert.Equal(t, 2, textCount) + testutil.Equal(t, 1, macroCount) + testutil.Equal(t, 2, textCount) } // ==================== Segment Tests ==================== func TestParseResult_GetMacros(t *testing.T) { + t.Parallel() result := &ParseResult{} result.AddTextSegment("text1") result.AddMacroSegment(&MacroNode{Name: "toc"}) @@ -221,17 +240,18 @@ func TestParseResult_GetMacros(t *testing.T) { result.AddMacroSegment(&MacroNode{Name: "info"}) macros := result.GetMacros() - require.Len(t, macros, 2) - assert.Equal(t, "toc", macros[0].Name) - assert.Equal(t, "info", macros[1].Name) + testutil.Len(t, macros, 2) + testutil.Equal(t, "toc", macros[0].Name) + testutil.Equal(t, "info", macros[1].Name) } func TestParseResult_MergeAdjacentText(t *testing.T) { + t.Parallel() result := &ParseResult{} result.AddTextSegment("hello ") result.AddTextSegment("world") // Should be merged into single segment - require.Len(t, result.Segments, 1) - assert.Equal(t, "hello world", result.Segments[0].Text) + testutil.Len(t, result.Segments, 1) + testutil.Equal(t, "hello world", result.Segments[0].Text) } diff --git a/tools/cfl/pkg/md/parser_xml.go b/tools/cfl/pkg/md/parser_xml.go index f7f74c0..d095ab7 100644 --- a/tools/cfl/pkg/md/parser_xml.go +++ b/tools/cfl/pkg/md/parser_xml.go @@ -1,4 +1,3 @@ -// parser_xml.go parses Confluence XML into MacroNode trees. package md import ( @@ -113,11 +112,9 @@ func ParseConfluenceXML(input string) (*ParseResult, error) { current := stack[len(stack)-1] result.AddWarning("unclosed macro: %s", current.node.Name) stack = stack[:len(stack)-1] - // Treat unclosed macro as text - if len(stack) > 0 { - // Add to parent body as-is (can't reconstruct XML properly) - } else { - result.AddMacroSegment(current.node) // best effort + // Treat unclosed macro as text; if top-level, add as best-effort segment + if len(stack) == 0 { + result.AddMacroSegment(current.node) } } diff --git a/tools/cfl/pkg/md/render.go b/tools/cfl/pkg/md/render.go index 5c94886..66cf217 100644 --- a/tools/cfl/pkg/md/render.go +++ b/tools/cfl/pkg/md/render.go @@ -1,5 +1,3 @@ -// render.go provides functions to render MacroNodes to Confluence storage format -// and ADF extension nodes. package md import ( @@ -8,6 +6,8 @@ import ( "strings" "github.com/open-cli-collective/atlassian-go/adf" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // RenderMacroToXML converts a MacroNode to Confluence XML storage format. @@ -47,6 +47,8 @@ func RenderMacroToXML(node *MacroNode) string { sb.WriteString(``) + case BodyTypeNone: + // no body wrapper needed } } @@ -164,7 +166,7 @@ func RenderMacroToADFNode(node *MacroNode) *adf.Node { if panelType, ok := panelMacros[node.Name]; ok { return &adf.Node{ Type: "panel", - Attrs: map[string]interface{}{"panelType": panelType}, + Attrs: map[string]any{"panelType": panelType}, Content: content, } } @@ -172,7 +174,7 @@ func RenderMacroToADFNode(node *MacroNode) *adf.Node { // Other body macros get bodiedExtension nodes return &adf.Node{ Type: "bodiedExtension", - Attrs: map[string]interface{}{ + Attrs: map[string]any{ "extensionType": "com.atlassian.confluence.macro.core", "extensionKey": node.Name, "parameters": params, @@ -185,7 +187,7 @@ func RenderMacroToADFNode(node *MacroNode) *adf.Node { // Bodyless macros get extension nodes return &adf.Node{ Type: "extension", - Attrs: map[string]interface{}{ + Attrs: map[string]any{ "extensionType": "com.atlassian.confluence.macro.core", "extensionKey": node.Name, "parameters": params, @@ -195,17 +197,17 @@ func RenderMacroToADFNode(node *MacroNode) *adf.Node { } // buildADFMacroParams builds the ADF parameters structure for a macro. -func buildADFMacroParams(node *MacroNode) map[string]interface{} { - macroParams := make(map[string]interface{}) +func buildADFMacroParams(node *MacroNode) map[string]any { + macroParams := make(map[string]any) for k, v := range node.Parameters { - macroParams[k] = map[string]interface{}{"value": v} + macroParams[k] = map[string]any{"value": v} } macroTitle := macroDisplayName(node.Name) - return map[string]interface{}{ + return map[string]any{ "macroParams": macroParams, - "macroMetadata": map[string]interface{}{ - "schemaVersion": map[string]interface{}{"value": "1"}, + "macroMetadata": map[string]any{ + "schemaVersion": map[string]any{"value": "1"}, "title": macroTitle, }, } @@ -225,5 +227,5 @@ func macroDisplayName(name string) string { if dn, ok := displayNames[name]; ok { return dn } - return strings.Title(name) //nolint:staticcheck + return cases.Title(language.English).String(name) } diff --git a/tools/cfl/pkg/md/render_test.go b/tools/cfl/pkg/md/render_test.go index b89d887..7206be9 100644 --- a/tools/cfl/pkg/md/render_test.go +++ b/tools/cfl/pkg/md/render_test.go @@ -4,43 +4,47 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestRenderMacroToXML_SimpleTOC(t *testing.T) { + t.Parallel() node := &MacroNode{Name: "toc"} xml := RenderMacroToXML(node) - assert.Contains(t, xml, `ac:name="toc"`) - assert.Contains(t, xml, `ac:schema-version="1"`) - assert.Contains(t, xml, `
    `) + testutil.Contains(t, xml, `ac:name="toc"`) + testutil.Contains(t, xml, `ac:schema-version="1"`) + testutil.Contains(t, xml, `
    `) } func TestRenderMacroToXML_TOCWithParams(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "toc", Parameters: map[string]string{"maxLevel": "3", "minLevel": "1"}, } xml := RenderMacroToXML(node) - assert.Contains(t, xml, `3`) - assert.Contains(t, xml, `1`) + testutil.Contains(t, xml, `3`) + testutil.Contains(t, xml, `1`) } func TestRenderMacroToXML_PanelWithBody(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "info", Body: "

    Content

    ", } xml := RenderMacroToXML(node) - assert.Contains(t, xml, `ac:name="info"`) - assert.Contains(t, xml, ``) - assert.Contains(t, xml, `

    Content

    `) - assert.Contains(t, xml, `
    `) + testutil.Contains(t, xml, `ac:name="info"`) + testutil.Contains(t, xml, ``) + testutil.Contains(t, xml, `

    Content

    `) + testutil.Contains(t, xml, `
    `) } func TestRenderMacroToXML_CodeWithCDATA(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "code", Parameters: map[string]string{"language": "go"}, @@ -48,42 +52,46 @@ func TestRenderMacroToXML_CodeWithCDATA(t *testing.T) { } xml := RenderMacroToXML(node) - assert.Contains(t, xml, `ac:name="code"`) - assert.Contains(t, xml, ``) + testutil.Contains(t, xml, `ac:name="code"`) + testutil.Contains(t, xml, ``) } func TestRenderMacroToXML_EscapesXML(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "toc", Parameters: map[string]string{"title": "A & B "}, } xml := RenderMacroToXML(node) - assert.Contains(t, xml, `A & B <test>`) + testutil.Contains(t, xml, `A & B <test>`) } func TestRenderMacroToBracket_SimpleTOC(t *testing.T) { + t.Parallel() node := &MacroNode{Name: "toc"} bracket := RenderMacroToBracket(node) - assert.Equal(t, "[TOC]", bracket) + testutil.Equal(t, "[TOC]", bracket) } func TestRenderMacroToBracket_TOCWithParams(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "toc", Parameters: map[string]string{"maxLevel": "3"}, } bracket := RenderMacroToBracket(node) - assert.Contains(t, bracket, "[TOC") - assert.Contains(t, bracket, "maxLevel=3") - assert.Contains(t, bracket, "]") + testutil.Contains(t, bracket, "[TOC") + testutil.Contains(t, bracket, "maxLevel=3") + testutil.Contains(t, bracket, "]") } func TestRenderMacroToBracket_PanelWithBody(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "info", Parameters: map[string]string{"title": "Important"}, @@ -91,40 +99,44 @@ func TestRenderMacroToBracket_PanelWithBody(t *testing.T) { } bracket := RenderMacroToBracket(node) - assert.Contains(t, bracket, "[INFO") - assert.Contains(t, bracket, "title=Important") - assert.Contains(t, bracket, "Content here") - assert.Contains(t, bracket, "[/INFO]") + testutil.Contains(t, bracket, "[INFO") + testutil.Contains(t, bracket, "title=Important") + testutil.Contains(t, bracket, "Content here") + testutil.Contains(t, bracket, "[/INFO]") } func TestRenderMacroToBracket_QuotedValues(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "info", Parameters: map[string]string{"title": "Hello World"}, } bracket := RenderMacroToBracket(node) - assert.Contains(t, bracket, `title="Hello World"`) + testutil.Contains(t, bracket, `title="Hello World"`) } func TestRenderMacroToBracketOpen_SimpleTOC(t *testing.T) { + t.Parallel() node := &MacroNode{Name: "toc"} bracket := RenderMacroToBracketOpen(node) - assert.Equal(t, "[TOC]", bracket) + testutil.Equal(t, "[TOC]", bracket) } func TestRenderMacroToBracketOpen_WithParams(t *testing.T) { + t.Parallel() node := &MacroNode{ Name: "info", Parameters: map[string]string{"title": "Hello World"}, } bracket := RenderMacroToBracketOpen(node) - assert.Contains(t, bracket, "[INFO") - assert.Contains(t, bracket, `title="Hello World"`) - assert.True(t, strings.HasSuffix(bracket, "]")) + testutil.Contains(t, bracket, "[INFO") + testutil.Contains(t, bracket, `title="Hello World"`) + testutil.True(t, strings.HasSuffix(bracket, "]")) } func TestFormatPlaceholder(t *testing.T) { - assert.Equal(t, "CFMACRO0END", FormatPlaceholder(0)) - assert.Equal(t, "CFMACRO42END", FormatPlaceholder(42)) + t.Parallel() + testutil.Equal(t, "CFMACRO0END", FormatPlaceholder(0)) + testutil.Equal(t, "CFMACRO42END", FormatPlaceholder(42)) } diff --git a/tools/cfl/pkg/md/roundtrip_test.go b/tools/cfl/pkg/md/roundtrip_test.go index 2af7104..64a3d17 100644 --- a/tools/cfl/pkg/md/roundtrip_test.go +++ b/tools/cfl/pkg/md/roundtrip_test.go @@ -4,79 +4,83 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) // TestRoundtrip verifies that macros survive MD→XHTML→MD conversion. func TestRoundtrip_TOC(t *testing.T) { + t.Parallel() input := "[TOC maxLevel=3]" // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) - assert.Contains(t, xhtml, `ac:name="toc"`) - assert.Contains(t, xhtml, `maxLevel`) + testutil.RequireNoError(t, err) + testutil.Contains(t, xhtml, `ac:name="toc"`) + testutil.Contains(t, xhtml, `maxLevel`) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) - assert.Contains(t, strings.ToUpper(md), "[TOC") - assert.Contains(t, md, "maxLevel") + testutil.RequireNoError(t, err) + testutil.Contains(t, strings.ToUpper(md), "[TOC") + testutil.Contains(t, md, "maxLevel") } func TestRoundtrip_InfoPanel(t *testing.T) { + t.Parallel() input := `[INFO title="Important"] This is important content. [/INFO]` // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) - assert.Contains(t, xhtml, `ac:name="info"`) - assert.Contains(t, xhtml, ``) + testutil.RequireNoError(t, err) + testutil.Contains(t, xhtml, `ac:name="info"`) + testutil.Contains(t, xhtml, ``) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) - assert.Contains(t, strings.ToUpper(md), "[INFO") - assert.Contains(t, md, "[/INFO]") - assert.Contains(t, md, "important content") + testutil.RequireNoError(t, err) + testutil.Contains(t, strings.ToUpper(md), "[INFO") + testutil.Contains(t, md, "[/INFO]") + testutil.Contains(t, md, "important content") } func TestRoundtrip_NestedMacros(t *testing.T) { + t.Parallel() input := `[INFO] Content with [TOC] inside. [/INFO]` // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) - assert.Contains(t, xhtml, `ac:name="info"`) - assert.Contains(t, xhtml, `ac:name="toc"`) + testutil.RequireNoError(t, err) + testutil.Contains(t, xhtml, `ac:name="info"`) + testutil.Contains(t, xhtml, `ac:name="toc"`) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) - assert.Contains(t, strings.ToUpper(md), "[INFO") - assert.Contains(t, strings.ToUpper(md), "[TOC") + testutil.RequireNoError(t, err) + testutil.Contains(t, strings.ToUpper(md), "[INFO") + testutil.Contains(t, strings.ToUpper(md), "[TOC") } func TestRoundtrip_AllPanelTypes(t *testing.T) { + t.Parallel() panelTypes := []string{"INFO", "WARNING", "NOTE", "TIP", "EXPAND"} for _, pt := range panelTypes { t.Run(pt, func(t *testing.T) { + t.Parallel() input := "[" + pt + "]Content[/" + pt + "]" xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) - assert.Contains(t, xhtml, `ac:name="`+strings.ToLower(pt)+`"`) + testutil.RequireNoError(t, err) + testutil.Contains(t, xhtml, `ac:name="`+strings.ToLower(pt)+`"`) md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) - assert.Contains(t, strings.ToUpper(md), "["+pt) - assert.Contains(t, strings.ToUpper(md), "[/"+pt+"]") + testutil.RequireNoError(t, err) + testutil.Contains(t, strings.ToUpper(md), "["+pt) + testutil.Contains(t, strings.ToUpper(md), "[/"+pt+"]") }) } } @@ -84,6 +88,7 @@ func TestRoundtrip_AllPanelTypes(t *testing.T) { // TestRoundtrip_NestedPosition verifies that nested macro position is preserved // through the complete MD→XHTML→MD cycle. func TestRoundtrip_NestedPosition(t *testing.T) { + t.Parallel() input := `[INFO] Before [TOC] @@ -92,26 +97,27 @@ After // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify order is preserved: Before < TOC < After beforeIdx := strings.Index(md, "Before") tocIdx := strings.Index(strings.ToUpper(md), "[TOC") afterIdx := strings.Index(md, "After") - assert.True(t, beforeIdx >= 0, "Before should be present") - assert.True(t, tocIdx >= 0, "TOC should be present") - assert.True(t, afterIdx >= 0, "After should be present") + testutil.True(t, beforeIdx >= 0, "Before should be present") + testutil.True(t, tocIdx >= 0, "TOC should be present") + testutil.True(t, afterIdx >= 0, "After should be present") - assert.True(t, beforeIdx < tocIdx, "Before should come before TOC") - assert.True(t, tocIdx < afterIdx, "TOC should come before After") + testutil.True(t, beforeIdx < tocIdx, "Before should come before TOC") + testutil.True(t, tocIdx < afterIdx, "TOC should come before After") } func TestRoundtrip_MultipleNestedMacros(t *testing.T) { + t.Parallel() input := `[INFO] Start [TOC] @@ -122,28 +128,29 @@ End // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) + testutil.RequireNoError(t, err) // All text and macros should be present - assert.Contains(t, md, "Start") - assert.Contains(t, md, "Middle") - assert.Contains(t, md, "End") - assert.Contains(t, strings.ToUpper(md), "[TOC") + testutil.Contains(t, md, "Start") + testutil.Contains(t, md, "Middle") + testutil.Contains(t, md, "End") + testutil.Contains(t, strings.ToUpper(md), "[TOC") // Verify order startIdx := strings.Index(md, "Start") middleIdx := strings.Index(md, "Middle") endIdx := strings.Index(md, "End") - assert.True(t, startIdx < middleIdx, "Start should come before Middle") - assert.True(t, middleIdx < endIdx, "Middle should come before End") + testutil.True(t, startIdx < middleIdx, "Start should come before Middle") + testutil.True(t, middleIdx < endIdx, "Middle should come before End") } func TestRoundtrip_DeeplyNested(t *testing.T) { + t.Parallel() input := `[INFO] Outer [WARNING] @@ -156,76 +163,78 @@ More outer // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify nesting in XHTML - assert.Contains(t, xhtml, `ac:name="info"`) - assert.Contains(t, xhtml, `ac:name="warning"`) - assert.Contains(t, xhtml, `ac:name="toc"`) + testutil.Contains(t, xhtml, `ac:name="info"`) + testutil.Contains(t, xhtml, `ac:name="warning"`) + testutil.Contains(t, xhtml, `ac:name="toc"`) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) + testutil.RequireNoError(t, err) // All elements should be present - assert.Contains(t, md, "Outer") - assert.Contains(t, strings.ToUpper(md), "[INFO") - assert.Contains(t, strings.ToUpper(md), "[WARNING") - assert.Contains(t, strings.ToUpper(md), "[TOC") - assert.Contains(t, md, "Inner") + testutil.Contains(t, md, "Outer") + testutil.Contains(t, strings.ToUpper(md), "[INFO") + testutil.Contains(t, strings.ToUpper(md), "[WARNING") + testutil.Contains(t, strings.ToUpper(md), "[TOC") + testutil.Contains(t, md, "Inner") } // TestRoundtrip_CloseTagNotDuplicated verifies that panel content appears exactly once // through the MD→XHTML→MD cycle (close tag is properly consumed, not left as literal text). func TestRoundtrip_CloseTagNotDuplicated(t *testing.T) { + t.Parallel() input := "[INFO]unique content[/INFO]" // MD → XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Content should appear exactly once in XHTML - assert.Equal(t, 1, strings.Count(xhtml, "unique content")) + testutil.Equal(t, 1, strings.Count(xhtml, "unique content")) // XHTML → MD md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Content should appear exactly once in MD - assert.Equal(t, 1, strings.Count(md, "unique content")) + testutil.Equal(t, 1, strings.Count(md, "unique content")) } // TestRoundtrip_NestedMacroInParagraph verifies the issue #56 fix. // Tests that nested self-closing macros survive the MD→XHTML→MD cycle // even when the XHTML wraps them in

    tags. func TestRoundtrip_NestedMacroInParagraph(t *testing.T) { + t.Parallel() // Start with markdown containing nested macro input := "[INFO]\n\n[TOC]\n\n[/INFO]\n\n# Header 1" // Convert to XHTML xhtml, err := ToConfluenceStorage([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify XHTML has correct structure - assert.Contains(t, xhtml, "ac:structured-macro") - assert.Contains(t, xhtml, `ac:name="info"`) - assert.Contains(t, xhtml, `ac:name="toc"`) + testutil.Contains(t, xhtml, "ac:structured-macro") + testutil.Contains(t, xhtml, `ac:name="info"`) + testutil.Contains(t, xhtml, `ac:name="toc"`) // Convert back to markdown md, err := FromConfluenceStorageWithOptions(xhtml, ConvertOptions{ShowMacros: true}) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify structure preserved (case-insensitive check for macro names) - assert.Contains(t, strings.ToUpper(md), "[INFO]") - assert.Contains(t, strings.ToUpper(md), "[TOC]") - assert.Contains(t, strings.ToUpper(md), "[/INFO]") - assert.Contains(t, md, "# Header 1") + testutil.Contains(t, strings.ToUpper(md), "[INFO]") + testutil.Contains(t, strings.ToUpper(md), "[TOC]") + testutil.Contains(t, strings.ToUpper(md), "[/INFO]") + testutil.Contains(t, md, "# Header 1") // Verify nesting order is preserved infoStart := strings.Index(strings.ToUpper(md), "[INFO]") tocPos := strings.Index(strings.ToUpper(md), "[TOC]") infoEnd := strings.Index(strings.ToUpper(md), "[/INFO]") - assert.True(t, infoStart < tocPos, "[INFO] should come before [TOC]") - assert.True(t, tocPos < infoEnd, "[TOC] should come before [/INFO]") + testutil.True(t, infoStart < tocPos, "[INFO] should come before [TOC]") + testutil.True(t, tocPos < infoEnd, "[TOC] should come before [/INFO]") } diff --git a/tools/cfl/pkg/md/to_adf.go b/tools/cfl/pkg/md/to_adf.go index 945ea14..57012e4 100644 --- a/tools/cfl/pkg/md/to_adf.go +++ b/tools/cfl/pkg/md/to_adf.go @@ -8,9 +8,13 @@ import ( "github.com/open-cli-collective/atlassian-go/adf" ) -// Type aliases for backward compatibility with the shared adf package. +// ADFDocument is an alias for adf.Document. type ADFDocument = adf.Document + +// ADFNode is an alias for adf.Node. type ADFNode = adf.Node + +// ADFMark is an alias for adf.Mark. type ADFMark = adf.Mark // ToADF converts markdown content to Atlassian Document Format (ADF) JSON. diff --git a/tools/cfl/pkg/md/to_adf_test.go b/tools/cfl/pkg/md/to_adf_test.go index b9e4a9c..72bac69 100644 --- a/tools/cfl/pkg/md/to_adf_test.go +++ b/tools/cfl/pkg/md/to_adf_test.go @@ -2,33 +2,35 @@ package md import ( "encoding/json" + "fmt" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestToADF_Paragraph(t *testing.T) { + t.Parallel() input := "Hello world" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "doc", doc.Type) - assert.Equal(t, 1, doc.Version) - require.Len(t, doc.Content, 1) + testutil.Equal(t, "doc", doc.Type) + testutil.Equal(t, 1, doc.Version) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) - require.Len(t, para.Content, 1) - assert.Equal(t, "text", para.Content[0].Type) - assert.Equal(t, "Hello world", para.Content[0].Text) + testutil.Equal(t, "paragraph", para.Type) + testutil.Len(t, para.Content, 1) + testutil.Equal(t, "text", para.Content[0].Type) + testutil.Equal(t, "Hello world", para.Content[0].Text) } func TestToADF_Headings(t *testing.T) { + t.Parallel() tests := []struct { name string markdown string @@ -45,24 +47,26 @@ func TestToADF_Headings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToADF([]byte(tt.markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) heading := doc.Content[0] - assert.Equal(t, "heading", heading.Type) - assert.EqualValues(t, tt.level, heading.Attrs["level"]) - require.Len(t, heading.Content, 1) - assert.Equal(t, tt.text, heading.Content[0].Text) + testutil.Equal(t, "heading", heading.Type) + testutil.Equal(t, heading.Attrs["level"], float64(tt.level)) + testutil.Len(t, heading.Content, 1) + testutil.Equal(t, tt.text, heading.Content[0].Text) }) } } func TestToADF_Formatting(t *testing.T) { + t.Parallel() tests := []struct { name string markdown string @@ -76,16 +80,17 @@ func TestToADF_Formatting(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToADF([]byte(tt.markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) + testutil.Equal(t, "paragraph", para.Type) // Find the text node with marks var foundMark bool @@ -99,21 +104,22 @@ func TestToADF_Formatting(t *testing.T) { } } } - assert.True(t, foundMark, "expected to find mark %s", tt.mark) + testutil.True(t, foundMark, fmt.Sprintf("expected to find mark %s", tt.mark)) }) } } func TestToADF_Links(t *testing.T) { + t.Parallel() input := "[Example](https://example.com)" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] // Find the link @@ -122,56 +128,59 @@ func TestToADF_Links(t *testing.T) { for _, mark := range node.Marks { if mark.Type == "link" { foundLink = true - assert.Equal(t, "https://example.com", mark.Attrs["href"]) - assert.Equal(t, "Example", node.Text) + testutil.Equal(t, "https://example.com", mark.Attrs["href"]) + testutil.Equal(t, "Example", node.Text) } } } - assert.True(t, foundLink, "expected to find link mark") + testutil.True(t, foundLink, "expected to find link mark") } func TestToADF_BulletList(t *testing.T) { + t.Parallel() input := "- Item one\n- Item two\n- Item three" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) list := doc.Content[0] - assert.Equal(t, "bulletList", list.Type) - assert.Len(t, list.Content, 3) + testutil.Equal(t, "bulletList", list.Type) + testutil.Len(t, list.Content, 3) for i, item := range list.Content { - assert.Equal(t, "listItem", item.Type) - require.Len(t, item.Content, 1) + testutil.Equal(t, "listItem", item.Type) + testutil.Len(t, item.Content, 1) para := item.Content[0] - assert.Equal(t, "paragraph", para.Type) + testutil.Equal(t, "paragraph", para.Type) expected := []string{"Item one", "Item two", "Item three"}[i] - require.Len(t, para.Content, 1) - assert.Equal(t, expected, para.Content[0].Text) + testutil.Len(t, para.Content, 1) + testutil.Equal(t, expected, para.Content[0].Text) } } func TestToADF_OrderedList(t *testing.T) { + t.Parallel() input := "1. First\n2. Second\n3. Third" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) list := doc.Content[0] - assert.Equal(t, "orderedList", list.Type) - assert.EqualValues(t, 1, list.Attrs["order"]) - assert.Len(t, list.Content, 3) + testutil.Equal(t, "orderedList", list.Type) + testutil.Equal(t, list.Attrs["order"], float64(1)) + testutil.Len(t, list.Content, 3) } func TestToADF_CodeBlock(t *testing.T) { + t.Parallel() tests := []struct { name string markdown string @@ -200,138 +209,145 @@ func TestToADF_CodeBlock(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToADF([]byte(tt.markdown)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) block := doc.Content[0] - assert.Equal(t, "codeBlock", block.Type) + testutil.Equal(t, "codeBlock", block.Type) if tt.language != "" { - assert.Equal(t, tt.language, block.Attrs["language"]) + testutil.Equal(t, tt.language, block.Attrs["language"]) } - require.Len(t, block.Content, 1) - assert.Equal(t, tt.code, block.Content[0].Text) + testutil.Len(t, block.Content, 1) + testutil.Equal(t, tt.code, block.Content[0].Text) }) } } func TestToADF_Blockquote(t *testing.T) { + t.Parallel() input := "> This is a quote" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) quote := doc.Content[0] - assert.Equal(t, "blockquote", quote.Type) - require.Len(t, quote.Content, 1) - assert.Equal(t, "paragraph", quote.Content[0].Type) + testutil.Equal(t, "blockquote", quote.Type) + testutil.Len(t, quote.Content, 1) + testutil.Equal(t, "paragraph", quote.Content[0].Type) } func TestToADF_HorizontalRule(t *testing.T) { + t.Parallel() input := "Above\n\n---\n\nBelow" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Len(t, doc.Content, 3) - assert.Equal(t, "paragraph", doc.Content[0].Type) - assert.Equal(t, "rule", doc.Content[1].Type) - assert.Equal(t, "paragraph", doc.Content[2].Type) + testutil.Len(t, doc.Content, 3) + testutil.Equal(t, "paragraph", doc.Content[0].Type) + testutil.Equal(t, "rule", doc.Content[1].Type) + testutil.Equal(t, "paragraph", doc.Content[2].Type) } func TestToADF_Table(t *testing.T) { + t.Parallel() input := "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) table := doc.Content[0] - assert.Equal(t, "table", table.Type) + testutil.Equal(t, "table", table.Type) // Should have 2 rows (header + 1 data row) - assert.Len(t, table.Content, 2) + testutil.Len(t, table.Content, 2) // First row should have tableHeader cells headerRow := table.Content[0] - assert.Equal(t, "tableRow", headerRow.Type) - assert.Len(t, headerRow.Content, 2) - assert.Equal(t, "tableHeader", headerRow.Content[0].Type) + testutil.Equal(t, "tableRow", headerRow.Type) + testutil.Len(t, headerRow.Content, 2) + testutil.Equal(t, "tableHeader", headerRow.Content[0].Type) // Second row should have tableCell cells dataRow := table.Content[1] - assert.Equal(t, "tableRow", dataRow.Type) - assert.Len(t, dataRow.Content, 2) - assert.Equal(t, "tableCell", dataRow.Content[0].Type) + testutil.Equal(t, "tableRow", dataRow.Type) + testutil.Len(t, dataRow.Content, 2) + testutil.Equal(t, "tableCell", dataRow.Content[0].Type) } func TestToADF_EmptyInput(t *testing.T) { + t.Parallel() result, err := ToADF([]byte("")) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - assert.Equal(t, "doc", doc.Type) - assert.Equal(t, 1, doc.Version) - assert.Empty(t, doc.Content) + testutil.Equal(t, "doc", doc.Type) + testutil.Equal(t, 1, doc.Version) + testutil.Empty(t, doc.Content) } func TestToADF_NestedList(t *testing.T) { + t.Parallel() input := "- Item one\n - Nested one\n - Nested two\n- Item two" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) list := doc.Content[0] - assert.Equal(t, "bulletList", list.Type) + testutil.Equal(t, "bulletList", list.Type) // First list item should contain a nested bulletList firstItem := list.Content[0] - assert.Equal(t, "listItem", firstItem.Type) + testutil.Equal(t, "listItem", firstItem.Type) // Should have paragraph + nested list var foundNestedList bool for _, child := range firstItem.Content { if child.Type == "bulletList" { foundNestedList = true - assert.Len(t, child.Content, 2) // Two nested items + testutil.Len(t, child.Content, 2) // Two nested items } } - assert.True(t, foundNestedList, "expected nested bullet list") + testutil.True(t, foundNestedList, "expected nested bullet list") } func TestToADF_BoldAndItalicCombined(t *testing.T) { + t.Parallel() input := "***bold and italic***" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] // Find the text node with both marks @@ -346,11 +362,12 @@ func TestToADF_BoldAndItalicCombined(t *testing.T) { } } } - assert.True(t, foundStrong, "expected strong mark") - assert.True(t, foundEm, "expected em mark") + testutil.True(t, foundStrong, "expected strong mark") + testutil.True(t, foundEm, "expected em mark") } func TestToADF_OutputIsValidJSON(t *testing.T) { + t.Parallel() // Test various inputs produce valid JSON inputs := []string{ "# Simple heading", @@ -362,88 +379,92 @@ func TestToADF_OutputIsValidJSON(t *testing.T) { for _, input := range inputs { result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Verify it's valid JSON - var parsed map[string]interface{} + var parsed map[string]any err = json.Unmarshal([]byte(result), &parsed) - require.NoError(t, err, "Output should be valid JSON for input: %s", input) + testutil.RequireNoError(t, err) // Verify basic structure - assert.Equal(t, "doc", parsed["type"]) - assert.EqualValues(t, 1, parsed["version"]) + testutil.Equal(t, "doc", parsed["type"]) + testutil.Equal(t, parsed["version"], float64(1)) } } func TestToADF_Images_AltText(t *testing.T) { + t.Parallel() input := "![Alt text](https://example.com/image.png)" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Images should be converted to text with alt text - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) - require.Len(t, para.Content, 1) - assert.Equal(t, "Alt text", para.Content[0].Text) + testutil.Equal(t, "paragraph", para.Type) + testutil.Len(t, para.Content, 1) + testutil.Equal(t, "Alt text", para.Content[0].Text) } func TestToADF_WhitespaceInCodeBlock(t *testing.T) { + t.Parallel() // Code with leading whitespace should be preserved input := "```\n indented code\n more indented\n```" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) block := doc.Content[0] - assert.Equal(t, "codeBlock", block.Type) - require.Len(t, block.Content, 1) + testutil.Equal(t, "codeBlock", block.Type) + testutil.Len(t, block.Content, 1) // Verify whitespace is preserved text := block.Content[0].Text - assert.Contains(t, text, " indented") - assert.Contains(t, text, " more indented") + testutil.Contains(t, text, " indented") + testutil.Contains(t, text, " more indented") } func TestToADF_NestedBlockquote(t *testing.T) { + t.Parallel() input := "> Quote with **bold** text\n>\n> And a list:\n> - Item 1\n> - Item 2" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) quote := doc.Content[0] - assert.Equal(t, "blockquote", quote.Type) + testutil.Equal(t, "blockquote", quote.Type) // Should have nested content - assert.True(t, len(quote.Content) > 0, "blockquote should have content") + testutil.True(t, len(quote.Content) > 0, "blockquote should have content") } func TestToADF_HardLineBreak(t *testing.T) { + t.Parallel() // Two spaces at end of line creates a hard break input := "Line one \nLine two" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have paragraph with hard break - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] - assert.Equal(t, "paragraph", para.Type) + testutil.Equal(t, "paragraph", para.Type) // Check for hardBreak node or separate text nodes var foundBreak bool @@ -460,21 +481,22 @@ func TestToADF_HardLineBreak(t *testing.T) { for _, node := range para.Content { fullText += node.Text } - assert.Contains(t, fullText, "Line one") - assert.Contains(t, fullText, "Line two") + testutil.Contains(t, fullText, "Line one") + testutil.Contains(t, fullText, "Line two") } } func TestToADF_InlineCodePreservesContent(t *testing.T) { + t.Parallel() input := "Use `fmt.Println()` to print" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) para := doc.Content[0] // Find the code-marked text @@ -483,83 +505,87 @@ func TestToADF_InlineCodePreservesContent(t *testing.T) { for _, mark := range node.Marks { if mark.Type == "code" { foundCode = true - assert.Equal(t, "fmt.Println()", node.Text) + testutil.Equal(t, "fmt.Println()", node.Text) } } } - assert.True(t, foundCode, "expected code mark") + testutil.True(t, foundCode, "expected code mark") } // --- Macro conversion tests --- func TestToADF_TOC_Simple(t *testing.T) { + t.Parallel() input := "[TOC]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) ext := doc.Content[0] - assert.Equal(t, "extension", ext.Type) - assert.Equal(t, "com.atlassian.confluence.macro.core", ext.Attrs["extensionType"]) - assert.Equal(t, "toc", ext.Attrs["extensionKey"]) - assert.Equal(t, "default", ext.Attrs["layout"]) + testutil.Equal(t, "extension", ext.Type) + testutil.Equal(t, "com.atlassian.confluence.macro.core", ext.Attrs["extensionType"]) + testutil.Equal(t, "toc", ext.Attrs["extensionKey"]) + testutil.Equal(t, "default", ext.Attrs["layout"]) // Verify parameters structure - params, ok := ext.Attrs["parameters"].(map[string]interface{}) - require.True(t, ok, "parameters should be a map") - metadata, ok := params["macroMetadata"].(map[string]interface{}) - require.True(t, ok, "macroMetadata should be a map") - schemaVersion, ok := metadata["schemaVersion"].(map[string]interface{}) - require.True(t, ok, "schemaVersion should be a map") - assert.Equal(t, "1", schemaVersion["value"]) + params, ok := ext.Attrs["parameters"].(map[string]any) + testutil.True(t, ok, "parameters should be a map") + metadata, ok := params["macroMetadata"].(map[string]any) + testutil.True(t, ok, "macroMetadata should be a map") + schemaVersion, ok := metadata["schemaVersion"].(map[string]any) + testutil.True(t, ok, "schemaVersion should be a map") + testutil.Equal(t, "1", schemaVersion["value"]) } func TestToADF_TOC_WithParams(t *testing.T) { + t.Parallel() input := "[TOC maxLevel=3]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) ext := doc.Content[0] - assert.Equal(t, "extension", ext.Type) - assert.Equal(t, "toc", ext.Attrs["extensionKey"]) + testutil.Equal(t, "extension", ext.Type) + testutil.Equal(t, "toc", ext.Attrs["extensionKey"]) // Verify macro param - params := ext.Attrs["parameters"].(map[string]interface{}) - macroParams := params["macroParams"].(map[string]interface{}) - maxLevel := macroParams["maxLevel"].(map[string]interface{}) - assert.Equal(t, "3", maxLevel["value"]) + params := ext.Attrs["parameters"].(map[string]any) + macroParams := params["macroParams"].(map[string]any) + maxLevel := macroParams["maxLevel"].(map[string]any) + testutil.Equal(t, "3", maxLevel["value"]) } func TestToADF_TOC_MultipleParams(t *testing.T) { + t.Parallel() input := "[TOC maxLevel=3 minLevel=1]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) ext := doc.Content[0] - params := ext.Attrs["parameters"].(map[string]interface{}) - macroParams := params["macroParams"].(map[string]interface{}) + params := ext.Attrs["parameters"].(map[string]any) + macroParams := params["macroParams"].(map[string]any) - maxLevel := macroParams["maxLevel"].(map[string]interface{}) - assert.Equal(t, "3", maxLevel["value"]) - minLevel := macroParams["minLevel"].(map[string]interface{}) - assert.Equal(t, "1", minLevel["value"]) + maxLevel := macroParams["maxLevel"].(map[string]any) + testutil.Equal(t, "3", maxLevel["value"]) + minLevel := macroParams["minLevel"].(map[string]any) + testutil.Equal(t, "1", minLevel["value"]) } func TestToADF_TOC_CaseInsensitive(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -571,65 +597,69 @@ func TestToADF_TOC_CaseInsensitive(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := ToADF([]byte(tt.input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) - assert.Equal(t, "extension", doc.Content[0].Type) - assert.Equal(t, "toc", doc.Content[0].Attrs["extensionKey"]) + testutil.Len(t, doc.Content, 1) + testutil.Equal(t, "extension", doc.Content[0].Type) + testutil.Equal(t, "toc", doc.Content[0].Attrs["extensionKey"]) }) } } func TestToADF_TOC_WithSurroundingContent(t *testing.T) { + t.Parallel() input := "Before content.\n\n[TOC]\n\n# Heading\n\nAfter content." result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have: paragraph, extension, heading, paragraph - require.Len(t, doc.Content, 4) - assert.Equal(t, "paragraph", doc.Content[0].Type) - assert.Equal(t, "extension", doc.Content[1].Type) - assert.Equal(t, "toc", doc.Content[1].Attrs["extensionKey"]) - assert.Equal(t, "heading", doc.Content[2].Type) - assert.Equal(t, "paragraph", doc.Content[3].Type) + testutil.Len(t, doc.Content, 4) + testutil.Equal(t, "paragraph", doc.Content[0].Type) + testutil.Equal(t, "extension", doc.Content[1].Type) + testutil.Equal(t, "toc", doc.Content[1].Attrs["extensionKey"]) + testutil.Equal(t, "heading", doc.Content[2].Type) + testutil.Equal(t, "paragraph", doc.Content[3].Type) } func TestToADF_TOC_InsideCodeBlock_Preserved(t *testing.T) { + t.Parallel() input := "```\n[TOC]\n```" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should be a code block, NOT an extension - require.Len(t, doc.Content, 1) - assert.Equal(t, "codeBlock", doc.Content[0].Type) - assert.Contains(t, doc.Content[0].Content[0].Text, "[TOC]") + testutil.Len(t, doc.Content, 1) + testutil.Equal(t, "codeBlock", doc.Content[0].Type) + testutil.Contains(t, doc.Content[0].Content[0].Text, "[TOC]") } func TestToADF_TOC_InsideInlineCode_Preserved(t *testing.T) { + t.Parallel() input := "Use `[TOC]` to add a table of contents." result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should be a paragraph with inline code, NOT an extension - require.Len(t, doc.Content, 1) - assert.Equal(t, "paragraph", doc.Content[0].Type) + testutil.Len(t, doc.Content, 1) + testutil.Equal(t, "paragraph", doc.Content[0].Type) // Find the code-marked text containing [TOC] var foundCode bool @@ -640,23 +670,24 @@ func TestToADF_TOC_InsideInlineCode_Preserved(t *testing.T) { } } } - assert.True(t, foundCode, "expected [TOC] as inline code, not a macro") + testutil.True(t, foundCode, "expected [TOC] as inline code, not a macro") } func TestToADF_InfoPanel(t *testing.T) { + t.Parallel() input := "[INFO]\nThis is important content.\n[/INFO]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) panel := doc.Content[0] - assert.Equal(t, "panel", panel.Type) - assert.Equal(t, "info", panel.Attrs["panelType"]) - require.True(t, len(panel.Content) > 0, "panel should have body content") + testutil.Equal(t, "panel", panel.Type) + testutil.Equal(t, "info", panel.Attrs["panelType"]) + testutil.True(t, len(panel.Content) > 0, "panel should have body content") // Body should contain the text var foundText bool @@ -669,111 +700,118 @@ func TestToADF_InfoPanel(t *testing.T) { } } } - assert.True(t, foundText, "panel body should contain the text") + testutil.True(t, foundText, "panel body should contain the text") } func TestToADF_WarningPanel(t *testing.T) { + t.Parallel() input := "[WARNING]\nBe careful!\n[/WARNING]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) panel := doc.Content[0] - assert.Equal(t, "panel", panel.Type) - assert.Equal(t, "warning", panel.Attrs["panelType"]) + testutil.Equal(t, "panel", panel.Type) + testutil.Equal(t, "warning", panel.Attrs["panelType"]) } func TestToADF_NotePanel(t *testing.T) { + t.Parallel() input := "[NOTE]\nTake note of this.\n[/NOTE]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) panel := doc.Content[0] - assert.Equal(t, "panel", panel.Type) - assert.Equal(t, "note", panel.Attrs["panelType"]) + testutil.Equal(t, "panel", panel.Type) + testutil.Equal(t, "note", panel.Attrs["panelType"]) } func TestToADF_TipPanel(t *testing.T) { + t.Parallel() input := "[TIP]\nHere is a tip.\n[/TIP]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) panel := doc.Content[0] - assert.Equal(t, "panel", panel.Type) - assert.Equal(t, "success", panel.Attrs["panelType"]) + testutil.Equal(t, "panel", panel.Type) + testutil.Equal(t, "success", panel.Attrs["panelType"]) } func TestToADF_NestedMacro_TOCInsideInfo(t *testing.T) { + t.Parallel() input := "[INFO]\nContent with [TOC] inside.\n[/INFO]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // The outer macro should be a panel - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) panel := doc.Content[0] - assert.Equal(t, "panel", panel.Type) + testutil.Equal(t, "panel", panel.Type) // The panel body may have the TOC placeholder resolved or the text // depending on how deep we recurse. At minimum, the panel should exist. - assert.True(t, len(panel.Content) > 0, "panel should have content") + testutil.True(t, len(panel.Content) > 0, "panel should have content") } func TestToADF_ExpandMacro(t *testing.T) { + t.Parallel() input := "[EXPAND]\nExpanded content here.\n[/EXPAND]" result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) - require.Len(t, doc.Content, 1) + testutil.Len(t, doc.Content, 1) ext := doc.Content[0] - assert.Equal(t, "bodiedExtension", ext.Type) - assert.Equal(t, "com.atlassian.confluence.macro.core", ext.Attrs["extensionType"]) - assert.Equal(t, "expand", ext.Attrs["extensionKey"]) - require.True(t, len(ext.Content) > 0, "bodied extension should have content") + testutil.Equal(t, "bodiedExtension", ext.Type) + testutil.Equal(t, "com.atlassian.confluence.macro.core", ext.Attrs["extensionType"]) + testutil.Equal(t, "expand", ext.Attrs["extensionKey"]) + testutil.True(t, len(ext.Content) > 0, "bodied extension should have content") } func TestToADF_MultipleMacroTypes(t *testing.T) { + t.Parallel() input := "[TOC]\n\n# Introduction\n\n[INFO]\nImportant note here.\n[/INFO]\n\n## Details\n\nSome details." result, err := ToADF([]byte(input)) - require.NoError(t, err) + testutil.RequireNoError(t, err) var doc ADFDocument err = json.Unmarshal([]byte(result), &doc) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have: extension(toc), heading, panel(info), heading, paragraph - require.Len(t, doc.Content, 5) - assert.Equal(t, "extension", doc.Content[0].Type) - assert.Equal(t, "toc", doc.Content[0].Attrs["extensionKey"]) - assert.Equal(t, "heading", doc.Content[1].Type) - assert.Equal(t, "panel", doc.Content[2].Type) - assert.Equal(t, "info", doc.Content[2].Attrs["panelType"]) - assert.Equal(t, "heading", doc.Content[3].Type) - assert.Equal(t, "paragraph", doc.Content[4].Type) + testutil.Len(t, doc.Content, 5) + testutil.Equal(t, "extension", doc.Content[0].Type) + testutil.Equal(t, "toc", doc.Content[0].Attrs["extensionKey"]) + testutil.Equal(t, "heading", doc.Content[1].Type) + testutil.Equal(t, "panel", doc.Content[2].Type) + testutil.Equal(t, "info", doc.Content[2].Attrs["panelType"]) + testutil.Equal(t, "heading", doc.Content[3].Type) + testutil.Equal(t, "paragraph", doc.Content[4].Type) } func TestToADF_MacroOutputIsValidJSON(t *testing.T) { + t.Parallel() inputs := []string{ "[TOC]", "[TOC maxLevel=3]", @@ -783,11 +821,11 @@ func TestToADF_MacroOutputIsValidJSON(t *testing.T) { for _, input := range inputs { result, err := ToADF([]byte(input)) - require.NoError(t, err, "should produce valid ADF for: %s", input) + testutil.RequireNoError(t, err) - var parsed map[string]interface{} + var parsed map[string]any err = json.Unmarshal([]byte(result), &parsed) - require.NoError(t, err, "output should be valid JSON for: %s", input) - assert.Equal(t, "doc", parsed["type"]) + testutil.RequireNoError(t, err) + testutil.Equal(t, "doc", parsed["type"]) } } diff --git a/tools/cfl/pkg/md/tokenizer_bracket.go b/tools/cfl/pkg/md/tokenizer_bracket.go index e249b9d..0bb920f 100644 --- a/tools/cfl/pkg/md/tokenizer_bracket.go +++ b/tools/cfl/pkg/md/tokenizer_bracket.go @@ -1,4 +1,3 @@ -// tokenizer_bracket.go implements tokenization for [MACRO]...[/MACRO] bracket syntax. package md import ( diff --git a/tools/cfl/pkg/md/tokenizer_bracket_test.go b/tools/cfl/pkg/md/tokenizer_bracket_test.go index e09bfaf..989f3a1 100644 --- a/tools/cfl/pkg/md/tokenizer_bracket_test.go +++ b/tools/cfl/pkg/md/tokenizer_bracket_test.go @@ -3,25 +3,27 @@ package md import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/open-cli-collective/atlassian-go/testutil" ) func TestTokenizeBrackets_EmptyInput(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets("") - require.NoError(t, err) - assert.Empty(t, tokens) + testutil.RequireNoError(t, err) + testutil.Empty(t, tokens) } func TestTokenizeBrackets_PlainText(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets("Hello world") - require.NoError(t, err) - require.Len(t, tokens, 1) - assert.Equal(t, BracketTokenText, tokens[0].Type) - assert.Equal(t, "Hello world", tokens[0].Text) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 1) + testutil.Equal(t, BracketTokenText, tokens[0].Type) + testutil.Equal(t, "Hello world", tokens[0].Text) } func TestTokenizeBrackets_SimpleMacro(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -43,16 +45,18 @@ func TestTokenizeBrackets_SimpleMacro(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets(tt.input) - require.NoError(t, err) - require.Len(t, tokens, tt.wantCount) - assert.Equal(t, tt.wantType, tokens[0].Type) - assert.Equal(t, tt.wantName, tokens[0].MacroName) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, tt.wantCount) + testutil.Equal(t, tt.wantType, tokens[0].Type) + testutil.Equal(t, tt.wantName, tokens[0].MacroName) }) } } func TestTokenizeBrackets_WithParameters(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -92,108 +96,115 @@ func TestTokenizeBrackets_WithParameters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets(tt.input) - require.NoError(t, err) - require.Len(t, tokens, 1) - assert.Equal(t, BracketTokenOpenTag, tokens[0].Type) - assert.Equal(t, tt.wantParams, tokens[0].Parameters) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 1) + testutil.Equal(t, BracketTokenOpenTag, tokens[0].Type) + testutil.Equal(t, tt.wantParams, tokens[0].Parameters) }) } } func TestTokenizeBrackets_OpenAndClose(t *testing.T) { + t.Parallel() input := "[INFO]content[/INFO]" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 3) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 3) - assert.Equal(t, BracketTokenOpenTag, tokens[0].Type) - assert.Equal(t, "INFO", tokens[0].MacroName) + testutil.Equal(t, BracketTokenOpenTag, tokens[0].Type) + testutil.Equal(t, "INFO", tokens[0].MacroName) - assert.Equal(t, BracketTokenText, tokens[1].Type) - assert.Equal(t, "content", tokens[1].Text) + testutil.Equal(t, BracketTokenText, tokens[1].Type) + testutil.Equal(t, "content", tokens[1].Text) - assert.Equal(t, BracketTokenCloseTag, tokens[2].Type) - assert.Equal(t, "INFO", tokens[2].MacroName) + testutil.Equal(t, BracketTokenCloseTag, tokens[2].Type) + testutil.Equal(t, "INFO", tokens[2].MacroName) } func TestTokenizeBrackets_WithSurroundingText(t *testing.T) { + t.Parallel() input := "Before [TOC] after" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 3) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 3) - assert.Equal(t, BracketTokenText, tokens[0].Type) - assert.Equal(t, "Before ", tokens[0].Text) + testutil.Equal(t, BracketTokenText, tokens[0].Type) + testutil.Equal(t, "Before ", tokens[0].Text) - assert.Equal(t, BracketTokenOpenTag, tokens[1].Type) - assert.Equal(t, "TOC", tokens[1].MacroName) + testutil.Equal(t, BracketTokenOpenTag, tokens[1].Type) + testutil.Equal(t, "TOC", tokens[1].MacroName) - assert.Equal(t, BracketTokenText, tokens[2].Type) - assert.Equal(t, " after", tokens[2].Text) + testutil.Equal(t, BracketTokenText, tokens[2].Type) + testutil.Equal(t, " after", tokens[2].Text) } func TestTokenizeBrackets_NestedMacros(t *testing.T) { + t.Parallel() input := "[INFO]outer [TOC] content[/INFO]" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 5) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 5) - assert.Equal(t, BracketTokenOpenTag, tokens[0].Type) - assert.Equal(t, "INFO", tokens[0].MacroName) + testutil.Equal(t, BracketTokenOpenTag, tokens[0].Type) + testutil.Equal(t, "INFO", tokens[0].MacroName) - assert.Equal(t, BracketTokenText, tokens[1].Type) - assert.Equal(t, "outer ", tokens[1].Text) + testutil.Equal(t, BracketTokenText, tokens[1].Type) + testutil.Equal(t, "outer ", tokens[1].Text) - assert.Equal(t, BracketTokenOpenTag, tokens[2].Type) - assert.Equal(t, "TOC", tokens[2].MacroName) + testutil.Equal(t, BracketTokenOpenTag, tokens[2].Type) + testutil.Equal(t, "TOC", tokens[2].MacroName) - assert.Equal(t, BracketTokenText, tokens[3].Type) - assert.Equal(t, " content", tokens[3].Text) + testutil.Equal(t, BracketTokenText, tokens[3].Type) + testutil.Equal(t, " content", tokens[3].Text) - assert.Equal(t, BracketTokenCloseTag, tokens[4].Type) - assert.Equal(t, "INFO", tokens[4].MacroName) + testutil.Equal(t, BracketTokenCloseTag, tokens[4].Type) + testutil.Equal(t, "INFO", tokens[4].MacroName) } func TestTokenizeBrackets_MultipleMacros(t *testing.T) { + t.Parallel() input := "[INFO]first[/INFO]\n[WARNING]second[/WARNING]" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 7) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 7) // First macro - assert.Equal(t, BracketTokenOpenTag, tokens[0].Type) - assert.Equal(t, "INFO", tokens[0].MacroName) - assert.Equal(t, BracketTokenText, tokens[1].Type) - assert.Equal(t, "first", tokens[1].Text) - assert.Equal(t, BracketTokenCloseTag, tokens[2].Type) - assert.Equal(t, "INFO", tokens[2].MacroName) + testutil.Equal(t, BracketTokenOpenTag, tokens[0].Type) + testutil.Equal(t, "INFO", tokens[0].MacroName) + testutil.Equal(t, BracketTokenText, tokens[1].Type) + testutil.Equal(t, "first", tokens[1].Text) + testutil.Equal(t, BracketTokenCloseTag, tokens[2].Type) + testutil.Equal(t, "INFO", tokens[2].MacroName) // Text between - assert.Equal(t, BracketTokenText, tokens[3].Type) - assert.Equal(t, "\n", tokens[3].Text) + testutil.Equal(t, BracketTokenText, tokens[3].Type) + testutil.Equal(t, "\n", tokens[3].Text) // Second macro - assert.Equal(t, BracketTokenOpenTag, tokens[4].Type) - assert.Equal(t, "WARNING", tokens[4].MacroName) - assert.Equal(t, BracketTokenText, tokens[5].Type) - assert.Equal(t, "second", tokens[5].Text) - assert.Equal(t, BracketTokenCloseTag, tokens[6].Type) - assert.Equal(t, "WARNING", tokens[6].MacroName) + testutil.Equal(t, BracketTokenOpenTag, tokens[4].Type) + testutil.Equal(t, "WARNING", tokens[4].MacroName) + testutil.Equal(t, BracketTokenText, tokens[5].Type) + testutil.Equal(t, "second", tokens[5].Text) + testutil.Equal(t, BracketTokenCloseTag, tokens[6].Type) + testutil.Equal(t, "WARNING", tokens[6].MacroName) } func TestTokenizeBrackets_Positions(t *testing.T) { + t.Parallel() input := "abc[TOC]def" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 3) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 3) - assert.Equal(t, 0, tokens[0].Position) // "abc" - assert.Equal(t, 3, tokens[1].Position) // "[TOC]" - assert.Equal(t, 8, tokens[2].Position) // "def" + testutil.Equal(t, 0, tokens[0].Position) // "abc" + testutil.Equal(t, 3, tokens[1].Position) // "[TOC]" + testutil.Equal(t, 8, tokens[2].Position) // "def" } func TestTokenizeBrackets_MalformedSyntax(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -223,8 +234,9 @@ func TestTokenizeBrackets_MalformedSyntax(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets(tt.input) - require.NoError(t, err, "should not error on malformed input") + testutil.RequireNoError(t, err) // Malformed macro syntax should be treated as text for _, tok := range tokens { if tok.Type == BracketTokenOpenTag || tok.Type == BracketTokenCloseTag { @@ -239,17 +251,19 @@ func TestTokenizeBrackets_MalformedSyntax(t *testing.T) { } func TestTokenizeBrackets_BracketsInQuotedValues(t *testing.T) { + t.Parallel() input := `[INFO title="[Important]"]content[/INFO]` tokens, err := TokenizeBrackets(input) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Should have: open tag, text, close tag - require.Len(t, tokens, 3) - assert.Equal(t, BracketTokenOpenTag, tokens[0].Type) - assert.Equal(t, "[Important]", tokens[0].Parameters["title"]) + testutil.Len(t, tokens, 3) + testutil.Equal(t, BracketTokenOpenTag, tokens[0].Type) + testutil.Equal(t, "[Important]", tokens[0].Parameters["title"]) } func TestTokenizeBrackets_EscapedQuotes(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -274,9 +288,10 @@ func TestTokenizeBrackets_EscapedQuotes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets(tt.input) - require.NoError(t, err) - require.Len(t, tokens, 1) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 1) // Escaped quotes should be unescaped in the returned value var actual string if val, ok := tokens[0].Parameters["title"]; ok { @@ -284,28 +299,30 @@ func TestTokenizeBrackets_EscapedQuotes(t *testing.T) { } else if val, ok := tokens[0].Parameters["msg"]; ok { actual = val } - assert.Equal(t, tt.expected, actual, "parameter value should have unescaped quotes") + testutil.Equal(t, tt.expected, actual) }) } } func TestTokenizeBrackets_MultilineBody(t *testing.T) { + t.Parallel() input := `[INFO] This is multiline content [/INFO]` tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 3) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 3) - assert.Equal(t, BracketTokenOpenTag, tokens[0].Type) - assert.Equal(t, BracketTokenText, tokens[1].Type) - assert.Contains(t, tokens[1].Text, "\n") - assert.Equal(t, BracketTokenCloseTag, tokens[2].Type) + testutil.Equal(t, BracketTokenOpenTag, tokens[0].Type) + testutil.Equal(t, BracketTokenText, tokens[1].Type) + testutil.Contains(t, tokens[1].Text, "\n") + testutil.Equal(t, BracketTokenCloseTag, tokens[2].Type) } func TestTokenizeBrackets_SelfClosing(t *testing.T) { + t.Parallel() tests := []struct { name string input string @@ -345,20 +362,22 @@ func TestTokenizeBrackets_SelfClosing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tokens, err := TokenizeBrackets(tt.input) - require.NoError(t, err) - require.Len(t, tokens, tt.wantCount) - assert.Equal(t, tt.wantType, tokens[0].Type) - assert.Equal(t, "TOC", tokens[0].MacroName) - assert.Equal(t, tt.wantParams, tokens[0].Parameters) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, tt.wantCount) + testutil.Equal(t, tt.wantType, tokens[0].Type) + testutil.Equal(t, "TOC", tokens[0].MacroName) + testutil.Equal(t, tt.wantParams, tokens[0].Parameters) }) } } func TestTokenizeBrackets_DeeplyNested(t *testing.T) { + t.Parallel() input := "[INFO][WARNING][NOTE]deep[/NOTE][/WARNING][/INFO]" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) + testutil.RequireNoError(t, err) // Count token types openCount := 0 @@ -372,26 +391,30 @@ func TestTokenizeBrackets_DeeplyNested(t *testing.T) { closeCount++ case BracketTokenText: textCount++ + case BracketTokenSelfClose: + // not expected in this test } } - assert.Equal(t, 3, openCount, "should have 3 open tags") - assert.Equal(t, 3, closeCount, "should have 3 close tags") - assert.Equal(t, 1, textCount, "should have 1 text token") + testutil.Equal(t, 3, openCount) + testutil.Equal(t, 3, closeCount) + testutil.Equal(t, 1, textCount) } func TestTokenizeBrackets_SpecialCharactersInBody(t *testing.T) { + t.Parallel() input := "[INFO] & < > \"[/INFO]" tokens, err := TokenizeBrackets(input) - require.NoError(t, err) - require.Len(t, tokens, 3) + testutil.RequireNoError(t, err) + testutil.Len(t, tokens, 3) - assert.Equal(t, BracketTokenText, tokens[1].Type) - assert.Contains(t, tokens[1].Text, "