feat(cli): add a json output and tests

This commit is contained in:
2025-11-21 12:59:36 +01:00
parent 30a2e88685
commit 7fca1f78dc
18 changed files with 829 additions and 95 deletions

View File

@@ -6,8 +6,9 @@ import (
"fmt"
"os"
"github.com/joho/godotenv"
"goyco/cmd/goyco/commands"
"github.com/joho/godotenv"
)
func loadDotEnv() {

View File

@@ -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...)
}
}

View File

@@ -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
})
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"})

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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)
}