From 7fca1f78dc6681b6df773b7c6369850cd2835324 Mon Sep 17 00:00:00 2001 From: Kharec Date: Fri, 21 Nov 2025 12:59:36 +0100 Subject: [PATCH] feat(cli): add a json output and tests --- README.md | 25 +++- cmd/goyco/cli.go | 3 +- cmd/goyco/commands/common.go | 54 ++++++++ cmd/goyco/commands/common_test.go | 38 ++++++ cmd/goyco/commands/daemon.go | 59 +++++++-- cmd/goyco/commands/daemon_test.go | 35 +++++ cmd/goyco/commands/migrate.go | 13 +- cmd/goyco/commands/migrate_test.go | 14 ++ cmd/goyco/commands/post.go | 74 ++++++++++- cmd/goyco/commands/post_test.go | 42 ++++++ cmd/goyco/commands/prune.go | 198 ++++++++++++++++++++++++----- cmd/goyco/commands/prune_test.go | 30 +++++ cmd/goyco/commands/seed.go | 69 +++++++--- cmd/goyco/commands/user.go | 158 +++++++++++++++++++---- cmd/goyco/commands/user_test.go | 62 +++++++++ cmd/goyco/fuzz_test.go | 7 +- cmd/goyco/main.go | 3 + cmd/goyco/server_test.go | 40 ++++-- 18 files changed, 829 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index fe4df5a..ef3bbce 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,29 @@ Goyco includes a comprehensive CLI for administration: ./bin/goyco prune all # Hard delete all users and posts ``` +### JSON Output + +All CLI commands support JSON output for easier parsing and integration with scripts. Use the `--json` flag to enable structured JSON output: + +```bash +# Get JSON output +./bin/goyco --json user list +./bin/goyco --json post list +./bin/goyco --json status + +# Example: Parse JSON output with jq +./bin/goyco --json user list | jq '.users[0].username' +./bin/goyco --json status | jq '.status' +``` + +**Note for destructive operations**: When using `--json` with `prune` commands, you must also use the `--yes` flag to skip interactive confirmation prompts: + +```bash +./bin/goyco --json prune posts --yes +./bin/goyco --json prune users --yes --with-posts +./bin/goyco --json prune all --yes +``` + ## Development ### Get the sources @@ -382,7 +405,7 @@ make fuzz-tests # Format code make format -# Run linter +# Run linter make lint ``` diff --git a/cmd/goyco/cli.go b/cmd/goyco/cli.go index 3f95c16..4bf800e 100644 --- a/cmd/goyco/cli.go +++ b/cmd/goyco/cli.go @@ -6,8 +6,9 @@ import ( "fmt" "os" - "github.com/joho/godotenv" "goyco/cmd/goyco/commands" + + "github.com/joho/godotenv" ) func loadDotEnv() { diff --git a/cmd/goyco/commands/common.go b/cmd/goyco/commands/common.go index 2d47403..f577157 100644 --- a/cmd/goyco/commands/common.go +++ b/cmd/goyco/commands/common.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "errors" "flag" "fmt" @@ -16,6 +17,23 @@ var ErrHelpRequested = errors.New("help requested") type DBConnector func(cfg *config.Config) (*gorm.DB, func() error, error) +var ( + jsonOutputMu sync.RWMutex + jsonOutput bool +) + +func SetJSONOutput(enabled bool) { + jsonOutputMu.Lock() + defer jsonOutputMu.Unlock() + jsonOutput = enabled +} + +func IsJSONOutput() bool { + jsonOutputMu.RLock() + defer jsonOutputMu.RUnlock() + return jsonOutput +} + var ( dbConnectorMu sync.RWMutex currentDBConnector = defaultDBConnector @@ -93,3 +111,39 @@ func truncate(in string, max int) string { } return in[:max-3] + "..." } + +func outputJSON(v interface{}) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(v) +} + +func outputMessage(message string, args ...interface{}) { + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "message": fmt.Sprintf(message, args...), + }) + } else { + fmt.Printf(message+"\n", args...) + } +} + +func outputError(message string, args ...interface{}) { + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "error": fmt.Sprintf(message, args...), + }) + } else { + fmt.Fprintf(os.Stderr, message+"\n", args...) + } +} + +func outputWarning(message string, args ...interface{}) { + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "warning": fmt.Sprintf(message, args...), + }) + } else { + fmt.Printf("Warning: "+message+"\n", args...) + } +} diff --git a/cmd/goyco/commands/common_test.go b/cmd/goyco/commands/common_test.go index 5e2935e..ba51ebe 100644 --- a/cmd/goyco/commands/common_test.go +++ b/cmd/goyco/commands/common_test.go @@ -217,3 +217,41 @@ func setInMemoryDBConnector(t *testing.T) { SetDBConnector(nil) }) } + +func TestSetJSONOutput(t *testing.T) { + t.Run("set and get JSON output", func(t *testing.T) { + SetJSONOutput(true) + if !IsJSONOutput() { + t.Error("expected JSON output to be enabled") + } + + SetJSONOutput(false) + if IsJSONOutput() { + t.Error("expected JSON output to be disabled") + } + }) + + t.Run("concurrent access", func(t *testing.T) { + SetJSONOutput(false) + done := make(chan bool) + + go func() { + for i := 0; i < 100; i++ { + SetJSONOutput(true) + _ = IsJSONOutput() + SetJSONOutput(false) + } + done <- true + }() + + go func() { + for i := 0; i < 100; i++ { + _ = IsJSONOutput() + } + done <- true + }() + + <-done + <-done + }) +} diff --git a/cmd/goyco/commands/daemon.go b/cmd/goyco/commands/daemon.go index 25e18e3..f1b46ba 100644 --- a/cmd/goyco/commands/daemon.go +++ b/cmd/goyco/commands/daemon.go @@ -86,23 +86,50 @@ func runStatusCommand(cfg *config.Config) error { pidFile := filepath.Join(pidDir, "goyco.pid") if !isDaemonRunning(pidFile) { - fmt.Println("Goyco is not running") + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "status": "not_running", + }) + } else { + fmt.Println("Goyco is not running") + } return nil } data, err := os.ReadFile(pidFile) if err != nil { - fmt.Printf("Goyco is running (PID file exists but cannot be read: %v)\n", err) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "status": "running", + "error": fmt.Sprintf("PID file exists but cannot be read: %v", err), + }) + } else { + fmt.Printf("Goyco is running (PID file exists but cannot be read: %v)\n", err) + } return nil } pid, err := strconv.Atoi(string(data)) if err != nil { - fmt.Printf("Goyco is running (PID file exists but contains invalid PID: %v)\n", err) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "status": "running", + "error": fmt.Sprintf("PID file exists but contains invalid PID: %v", err), + }) + } else { + fmt.Printf("Goyco is running (PID file exists but contains invalid PID: %v)\n", err) + } return nil } - fmt.Printf("Goyco is running (PID %d)\n", pid) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "status": "running", + "pid": pid, + }) + } else { + fmt.Printf("Goyco is running (PID %d)\n", pid) + } return nil } @@ -143,7 +170,14 @@ func stopDaemon(cfg *config.Config) error { _ = os.Remove(pidFile) - fmt.Printf("Goyco stopped (PID %d)\n", pid) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "stopped", + "pid": pid, + }) + } else { + fmt.Printf("Goyco stopped (PID %d)\n", pid) + } return nil } @@ -184,9 +218,18 @@ func runDaemon(cfg *config.Config) error { if err := writePIDFile(pidFile, pid); err != nil { return fmt.Errorf("cannot write PID file: %w", err) } - fmt.Printf("Goyco started with PID %d\n", pid) - fmt.Printf("PID file: %s\n", pidFile) - fmt.Printf("Log file: %s\n", logFile) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "started", + "pid": pid, + "pid_file": pidFile, + "log_file": logFile, + }) + } else { + fmt.Printf("Goyco started with PID %d\n", pid) + fmt.Printf("PID file: %s\n", pidFile) + fmt.Printf("Log file: %s\n", logFile) + } return nil } diff --git a/cmd/goyco/commands/daemon_test.go b/cmd/goyco/commands/daemon_test.go index 99a4e24..0c2b469 100644 --- a/cmd/goyco/commands/daemon_test.go +++ b/cmd/goyco/commands/daemon_test.go @@ -100,6 +100,20 @@ func TestRunStatusCommand(t *testing.T) { } }) + t.Run("daemon not running with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + tempDir := t.TempDir() + cfg.PIDDir = tempDir + + err := runStatusCommand(cfg) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("daemon running with valid PID", func(t *testing.T) { tempDir := t.TempDir() cfg.PIDDir = tempDir @@ -118,6 +132,27 @@ func TestRunStatusCommand(t *testing.T) { } }) + t.Run("daemon running with valid PID and JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + tempDir := t.TempDir() + cfg.PIDDir = tempDir + + pidFile := filepath.Join(tempDir, "goyco.pid") + currentPID := os.Getpid() + err := os.WriteFile(pidFile, []byte(strconv.Itoa(currentPID)), 0644) + if err != nil { + t.Fatalf("Failed to create PID file: %v", err) + } + + err = runStatusCommand(cfg) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("daemon running with invalid PID file", func(t *testing.T) { tempDir := t.TempDir() cfg.PIDDir = tempDir diff --git a/cmd/goyco/commands/migrate.go b/cmd/goyco/commands/migrate.go index 24bd266..322abd3 100644 --- a/cmd/goyco/commands/migrate.go +++ b/cmd/goyco/commands/migrate.go @@ -30,11 +30,20 @@ func HandleMigrateCommand(cfg *config.Config, name string, args []string) error } func runMigrateCommand(db *gorm.DB) error { - fmt.Println("Running database migrations...") + if !IsJSONOutput() { + fmt.Println("Running database migrations...") + } if err := database.Migrate(db); err != nil { return fmt.Errorf("run migrations: %w", err) } - fmt.Println("Migrations applied successfully") + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "migrations_applied", + "status": "success", + }) + } else { + fmt.Println("Migrations applied successfully") + } return nil } diff --git a/cmd/goyco/commands/migrate_test.go b/cmd/goyco/commands/migrate_test.go index 252a955..d4cd0c1 100644 --- a/cmd/goyco/commands/migrate_test.go +++ b/cmd/goyco/commands/migrate_test.go @@ -39,4 +39,18 @@ func TestHandleMigrateCommand(t *testing.T) { t.Fatalf("unexpected error running migrations: %v", err) } }) + + t.Run("runs migrations with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + cfg := testutils.NewTestConfig() + setInMemoryDBConnector(t) + + err := HandleMigrateCommand(cfg, "migrate", []string{}) + + if err != nil { + t.Fatalf("unexpected error running migrations: %v", err) + } + }) } diff --git a/cmd/goyco/commands/post.go b/cmd/goyco/commands/post.go index ee285d3..8ef6d84 100644 --- a/cmd/goyco/commands/post.go +++ b/cmd/goyco/commands/post.go @@ -90,7 +90,14 @@ func postDelete(repo repositories.PostRepository, args []string) error { return fmt.Errorf("delete post: %w", err) } - fmt.Printf("Post deleted: ID=%d\n", id) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "post_deleted", + "id": id, + }) + } else { + fmt.Printf("Post deleted: ID=%d\n", id) + } return nil } @@ -126,6 +133,38 @@ func postList(postQueries *services.PostQueries, args []string) error { return fmt.Errorf("list posts: %w", err) } + if IsJSONOutput() { + type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + AuthorID uint `json:"author_id"` + Score int `json:"score"` + CreatedAt string `json:"created_at"` + } + postsJSON := make([]postJSON, len(posts)) + for i, p := range posts { + authorID := uint(0) + if p.AuthorID != nil { + authorID = *p.AuthorID + } + if p.Author.ID != 0 { + authorID = p.Author.ID + } + postsJSON[i] = postJSON{ + ID: p.ID, + Title: p.Title, + AuthorID: authorID, + Score: p.Score, + CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), + } + } + outputJSON(map[string]interface{}{ + "posts": postsJSON, + "count": len(postsJSON), + }) + return nil + } + if len(posts) == 0 { fmt.Println("No posts found") return nil @@ -234,6 +273,39 @@ func postSearch(postQueries *services.PostQueries, args []string) error { return fmt.Errorf("search posts: %w", err) } + if IsJSONOutput() { + type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + AuthorID uint `json:"author_id"` + Score int `json:"score"` + CreatedAt string `json:"created_at"` + } + postsJSON := make([]postJSON, len(posts)) + for i, p := range posts { + authorID := uint(0) + if p.AuthorID != nil { + authorID = *p.AuthorID + } + if p.Author.ID != 0 { + authorID = p.Author.ID + } + postsJSON[i] = postJSON{ + ID: p.ID, + Title: p.Title, + AuthorID: authorID, + Score: p.Score, + CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), + } + } + outputJSON(map[string]interface{}{ + "search_term": sanitizedTerm, + "posts": postsJSON, + "count": len(postsJSON), + }) + return nil + } + if len(posts) == 0 { fmt.Println("No posts found matching your search") return nil diff --git a/cmd/goyco/commands/post_test.go b/cmd/goyco/commands/post_test.go index 1e90d55..20ce5a4 100644 --- a/cmd/goyco/commands/post_test.go +++ b/cmd/goyco/commands/post_test.go @@ -89,6 +89,26 @@ func TestPostDelete(t *testing.T) { } }) + t.Run("successful delete with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + freshMockRepo := testutils.NewMockPostRepository() + testPost := &database.Post{ + Title: "Test Post", + Content: "Test Content", + AuthorID: &[]uint{1}[0], + Score: 0, + } + _ = freshMockRepo.Create(testPost) + + err := postDelete(freshMockRepo, []string{"1"}) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("missing id", func(t *testing.T) { err := postDelete(mockRepo, []string{}) @@ -174,6 +194,17 @@ func TestPostList(t *testing.T) { } }) + t.Run("list all posts with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + err := postList(postQueries, []string{}) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("list with limit", func(t *testing.T) { err := postList(postQueries, []string{"--limit", "1"}) @@ -271,6 +302,17 @@ func TestPostSearch(t *testing.T) { } }) + t.Run("search with results and JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + err := postSearch(postQueries, []string{"Go"}) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("case insensitive search", func(t *testing.T) { mockRepo.SearchCalls = nil diff --git a/cmd/goyco/commands/prune.go b/cmd/goyco/commands/prune.go index 22f9455..32675fb 100644 --- a/cmd/goyco/commands/prune.go +++ b/cmd/goyco/commands/prune.go @@ -62,6 +62,7 @@ func printPruneUsage() { func prunePosts(postRepo repositories.PostRepository, args []string) error { fs := flag.NewFlagSet("prune posts", flag.ContinueOnError) dryRun := fs.Bool("dry-run", false, "preview what would be deleted without actually deleting") + yes := fs.Bool("yes", false, "skip confirmation prompt") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { @@ -73,6 +74,49 @@ func prunePosts(postRepo repositories.PostRepository, args []string) error { return fmt.Errorf("get posts by deleted users: %w", err) } + if IsJSONOutput() { + type postJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + URL string `json:"url"` + } + postsJSON := make([]postJSON, len(posts)) + for i, post := range posts { + authorName := "(deleted)" + if post.Author.ID != 0 { + authorName = post.Author.Username + } + postsJSON[i] = postJSON{ + ID: post.ID, + Title: post.Title, + Author: authorName, + URL: post.URL, + } + } + if *dryRun { + outputJSON(map[string]interface{}{ + "action": "prune_posts", + "dry_run": true, + "posts": postsJSON, + "count": len(postsJSON), + }) + return nil + } + if !*yes { + return fmt.Errorf("confirmation required. Use --yes to skip prompt in JSON mode") + } + deletedCount, err := postRepo.HardDeletePostsByDeletedUsers() + if err != nil { + return fmt.Errorf("hard delete posts: %w", err) + } + outputJSON(map[string]interface{}{ + "action": "prune_posts", + "deleted_count": deletedCount, + }) + return nil + } + if len(posts) == 0 { fmt.Println("No posts found for deleted users") return nil @@ -93,15 +137,17 @@ func prunePosts(postRepo repositories.PostRepository, args []string) error { return nil } - fmt.Printf("\nAre you sure you want to permanently delete %d posts? (yes/no): ", len(posts)) - var confirmation string - if _, err := fmt.Scanln(&confirmation); err != nil { - return fmt.Errorf("read confirmation: %w", err) - } + if !*yes { + fmt.Printf("\nAre you sure you want to permanently delete %d posts? (yes/no): ", len(posts)) + var confirmation string + if _, err := fmt.Scanln(&confirmation); err != nil { + return fmt.Errorf("read confirmation: %w", err) + } - if confirmation != "yes" { - fmt.Println("Operation cancelled") - return nil + if confirmation != "yes" { + fmt.Println("Operation cancelled") + return nil + } } deletedCount, err := postRepo.HardDeletePostsByDeletedUsers() @@ -117,6 +163,7 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post fs := flag.NewFlagSet("prune users", flag.ContinueOnError) dryRun := fs.Bool("dry-run", false, "preview what would be deleted without actually deleting") deletePosts := fs.Bool("with-posts", false, "also delete all posts when deleting users") + yes := fs.Bool("yes", false, "skip confirmation prompt") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { @@ -130,7 +177,14 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post userCount := len(users) if userCount == 0 { - fmt.Println("No users found to delete") + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "prune_users", + "count": 0, + }) + } else { + fmt.Println("No users found to delete") + } return nil } @@ -142,6 +196,61 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post } } + if IsJSONOutput() { + type userJSON struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + } + usersJSON := make([]userJSON, len(users)) + for i, user := range users { + usersJSON[i] = userJSON{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + } + } + if *dryRun { + outputJSON(map[string]interface{}{ + "action": "prune_users", + "dry_run": true, + "users": usersJSON, + "user_count": userCount, + "post_count": postCount, + "with_posts": *deletePosts, + }) + return nil + } + if !*yes { + return fmt.Errorf("confirmation required. Use --yes to skip prompt or --json for non-interactive mode") + } + if *deletePosts { + totalDeleted, err := userRepo.HardDeleteAll() + if err != nil { + return fmt.Errorf("hard delete all users and posts: %w", err) + } + outputJSON(map[string]interface{}{ + "action": "prune_users", + "deleted_count": totalDeleted, + "with_posts": true, + }) + } else { + deletedCount := 0 + for _, user := range users { + if err := userRepo.SoftDeleteWithPosts(user.ID); err != nil { + return fmt.Errorf("soft delete user %d: %w", user.ID, err) + } + deletedCount++ + } + outputJSON(map[string]interface{}{ + "action": "prune_users", + "deleted_count": deletedCount, + "with_posts": false, + }) + } + return nil + } + fmt.Printf("Found %d users", userCount) if *deletePosts { fmt.Printf(" and %d posts", postCount) @@ -158,21 +267,23 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post return nil } - confirmMsg := fmt.Sprintf("\nAre you sure you want to permanently delete %d users", userCount) - if *deletePosts { - confirmMsg += fmt.Sprintf(" and %d posts", postCount) - } - confirmMsg += "? (yes/no): " - fmt.Print(confirmMsg) + if !*yes { + confirmMsg := fmt.Sprintf("\nAre you sure you want to permanently delete %d users", userCount) + if *deletePosts { + confirmMsg += fmt.Sprintf(" and %d posts", postCount) + } + confirmMsg += "? (yes/no): " + fmt.Print(confirmMsg) - var confirmation string - if _, err := fmt.Scanln(&confirmation); err != nil { - return fmt.Errorf("read confirmation: %w", err) - } + var confirmation string + if _, err := fmt.Scanln(&confirmation); err != nil { + return fmt.Errorf("read confirmation: %w", err) + } - if confirmation != "yes" { - fmt.Println("Operation cancelled") - return nil + if confirmation != "yes" { + fmt.Println("Operation cancelled") + return nil + } } if *deletePosts { @@ -198,6 +309,7 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRepository, args []string) error { fs := flag.NewFlagSet("prune all", flag.ContinueOnError) dryRun := fs.Bool("dry-run", false, "preview what would be deleted without actually deleting") + yes := fs.Bool("yes", false, "skip confirmation prompt") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { @@ -214,6 +326,30 @@ func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRe return fmt.Errorf("get post count: %w", err) } + if IsJSONOutput() { + if *dryRun { + outputJSON(map[string]interface{}{ + "action": "prune_all", + "dry_run": true, + "user_count": len(userCount), + "post_count": postCount, + }) + return nil + } + if !*yes { + return fmt.Errorf("confirmation required. Use --yes to skip prompt or --json for non-interactive mode") + } + totalDeleted, err := userRepo.HardDeleteAll() + if err != nil { + return fmt.Errorf("hard delete all: %w", err) + } + outputJSON(map[string]interface{}{ + "action": "prune_all", + "deleted_count": totalDeleted, + }) + return nil + } + fmt.Printf("Found %d users and %d posts to delete\n", len(userCount), postCount) if *dryRun { @@ -221,15 +357,17 @@ func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRe return nil } - fmt.Printf("\nAre you sure you want to permanently delete ALL %d users and %d posts? (yes/no): ", len(userCount), postCount) - var confirmation string - if _, err := fmt.Scanln(&confirmation); err != nil { - return fmt.Errorf("read confirmation: %w", err) - } + if !*yes { + fmt.Printf("\nAre you sure you want to permanently delete ALL %d users and %d posts? (yes/no): ", len(userCount), postCount) + var confirmation string + if _, err := fmt.Scanln(&confirmation); err != nil { + return fmt.Errorf("read confirmation: %w", err) + } - if confirmation != "yes" { - fmt.Println("Operation cancelled") - return nil + if confirmation != "yes" { + fmt.Println("Operation cancelled") + return nil + } } totalDeleted, err := userRepo.HardDeleteAll() diff --git a/cmd/goyco/commands/prune_test.go b/cmd/goyco/commands/prune_test.go index 0182cb6..f260715 100644 --- a/cmd/goyco/commands/prune_test.go +++ b/cmd/goyco/commands/prune_test.go @@ -110,6 +110,16 @@ func TestPrunePosts(t *testing.T) { t.Errorf("prunePosts() with dry-run error = %v", err) } + t.Run("prunePosts with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + err := prunePosts(postRepo, []string{"--dry-run"}) + if err != nil { + t.Errorf("prunePosts() with dry-run and JSON output error = %v", err) + } + }) + post1 := database.Post{ ID: 1, Title: "Post by deleted user 1", @@ -138,6 +148,16 @@ func TestPrunePosts(t *testing.T) { if err != nil { t.Errorf("prunePosts() with dry-run error = %v", err) } + + t.Run("prunePosts with JSON output and mock data", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + err := prunePosts(postRepo, []string{"--dry-run"}) + if err != nil { + t.Errorf("prunePosts() with dry-run and JSON output error = %v", err) + } + }) } func TestPruneAll(t *testing.T) { @@ -175,6 +195,16 @@ func TestPruneAll(t *testing.T) { if err != nil { t.Errorf("pruneAll() with dry-run error = %v", err) } + + t.Run("pruneAll with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + err := pruneAll(userRepo, postRepo, []string{"--dry-run"}) + if err != nil { + t.Errorf("pruneAll() with dry-run and JSON output error = %v", err) + } + }) } func TestPrunePostsWithError(t *testing.T) { diff --git a/cmd/goyco/commands/seed.go b/cmd/goyco/commands/seed.go index 58bc99d..8e76348 100644 --- a/cmd/goyco/commands/seed.go +++ b/cmd/goyco/commands/seed.go @@ -69,54 +69,91 @@ func seedDatabase(userRepo repositories.UserRepository, postRepo repositories.Po return err } - fmt.Println("Starting database seeding...") + if !IsJSONOutput() { + fmt.Println("Starting database seeding...") + } spinner := NewSpinner("Creating seed user") - spinner.Spin() + if !IsJSONOutput() { + spinner.Spin() + } seedUser, err := ensureSeedUser(userRepo) if err != nil { - spinner.Complete() + if !IsJSONOutput() { + spinner.Complete() + } return fmt.Errorf("ensure seed user: %w", err) } - spinner.Complete() - - fmt.Printf("Seed user ready: ID=%d Username=%s\n", seedUser.ID, seedUser.Username) + if !IsJSONOutput() { + spinner.Complete() + fmt.Printf("Seed user ready: ID=%d Username=%s\n", seedUser.ID, seedUser.Username) + } processor := NewParallelProcessor() - progress := NewProgressIndicator(*numUsers, "Creating users (parallel)") + var progress *ProgressIndicator + if !IsJSONOutput() { + progress = NewProgressIndicator(*numUsers, "Creating users (parallel)") + } users, err := processor.CreateUsersInParallel(userRepo, *numUsers, progress) if err != nil { return fmt.Errorf("create random users: %w", err) } - progress.Complete() + if !IsJSONOutput() && progress != nil { + progress.Complete() + } allUsers := append([]database.User{*seedUser}, users...) - progress = NewProgressIndicator(*numPosts, "Creating posts (parallel)") + if !IsJSONOutput() { + progress = NewProgressIndicator(*numPosts, "Creating posts (parallel)") + } posts, err := processor.CreatePostsInParallel(postRepo, seedUser.ID, *numPosts, progress) if err != nil { return fmt.Errorf("create random posts: %w", err) } - progress.Complete() + if !IsJSONOutput() && progress != nil { + progress.Complete() + } - progress = NewProgressIndicator(len(posts), "Creating votes (parallel)") + if !IsJSONOutput() { + progress = NewProgressIndicator(len(posts), "Creating votes (parallel)") + } votes, err := processor.CreateVotesInParallel(voteRepo, allUsers, posts, *votesPerPost, progress) if err != nil { return fmt.Errorf("create random votes: %w", err) } - progress.Complete() + if !IsJSONOutput() && progress != nil { + progress.Complete() + } - progress = NewProgressIndicator(len(posts), "Updating scores (parallel)") + if !IsJSONOutput() { + progress = NewProgressIndicator(len(posts), "Updating scores (parallel)") + } err = processor.UpdatePostScoresInParallel(postRepo, voteRepo, posts, progress) if err != nil { return fmt.Errorf("update post scores: %w", err) } - progress.Complete() + if !IsJSONOutput() && progress != nil { + progress.Complete() + } - fmt.Println("Database seeding completed successfully!") - fmt.Printf("Created %d users, %d posts, and %d votes\n", len(allUsers), len(posts), votes) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "seed_completed", + "users": len(allUsers), + "posts": len(posts), + "votes": votes, + "seed_user": map[string]interface{}{ + "id": seedUser.ID, + "username": seedUser.Username, + }, + }) + } else { + fmt.Println("Database seeding completed successfully!") + fmt.Printf("Created %d users, %d posts, and %d votes\n", len(allUsers), len(posts), votes) + } return nil } diff --git a/cmd/goyco/commands/user.go b/cmd/goyco/commands/user.go index 176b9eb..04b19d6 100644 --- a/cmd/goyco/commands/user.go +++ b/cmd/goyco/commands/user.go @@ -98,7 +98,7 @@ func userCreate(cfg *config.Config, repo repositories.UserRepository, args []str auditLogger, err := NewAuditLogger(cfg.LogDir) if err != nil { - fmt.Printf("Warning: Could not initialize audit logging: %v\n", err) + outputWarning("Could not initialize audit logging: %v", err) auditLogger = nil } @@ -168,7 +168,16 @@ func userCreate(cfg *config.Config, repo repositories.UserRepository, args []str auditLogger.LogUserCreation(user.ID, user.Username, user.Email, true, nil) } - fmt.Printf("User created: %s (%s)\n", user.Username, user.Email) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_created", + "id": user.ID, + "username": user.Username, + "email": user.Email, + }) + } else { + fmt.Printf("User created: %s (%s)\n", user.Username, user.Email) + } return nil } @@ -286,7 +295,16 @@ func userUpdate(cfg *config.Config, repo repositories.UserRepository, refreshTok return handleDatabaseConstraintError(err) } - fmt.Printf("User updated: %s (%s)\n", user.Username, user.Email) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_updated", + "id": user.ID, + "username": user.Username, + "email": user.Email, + }) + } else { + fmt.Printf("User updated: %s (%s)\n", user.Username, user.Email) + } return nil } @@ -383,16 +401,13 @@ func userDelete(cfg *config.Config, repo repositories.UserRepository, args []str } var deleteErr error + var postsDeleted bool if *deletePosts { deleteErr = repo.HardDelete(uint(id)) - if deleteErr == nil { - fmt.Printf("User deleted: ID=%d (posts also deleted)\n", id) - } + postsDeleted = true } else { deleteErr = repo.SoftDeleteWithPosts(uint(id)) - if deleteErr == nil { - fmt.Printf("User deleted: ID=%d (posts kept)\n", id) - } + postsDeleted = false } if deleteErr != nil { @@ -402,10 +417,30 @@ func userDelete(cfg *config.Config, repo repositories.UserRepository, args []str emailSender := services.NewSMTPSenderWithTimeout(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.From, cfg.SMTP.Timeout) subject, body := services.GenerateAdminAccountDeletionNotificationEmail(user.Username, cfg.App.AdminEmail, cfg.App.BaseURL, cfg.App.Title, *deletePosts) + emailSent := true if err := emailSender.Send(user.Email, subject, body); err != nil { - fmt.Printf("Warning: Could not send notification email to %s: %v\n", user.Email, err) + outputWarning("Could not send notification email to %s: %v", user.Email, err) + emailSent = false + } + + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_deleted", + "id": id, + "username": user.Username, + "email": user.Email, + "posts_deleted": postsDeleted, + "email_sent": emailSent, + }) } else { - fmt.Printf("Notification email sent to %s\n", user.Email) + if postsDeleted { + fmt.Printf("User deleted: ID=%d (posts also deleted)\n", id) + } else { + fmt.Printf("User deleted: ID=%d (posts kept)\n", id) + } + if emailSent { + fmt.Printf("Notification email sent to %s\n", user.Email) + } } return nil @@ -426,6 +461,31 @@ func userList(repo repositories.UserRepository, args []string) error { return fmt.Errorf("list users: %w", err) } + if IsJSONOutput() { + type userJSON struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Locked bool `json:"locked"` + CreatedAt string `json:"created_at"` + } + usersJSON := make([]userJSON, len(users)) + for i, u := range users { + usersJSON[i] = userJSON{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Locked: u.Locked, + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + } + } + outputJSON(map[string]interface{}{ + "users": usersJSON, + "count": len(usersJSON), + }) + return nil + } + if len(users) == 0 { fmt.Println("No users found") return nil @@ -517,7 +577,16 @@ func userLock(cfg *config.Config, repo repositories.UserRepository, args []strin } if user.Locked { - fmt.Printf("User is already locked: %s\n", user.Username) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_lock", + "id": id, + "username": user.Username, + "status": "already_locked", + }) + } else { + fmt.Printf("User is already locked: %s\n", user.Username) + } return nil } @@ -525,15 +594,27 @@ func userLock(cfg *config.Config, repo repositories.UserRepository, args []strin return fmt.Errorf("lock user: %w", err) } - fmt.Printf("User locked: %s\n", user.Username) - emailSender := services.NewSMTPSenderWithTimeout(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.From, cfg.SMTP.Timeout) subject, body := services.GenerateAccountLockNotificationEmail(user.Username, cfg.App.AdminEmail, cfg.App.Title) + emailSent := true if err := emailSender.Send(user.Email, subject, body); err != nil { - fmt.Printf("Warning: Could not send notification email to %s: %v\n", user.Email, err) + outputWarning("Could not send notification email to %s: %v", user.Email, err) + emailSent = false + } + + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_locked", + "id": id, + "username": user.Username, + "email_sent": emailSent, + }) } else { - fmt.Printf("Notification email sent to %s\n", user.Email) + fmt.Printf("User locked: %s\n", user.Username) + if emailSent { + fmt.Printf("Notification email sent to %s\n", user.Email) + } } return nil @@ -571,7 +652,16 @@ func userUnlock(cfg *config.Config, repo repositories.UserRepository, args []str } if !user.Locked { - fmt.Printf("User is already unlocked: %s\n", user.Username) + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_unlock", + "id": id, + "username": user.Username, + "status": "already_unlocked", + }) + } else { + fmt.Printf("User is already unlocked: %s\n", user.Username) + } return nil } @@ -579,15 +669,27 @@ func userUnlock(cfg *config.Config, repo repositories.UserRepository, args []str return fmt.Errorf("unlock user: %w", err) } - fmt.Printf("User unlocked: %s\n", user.Username) - emailSender := services.NewSMTPSenderWithTimeout(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.From, cfg.SMTP.Timeout) subject, body := services.GenerateAccountUnlockNotificationEmail(user.Username, cfg.App.AdminEmail, cfg.App.BaseURL, cfg.App.Title) + emailSent := true if err := emailSender.Send(user.Email, subject, body); err != nil { - fmt.Printf("Warning: Could not send notification email to %s: %v\n", user.Email, err) + outputWarning("Could not send notification email to %s: %v", user.Email, err) + emailSent = false + } + + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "user_unlocked", + "id": id, + "username": user.Username, + "email_sent": emailSent, + }) } else { - fmt.Printf("Notification email sent to %s\n", user.Email) + fmt.Printf("User unlocked: %s\n", user.Username) + if emailSent { + fmt.Printf("Notification email sent to %s\n", user.Email) + } } return nil @@ -629,8 +731,18 @@ func resetUserPassword(cfg *config.Config, repo repositories.UserRepository, ses return fmt.Errorf("send password reset email: %w", err) } - fmt.Printf("Password reset for user %s: Temporary password sent to %s\n", user.Username, user.Email) - fmt.Printf("⚠️ User must change this password on next login!\n") + if IsJSONOutput() { + outputJSON(map[string]interface{}{ + "action": "password_reset", + "id": userID, + "username": user.Username, + "email": user.Email, + "message": "Temporary password sent. User must change password on next login.", + }) + } else { + fmt.Printf("Password reset for user %s: Temporary password sent to %s\n", user.Username, user.Email) + fmt.Printf("⚠️ User must change this password on next login!\n") + } return nil } diff --git a/cmd/goyco/commands/user_test.go b/cmd/goyco/commands/user_test.go index e772018..0cd6bac 100644 --- a/cmd/goyco/commands/user_test.go +++ b/cmd/goyco/commands/user_test.go @@ -81,6 +81,22 @@ func TestUserCreate(t *testing.T) { } }) + t.Run("successful creation with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + mockRepo := testutils.NewMockUserRepository() + err := userCreate(cfg, mockRepo, []string{ + "--username", "testuser", + "--email", "test@example.com", + "--password", "StrongPass123!", + }) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("missing username", func(t *testing.T) { mockRepo := testutils.NewMockUserRepository() err := userCreate(cfg, mockRepo, []string{ @@ -239,6 +255,22 @@ func TestUserUpdate(t *testing.T) { } }) + t.Run("successful update username with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + cfg := &config.Config{} + mockRefreshRepo := &mockRefreshTokenRepo{} + err := userUpdate(cfg, mockRepo, mockRefreshRepo, []string{ + "1", + "--username", "newusername", + }) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("successful update email", func(t *testing.T) { cfg := &config.Config{} mockRefreshRepo := &mockRefreshTokenRepo{} @@ -351,6 +383,25 @@ func TestUserDelete(t *testing.T) { } }) + t.Run("successful delete with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + testUser := &database.User{ + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + } + freshMockRepo := testutils.NewMockUserRepository() + _ = freshMockRepo.Create(testUser) + + err := userDelete(cfg, freshMockRepo, []string{"1"}) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("successful delete with posts", func(t *testing.T) { testUser2 := &database.User{ Username: "testuser2", @@ -459,6 +510,17 @@ func TestUserList(t *testing.T) { } }) + t.Run("list all users with JSON output", func(t *testing.T) { + SetJSONOutput(true) + defer SetJSONOutput(false) + + err := userList(mockRepo, []string{}) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("list with limit", func(t *testing.T) { err := userList(mockRepo, []string{"--limit", "1"}) diff --git a/cmd/goyco/fuzz_test.go b/cmd/goyco/fuzz_test.go index 3a15221..74ee4a9 100644 --- a/cmd/goyco/fuzz_test.go +++ b/cmd/goyco/fuzz_test.go @@ -172,11 +172,12 @@ func FuzzRunCommandHandler(f *testing.F) { err := handleRunCommand(cfg, args) - if len(args) > 0 && args[0] == "--help" { + switch { + case len(args) > 0 && args[0] == "--help": if err != nil { t.Logf("Help flag should not error, got: %v", err) } - } else if len(args) > 0 { + case len(args) > 0: if err == nil { return } @@ -190,7 +191,7 @@ func FuzzRunCommandHandler(f *testing.F) { if !strings.Contains(errMsg, "unexpected arguments") { t.Logf("Got error (may be acceptable for server setup): %v", err) } - } else { + default: if err != nil && strings.Contains(err.Error(), "unexpected arguments") { t.Fatalf("Empty args should not trigger 'unexpected arguments' error: %v", err) } diff --git a/cmd/goyco/main.go b/cmd/goyco/main.go index 5607ea9..d2ae22f 100644 --- a/cmd/goyco/main.go +++ b/cmd/goyco/main.go @@ -67,6 +67,7 @@ func run(args []string) error { rootFS.SetOutput(os.Stderr) rootFS.Usage = printRootUsage showHelp := rootFS.Bool("help", false, "show this help message") + jsonOutput := rootFS.Bool("json", false, "output results in JSON format") if err := rootFS.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -80,6 +81,8 @@ func run(args []string) error { return nil } + commands.SetJSONOutput(*jsonOutput) + remaining := rootFS.Args() if len(remaining) == 0 { printRootUsage() diff --git a/cmd/goyco/server_test.go b/cmd/goyco/server_test.go index a2cbc40..260235c 100644 --- a/cmd/goyco/server_test.go +++ b/cmd/goyco/server_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/tls" "errors" "flag" @@ -95,7 +96,12 @@ func TestServerConfigurationFromConfig(t *testing.T) { testServer := httptest.NewServer(srv.Handler) defer testServer.Close() - resp, err := http.Get(testServer.URL + "/health") + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL+"/health", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to make request: %v", err) } @@ -157,8 +163,9 @@ func TestTLSWiringFromConfig(t *testing.T) { expectedAddr := cfg.Server.Host + ":" + cfg.Server.Port srv := &http.Server{ - Addr: expectedAddr, - Handler: router, + Addr: expectedAddr, + Handler: router, + ReadHeaderTimeout: 5 * time.Second, } if srv.Addr != expectedAddr { @@ -201,7 +208,12 @@ func TestTLSWiringFromConfig(t *testing.T) { }, } - resp, err := client.Get(testServer.URL + "/health") + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL+"/health", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := client.Do(req) if err != nil { t.Fatalf("Failed to make TLS request: %v", err) } @@ -215,10 +227,8 @@ func TestTLSWiringFromConfig(t *testing.T) { if resp.TLS == nil { t.Error("Expected TLS connection info to be present in response") - } else { - if resp.TLS.Version < tls.VersionTLS12 { - t.Errorf("Expected TLS version 1.2 or higher, got %x", resp.TLS.Version) - } + } else if resp.TLS.Version < tls.VersionTLS12 { + t.Errorf("Expected TLS version 1.2 or higher, got %x", resp.TLS.Version) } } } @@ -358,7 +368,12 @@ func TestServerInitializationFlow(t *testing.T) { testServer := httptest.NewServer(srv.Handler) defer testServer.Close() - resp, err := http.Get(testServer.URL + "/health") + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL+"/health", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to make request: %v", err) } @@ -370,7 +385,12 @@ func TestServerInitializationFlow(t *testing.T) { t.Errorf("Expected status 200, got %d", resp.StatusCode) } - resp, err = http.Get(testServer.URL + "/api") + req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL+"/api", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to make request: %v", err) }