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

@@ -251,6 +251,29 @@ Goyco includes a comprehensive CLI for administration:
./bin/goyco prune all # Hard delete all users and posts ./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 ## Development
### Get the sources ### Get the sources

View File

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

View File

@@ -1,6 +1,7 @@
package commands package commands
import ( import (
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -16,6 +17,23 @@ var ErrHelpRequested = errors.New("help requested")
type DBConnector func(cfg *config.Config) (*gorm.DB, func() error, error) 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 ( var (
dbConnectorMu sync.RWMutex dbConnectorMu sync.RWMutex
currentDBConnector = defaultDBConnector currentDBConnector = defaultDBConnector
@@ -93,3 +111,39 @@ func truncate(in string, max int) string {
} }
return in[:max-3] + "..." 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) 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") pidFile := filepath.Join(pidDir, "goyco.pid")
if !isDaemonRunning(pidFile) { if !isDaemonRunning(pidFile) {
if IsJSONOutput() {
outputJSON(map[string]interface{}{
"status": "not_running",
})
} else {
fmt.Println("Goyco is not running") fmt.Println("Goyco is not running")
}
return nil return nil
} }
data, err := os.ReadFile(pidFile) data, err := os.ReadFile(pidFile)
if err != nil { if err != nil {
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) fmt.Printf("Goyco is running (PID file exists but cannot be read: %v)\n", err)
}
return nil return nil
} }
pid, err := strconv.Atoi(string(data)) pid, err := strconv.Atoi(string(data))
if err != nil { if err != nil {
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) fmt.Printf("Goyco is running (PID file exists but contains invalid PID: %v)\n", err)
}
return nil return nil
} }
if IsJSONOutput() {
outputJSON(map[string]interface{}{
"status": "running",
"pid": pid,
})
} else {
fmt.Printf("Goyco is running (PID %d)\n", pid) fmt.Printf("Goyco is running (PID %d)\n", pid)
}
return nil return nil
} }
@@ -143,7 +170,14 @@ func stopDaemon(cfg *config.Config) error {
_ = os.Remove(pidFile) _ = os.Remove(pidFile)
if IsJSONOutput() {
outputJSON(map[string]interface{}{
"action": "stopped",
"pid": pid,
})
} else {
fmt.Printf("Goyco stopped (PID %d)\n", pid) fmt.Printf("Goyco stopped (PID %d)\n", pid)
}
return nil return nil
} }
@@ -184,9 +218,18 @@ func runDaemon(cfg *config.Config) error {
if err := writePIDFile(pidFile, pid); err != nil { if err := writePIDFile(pidFile, pid); err != nil {
return fmt.Errorf("cannot write PID file: %w", err) return fmt.Errorf("cannot write PID file: %w", err)
} }
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("Goyco started with PID %d\n", pid)
fmt.Printf("PID file: %s\n", pidFile) fmt.Printf("PID file: %s\n", pidFile)
fmt.Printf("Log file: %s\n", logFile) fmt.Printf("Log file: %s\n", logFile)
}
return nil 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) { t.Run("daemon running with valid PID", func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
cfg.PIDDir = 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) { t.Run("daemon running with invalid PID file", func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
cfg.PIDDir = 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 { func runMigrateCommand(db *gorm.DB) error {
if !IsJSONOutput() {
fmt.Println("Running database migrations...") fmt.Println("Running database migrations...")
}
if err := database.Migrate(db); err != nil { if err := database.Migrate(db); err != nil {
return fmt.Errorf("run migrations: %w", err) return fmt.Errorf("run migrations: %w", err)
} }
if IsJSONOutput() {
outputJSON(map[string]interface{}{
"action": "migrations_applied",
"status": "success",
})
} else {
fmt.Println("Migrations applied successfully") fmt.Println("Migrations applied successfully")
}
return nil return nil
} }

View File

@@ -39,4 +39,18 @@ func TestHandleMigrateCommand(t *testing.T) {
t.Fatalf("unexpected error running migrations: %v", err) 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) return fmt.Errorf("delete post: %w", err)
} }
if IsJSONOutput() {
outputJSON(map[string]interface{}{
"action": "post_deleted",
"id": id,
})
} else {
fmt.Printf("Post deleted: ID=%d\n", id) fmt.Printf("Post deleted: ID=%d\n", id)
}
return nil return nil
} }
@@ -126,6 +133,38 @@ func postList(postQueries *services.PostQueries, args []string) error {
return fmt.Errorf("list posts: %w", err) 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 { if len(posts) == 0 {
fmt.Println("No posts found") fmt.Println("No posts found")
return nil return nil
@@ -234,6 +273,39 @@ func postSearch(postQueries *services.PostQueries, args []string) error {
return fmt.Errorf("search posts: %w", err) 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 { if len(posts) == 0 {
fmt.Println("No posts found matching your search") fmt.Println("No posts found matching your search")
return nil 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) { t.Run("missing id", func(t *testing.T) {
err := postDelete(mockRepo, []string{}) 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) { t.Run("list with limit", func(t *testing.T) {
err := postList(postQueries, []string{"--limit", "1"}) 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) { t.Run("case insensitive search", func(t *testing.T) {
mockRepo.SearchCalls = nil mockRepo.SearchCalls = nil

View File

@@ -62,6 +62,7 @@ func printPruneUsage() {
func prunePosts(postRepo repositories.PostRepository, args []string) error { func prunePosts(postRepo repositories.PostRepository, args []string) error {
fs := flag.NewFlagSet("prune posts", flag.ContinueOnError) fs := flag.NewFlagSet("prune posts", flag.ContinueOnError)
dryRun := fs.Bool("dry-run", false, "preview what would be deleted without actually deleting") 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) fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil { 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) 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 { if len(posts) == 0 {
fmt.Println("No posts found for deleted users") fmt.Println("No posts found for deleted users")
return nil return nil
@@ -93,6 +137,7 @@ func prunePosts(postRepo repositories.PostRepository, args []string) error {
return nil return nil
} }
if !*yes {
fmt.Printf("\nAre you sure you want to permanently delete %d posts? (yes/no): ", len(posts)) fmt.Printf("\nAre you sure you want to permanently delete %d posts? (yes/no): ", len(posts))
var confirmation string var confirmation string
if _, err := fmt.Scanln(&confirmation); err != nil { if _, err := fmt.Scanln(&confirmation); err != nil {
@@ -103,6 +148,7 @@ func prunePosts(postRepo repositories.PostRepository, args []string) error {
fmt.Println("Operation cancelled") fmt.Println("Operation cancelled")
return nil return nil
} }
}
deletedCount, err := postRepo.HardDeletePostsByDeletedUsers() deletedCount, err := postRepo.HardDeletePostsByDeletedUsers()
if err != nil { if err != nil {
@@ -117,6 +163,7 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post
fs := flag.NewFlagSet("prune users", flag.ContinueOnError) fs := flag.NewFlagSet("prune users", flag.ContinueOnError)
dryRun := fs.Bool("dry-run", false, "preview what would be deleted without actually deleting") 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") 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) fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@@ -130,7 +177,14 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post
userCount := len(users) userCount := len(users)
if userCount == 0 { if userCount == 0 {
if IsJSONOutput() {
outputJSON(map[string]interface{}{
"action": "prune_users",
"count": 0,
})
} else {
fmt.Println("No users found to delete") fmt.Println("No users found to delete")
}
return nil 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) fmt.Printf("Found %d users", userCount)
if *deletePosts { if *deletePosts {
fmt.Printf(" and %d posts", postCount) fmt.Printf(" and %d posts", postCount)
@@ -158,6 +267,7 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post
return nil return nil
} }
if !*yes {
confirmMsg := fmt.Sprintf("\nAre you sure you want to permanently delete %d users", userCount) confirmMsg := fmt.Sprintf("\nAre you sure you want to permanently delete %d users", userCount)
if *deletePosts { if *deletePosts {
confirmMsg += fmt.Sprintf(" and %d posts", postCount) confirmMsg += fmt.Sprintf(" and %d posts", postCount)
@@ -174,6 +284,7 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post
fmt.Println("Operation cancelled") fmt.Println("Operation cancelled")
return nil return nil
} }
}
if *deletePosts { if *deletePosts {
totalDeleted, err := userRepo.HardDeleteAll() totalDeleted, err := userRepo.HardDeleteAll()
@@ -198,6 +309,7 @@ func pruneUsers(userRepo repositories.UserRepository, postRepo repositories.Post
func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRepository, args []string) error { func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRepository, args []string) error {
fs := flag.NewFlagSet("prune all", flag.ContinueOnError) fs := flag.NewFlagSet("prune all", flag.ContinueOnError)
dryRun := fs.Bool("dry-run", false, "preview what would be deleted without actually deleting") 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) fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil { 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) 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) fmt.Printf("Found %d users and %d posts to delete\n", len(userCount), postCount)
if *dryRun { if *dryRun {
@@ -221,6 +357,7 @@ func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRe
return nil return nil
} }
if !*yes {
fmt.Printf("\nAre you sure you want to permanently delete ALL %d users and %d posts? (yes/no): ", len(userCount), postCount) fmt.Printf("\nAre you sure you want to permanently delete ALL %d users and %d posts? (yes/no): ", len(userCount), postCount)
var confirmation string var confirmation string
if _, err := fmt.Scanln(&confirmation); err != nil { if _, err := fmt.Scanln(&confirmation); err != nil {
@@ -231,6 +368,7 @@ func pruneAll(userRepo repositories.UserRepository, postRepo repositories.PostRe
fmt.Println("Operation cancelled") fmt.Println("Operation cancelled")
return nil return nil
} }
}
totalDeleted, err := userRepo.HardDeleteAll() totalDeleted, err := userRepo.HardDeleteAll()
if err != nil { if err != nil {

View File

@@ -110,6 +110,16 @@ func TestPrunePosts(t *testing.T) {
t.Errorf("prunePosts() with dry-run error = %v", err) 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{ post1 := database.Post{
ID: 1, ID: 1,
Title: "Post by deleted user 1", Title: "Post by deleted user 1",
@@ -138,6 +148,16 @@ func TestPrunePosts(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("prunePosts() with dry-run error = %v", err) 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) { func TestPruneAll(t *testing.T) {
@@ -175,6 +195,16 @@ func TestPruneAll(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("pruneAll() with dry-run error = %v", err) 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) { func TestPrunePostsWithError(t *testing.T) {

View File

@@ -69,54 +69,91 @@ func seedDatabase(userRepo repositories.UserRepository, postRepo repositories.Po
return err return err
} }
if !IsJSONOutput() {
fmt.Println("Starting database seeding...") fmt.Println("Starting database seeding...")
}
spinner := NewSpinner("Creating seed user") spinner := NewSpinner("Creating seed user")
if !IsJSONOutput() {
spinner.Spin() spinner.Spin()
}
seedUser, err := ensureSeedUser(userRepo) seedUser, err := ensureSeedUser(userRepo)
if err != nil { if err != nil {
if !IsJSONOutput() {
spinner.Complete() spinner.Complete()
}
return fmt.Errorf("ensure seed user: %w", err) return fmt.Errorf("ensure seed user: %w", err)
} }
if !IsJSONOutput() {
spinner.Complete() spinner.Complete()
fmt.Printf("Seed user ready: ID=%d Username=%s\n", seedUser.ID, seedUser.Username) fmt.Printf("Seed user ready: ID=%d Username=%s\n", seedUser.ID, seedUser.Username)
}
processor := NewParallelProcessor() 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) users, err := processor.CreateUsersInParallel(userRepo, *numUsers, progress)
if err != nil { if err != nil {
return fmt.Errorf("create random users: %w", err) return fmt.Errorf("create random users: %w", err)
} }
if !IsJSONOutput() && progress != nil {
progress.Complete() progress.Complete()
}
allUsers := append([]database.User{*seedUser}, users...) allUsers := append([]database.User{*seedUser}, users...)
if !IsJSONOutput() {
progress = NewProgressIndicator(*numPosts, "Creating posts (parallel)") progress = NewProgressIndicator(*numPosts, "Creating posts (parallel)")
}
posts, err := processor.CreatePostsInParallel(postRepo, seedUser.ID, *numPosts, progress) posts, err := processor.CreatePostsInParallel(postRepo, seedUser.ID, *numPosts, progress)
if err != nil { if err != nil {
return fmt.Errorf("create random posts: %w", err) return fmt.Errorf("create random posts: %w", err)
} }
if !IsJSONOutput() && progress != nil {
progress.Complete() progress.Complete()
}
if !IsJSONOutput() {
progress = NewProgressIndicator(len(posts), "Creating votes (parallel)") progress = NewProgressIndicator(len(posts), "Creating votes (parallel)")
}
votes, err := processor.CreateVotesInParallel(voteRepo, allUsers, posts, *votesPerPost, progress) votes, err := processor.CreateVotesInParallel(voteRepo, allUsers, posts, *votesPerPost, progress)
if err != nil { if err != nil {
return fmt.Errorf("create random votes: %w", err) return fmt.Errorf("create random votes: %w", err)
} }
if !IsJSONOutput() && progress != nil {
progress.Complete() progress.Complete()
}
if !IsJSONOutput() {
progress = NewProgressIndicator(len(posts), "Updating scores (parallel)") progress = NewProgressIndicator(len(posts), "Updating scores (parallel)")
}
err = processor.UpdatePostScoresInParallel(postRepo, voteRepo, posts, progress) err = processor.UpdatePostScoresInParallel(postRepo, voteRepo, posts, progress)
if err != nil { if err != nil {
return fmt.Errorf("update post scores: %w", err) return fmt.Errorf("update post scores: %w", err)
} }
if !IsJSONOutput() && progress != nil {
progress.Complete() progress.Complete()
}
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.Println("Database seeding completed successfully!")
fmt.Printf("Created %d users, %d posts, and %d votes\n", len(allUsers), len(posts), votes) fmt.Printf("Created %d users, %d posts, and %d votes\n", len(allUsers), len(posts), votes)
}
return nil return nil
} }

View File

@@ -98,7 +98,7 @@ func userCreate(cfg *config.Config, repo repositories.UserRepository, args []str
auditLogger, err := NewAuditLogger(cfg.LogDir) auditLogger, err := NewAuditLogger(cfg.LogDir)
if err != nil { if err != nil {
fmt.Printf("Warning: Could not initialize audit logging: %v\n", err) outputWarning("Could not initialize audit logging: %v", err)
auditLogger = nil 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) auditLogger.LogUserCreation(user.ID, user.Username, user.Email, true, nil)
} }
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) fmt.Printf("User created: %s (%s)\n", user.Username, user.Email)
}
return nil return nil
} }
@@ -286,7 +295,16 @@ func userUpdate(cfg *config.Config, repo repositories.UserRepository, refreshTok
return handleDatabaseConstraintError(err) return handleDatabaseConstraintError(err)
} }
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) fmt.Printf("User updated: %s (%s)\n", user.Username, user.Email)
}
return nil return nil
} }
@@ -383,16 +401,13 @@ func userDelete(cfg *config.Config, repo repositories.UserRepository, args []str
} }
var deleteErr error var deleteErr error
var postsDeleted bool
if *deletePosts { if *deletePosts {
deleteErr = repo.HardDelete(uint(id)) deleteErr = repo.HardDelete(uint(id))
if deleteErr == nil { postsDeleted = true
fmt.Printf("User deleted: ID=%d (posts also deleted)\n", id)
}
} else { } else {
deleteErr = repo.SoftDeleteWithPosts(uint(id)) deleteErr = repo.SoftDeleteWithPosts(uint(id))
if deleteErr == nil { postsDeleted = false
fmt.Printf("User deleted: ID=%d (posts kept)\n", id)
}
} }
if deleteErr != nil { if deleteErr != nil {
@@ -402,11 +417,31 @@ 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) 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) 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 { 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 { } else {
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) fmt.Printf("Notification email sent to %s\n", user.Email)
} }
}
return nil return nil
} }
@@ -426,6 +461,31 @@ func userList(repo repositories.UserRepository, args []string) error {
return fmt.Errorf("list users: %w", err) 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 { if len(users) == 0 {
fmt.Println("No users found") fmt.Println("No users found")
return nil return nil
@@ -517,7 +577,16 @@ func userLock(cfg *config.Config, repo repositories.UserRepository, args []strin
} }
if user.Locked { if user.Locked {
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) fmt.Printf("User is already locked: %s\n", user.Username)
}
return nil return nil
} }
@@ -525,16 +594,28 @@ func userLock(cfg *config.Config, repo repositories.UserRepository, args []strin
return fmt.Errorf("lock user: %w", err) 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) 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) subject, body := services.GenerateAccountLockNotificationEmail(user.Username, cfg.App.AdminEmail, cfg.App.Title)
emailSent := true
if err := emailSender.Send(user.Email, subject, body); err != nil { 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 { } else {
fmt.Printf("User locked: %s\n", user.Username)
if emailSent {
fmt.Printf("Notification email sent to %s\n", user.Email) fmt.Printf("Notification email sent to %s\n", user.Email)
} }
}
return nil return nil
} }
@@ -571,7 +652,16 @@ func userUnlock(cfg *config.Config, repo repositories.UserRepository, args []str
} }
if !user.Locked { if !user.Locked {
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) fmt.Printf("User is already unlocked: %s\n", user.Username)
}
return nil return nil
} }
@@ -579,16 +669,28 @@ func userUnlock(cfg *config.Config, repo repositories.UserRepository, args []str
return fmt.Errorf("unlock user: %w", err) 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) 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) 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 { 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 { } else {
fmt.Printf("User unlocked: %s\n", user.Username)
if emailSent {
fmt.Printf("Notification email sent to %s\n", user.Email) fmt.Printf("Notification email sent to %s\n", user.Email)
} }
}
return nil return nil
} }
@@ -629,8 +731,18 @@ func resetUserPassword(cfg *config.Config, repo repositories.UserRepository, ses
return fmt.Errorf("send password reset email: %w", err) return fmt.Errorf("send password reset email: %w", err)
} }
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("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") fmt.Printf("⚠️ User must change this password on next login!\n")
}
return nil 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) { t.Run("missing username", func(t *testing.T) {
mockRepo := testutils.NewMockUserRepository() mockRepo := testutils.NewMockUserRepository()
err := userCreate(cfg, mockRepo, []string{ 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) { t.Run("successful update email", func(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
mockRefreshRepo := &mockRefreshTokenRepo{} 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) { t.Run("successful delete with posts", func(t *testing.T) {
testUser2 := &database.User{ testUser2 := &database.User{
Username: "testuser2", 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) { t.Run("list with limit", func(t *testing.T) {
err := userList(mockRepo, []string{"--limit", "1"}) err := userList(mockRepo, []string{"--limit", "1"})

View File

@@ -172,11 +172,12 @@ func FuzzRunCommandHandler(f *testing.F) {
err := handleRunCommand(cfg, args) err := handleRunCommand(cfg, args)
if len(args) > 0 && args[0] == "--help" { switch {
case len(args) > 0 && args[0] == "--help":
if err != nil { if err != nil {
t.Logf("Help flag should not error, got: %v", err) t.Logf("Help flag should not error, got: %v", err)
} }
} else if len(args) > 0 { case len(args) > 0:
if err == nil { if err == nil {
return return
} }
@@ -190,7 +191,7 @@ func FuzzRunCommandHandler(f *testing.F) {
if !strings.Contains(errMsg, "unexpected arguments") { if !strings.Contains(errMsg, "unexpected arguments") {
t.Logf("Got error (may be acceptable for server setup): %v", err) t.Logf("Got error (may be acceptable for server setup): %v", err)
} }
} else { default:
if err != nil && strings.Contains(err.Error(), "unexpected arguments") { if err != nil && strings.Contains(err.Error(), "unexpected arguments") {
t.Fatalf("Empty args should not trigger 'unexpected arguments' error: %v", err) 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.SetOutput(os.Stderr)
rootFS.Usage = printRootUsage rootFS.Usage = printRootUsage
showHelp := rootFS.Bool("help", false, "show this help message") 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 err := rootFS.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) { if errors.Is(err, flag.ErrHelp) {
@@ -80,6 +81,8 @@ func run(args []string) error {
return nil return nil
} }
commands.SetJSONOutput(*jsonOutput)
remaining := rootFS.Args() remaining := rootFS.Args()
if len(remaining) == 0 { if len(remaining) == 0 {
printRootUsage() printRootUsage()

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"flag" "flag"
@@ -95,7 +96,12 @@ func TestServerConfigurationFromConfig(t *testing.T) {
testServer := httptest.NewServer(srv.Handler) testServer := httptest.NewServer(srv.Handler)
defer testServer.Close() 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 { if err != nil {
t.Fatalf("Failed to make request: %v", err) t.Fatalf("Failed to make request: %v", err)
} }
@@ -159,6 +165,7 @@ func TestTLSWiringFromConfig(t *testing.T) {
srv := &http.Server{ srv := &http.Server{
Addr: expectedAddr, Addr: expectedAddr,
Handler: router, Handler: router,
ReadHeaderTimeout: 5 * time.Second,
} }
if srv.Addr != expectedAddr { 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 { if err != nil {
t.Fatalf("Failed to make TLS request: %v", err) t.Fatalf("Failed to make TLS request: %v", err)
} }
@@ -215,12 +227,10 @@ func TestTLSWiringFromConfig(t *testing.T) {
if resp.TLS == nil { if resp.TLS == nil {
t.Error("Expected TLS connection info to be present in response") t.Error("Expected TLS connection info to be present in response")
} else { } else if resp.TLS.Version < tls.VersionTLS12 {
if resp.TLS.Version < tls.VersionTLS12 {
t.Errorf("Expected TLS version 1.2 or higher, got %x", resp.TLS.Version) t.Errorf("Expected TLS version 1.2 or higher, got %x", resp.TLS.Version)
} }
} }
}
} }
func TestConfigLoadingInCLI(t *testing.T) { func TestConfigLoadingInCLI(t *testing.T) {
@@ -358,7 +368,12 @@ func TestServerInitializationFlow(t *testing.T) {
testServer := httptest.NewServer(srv.Handler) testServer := httptest.NewServer(srv.Handler)
defer testServer.Close() 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 { if err != nil {
t.Fatalf("Failed to make request: %v", err) 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) 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 { if err != nil {
t.Fatalf("Failed to make request: %v", err) t.Fatalf("Failed to make request: %v", err)
} }