cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

refactor: Created CommandGroup interface

refactor: Refactored BookHandler methods and tests for improved clarity

- Consolidated book search and addition logic into SearchAndAddBook method.
- Revised tests
- Removed deprecated methods and streamlined test cases

+1134 -721
+160 -194
cmd/commands.go
··· 1 /* 2 - TODO: Implement movie addition 3 - TODO: Implement movie listing 4 - TODO: Implement movie watched status 5 - TODO: Implement movie removal 6 - TODO: Implement TV show addition 7 - TODO: Implement TV show listing 8 - TODO: Implement TV show watched status 9 - TODO: Implement TV show removal 10 TODO: Implement config management 11 */ 12 package main ··· 16 "strconv" 17 "strings" 18 19 - "github.com/charmbracelet/log" 20 - 21 "github.com/spf13/cobra" 22 "github.com/stormlightlabs/noteleaf/internal/handlers" 23 - "github.com/stormlightlabs/noteleaf/internal/ui" 24 ) 25 26 - func rootCmd() *cobra.Command { 27 - root := &cobra.Command{ 28 - Use: "noteleaf", 29 - Long: ui.Georgia.ColoredInViewport(), 30 - Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", 31 - RunE: func(c *cobra.Command, args []string) error { 32 - if len(args) == 0 { 33 - return c.Help() 34 - } 35 - 36 - output := strings.Join(args, " ") 37 - fmt.Println(output) 38 - return nil 39 - }, 40 - } 41 - 42 - root.SetHelpCommand(&cobra.Command{Hidden: true}) 43 - cobra.EnableCommandSorting = false 44 - 45 - root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) 46 - root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) 47 - return root 48 } 49 50 - func todoCmd() *cobra.Command { 51 - root := &cobra.Command{Use: "todo", Aliases: []string{"task"}, Short: "task management"} 52 - 53 - handler, err := handlers.NewTaskHandler() 54 - if err != nil { 55 - log.Fatalf("failed to create task handler: %v", err) 56 - } 57 - 58 - for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 59 - addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, 60 - deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, 61 - taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, 62 - } { 63 - cmd := init(handler) 64 - root.AddCommand(cmd) 65 - } 66 - 67 - return root 68 } 69 70 - func mediaCmd() *cobra.Command { 71 - cmd := &cobra.Command{Use: "media", Short: "Manage media queues (books, movies, TV shows)"} 72 - for _, init := range []func() *cobra.Command{bookMediaCmd, movieMediaCmd, tvMediaCmd} { 73 - cmd.AddCommand(init()) 74 - } 75 - return cmd 76 } 77 78 - func movieMediaCmd() *cobra.Command { 79 root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} 80 81 - root.AddCommand(&cobra.Command{ 82 - Use: "add [title]", 83 - Short: "Add movie to watch queue", 84 - Args: cobra.MinimumNArgs(1), 85 RunE: func(cmd *cobra.Command, args []string) error { 86 - title := args[0] 87 - fmt.Printf("Adding movie: %s\n", title) 88 - return nil 89 }, 90 - }) 91 92 root.AddCommand(&cobra.Command{ 93 - Use: "list", 94 - Short: "List movies in queue", 95 RunE: func(cmd *cobra.Command, args []string) error { 96 - fmt.Println("Listing movies...") 97 - return nil 98 }, 99 }) 100 ··· 104 Aliases: []string{"seen"}, 105 Args: cobra.ExactArgs(1), 106 RunE: func(cmd *cobra.Command, args []string) error { 107 - fmt.Printf("Marking movie %s as watched\n", args[0]) 108 - return nil 109 }, 110 }) 111 ··· 115 Aliases: []string{"rm"}, 116 Args: cobra.ExactArgs(1), 117 RunE: func(cmd *cobra.Command, args []string) error { 118 - fmt.Printf("Removing movie %s from queue\n", args[0]) 119 - return nil 120 }, 121 }) 122 123 return root 124 } 125 126 - func tvMediaCmd() *cobra.Command { 127 root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} 128 129 root.AddCommand(&cobra.Command{ 130 - Use: "add [title]", 131 - Short: "Add TV show to watch queue", 132 - Args: cobra.MinimumNArgs(1), 133 RunE: func(cmd *cobra.Command, args []string) error { 134 - title := args[0] 135 - fmt.Printf("Adding TV show: %s\n", title) 136 - return nil 137 }, 138 }) 139 140 root.AddCommand(&cobra.Command{ 141 - Use: "list", 142 - Short: "List TV shows in queue", 143 RunE: func(cmd *cobra.Command, args []string) error { 144 - fmt.Println("Listing TV shows...") 145 - return nil 146 }, 147 }) 148 ··· 152 Aliases: []string{"seen"}, 153 Args: cobra.ExactArgs(1), 154 RunE: func(cmd *cobra.Command, args []string) error { 155 - fmt.Printf("Marking TV show %s as watched\n", args[0]) 156 - return nil 157 }, 158 }) 159 ··· 163 Aliases: []string{"rm"}, 164 Args: cobra.ExactArgs(1), 165 RunE: func(cmd *cobra.Command, args []string) error { 166 - fmt.Printf("Removing TV show %s from queue\n", args[0]) 167 - return nil 168 }, 169 }) 170 171 return root 172 } 173 174 - func bookMediaCmd() *cobra.Command { 175 root := &cobra.Command{Use: "book", Short: "Manage reading list"} 176 177 addCmd := &cobra.Command{ ··· 183 Use the -i flag for an interactive interface with navigation keys.`, 184 RunE: func(cmd *cobra.Command, args []string) error { 185 interactive, _ := cmd.Flags().GetBool("interactive") 186 - return handlers.SearchAndAddWithOptions(cmd.Context(), args, interactive) 187 }, 188 } 189 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") ··· 193 Use: "list [--all|--reading|--finished|--queued]", 194 Short: "Show reading queue with progress", 195 RunE: func(cmd *cobra.Command, args []string) error { 196 - return handlers.ListBooks(cmd.Context(), args) 197 }, 198 }) 199 ··· 202 Short: "Mark book as currently reading", 203 Args: cobra.ExactArgs(1), 204 RunE: func(cmd *cobra.Command, args []string) error { 205 - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "reading"}) 206 }, 207 }) 208 ··· 212 Aliases: []string{"read"}, 213 Args: cobra.ExactArgs(1), 214 RunE: func(cmd *cobra.Command, args []string) error { 215 - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "finished"}) 216 }, 217 }) 218 ··· 222 Aliases: []string{"rm"}, 223 Args: cobra.ExactArgs(1), 224 RunE: func(cmd *cobra.Command, args []string) error { 225 - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "removed"}) 226 }, 227 }) 228 ··· 231 Short: "Update reading progress percentage (0-100)", 232 Args: cobra.ExactArgs(2), 233 RunE: func(cmd *cobra.Command, args []string) error { 234 - return handlers.UpdateBookProgress(cmd.Context(), args) 235 }, 236 }) 237 ··· 240 Short: "Update book status (queued|reading|finished|removed)", 241 Args: cobra.ExactArgs(2), 242 RunE: func(cmd *cobra.Command, args []string) error { 243 - return handlers.UpdateBookStatus(cmd.Context(), args) 244 }, 245 }) 246 247 return root 248 } 249 250 - func noteCmd() *cobra.Command { 251 - root := &cobra.Command{Use: "note", Short: "Manage notes"} 252 253 - handler, err := handlers.NewNoteHandler() 254 - if err != nil { 255 - log.Fatalf("failed to instantiate note handler: %v", err) 256 - } 257 258 createCmd := &cobra.Command{ 259 Use: "create [title] [content...]", ··· 271 content = strings.Join(args[1:], " ") 272 } 273 274 - if err != nil { 275 - return err 276 - } 277 - defer handler.Close() 278 - return handler.Create(cmd.Context(), title, content, filePath, interactive) 279 }, 280 } 281 createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor") ··· 298 } 299 } 300 301 - handler, err := handlers.NewNoteHandler() 302 - if err != nil { 303 - return err 304 - } 305 - defer handler.Close() 306 - return handler.List(cmd.Context(), false, archived, tags) 307 }, 308 } 309 listCmd.Flags().BoolP("archived", "a", false, "Show archived notes") ··· 320 if err != nil { 321 return fmt.Errorf("invalid note ID: %s", args[0]) 322 } 323 - handler, err := handlers.NewNoteHandler() 324 - if err != nil { 325 - return err 326 - } 327 - defer handler.Close() 328 - return handler.View(cmd.Context(), noteID) 329 }, 330 }) 331 ··· 338 if err != nil { 339 return fmt.Errorf("invalid note ID: %s", args[0]) 340 } 341 - handler, err := handlers.NewNoteHandler() 342 - if err != nil { 343 - return err 344 - } 345 - defer handler.Close() 346 - return handler.Edit(cmd.Context(), noteID) 347 }, 348 }) 349 ··· 357 if err != nil { 358 return fmt.Errorf("invalid note ID: %s", args[0]) 359 } 360 - handler, err := handlers.NewNoteHandler() 361 - if err != nil { 362 - return err 363 - } 364 - defer handler.Close() 365 - return handler.Delete(cmd.Context(), noteID) 366 }, 367 }) 368 369 return root 370 } 371 - 372 - func statusCmd() *cobra.Command { 373 - return &cobra.Command{ 374 - Use: "status", 375 - Short: "Show application status and configuration", 376 - RunE: func(cmd *cobra.Command, args []string) error { 377 - return handlers.Status(cmd.Context(), args) 378 - }, 379 - } 380 - } 381 - 382 - func resetCmd() *cobra.Command { 383 - return &cobra.Command{ 384 - Use: "reset", 385 - Short: "Reset the application (removes all data)", 386 - RunE: func(cmd *cobra.Command, args []string) error { 387 - return handlers.Reset(cmd.Context(), args) 388 - }, 389 - } 390 - } 391 - 392 - func setupCmd() *cobra.Command { 393 - handler, err := handlers.NewSeedHandler() 394 - if err != nil { 395 - log.Fatalf("failed to instantiate seed handler: %v", err) 396 - } 397 - 398 - root := &cobra.Command{ 399 - Use: "setup", 400 - Short: "Initialize and manage application setup", 401 - RunE: func(c *cobra.Command, args []string) error { 402 - return handlers.Setup(c.Context(), args) 403 - }, 404 - } 405 - 406 - seedCmd := &cobra.Command{ 407 - Use: "seed", 408 - Short: "Populate database with test data", 409 - Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", 410 - RunE: func(c *cobra.Command, args []string) error { 411 - force, _ := c.Flags().GetBool("force") 412 - return handler.Seed(c.Context(), force) 413 - }, 414 - } 415 - seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") 416 - 417 - root.AddCommand(seedCmd) 418 - return root 419 - } 420 - 421 - func confCmd() *cobra.Command { 422 - return &cobra.Command{ 423 - Use: "config [key] [value]", 424 - Short: "Manage configuration", 425 - Args: cobra.ExactArgs(2), 426 - RunE: func(c *cobra.Command, args []string) error { 427 - key, value := args[0], args[1] 428 - fmt.Printf("Setting config %s = %s\n", key, value) 429 - return nil 430 - }, 431 - } 432 - }
··· 1 /* 2 TODO: Implement config management 3 */ 4 package main ··· 8 "strconv" 9 "strings" 10 11 "github.com/spf13/cobra" 12 "github.com/stormlightlabs/noteleaf/internal/handlers" 13 ) 14 15 + // CommandGroup represents a group of related CLI commands 16 + type CommandGroup interface { 17 + Create() *cobra.Command 18 } 19 20 + // MovieCommand implements CommandGroup for movie-related commands 21 + type MovieCommand struct { 22 + handler *handlers.MovieHandler 23 } 24 25 + // NewMovieCommand creates a new MovieCommands with the given handler 26 + func NewMovieCommand(handler *handlers.MovieHandler) *MovieCommand { 27 + return &MovieCommand{handler: handler} 28 } 29 30 + func (c *MovieCommand) Create() *cobra.Command { 31 root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} 32 33 + addCmd := &cobra.Command{ 34 + Use: "add [search query...]", 35 + Short: "Search and add movie to watch queue", 36 + Long: `Search for movies and add them to your watch queue. 37 + 38 + By default, shows search results in a simple list format where you can select by number. 39 + Use the -i flag for an interactive interface with navigation keys.`, 40 RunE: func(cmd *cobra.Command, args []string) error { 41 + if len(args) == 0 { 42 + return fmt.Errorf("search query cannot be empty") 43 + } 44 + interactive, _ := cmd.Flags().GetBool("interactive") 45 + query := strings.Join(args, " ") 46 + 47 + return c.handler.SearchAndAddMovie(cmd.Context(), query, interactive) 48 }, 49 + } 50 + addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") 51 + root.AddCommand(addCmd) 52 53 root.AddCommand(&cobra.Command{ 54 + Use: "list [--all|--watched|--queued]", 55 + Short: "List movies in queue with status filtering", 56 RunE: func(cmd *cobra.Command, args []string) error { 57 + var status string 58 + if len(args) > 0 { 59 + switch args[0] { 60 + case "--all": 61 + status = "" 62 + case "--watched": 63 + status = "watched" 64 + case "--queued": 65 + status = "queued" 66 + default: 67 + return fmt.Errorf("invalid status filter: %s (use: --all, --watched, --queued)", args[0]) 68 + } 69 + } 70 + 71 + return c.handler.ListMovies(cmd.Context(), status) 72 }, 73 }) 74 ··· 78 Aliases: []string{"seen"}, 79 Args: cobra.ExactArgs(1), 80 RunE: func(cmd *cobra.Command, args []string) error { 81 + return c.handler.MarkMovieWatched(cmd.Context(), args[0]) 82 }, 83 }) 84 ··· 88 Aliases: []string{"rm"}, 89 Args: cobra.ExactArgs(1), 90 RunE: func(cmd *cobra.Command, args []string) error { 91 + return c.handler.RemoveMovie(cmd.Context(), args[0]) 92 }, 93 }) 94 95 return root 96 } 97 98 + // TVCommand implements [CommandGroup] for TV show-related commands 99 + type TVCommand struct { 100 + handler *handlers.TVHandler 101 + } 102 + 103 + // NewTVCommand creates a new [TVCommand] with the given handler 104 + func NewTVCommand(handler *handlers.TVHandler) *TVCommand { 105 + return &TVCommand{handler: handler} 106 + } 107 + 108 + func (c *TVCommand) Create() *cobra.Command { 109 root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} 110 111 + addCmd := &cobra.Command{ 112 + Use: "add [search query...]", 113 + Short: "Search and add TV show to watch queue", 114 + Long: `Search for TV shows and add them to your watch queue. 115 + 116 + By default, shows search results in a simple list format where you can select by number. 117 + Use the -i flag for an interactive interface with navigation keys.`, 118 + RunE: func(cmd *cobra.Command, args []string) error { 119 + if len(args) == 0 { 120 + return fmt.Errorf("search query cannot be empty") 121 + } 122 + interactive, _ := cmd.Flags().GetBool("interactive") 123 + query := strings.Join(args, " ") 124 + 125 + return c.handler.SearchAndAddTV(cmd.Context(), query, interactive) 126 + }, 127 + } 128 + addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for TV show selection") 129 + root.AddCommand(addCmd) 130 + 131 root.AddCommand(&cobra.Command{ 132 + Use: "list [--all|--queued|--watching|--watched]", 133 + Short: "List TV shows in queue with status filtering", 134 RunE: func(cmd *cobra.Command, args []string) error { 135 + var status string 136 + if len(args) > 0 { 137 + switch args[0] { 138 + case "--all": 139 + status = "" 140 + case "--queued": 141 + status = "queued" 142 + case "--watching": 143 + status = "watching" 144 + case "--watched": 145 + status = "watched" 146 + default: 147 + return fmt.Errorf("invalid status filter: %s (use: --all, --queued, --watching, --watched)", args[0]) 148 + } 149 + } 150 + 151 + return c.handler.ListTVShows(cmd.Context(), status) 152 }, 153 }) 154 155 root.AddCommand(&cobra.Command{ 156 + Use: "watching [id]", 157 + Short: "Mark TV show as currently watching", 158 + Args: cobra.ExactArgs(1), 159 RunE: func(cmd *cobra.Command, args []string) error { 160 + return c.handler.MarkTVShowWatching(cmd.Context(), args[0]) 161 }, 162 }) 163 ··· 167 Aliases: []string{"seen"}, 168 Args: cobra.ExactArgs(1), 169 RunE: func(cmd *cobra.Command, args []string) error { 170 + return c.handler.MarkTVShowWatched(cmd.Context(), args[0]) 171 }, 172 }) 173 ··· 177 Aliases: []string{"rm"}, 178 Args: cobra.ExactArgs(1), 179 RunE: func(cmd *cobra.Command, args []string) error { 180 + return c.handler.RemoveTVShow(cmd.Context(), args[0]) 181 }, 182 }) 183 184 return root 185 } 186 187 + // BookCommand implements CommandGroup for book-related commands 188 + type BookCommand struct { 189 + handler *handlers.BookHandler 190 + } 191 + 192 + // NewBookCommand creates a new BookCommand with the given handler 193 + func NewBookCommand(handler *handlers.BookHandler) *BookCommand { 194 + return &BookCommand{handler: handler} 195 + } 196 + 197 + func (c *BookCommand) Create() *cobra.Command { 198 root := &cobra.Command{Use: "book", Short: "Manage reading list"} 199 200 addCmd := &cobra.Command{ ··· 206 Use the -i flag for an interactive interface with navigation keys.`, 207 RunE: func(cmd *cobra.Command, args []string) error { 208 interactive, _ := cmd.Flags().GetBool("interactive") 209 + return c.handler.SearchAndAddBook(cmd.Context(), args, interactive) 210 }, 211 } 212 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") ··· 216 Use: "list [--all|--reading|--finished|--queued]", 217 Short: "Show reading queue with progress", 218 RunE: func(cmd *cobra.Command, args []string) error { 219 + var status string 220 + if len(args) > 0 { 221 + switch args[0] { 222 + case "--all": 223 + status = "" 224 + case "--reading": 225 + status = "reading" 226 + case "--finished": 227 + status = "finished" 228 + case "--queued": 229 + status = "queued" 230 + default: 231 + return fmt.Errorf("invalid status filter: %s (use: --all, --reading, --finished, --queued)", args[0]) 232 + } 233 + } 234 + return c.handler.ListBooks(cmd.Context(), status) 235 }, 236 }) 237 ··· 240 Short: "Mark book as currently reading", 241 Args: cobra.ExactArgs(1), 242 RunE: func(cmd *cobra.Command, args []string) error { 243 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "reading") 244 }, 245 }) 246 ··· 250 Aliases: []string{"read"}, 251 Args: cobra.ExactArgs(1), 252 RunE: func(cmd *cobra.Command, args []string) error { 253 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "finished") 254 }, 255 }) 256 ··· 260 Aliases: []string{"rm"}, 261 Args: cobra.ExactArgs(1), 262 RunE: func(cmd *cobra.Command, args []string) error { 263 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "removed") 264 }, 265 }) 266 ··· 269 Short: "Update reading progress percentage (0-100)", 270 Args: cobra.ExactArgs(2), 271 RunE: func(cmd *cobra.Command, args []string) error { 272 + progress, err := strconv.Atoi(args[1]) 273 + if err != nil { 274 + return fmt.Errorf("invalid progress percentage: %s", args[1]) 275 + } 276 + return c.handler.UpdateBookProgressByID(cmd.Context(), args[0], progress) 277 }, 278 }) 279 ··· 282 Short: "Update book status (queued|reading|finished|removed)", 283 Args: cobra.ExactArgs(2), 284 RunE: func(cmd *cobra.Command, args []string) error { 285 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], args[1]) 286 }, 287 }) 288 289 return root 290 } 291 292 + // NoteCommand implements [CommandGroup] for note-related commands 293 + type NoteCommand struct { 294 + handler *handlers.NoteHandler 295 + } 296 297 + // NewNoteCommand creates a new NoteCommand with the given handler 298 + func NewNoteCommand(handler *handlers.NoteHandler) *NoteCommand { 299 + return &NoteCommand{handler: handler} 300 + } 301 + 302 + func (c *NoteCommand) Create() *cobra.Command { 303 + root := &cobra.Command{Use: "note", Short: "Manage notes"} 304 305 createCmd := &cobra.Command{ 306 Use: "create [title] [content...]", ··· 318 content = strings.Join(args[1:], " ") 319 } 320 321 + defer c.handler.Close() 322 + return c.handler.Create(cmd.Context(), title, content, filePath, interactive) 323 }, 324 } 325 createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor") ··· 342 } 343 } 344 345 + defer c.handler.Close() 346 + return c.handler.List(cmd.Context(), false, archived, tags) 347 }, 348 } 349 listCmd.Flags().BoolP("archived", "a", false, "Show archived notes") ··· 360 if err != nil { 361 return fmt.Errorf("invalid note ID: %s", args[0]) 362 } 363 + defer c.handler.Close() 364 + return c.handler.View(cmd.Context(), noteID) 365 }, 366 }) 367 ··· 374 if err != nil { 375 return fmt.Errorf("invalid note ID: %s", args[0]) 376 } 377 + defer c.handler.Close() 378 + return c.handler.Edit(cmd.Context(), noteID) 379 }, 380 }) 381 ··· 389 if err != nil { 390 return fmt.Errorf("invalid note ID: %s", args[0]) 391 } 392 + defer c.handler.Close() 393 + return c.handler.Delete(cmd.Context(), noteID) 394 }, 395 }) 396 397 return root 398 }
+356
cmd/commands_test.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "slices" 7 + "testing" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/handlers" 10 + ) 11 + 12 + func setupCommandTest(t *testing.T) func() { 13 + tempDir, err := os.MkdirTemp("", "noteleaf-cmd-test-*") 14 + if err != nil { 15 + t.Fatalf("Failed to create temp dir: %v", err) 16 + } 17 + 18 + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") 19 + os.Setenv("XDG_CONFIG_HOME", tempDir) 20 + 21 + cleanup := func() { 22 + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) 23 + os.RemoveAll(tempDir) 24 + } 25 + 26 + ctx := context.Background() 27 + err = handlers.Setup(ctx, []string{}) 28 + if err != nil { 29 + cleanup() 30 + t.Fatalf("Failed to setup database: %v", err) 31 + } 32 + 33 + return cleanup 34 + } 35 + 36 + func createTestTaskHandler(t *testing.T) (*handlers.TaskHandler, func()) { 37 + cleanup := setupCommandTest(t) 38 + handler, err := handlers.NewTaskHandler() 39 + if err != nil { 40 + cleanup() 41 + t.Fatalf("Failed to create test task handler: %v", err) 42 + } 43 + return handler, func() { 44 + handler.Close() 45 + cleanup() 46 + } 47 + } 48 + 49 + func createTestMovieHandler(t *testing.T) (*handlers.MovieHandler, func()) { 50 + cleanup := setupCommandTest(t) 51 + handler, err := handlers.NewMovieHandler() 52 + if err != nil { 53 + cleanup() 54 + t.Fatalf("Failed to create test movie handler: %v", err) 55 + } 56 + return handler, func() { 57 + handler.Close() 58 + cleanup() 59 + } 60 + } 61 + 62 + func createTestTVHandler(t *testing.T) (*handlers.TVHandler, func()) { 63 + cleanup := setupCommandTest(t) 64 + handler, err := handlers.NewTVHandler() 65 + if err != nil { 66 + cleanup() 67 + t.Fatalf("Failed to create test TV handler: %v", err) 68 + } 69 + return handler, func() { 70 + handler.Close() 71 + cleanup() 72 + } 73 + } 74 + 75 + func createTestNoteHandler(t *testing.T) (*handlers.NoteHandler, func()) { 76 + cleanup := setupCommandTest(t) 77 + handler, err := handlers.NewNoteHandler() 78 + if err != nil { 79 + cleanup() 80 + t.Fatalf("Failed to create test note handler: %v", err) 81 + } 82 + return handler, func() { 83 + handler.Close() 84 + cleanup() 85 + } 86 + } 87 + 88 + func createTestBookHandler(t *testing.T) (*handlers.BookHandler, func()) { 89 + cleanup := setupCommandTest(t) 90 + handler, err := handlers.NewBookHandler() 91 + if err != nil { 92 + cleanup() 93 + t.Fatalf("Failed to create test book handler: %v", err) 94 + } 95 + return handler, func() { 96 + handler.Close() 97 + cleanup() 98 + } 99 + } 100 + 101 + func findSubcommand(commands []string, target string) bool { 102 + return slices.Contains(commands, target) 103 + } 104 + 105 + func TestCommandGroup(t *testing.T) { 106 + t.Run("Interface Implementations", func(t *testing.T) { 107 + taskHandler, taskCleanup := createTestTaskHandler(t) 108 + defer taskCleanup() 109 + 110 + movieHandler, movieCleanup := createTestMovieHandler(t) 111 + defer movieCleanup() 112 + 113 + tvHandler, tvCleanup := createTestTVHandler(t) 114 + defer tvCleanup() 115 + 116 + noteHandler, noteCleanup := createTestNoteHandler(t) 117 + defer noteCleanup() 118 + 119 + bookHandler, bookCleanup := createTestBookHandler(t) 120 + defer bookCleanup() 121 + 122 + var _ CommandGroup = NewTaskCommand(taskHandler) 123 + var _ CommandGroup = NewMovieCommand(movieHandler) 124 + var _ CommandGroup = NewTVCommand(tvHandler) 125 + var _ CommandGroup = NewNoteCommand(noteHandler) 126 + var _ CommandGroup = NewBookCommand(bookHandler) 127 + }) 128 + 129 + t.Run("Create", func(t *testing.T) { 130 + t.Run("TaskCommand", func(t *testing.T) { 131 + handler, cleanup := createTestTaskHandler(t) 132 + defer cleanup() 133 + 134 + commands := NewTaskCommand(handler) 135 + cmd := commands.Create() 136 + 137 + if cmd == nil { 138 + t.Fatal("Create returned nil") 139 + } 140 + if cmd.Use != "todo" { 141 + t.Errorf("Expected Use to be 'todo', got '%s'", cmd.Use) 142 + } 143 + if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "task" { 144 + t.Errorf("Expected aliases to be ['task'], got %v", cmd.Aliases) 145 + } 146 + if cmd.Short != "task management" { 147 + t.Errorf("Expected Short to be 'task management', got '%s'", cmd.Short) 148 + } 149 + if !cmd.HasSubCommands() { 150 + t.Error("Expected command to have subcommands") 151 + } 152 + }) 153 + 154 + t.Run("MovieCommand", func(t *testing.T) { 155 + handler, cleanup := createTestMovieHandler(t) 156 + defer cleanup() 157 + 158 + commands := NewMovieCommand(handler) 159 + cmd := commands.Create() 160 + 161 + if cmd == nil { 162 + t.Fatal("Create returned nil") 163 + } 164 + if cmd.Use != "movie" { 165 + t.Errorf("Expected Use to be 'movie', got '%s'", cmd.Use) 166 + } 167 + if cmd.Short != "Manage movie watch queue" { 168 + t.Errorf("Expected Short to be 'Manage movie watch queue', got '%s'", cmd.Short) 169 + } 170 + if !cmd.HasSubCommands() { 171 + t.Error("Expected command to have subcommands") 172 + } 173 + 174 + subcommands := cmd.Commands() 175 + subcommandNames := make([]string, len(subcommands)) 176 + for i, subcmd := range subcommands { 177 + subcommandNames[i] = subcmd.Use 178 + } 179 + 180 + expectedSubcommands := []string{ 181 + "add [search query...]", 182 + "list [--all|--watched|--queued]", 183 + "watched [id]", 184 + "remove [id]", 185 + } 186 + 187 + for _, expected := range expectedSubcommands { 188 + if !findSubcommand(subcommandNames, expected) { 189 + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) 190 + } 191 + } 192 + }) 193 + 194 + t.Run("TVCommand", func(t *testing.T) { 195 + handler, cleanup := createTestTVHandler(t) 196 + defer cleanup() 197 + 198 + commands := NewTVCommand(handler) 199 + cmd := commands.Create() 200 + 201 + if cmd == nil { 202 + t.Fatal("Create returned nil") 203 + } 204 + if cmd.Use != "tv" { 205 + t.Errorf("Expected Use to be 'tv', got '%s'", cmd.Use) 206 + } 207 + if cmd.Short != "Manage TV show watch queue" { 208 + t.Errorf("Expected Short to be 'Manage TV show watch queue', got '%s'", cmd.Short) 209 + } 210 + if !cmd.HasSubCommands() { 211 + t.Error("Expected command to have subcommands") 212 + } 213 + 214 + subcommands := cmd.Commands() 215 + subcommandNames := make([]string, len(subcommands)) 216 + for i, subcmd := range subcommands { 217 + subcommandNames[i] = subcmd.Use 218 + } 219 + 220 + expectedSubcommands := []string{ 221 + "add [search query...]", 222 + "list [--all|--queued|--watching|--watched]", 223 + "watching [id]", 224 + "watched [id]", 225 + "remove [id]", 226 + } 227 + 228 + for _, expected := range expectedSubcommands { 229 + if !findSubcommand(subcommandNames, expected) { 230 + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) 231 + } 232 + } 233 + }) 234 + 235 + t.Run("NoteCommand", func(t *testing.T) { 236 + handler, cleanup := createTestNoteHandler(t) 237 + defer cleanup() 238 + 239 + commands := NewNoteCommand(handler) 240 + cmd := commands.Create() 241 + 242 + if cmd == nil { 243 + t.Fatal("Create returned nil") 244 + } 245 + if cmd.Use != "note" { 246 + t.Errorf("Expected Use to be 'note', got '%s'", cmd.Use) 247 + } 248 + if cmd.Short != "Manage notes" { 249 + t.Errorf("Expected Short to be 'Manage notes', got '%s'", cmd.Short) 250 + } 251 + if !cmd.HasSubCommands() { 252 + t.Error("Expected command to have subcommands") 253 + } 254 + 255 + subcommands := cmd.Commands() 256 + subcommandNames := make([]string, len(subcommands)) 257 + for i, subcmd := range subcommands { 258 + subcommandNames[i] = subcmd.Use 259 + } 260 + 261 + expectedSubcommands := []string{ 262 + "create [title] [content...]", 263 + "list [--archived] [--tags=tag1,tag2]", 264 + "read [note-id]", 265 + "edit [note-id]", 266 + "remove [note-id]", 267 + } 268 + 269 + for _, expected := range expectedSubcommands { 270 + if !findSubcommand(subcommandNames, expected) { 271 + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) 272 + } 273 + } 274 + }) 275 + 276 + t.Run("BookCommand", func(t *testing.T) { 277 + handler, cleanup := createTestBookHandler(t) 278 + defer cleanup() 279 + 280 + commands := NewBookCommand(handler) 281 + cmd := commands.Create() 282 + 283 + if cmd == nil { 284 + t.Fatal("Create returned nil") 285 + } 286 + if cmd.Use != "book" { 287 + t.Errorf("Expected Use to be 'book', got '%s'", cmd.Use) 288 + } 289 + if cmd.Short != "Manage reading list" { 290 + t.Errorf("Expected Short to be 'Manage reading list', got '%s'", cmd.Short) 291 + } 292 + if !cmd.HasSubCommands() { 293 + t.Error("Expected command to have subcommands") 294 + } 295 + 296 + subcommands := cmd.Commands() 297 + subcommandNames := make([]string, len(subcommands)) 298 + for i, subcmd := range subcommands { 299 + subcommandNames[i] = subcmd.Use 300 + } 301 + 302 + expectedSubcommands := []string{ 303 + "add [search query...]", 304 + "list [--all|--reading|--finished|--queued]", 305 + "reading <id>", 306 + "finished <id>", 307 + "remove <id>", 308 + "progress <id> <percentage>", 309 + "update <id> <status>", 310 + } 311 + 312 + for _, expected := range expectedSubcommands { 313 + if !findSubcommand(subcommandNames, expected) { 314 + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) 315 + } 316 + } 317 + }) 318 + 319 + t.Run("all command groups implement Create", func(t *testing.T) { 320 + taskHandler, taskCleanup := createTestTaskHandler(t) 321 + defer taskCleanup() 322 + 323 + movieHandler, movieCleanup := createTestMovieHandler(t) 324 + defer movieCleanup() 325 + 326 + tvHandler, tvCleanup := createTestTVHandler(t) 327 + defer tvCleanup() 328 + 329 + noteHandler, noteCleanup := createTestNoteHandler(t) 330 + defer noteCleanup() 331 + 332 + bookHandler, bookCleanup := createTestBookHandler(t) 333 + defer bookCleanup() 334 + 335 + groups := []CommandGroup{ 336 + NewTaskCommand(taskHandler), 337 + NewMovieCommand(movieHandler), 338 + NewTVCommand(tvHandler), 339 + NewNoteCommand(noteHandler), 340 + NewBookCommand(bookHandler), 341 + } 342 + 343 + for i, group := range groups { 344 + cmd := group.Create() 345 + if cmd == nil { 346 + t.Errorf("CommandGroup %d returned nil from Create()", i) 347 + continue 348 + } 349 + if cmd.Use == "" { 350 + t.Errorf("CommandGroup %d returned command with empty Use", i) 351 + } 352 + } 353 + }) 354 + }) 355 + 356 + }
+130 -5
cmd/main.go
··· 4 "context" 5 "fmt" 6 "os" 7 8 "github.com/charmbracelet/fang" 9 "github.com/spf13/cobra" 10 "github.com/stormlightlabs/noteleaf/internal/store" 11 "github.com/stormlightlabs/noteleaf/internal/ui" 12 "github.com/stormlightlabs/noteleaf/internal/utils" ··· 28 if config, err := store.LoadConfig(); err != nil { 29 return nil, fmt.Errorf("failed to load configuration: %w", err) 30 } else { 31 - return &App{db: db, config: config}, nil 32 } 33 } 34 ··· 40 return nil 41 } 42 43 func main() { 44 logger := utils.NewLogger("info", "text") 45 utils.Logger = logger ··· 50 } 51 defer app.Close() 52 53 root := rootCmd() 54 - core := []func() *cobra.Command{todoCmd, mediaCmd, noteCmd} 55 - mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd} 56 57 - for _, cmdFunc := range core { 58 - cmd := cmdFunc() 59 cmd.GroupID = "core" 60 root.AddCommand(cmd) 61 } 62 63 for _, cmdFunc := range mgmt { 64 cmd := cmdFunc() 65 cmd.GroupID = "management"
··· 4 "context" 5 "fmt" 6 "os" 7 + "strings" 8 9 "github.com/charmbracelet/fang" 10 + "github.com/charmbracelet/log" 11 "github.com/spf13/cobra" 12 + "github.com/stormlightlabs/noteleaf/internal/handlers" 13 "github.com/stormlightlabs/noteleaf/internal/store" 14 "github.com/stormlightlabs/noteleaf/internal/ui" 15 "github.com/stormlightlabs/noteleaf/internal/utils" ··· 31 if config, err := store.LoadConfig(); err != nil { 32 return nil, fmt.Errorf("failed to load configuration: %w", err) 33 } else { 34 + return &App{db, config}, nil 35 } 36 } 37 ··· 43 return nil 44 } 45 46 + func statusCmd() *cobra.Command { 47 + return &cobra.Command{ 48 + Use: "status", 49 + Short: "Show application status and configuration", 50 + RunE: func(cmd *cobra.Command, args []string) error { 51 + return handlers.Status(cmd.Context(), args) 52 + }, 53 + } 54 + } 55 + 56 + func resetCmd() *cobra.Command { 57 + return &cobra.Command{ 58 + Use: "reset", 59 + Short: "Reset the application (removes all data)", 60 + RunE: func(cmd *cobra.Command, args []string) error { 61 + return handlers.Reset(cmd.Context(), args) 62 + }, 63 + } 64 + } 65 + 66 + func rootCmd() *cobra.Command { 67 + root := &cobra.Command{ 68 + Use: "noteleaf", 69 + Long: ui.Georgia.ColoredInViewport(), 70 + Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", 71 + RunE: func(c *cobra.Command, args []string) error { 72 + if len(args) == 0 { 73 + return c.Help() 74 + } 75 + 76 + output := strings.Join(args, " ") 77 + fmt.Println(output) 78 + return nil 79 + }, 80 + } 81 + 82 + root.SetHelpCommand(&cobra.Command{Hidden: true}) 83 + cobra.EnableCommandSorting = false 84 + 85 + root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) 86 + root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) 87 + return root 88 + } 89 + 90 + func setupCmd() *cobra.Command { 91 + handler, err := handlers.NewSeedHandler() 92 + if err != nil { 93 + log.Fatalf("failed to instantiate seed handler: %v", err) 94 + } 95 + 96 + root := &cobra.Command{ 97 + Use: "setup", 98 + Short: "Initialize and manage application setup", 99 + RunE: func(c *cobra.Command, args []string) error { 100 + return handlers.Setup(c.Context(), args) 101 + }, 102 + } 103 + 104 + seedCmd := &cobra.Command{ 105 + Use: "seed", 106 + Short: "Populate database with test data", 107 + Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", 108 + RunE: func(c *cobra.Command, args []string) error { 109 + force, _ := c.Flags().GetBool("force") 110 + return handler.Seed(c.Context(), force) 111 + }, 112 + } 113 + seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") 114 + 115 + root.AddCommand(seedCmd) 116 + return root 117 + } 118 + 119 + func confCmd() *cobra.Command { 120 + return &cobra.Command{ 121 + Use: "config [key] [value]", 122 + Short: "Manage configuration", 123 + Args: cobra.ExactArgs(2), 124 + RunE: func(c *cobra.Command, args []string) error { 125 + key, value := args[0], args[1] 126 + fmt.Printf("Setting config %s = %s\n", key, value) 127 + return nil 128 + }, 129 + } 130 + } 131 + 132 func main() { 133 logger := utils.NewLogger("info", "text") 134 utils.Logger = logger ··· 139 } 140 defer app.Close() 141 142 + taskHandler, err := handlers.NewTaskHandler() 143 + if err != nil { 144 + log.Fatalf("failed to create task handler: %v", err) 145 + } 146 + 147 + movieHandler, err := handlers.NewMovieHandler() 148 + if err != nil { 149 + log.Fatalf("failed to create movie handler: %v", err) 150 + } 151 + 152 + tvHandler, err := handlers.NewTVHandler() 153 + if err != nil { 154 + log.Fatalf("failed to create TV handler: %v", err) 155 + } 156 + 157 + noteHandler, err := handlers.NewNoteHandler() 158 + if err != nil { 159 + log.Fatalf("failed to create note handler: %v", err) 160 + } 161 + 162 + bookHandler, err := handlers.NewBookHandler() 163 + if err != nil { 164 + log.Fatalf("failed to create book handler: %v", err) 165 + } 166 + 167 root := rootCmd() 168 + 169 + coreGroups := []CommandGroup{ 170 + NewTaskCommand(taskHandler), 171 + NewNoteCommand(noteHandler), 172 + } 173 174 + for _, group := range coreGroups { 175 + cmd := group.Create() 176 cmd.GroupID = "core" 177 root.AddCommand(cmd) 178 } 179 180 + mediaCmd := &cobra.Command{Use: "media", Short: "Manage media queues (books, movies, TV shows)"} 181 + mediaCmd.GroupID = "core" 182 + mediaCmd.AddCommand(NewMovieCommand(movieHandler).Create()) 183 + mediaCmd.AddCommand(NewTVCommand(tvHandler).Create()) 184 + mediaCmd.AddCommand(NewBookCommand(bookHandler).Create()) 185 + root.AddCommand(mediaCmd) 186 + 187 + mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd} 188 for _, cmdFunc := range mgmt { 189 cmd := cmdFunc() 190 cmd.GroupID = "management"
+25
cmd/task_commands.go
··· 5 "github.com/stormlightlabs/noteleaf/internal/handlers" 6 ) 7 8 func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { 9 cmd := &cobra.Command{ 10 Use: "add [description]",
··· 5 "github.com/stormlightlabs/noteleaf/internal/handlers" 6 ) 7 8 + // TaskCommand implements CommandGroup for task-related commands 9 + type TaskCommand struct { 10 + handler *handlers.TaskHandler 11 + } 12 + 13 + // NewTaskCommand creates a new TaskCommands with the given handler 14 + func NewTaskCommand(handler *handlers.TaskHandler) *TaskCommand { 15 + return &TaskCommand{handler: handler} 16 + } 17 + 18 + func (c *TaskCommand) Create() *cobra.Command { 19 + root := &cobra.Command{Use: "todo", Aliases: []string{"task"}, Short: "task management"} 20 + 21 + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 22 + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, 23 + deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, 24 + taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, 25 + } { 26 + cmd := init(c.handler) 27 + root.AddCommand(cmd) 28 + } 29 + 30 + return root 31 + } 32 + 33 func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { 34 cmd := &cobra.Command{ 35 Use: "add [description]",
+1 -1
codecov.yml
··· 20 - "**/*_test.go" 21 - "**/testdata/**" 22 - "**/vendor/**" 23 - - "cmd/*.go" 24 - "internal/repo/test_utilities.go" 25 - "internal/handlers/test_utilities.go"
··· 20 - "**/*_test.go" 21 - "**/testdata/**" 22 - "**/vendor/**" 23 + - "cmd/main.go" 24 - "internal/repo/test_utilities.go" 25 - "internal/handlers/test_utilities.go"
+33
docs/cli.md
···
··· 1 + # CLI Docs 2 + 3 + ## CommandGroup Interface Pattern 4 + 5 + This section outlines the CommandGroup interface pattern for implementing CLI commands in the noteleaf application. 6 + 7 + ### Core Concepts 8 + 9 + Each major command group implements the CommandGroup interface with a `Create() *cobra.Command` method. Command groups receive handlers as constructor dependencies, enabling dependency injection for testing. Handler initialization occurs centrally in main.go with `log.Fatalf` error handling to fail fast during application startup. 10 + 11 + ### CommandGroup Interface 12 + 13 + interface `CommandGroup` provides a consistent contract for all command groups. Each implementation encapsulates related commands and the shared handler dependency. The Create method returns a fully configured cobra command tree. 14 + 15 + #### Implementations 16 + 17 + TaskCommands handles todo and task-related operations using TaskHandler. MovieCommand manages movie queue operations via MovieHandler. 18 + TVCommand handles TV show queue operations through TVHandler. NoteCommand manages note operations using NoteHandler. 19 + 20 + ### Handler Lifecycle 21 + 22 + Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures. 23 + Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances. 24 + 25 + ### Testing Benefits 26 + 27 + `CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing. 28 + Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing. 29 + 30 + ### Registry Pattern 31 + 32 + `main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality. 33 + Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups.
+106
docs/testing.md
···
··· 1 + # Testing Documentation 2 + 3 + This document outlines the testing patterns and practices used in the noteleaf application. 4 + 5 + ## Testing Principles 6 + 7 + The codebase follows Go's standard testing practices without external libraries. Tests use only the standard library `testing` package and avoid mock frameworks or assertion libraries. This keeps dependencies minimal and tests readable using standard Go patterns. 8 + 9 + ## Test File Organization 10 + 11 + Test files follow the standard Go convention of `*_test.go` naming. Each package contains its own test files alongside the source code. Test files are organized by functionality and mirror the structure of the source code they test. 12 + 13 + ## Testing Patterns 14 + 15 + ### Handler Creation Pattern 16 + 17 + Tests create real handler instances using temporary databases to ensure test isolation. Factory functions handle both database setup and handler initialization, returning both the handler and a cleanup function. 18 + 19 + ### Database Isolation 20 + 21 + Tests use temporary directories and environment variable manipulation to create isolated database instances. Each test gets its own temporary SQLite database that is automatically cleaned up after the test completes. 22 + 23 + The `setupCommandTest` function creates a temporary directory, sets `XDG_CONFIG_HOME` to point to it, and initializes the database schema. This ensures tests don't interfere with each other or with development data. 24 + 25 + ### Resource Management 26 + 27 + Tests properly manage resources using cleanup functions returned by factory methods. The cleanup function handles both handler closure and temporary directory removal. This pattern ensures complete resource cleanup even if tests fail. 28 + 29 + ### Error Handling 30 + 31 + Tests use `t.Fatal` for setup errors that prevent test execution and `t.Error` for test assertion failures. Fatal errors stop test execution while errors allow tests to continue checking other conditions. 32 + 33 + ### Command Structure Testing 34 + 35 + Command group tests verify cobra command structure including use strings, aliases, short descriptions, and subcommand presence. Tests check that commands are properly configured without executing their logic. 36 + 37 + ### Interface Compliance Testing 38 + 39 + Tests verify interface compliance using compile-time checks with blank identifier assignments. This ensures structs implement expected interfaces without runtime overhead. 40 + 41 + ```go 42 + var _ CommandGroup = NewTaskCommands(handler) 43 + ``` 44 + 45 + ## Test Organization Patterns 46 + 47 + ### Single Root Test Pattern 48 + 49 + The preferred test organization pattern uses a single root test function with nested subtests using `t.Run`. This provides clear hierarchical organization and allows running specific test sections while maintaining shared setup and context. 50 + 51 + ```go 52 + func TestCommandGroup(t *testing.T) { 53 + t.Run("Interface Implementations", func(t *testing.T) { 54 + // Test interface compliance 55 + }) 56 + 57 + t.Run("Create", func(t *testing.T) { 58 + t.Run("TaskCommand", func(t *testing.T) { 59 + // Test task command creation 60 + }) 61 + t.Run("MovieCommand", func(t *testing.T) { 62 + // Test movie command creation 63 + }) 64 + }) 65 + } 66 + ``` 67 + 68 + This pattern offers several advantages: clear test hierarchy with logical grouping, ability to run specific test sections with `go test -run TestCommandGroup/Create/TaskCommand`, consistent test structure across the codebase, and shared setup that can be inherited by subtests. 69 + 70 + ### Integration vs Unit Testing 71 + 72 + The codebase emphasizes integration testing over heavy mocking. Tests use real handlers and services to verify actual behavior rather than mocked interactions. This approach catches integration issues while maintaining test reliability. 73 + 74 + ### Static Output Testing 75 + 76 + UI components support static output modes for testing. Tests capture output using bytes.Buffer and verify content using string contains checks rather than exact string matching for better test maintainability. 77 + 78 + ## Test Utilities 79 + 80 + ### Helper Functions 81 + 82 + Test files include helper functions for creating test data and finding elements in collections. These utilities reduce code duplication and improve test readability. 83 + 84 + ### Mock Data Creation 85 + 86 + Tests create realistic mock data using factory functions that return properly initialized structs with sensible defaults. This approach provides consistent test data across different test cases. 87 + 88 + ## Testing CLI Commands 89 + 90 + Command group tests focus on structure verification rather than execution testing. Tests check command configuration, subcommand presence, and interface compliance. This approach ensures command trees are properly constructed without requiring complex execution mocking. 91 + 92 + ### CommandGroup Interface Testing 93 + 94 + The CommandGroup interface enables testable CLI architecture. Tests verify that command groups implement the interface correctly and return properly configured cobra commands. This pattern separates command structure from command execution. 95 + 96 + Interface compliance is tested using compile-time checks within the "Interface Implementations" subtest, ensuring all command structs properly implement the CommandGroup interface without runtime overhead. 97 + 98 + ## Performance Considerations 99 + 100 + Tests avoid expensive operations in setup functions. Handler creation uses real instances but tests focus on structure verification rather than full execution paths. This keeps test suites fast while maintaining coverage of critical functionality. 101 + 102 + The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases. 103 + 104 + ## Best Practices Summary 105 + 106 + Use factory functions for test handler creation with proper cleanup patterns. Organize tests using single root test functions with nested subtests for clear hierarchy. Manage resources with cleanup functions returned by factory methods. Prefer integration testing over mocking for real-world behavior validation. Verify interface compliance at compile time within dedicated subtests. Focus command tests on structure verification rather than execution testing. Leverage the single root test pattern for logical grouping and selective test execution. Use realistic test data with factory functions for consistent test scenarios.
+10 -9
go.mod
··· 4 5 require ( 6 github.com/BurntSushi/toml v1.5.0 7 - github.com/charmbracelet/fang v0.3.0 8 github.com/mattn/go-sqlite3 v1.14.32 9 github.com/spf13/cobra v1.9.1 10 ) ··· 48 github.com/muesli/reflow v0.3.0 // indirect 49 github.com/yuin/goldmark v1.7.8 // indirect 50 github.com/yuin/goldmark-emoji v1.0.5 // indirect 51 - golang.org/x/net v0.39.0 // indirect 52 - golang.org/x/sync v0.16.0 // indirect 53 - golang.org/x/term v0.31.0 // indirect 54 ) 55 56 require ( 57 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 58 github.com/charmbracelet/bubbles v0.21.0 59 - github.com/charmbracelet/colorprofile v0.3.1 // indirect 60 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 61 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 62 github.com/charmbracelet/log v0.4.2 63 - github.com/charmbracelet/x/ansi v0.9.3 // indirect 64 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 65 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 66 github.com/charmbracelet/x/term v0.2.1 // indirect 67 github.com/go-logfmt/logfmt v0.6.0 // indirect 68 github.com/gocolly/colly/v2 v2.2.0 69 github.com/inconshreveable/mousetrap v1.1.0 // indirect 70 - github.com/lucasb-eyer/go-colorful v1.2.0 71 github.com/mattn/go-isatty v0.0.20 // indirect 72 github.com/mattn/go-runewidth v0.0.16 // indirect 73 github.com/muesli/cancelreader v0.2.2 // indirect ··· 80 github.com/spf13/pflag v1.0.6 // indirect 81 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 82 golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 83 - golang.org/x/sys v0.33.0 // indirect 84 - golang.org/x/text v0.28.0 85 )
··· 4 5 require ( 6 github.com/BurntSushi/toml v1.5.0 7 + github.com/charmbracelet/fang v0.4.2 8 github.com/mattn/go-sqlite3 v1.14.32 9 github.com/spf13/cobra v1.9.1 10 ) ··· 48 github.com/muesli/reflow v0.3.0 // indirect 49 github.com/yuin/goldmark v1.7.8 // indirect 50 github.com/yuin/goldmark-emoji v1.0.5 // indirect 51 + golang.org/x/net v0.44.0 // indirect 52 + golang.org/x/sync v0.17.0 // indirect 53 + golang.org/x/term v0.35.0 // indirect 54 ) 55 56 require ( 57 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 58 github.com/charmbracelet/bubbles v0.21.0 59 + github.com/charmbracelet/colorprofile v0.3.2 // indirect 60 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 61 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 62 github.com/charmbracelet/log v0.4.2 63 + github.com/charmbracelet/x/ansi v0.10.1 // indirect 64 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 65 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 66 github.com/charmbracelet/x/term v0.2.1 // indirect 67 github.com/go-logfmt/logfmt v0.6.0 // indirect 68 github.com/gocolly/colly/v2 v2.2.0 69 github.com/inconshreveable/mousetrap v1.1.0 // indirect 70 + github.com/lucasb-eyer/go-colorful v1.3.0 71 github.com/mattn/go-isatty v0.0.20 // indirect 72 github.com/mattn/go-runewidth v0.0.16 // indirect 73 github.com/muesli/cancelreader v0.2.2 // indirect ··· 80 github.com/spf13/pflag v1.0.6 // indirect 81 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 82 golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 83 + golang.org/x/sys v0.36.0 // indirect 84 + golang.org/x/text v0.29.0 85 + golang.org/x/tools v0.37.0 86 )
+20 -18
go.sum
··· 31 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 32 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 33 github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 34 - github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 35 - github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 36 - github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= 37 - github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M= 38 github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 39 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 40 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= ··· 43 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 44 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 45 github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 46 - github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 47 - github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 48 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 49 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 50 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= ··· 89 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 90 github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 91 github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 92 - github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 93 - github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 94 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 95 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 96 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= ··· 171 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 172 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 173 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 174 - golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 175 - golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 176 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 177 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 178 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 180 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 181 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 182 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 183 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 184 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 185 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 186 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 187 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 196 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 197 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 198 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 199 - golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 200 - golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 201 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 202 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 203 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= ··· 208 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 209 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 210 golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 211 - golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 212 - golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 213 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 214 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 215 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= ··· 220 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 221 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 222 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 223 - golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 224 - golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 225 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 226 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 227 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 230 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 231 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 232 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 233 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 234 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 235 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
··· 31 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 32 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 33 github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 34 + github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= 35 + github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= 36 + github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas= 37 + github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= 38 github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 39 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 40 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= ··· 43 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 44 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 45 github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 46 + github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 47 + github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 48 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 49 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 50 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= ··· 89 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 90 github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 91 github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 92 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 93 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 94 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 95 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 96 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= ··· 171 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 172 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 173 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 174 + golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= 175 + golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 176 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 177 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 178 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 180 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 181 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 182 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 183 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 184 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 185 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 186 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 187 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 196 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 197 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 198 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 199 + golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 200 + golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 201 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 202 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 203 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= ··· 208 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 209 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 210 golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 211 + golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= 212 + golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 213 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 214 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 215 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= ··· 220 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 221 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 222 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 223 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 224 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 225 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 226 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 227 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 230 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 231 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 232 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 233 + golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= 234 + golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= 235 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 236 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 237 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+32 -136
internal/handlers/books.go
··· 5 "fmt" 6 "os" 7 "slices" 8 "time" 9 10 "github.com/stormlightlabs/noteleaf/internal/models" ··· 53 return h.db.Close() 54 } 55 56 - // SearchAndAdd searches for books and allows user to select and add to queue 57 - func SearchAndAdd(ctx context.Context, args []string) error { 58 - handler, err := NewBookHandler() 59 - if err != nil { 60 - return fmt.Errorf("failed to initialize book handler: %w", err) 61 - } 62 - defer handler.Close() 63 64 - return handler.searchAndAdd(ctx, args) 65 - } 66 - 67 - // SearchAndAddWithOptions searches for books with interactive option 68 - func SearchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error { 69 - handler, err := NewBookHandler() 70 - if err != nil { 71 - return fmt.Errorf("failed to initialize book handler: %w", err) 72 } 73 - defer handler.Close() 74 75 - return handler.searchAndAddWithOptions(ctx, args, interactive) 76 - } 77 - 78 - func (h *BookHandler) searchAndAdd(ctx context.Context, args []string) error { 79 - if len(args) == 0 { 80 - return fmt.Errorf("usage: book add <search query> [-i for interactive mode]") 81 } 82 83 - interactive := false 84 - searchArgs := args 85 - if len(args) > 0 && args[len(args)-1] == "-i" { 86 - interactive = true 87 - searchArgs = args[:len(args)-1] 88 } 89 90 - if len(searchArgs) == 0 { 91 - return fmt.Errorf("search query cannot be empty") 92 } 93 94 - query := searchArgs[0] 95 - if len(searchArgs) > 1 { 96 - for _, arg := range searchArgs[1:] { 97 - query += " " + arg 98 } 99 } 100 101 - return h.searchAndAddWithOptions(ctx, searchArgs, interactive) 102 } 103 104 - func (h *BookHandler) searchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error { 105 if len(args) == 0 { 106 return fmt.Errorf("usage: book add <search query>") 107 } ··· 187 return nil 188 } 189 190 - // ListBooks lists all books in the queue 191 - func ListBooks(ctx context.Context, args []string) error { 192 - handler, err := NewBookHandler() 193 - if err != nil { 194 - return fmt.Errorf("failed to initialize book handler: %w", err) 195 - } 196 - defer handler.Close() 197 - 198 - return handler.listBooks(ctx, args) 199 - } 200 - 201 - func (h *BookHandler) listBooks(ctx context.Context, args []string) error { 202 - status := "queued" 203 - if len(args) > 0 { 204 - switch args[0] { 205 - case "all", "--all", "-a": 206 - status = "" 207 - case "reading", "--reading", "-r": 208 - status = "reading" 209 - case "finished", "--finished", "-f": 210 - status = "finished" 211 - case "queued", "--queued", "-q": 212 - status = "queued" 213 - } 214 - } 215 - 216 var books []*models.Book 217 var err error 218 ··· 252 return nil 253 } 254 255 - func UpdateBookStatus(ctx context.Context, args []string) error { 256 - handler, err := NewBookHandler() 257 if err != nil { 258 - return fmt.Errorf("failed to initialize book handler: %w", err) 259 - } 260 - defer handler.Close() 261 - 262 - return handler.updateBookStatus(ctx, args) 263 - } 264 - 265 - func (h *BookHandler) updateBookStatus(ctx context.Context, args []string) error { 266 - if len(args) < 2 { 267 - return fmt.Errorf("usage: book update <id> <status>") 268 - } 269 - 270 - var bookID int64 271 - if _, err := fmt.Sscanf(args[0], "%d", &bookID); err != nil { 272 - return fmt.Errorf("invalid book ID: %s", args[0]) 273 } 274 275 - status := args[1] 276 validStatuses := []string{"queued", "reading", "finished", "removed"} 277 - valid := slices.Contains(validStatuses, status) 278 - if !valid { 279 return fmt.Errorf("invalid status: %s (valid: %v)", status, validStatuses) 280 } 281 ··· 303 return nil 304 } 305 306 - // UpdateBookProgress updates a book's reading progress percentage 307 - func UpdateBookProgress(ctx context.Context, args []string) error { 308 - handler, err := NewBookHandler() 309 if err != nil { 310 - return fmt.Errorf("failed to initialize book handler: %w", err) 311 - } 312 - defer handler.Close() 313 - 314 - return handler.updateBookProgress(ctx, args) 315 - } 316 - 317 - func (h *BookHandler) updateBookProgress(ctx context.Context, args []string) error { 318 - if len(args) < 2 { 319 - return fmt.Errorf("usage: book progress <id> <percentage>") 320 - } 321 - 322 - var bookID int64 323 - if _, err := fmt.Sscanf(args[0], "%d", &bookID); err != nil { 324 - return fmt.Errorf("invalid book ID: %s", args[0]) 325 - } 326 - 327 - var progress int 328 - if _, err := fmt.Sscanf(args[1], "%d", &progress); err != nil { 329 - return fmt.Errorf("invalid progress percentage: %s", args[1]) 330 } 331 332 if progress < 0 || progress > 100 { ··· 368 fmt.Println() 369 return nil 370 } 371 - 372 - func (h *BookHandler) printBook(book *models.Book) { 373 - fmt.Printf("[%d] %s", book.ID, book.Title) 374 - 375 - if book.Author != "" { 376 - fmt.Printf(" by %s", book.Author) 377 - } 378 - 379 - if book.Status != "queued" { 380 - fmt.Printf(" (%s)", book.Status) 381 - } 382 - 383 - if book.Progress > 0 { 384 - fmt.Printf(" [%d%%]", book.Progress) 385 - } 386 - 387 - if book.Rating > 0 { 388 - fmt.Printf(" โ˜…%.1f", book.Rating) 389 - } 390 - 391 - fmt.Println() 392 - 393 - if book.Notes != "" { 394 - notes := book.Notes 395 - if len(notes) > 80 { 396 - notes = notes[:77] + "..." 397 - } 398 - fmt.Printf(" %s\n", notes) 399 - } 400 - 401 - fmt.Println() 402 - }
··· 5 "fmt" 6 "os" 7 "slices" 8 + "strconv" 9 "time" 10 11 "github.com/stormlightlabs/noteleaf/internal/models" ··· 54 return h.db.Close() 55 } 56 57 + func (h *BookHandler) printBook(book *models.Book) { 58 + fmt.Printf("[%d] %s", book.ID, book.Title) 59 60 + if book.Author != "" { 61 + fmt.Printf(" by %s", book.Author) 62 } 63 64 + if book.Status != "queued" { 65 + fmt.Printf(" (%s)", book.Status) 66 } 67 68 + if book.Progress > 0 { 69 + fmt.Printf(" [%d%%]", book.Progress) 70 } 71 72 + if book.Rating > 0 { 73 + fmt.Printf(" โ˜…%.1f", book.Rating) 74 } 75 76 + fmt.Println() 77 + 78 + if book.Notes != "" { 79 + notes := book.Notes 80 + if len(notes) > 80 { 81 + notes = notes[:77] + "..." 82 } 83 + fmt.Printf(" %s\n", notes) 84 } 85 86 + fmt.Println() 87 } 88 89 + // SearchAndAddBook searches for books and allows user to select and add to queue 90 + func (h *BookHandler) SearchAndAddBook(ctx context.Context, args []string, interactive bool) error { 91 if len(args) == 0 { 92 return fmt.Errorf("usage: book add <search query>") 93 } ··· 173 return nil 174 } 175 176 + // ListBooks lists all books with status filtering 177 + func (h *BookHandler) ListBooks(ctx context.Context, status string) error { 178 var books []*models.Book 179 var err error 180 ··· 214 return nil 215 } 216 217 + // UpdateBookStatusByID changes the status of a book 218 + func (h *BookHandler) UpdateBookStatusByID(ctx context.Context, id, status string) error { 219 + bookID, err := strconv.ParseInt(id, 10, 64) 220 if err != nil { 221 + return fmt.Errorf("invalid book ID: %s", id) 222 } 223 224 validStatuses := []string{"queued", "reading", "finished", "removed"} 225 + if !slices.Contains(validStatuses, status) { 226 return fmt.Errorf("invalid status: %s (valid: %v)", status, validStatuses) 227 } 228 ··· 250 return nil 251 } 252 253 + // UpdateBookProgressByID updates a book's reading progress percentage 254 + func (h *BookHandler) UpdateBookProgressByID(ctx context.Context, id string, progress int) error { 255 + bookID, err := strconv.ParseInt(id, 10, 64) 256 if err != nil { 257 + return fmt.Errorf("invalid book ID: %s", id) 258 } 259 260 if progress < 0 || progress > 100 { ··· 296 fmt.Println() 297 return nil 298 }
+261 -358
internal/handlers/books_test.go
··· 104 }) 105 }) 106 107 - t.Run("Search & Add", func(t *testing.T) { 108 _, cleanup := setupBookTest(t) 109 defer cleanup() 110 111 - t.Run("fails with empty args", func(t *testing.T) { 112 - ctx := context.Background() 113 - args := []string{} 114 - 115 - err := SearchAndAdd(ctx, args) 116 - if err == nil { 117 - t.Error("Expected error for empty args") 118 - } 119 120 - if !strings.Contains(err.Error(), "usage: book add") { 121 - t.Errorf("Expected usage error, got: %v", err) 122 - } 123 - }) 124 - 125 - t.Run("fails with empty search", func(t *testing.T) { 126 ctx := context.Background() 127 - args := []string{"-i"} 128 - 129 - err := SearchAndAdd(ctx, args) 130 - if err == nil { 131 - t.Error("Expected error for empty search query") 132 - } 133 - 134 - if !strings.Contains(err.Error(), "search query cannot be empty") { 135 - t.Errorf("Expected empty search query error, got: %v", err) 136 - } 137 - }) 138 - 139 - t.Run("with options", func(t *testing.T) { 140 - _, cleanup := setupBookTest(t) 141 - defer cleanup() 142 - 143 t.Run("fails with empty args", func(t *testing.T) { 144 - ctx := context.Background() 145 args := []string{} 146 - 147 - err := SearchAndAddWithOptions(ctx, args, false) 148 if err == nil { 149 t.Error("Expected error for empty args") 150 } ··· 154 } 155 }) 156 157 - t.Run("handles search service errors", func(t *testing.T) { 158 - ctx := context.Background() 159 - args := []string{"test", "book"} 160 - 161 - err := SearchAndAddWithOptions(ctx, args, false) 162 - if err == nil { 163 - t.Error("Expected error due to mocked service") 164 - } 165 - if strings.Contains(err.Error(), "usage:") { 166 - t.Error("Should not show usage error for valid args") 167 } 168 }) 169 170 - }) 171 - }) 172 173 - t.Run("List", func(t *testing.T) { 174 - _, cleanup := setupBookTest(t) 175 - defer cleanup() 176 177 - ctx := context.Background() 178 - 179 - handler, err := NewBookHandler() 180 - if err != nil { 181 - t.Fatalf("Failed to create handler: %v", err) 182 - } 183 - defer handler.Close() 184 - 185 - _ = createTestBook(t, handler, ctx) 186 - 187 - book2 := &models.Book{ 188 - Title: "Reading Book", 189 - Author: "Reading Author", 190 - Status: "reading", 191 - Added: time.Now(), 192 - } 193 - id2, err := handler.repos.Books.Create(ctx, book2) 194 - if err != nil { 195 - t.Fatalf("Failed to create book2: %v", err) 196 - } 197 - book2.ID = id2 198 - 199 - book3 := &models.Book{ 200 - Title: "Finished Book", 201 - Author: "Finished Author", 202 - Status: "finished", 203 - Added: time.Now(), 204 - } 205 - id3, err := handler.repos.Books.Create(ctx, book3) 206 - if err != nil { 207 - t.Fatalf("Failed to create book3: %v", err) 208 - } 209 - book3.ID = id3 210 - 211 - t.Run("lists queued books by default", func(t *testing.T) { 212 - args := []string{} 213 - 214 - err := ListBooks(ctx, args) 215 - if err != nil { 216 - t.Errorf("ListBooks failed: %v", err) 217 - } 218 }) 219 220 - t.Run("filters by status - all", func(t *testing.T) { 221 - args := []string{"all"} 222 - 223 - err := ListBooks(ctx, args) 224 - if err != nil { 225 - t.Errorf("ListBooks with status all failed: %v", err) 226 - } 227 - }) 228 - 229 - t.Run("filters by status - reading", func(t *testing.T) { 230 - args := []string{"reading"} 231 232 - err := ListBooks(ctx, args) 233 - if err != nil { 234 - t.Errorf("ListBooks with status reading failed: %v", err) 235 - } 236 - }) 237 238 - t.Run("filters by status - finished", func(t *testing.T) { 239 - args := []string{"finished"} 240 241 - err := ListBooks(ctx, args) 242 - if err != nil { 243 - t.Errorf("ListBooks with status finished failed: %v", err) 244 } 245 - }) 246 - 247 - t.Run("filters by status - queued", func(t *testing.T) { 248 - args := []string{"queued"} 249 - 250 - err := ListBooks(ctx, args) 251 if err != nil { 252 - t.Errorf("ListBooks with status queued failed: %v", err) 253 } 254 - }) 255 256 - t.Run("handles various flag formats", func(t *testing.T) { 257 - statusVariants := [][]string{ 258 - {"--all"}, {"-a"}, 259 - {"--reading"}, {"-r"}, 260 - {"--finished"}, {"-f"}, 261 - {"--queued"}, {"-q"}, 262 } 263 - 264 - for _, args := range statusVariants { 265 - err := ListBooks(ctx, args) 266 - if err != nil { 267 - t.Errorf("ListBooks with args %v failed: %v", args, err) 268 - } 269 - } 270 - }) 271 - }) 272 - 273 - t.Run("Update", func(t *testing.T) { 274 - t.Run("Update status", func(t *testing.T) { 275 - _, cleanup := setupBookTest(t) 276 - defer cleanup() 277 - 278 - ctx := context.Background() 279 - 280 - handler, err := NewBookHandler() 281 if err != nil { 282 - t.Fatalf("Failed to create handler: %v", err) 283 } 284 - defer handler.Close() 285 - 286 - book := createTestBook(t, handler, ctx) 287 - 288 - t.Run("updates book status successfully", func(t *testing.T) { 289 - args := []string{strconv.FormatInt(book.ID, 10), "reading"} 290 291 - err := UpdateBookStatus(ctx, args) 292 if err != nil { 293 - t.Errorf("UpdateBookStatus failed: %v", err) 294 } 295 296 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 297 if err != nil { 298 - t.Fatalf("Failed to get updated book: %v", err) 299 - } 300 - 301 - if updatedBook.Status != "reading" { 302 - t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 303 - } 304 - 305 - if updatedBook.Started == nil { 306 - t.Error("Expected started time to be set") 307 } 308 }) 309 310 - t.Run("updates to finished status", func(t *testing.T) { 311 - args := []string{strconv.FormatInt(book.ID, 10), "finished"} 312 - 313 - err := UpdateBookStatus(ctx, args) 314 if err != nil { 315 - t.Errorf("UpdateBookStatus failed: %v", err) 316 } 317 318 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 319 if err != nil { 320 - t.Fatalf("Failed to get updated book: %v", err) 321 - } 322 - 323 - if updatedBook.Status != "finished" { 324 - t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 325 - } 326 - 327 - if updatedBook.Finished == nil { 328 - t.Error("Expected finished time to be set") 329 - } 330 - 331 - if updatedBook.Progress != 100 { 332 - t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 333 } 334 }) 335 336 - t.Run("fails with insufficient arguments", func(t *testing.T) { 337 - args := []string{strconv.FormatInt(book.ID, 10)} 338 - 339 - err := UpdateBookStatus(ctx, args) 340 - if err == nil { 341 - t.Error("Expected error for insufficient arguments") 342 - } 343 - 344 - if !strings.Contains(err.Error(), "usage: book update") { 345 - t.Errorf("Expected usage error, got: %v", err) 346 } 347 }) 348 349 - t.Run("fails with invalid book ID", func(t *testing.T) { 350 - args := []string{"invalid-id", "reading"} 351 - 352 - err := UpdateBookStatus(ctx, args) 353 - if err == nil { 354 - t.Error("Expected error for invalid book ID") 355 } 356 357 - if !strings.Contains(err.Error(), "invalid book ID") { 358 - t.Errorf("Expected invalid book ID error, got: %v", err) 359 } 360 }) 361 362 - t.Run("fails with invalid status", func(t *testing.T) { 363 - args := []string{strconv.FormatInt(book.ID, 10), "invalid-status"} 364 365 - err := UpdateBookStatus(ctx, args) 366 - if err == nil { 367 - t.Error("Expected error for invalid status") 368 - } 369 370 - if !strings.Contains(err.Error(), "invalid status") { 371 - t.Errorf("Expected invalid status error, got: %v", err) 372 - } 373 - }) 374 375 - t.Run("fails with non-existent book ID", func(t *testing.T) { 376 - args := []string{"99999", "reading"} 377 378 - err := UpdateBookStatus(ctx, args) 379 - if err == nil { 380 - t.Error("Expected error for non-existent book ID") 381 - } 382 383 - if !strings.Contains(err.Error(), "failed to get book") { 384 - t.Errorf("Expected book not found error, got: %v", err) 385 - } 386 - }) 387 388 - t.Run("validates all status options", func(t *testing.T) { 389 - validStatuses := []string{"queued", "reading", "finished", "removed"} 390 391 - for _, status := range validStatuses { 392 - args := []string{strconv.FormatInt(book.ID, 10), status} 393 394 - err := UpdateBookStatus(ctx, args) 395 - if err != nil { 396 - t.Errorf("UpdateBookStatus with status %s failed: %v", status, err) 397 } 398 - } 399 - }) 400 - }) 401 402 - t.Run("progress", func(t *testing.T) { 403 - _, cleanup := setupBookTest(t) 404 - defer cleanup() 405 406 - ctx := context.Background() 407 408 - handler, err := NewBookHandler() 409 - if err != nil { 410 - t.Fatalf("Failed to create handler: %v", err) 411 - } 412 - defer handler.Close() 413 414 - book := createTestBook(t, handler, ctx) 415 416 - t.Run("updates progress successfully", func(t *testing.T) { 417 - args := []string{strconv.FormatInt(book.ID, 10), "50"} 418 419 - err := UpdateBookProgress(ctx, args) 420 - if err != nil { 421 - t.Errorf("UpdateBookProgress failed: %v", err) 422 - } 423 424 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 425 - if err != nil { 426 - t.Fatalf("Failed to get updated book: %v", err) 427 - } 428 429 - if updatedBook.Progress != 50 { 430 - t.Errorf("Expected progress 50, got %d", updatedBook.Progress) 431 - } 432 433 - if updatedBook.Status != "reading" { 434 - t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 435 - } 436 437 - if updatedBook.Started == nil { 438 - t.Error("Expected started time to be set") 439 - } 440 - }) 441 442 - t.Run("auto-completes book at 100%", func(t *testing.T) { 443 - args := []string{strconv.FormatInt(book.ID, 10), "100"} 444 445 - err := UpdateBookProgress(ctx, args) 446 if err != nil { 447 - t.Errorf("UpdateBookProgress failed: %v", err) 448 } 449 450 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 451 - if err != nil { 452 - t.Fatalf("Failed to get updated book: %v", err) 453 - } 454 455 - if updatedBook.Progress != 100 { 456 - t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 457 - } 458 459 - if updatedBook.Status != "finished" { 460 - t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 461 - } 462 463 - if updatedBook.Finished == nil { 464 - t.Error("Expected finished time to be set") 465 - } 466 - }) 467 468 - t.Run("resets to queued at 0%", func(t *testing.T) { 469 - book.Status = "reading" 470 - now := time.Now() 471 - book.Started = &now 472 - handler.repos.Books.Update(ctx, book) 473 474 - args := []string{strconv.FormatInt(book.ID, 10), "0"} 475 476 - err := UpdateBookProgress(ctx, args) 477 - if err != nil { 478 - t.Errorf("UpdateBookProgress failed: %v", err) 479 - } 480 481 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 482 - if err != nil { 483 - t.Fatalf("Failed to get updated book: %v", err) 484 - } 485 486 - if updatedBook.Progress != 0 { 487 - t.Errorf("Expected progress 0, got %d", updatedBook.Progress) 488 - } 489 490 - if updatedBook.Status != "queued" { 491 - t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status) 492 - } 493 494 - if updatedBook.Started != nil { 495 - t.Error("Expected started time to be nil") 496 - } 497 - }) 498 499 - t.Run("fails with insufficient arguments", func(t *testing.T) { 500 - args := []string{strconv.FormatInt(book.ID, 10)} 501 502 - err := UpdateBookProgress(ctx, args) 503 - if err == nil { 504 - t.Error("Expected error for insufficient arguments") 505 - } 506 507 - if !strings.Contains(err.Error(), "usage: book progress") { 508 - t.Errorf("Expected usage error, got: %v", err) 509 - } 510 - }) 511 512 - t.Run("fails with invalid book ID", func(t *testing.T) { 513 - args := []string{"invalid-id", "50"} 514 515 - err := UpdateBookProgress(ctx, args) 516 - if err == nil { 517 - t.Error("Expected error for invalid book ID") 518 - } 519 520 - if !strings.Contains(err.Error(), "invalid book ID") { 521 - t.Errorf("Expected invalid book ID error, got: %v", err) 522 - } 523 - }) 524 525 - t.Run("fails with invalid progress percentage", func(t *testing.T) { 526 - args := []string{strconv.FormatInt(book.ID, 10), "invalid-progress"} 527 528 - err := UpdateBookProgress(ctx, args) 529 - if err == nil { 530 - t.Error("Expected error for invalid progress percentage") 531 - } 532 533 - if !strings.Contains(err.Error(), "invalid progress percentage") { 534 - t.Errorf("Expected invalid progress percentage error, got: %v", err) 535 - } 536 - }) 537 538 - t.Run("fails with progress out of range", func(t *testing.T) { 539 - testCases := []string{"-1", "101", "150"} 540 541 - for _, progress := range testCases { 542 - args := []string{strconv.FormatInt(book.ID, 10), progress} 543 544 - err := UpdateBookProgress(ctx, args) 545 if err == nil { 546 - t.Errorf("Expected error for progress %s", progress) 547 } 548 549 - if !strings.Contains(err.Error(), "progress must be between 0 and 100") { 550 - t.Errorf("Expected range error for progress %s, got: %v", progress, err) 551 } 552 - } 553 - }) 554 - 555 - t.Run("fails with non-existent book ID", func(t *testing.T) { 556 - args := []string{"99999", "50"} 557 - 558 - err := UpdateBookProgress(ctx, args) 559 - if err == nil { 560 - t.Error("Expected error for non-existent book ID") 561 - } 562 - 563 - if !strings.Contains(err.Error(), "failed to get book") { 564 - t.Errorf("Expected book not found error, got: %v", err) 565 - } 566 }) 567 }) 568 }) ··· 676 t.Errorf("Expected initial status 'queued', got '%s'", book.Status) 677 } 678 679 - err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "25"}) 680 if err != nil { 681 t.Errorf("Failed to update progress: %v", err) 682 } ··· 690 t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 691 } 692 693 - err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "100"}) 694 if err != nil { 695 t.Errorf("Failed to complete book: %v", err) 696 } ··· 731 732 go func() { 733 time.Sleep(time.Millisecond * 10) 734 - done <- ListBooks(ctx, []string{}) 735 }() 736 737 go func() { 738 time.Sleep(time.Millisecond * 15) 739 - done <- UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "50"}) 740 }() 741 742 go func() { 743 time.Sleep(time.Millisecond * 20) 744 - done <- UpdateBookStatus(ctx, []string{strconv.FormatInt(book.ID, 10), "finished"}) 745 }() 746 747 for i := range 3 {
··· 104 }) 105 }) 106 107 + t.Run("BookHandler instance methods", func(t *testing.T) { 108 _, cleanup := setupBookTest(t) 109 defer cleanup() 110 111 + handler, err := NewBookHandler() 112 + if err != nil { 113 + t.Fatalf("Failed to create handler: %v", err) 114 + } 115 + defer handler.Close() 116 117 + t.Run("Search & Add", func(t *testing.T) { 118 ctx := context.Background() 119 t.Run("fails with empty args", func(t *testing.T) { 120 args := []string{} 121 + err := handler.SearchAndAddBook(ctx, args, false) 122 if err == nil { 123 t.Error("Expected error for empty args") 124 } ··· 128 } 129 }) 130 131 + t.Run("handles empty search", func(t *testing.T) { 132 + args := []string{""} 133 + err := handler.SearchAndAddBook(ctx, args, false) 134 + if err != nil && !strings.Contains(err.Error(), "No books found") { 135 + t.Errorf("Expected no error or 'No books found', got: %v", err) 136 } 137 }) 138 139 + t.Run("with options", func(t *testing.T) { 140 + ctx := context.Background() 141 + t.Run("fails with empty args", func(t *testing.T) { 142 + args := []string{} 143 + err := handler.SearchAndAddBook(ctx, args, false) 144 + if err == nil { 145 + t.Error("Expected error for empty args") 146 + } 147 148 + if !strings.Contains(err.Error(), "usage: book add") { 149 + t.Errorf("Expected usage error, got: %v", err) 150 + } 151 + }) 152 153 + t.Run("handles search service errors", func(t *testing.T) { 154 + args := []string{"test", "book"} 155 + err := handler.SearchAndAddBook(ctx, args, false) 156 + if err == nil { 157 + t.Error("Expected error due to mocked service") 158 + } 159 + if strings.Contains(err.Error(), "usage:") { 160 + t.Error("Should not show usage error for valid args") 161 + } 162 + }) 163 + }) 164 }) 165 166 + t.Run("List", func(t *testing.T) { 167 168 + ctx := context.Background() 169 170 + _ = createTestBook(t, handler, ctx) 171 172 + book2 := &models.Book{ 173 + Title: "Reading Book", 174 + Author: "Reading Author", 175 + Status: "reading", 176 + Added: time.Now(), 177 } 178 + id2, err := handler.repos.Books.Create(ctx, book2) 179 if err != nil { 180 + t.Fatalf("Failed to create book2: %v", err) 181 } 182 + book2.ID = id2 183 184 + book3 := &models.Book{ 185 + Title: "Finished Book", 186 + Author: "Finished Author", 187 + Status: "finished", 188 + Added: time.Now(), 189 } 190 + id3, err := handler.repos.Books.Create(ctx, book3) 191 if err != nil { 192 + t.Fatalf("Failed to create book3: %v", err) 193 } 194 + book3.ID = id3 195 196 + t.Run("lists queued books by default", func(t *testing.T) { 197 + err := handler.ListBooks(ctx, "queued") 198 if err != nil { 199 + t.Errorf("ListBooks failed: %v", err) 200 } 201 + }) 202 203 + t.Run("filters by status - all", func(t *testing.T) { 204 + err := handler.ListBooks(ctx, "") 205 if err != nil { 206 + t.Errorf("ListBooks with status all failed: %v", err) 207 } 208 }) 209 210 + t.Run("filters by status - reading", func(t *testing.T) { 211 + err := handler.ListBooks(ctx, "reading") 212 if err != nil { 213 + t.Errorf("ListBooks with status reading failed: %v", err) 214 } 215 + }) 216 217 + t.Run("filters by status - finished", func(t *testing.T) { 218 + err := handler.ListBooks(ctx, "finished") 219 if err != nil { 220 + t.Errorf("ListBooks with status finished failed: %v", err) 221 } 222 }) 223 224 + t.Run("filters by status - queued", func(t *testing.T) { 225 + err := handler.ListBooks(ctx, "queued") 226 + if err != nil { 227 + t.Errorf("ListBooks with status queued failed: %v", err) 228 } 229 }) 230 231 + t.Run("handles various flag formats", func(t *testing.T) { 232 + statusVariants := map[string]string{ 233 + "--all": "", "-a": "", 234 + "--reading": "reading", "-r": "reading", 235 + "--finished": "finished", "-f": "finished", 236 + "--queued": "queued", "-q": "queued", 237 } 238 239 + for flag, status := range statusVariants { 240 + err := handler.ListBooks(ctx, status) 241 + if err != nil { 242 + t.Errorf("ListBooks with flag %s (status %s) failed: %v", flag, status, err) 243 + } 244 } 245 }) 246 + }) 247 248 + t.Run("Update", func(t *testing.T) { 249 + t.Run("Update status", func(t *testing.T) { 250 + ctx := context.Background() 251 + book := createTestBook(t, handler, ctx) 252 253 + t.Run("updates book status successfully", func(t *testing.T) { 254 + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "reading") 255 + if err != nil { 256 + t.Errorf("UpdateBookStatusByID failed: %v", err) 257 + } 258 259 + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 260 + if err != nil { 261 + t.Fatalf("Failed to get updated book: %v", err) 262 + } 263 264 + if updatedBook.Status != "reading" { 265 + t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 266 + } 267 268 + if updatedBook.Started == nil { 269 + t.Error("Expected started time to be set") 270 + } 271 + }) 272 273 + t.Run("updates to finished status", func(t *testing.T) { 274 + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") 275 + if err != nil { 276 + t.Errorf("UpdateBookStatusByID failed: %v", err) 277 + } 278 279 + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 280 + if err != nil { 281 + t.Fatalf("Failed to get updated book: %v", err) 282 + } 283 284 + if updatedBook.Status != "finished" { 285 + t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 286 + } 287 288 + if updatedBook.Finished == nil { 289 + t.Error("Expected finished time to be set") 290 } 291 292 + if updatedBook.Progress != 100 { 293 + t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 294 + } 295 + }) 296 297 + t.Run("fails with invalid book ID", func(t *testing.T) { 298 + err := handler.UpdateBookStatusByID(ctx, "invalid-id", "reading") 299 + if err == nil { 300 + t.Error("Expected error for invalid book ID") 301 + } 302 303 + if !strings.Contains(err.Error(), "invalid book ID") { 304 + t.Errorf("Expected invalid book ID error, got: %v", err) 305 + } 306 + }) 307 308 + t.Run("fails with invalid status", func(t *testing.T) { 309 + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "invalid-status") 310 + if err == nil { 311 + t.Error("Expected error for invalid status") 312 + } 313 314 + if !strings.Contains(err.Error(), "invalid status") { 315 + t.Errorf("Expected invalid status error, got: %v", err) 316 + } 317 + }) 318 319 + t.Run("fails with non-existent book ID", func(t *testing.T) { 320 + err := handler.UpdateBookStatusByID(ctx, "99999", "reading") 321 + if err == nil { 322 + t.Error("Expected error for non-existent book ID") 323 + } 324 325 + if !strings.Contains(err.Error(), "failed to get book") { 326 + t.Errorf("Expected book not found error, got: %v", err) 327 + } 328 + }) 329 330 + t.Run("validates all status options", func(t *testing.T) { 331 + validStatuses := []string{"queued", "reading", "finished", "removed"} 332 333 + for _, status := range validStatuses { 334 + err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), status) 335 + if err != nil { 336 + t.Errorf("UpdateBookStatusByID with status %s failed: %v", status, err) 337 + } 338 + } 339 + }) 340 + }) 341 342 + t.Run("progress", func(t *testing.T) { 343 + _, cleanup := setupBookTest(t) 344 + defer cleanup() 345 346 + ctx := context.Background() 347 348 + handler, err := NewBookHandler() 349 if err != nil { 350 + t.Fatalf("Failed to create handler: %v", err) 351 } 352 + defer handler.Close() 353 354 + book := createTestBook(t, handler, ctx) 355 356 + t.Run("updates progress successfully", func(t *testing.T) { 357 + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) 358 + if err != nil { 359 + t.Errorf("UpdateBookProgressByID failed: %v", err) 360 + } 361 362 + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 363 + if err != nil { 364 + t.Fatalf("Failed to get updated book: %v", err) 365 + } 366 367 + if updatedBook.Progress != 50 { 368 + t.Errorf("Expected progress 50, got %d", updatedBook.Progress) 369 + } 370 371 + if updatedBook.Status != "reading" { 372 + t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 373 + } 374 375 + if updatedBook.Started == nil { 376 + t.Error("Expected started time to be set") 377 + } 378 + }) 379 380 + t.Run("auto-completes book at 100%", func(t *testing.T) { 381 + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) 382 + if err != nil { 383 + t.Errorf("UpdateBookProgressByID failed: %v", err) 384 + } 385 386 + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 387 + if err != nil { 388 + t.Fatalf("Failed to get updated book: %v", err) 389 + } 390 391 + if updatedBook.Progress != 100 { 392 + t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 393 + } 394 395 + if updatedBook.Status != "finished" { 396 + t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 397 + } 398 399 + if updatedBook.Finished == nil { 400 + t.Error("Expected finished time to be set") 401 + } 402 + }) 403 404 + t.Run("resets to queued at 0%", func(t *testing.T) { 405 + book.Status = "reading" 406 + now := time.Now() 407 + book.Started = &now 408 + handler.repos.Books.Update(ctx, book) 409 410 + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 0) 411 + if err != nil { 412 + t.Errorf("UpdateBookProgressByID failed: %v", err) 413 + } 414 415 + updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 416 + if err != nil { 417 + t.Fatalf("Failed to get updated book: %v", err) 418 + } 419 420 + if updatedBook.Progress != 0 { 421 + t.Errorf("Expected progress 0, got %d", updatedBook.Progress) 422 + } 423 424 + if updatedBook.Status != "queued" { 425 + t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status) 426 + } 427 428 + if updatedBook.Started != nil { 429 + t.Error("Expected started time to be nil") 430 + } 431 + }) 432 433 + t.Run("fails with invalid book ID", func(t *testing.T) { 434 + err := handler.UpdateBookProgressByID(ctx, "invalid-id", 50) 435 + if err == nil { 436 + t.Error("Expected error for invalid book ID") 437 + } 438 439 + if !strings.Contains(err.Error(), "invalid book ID") { 440 + t.Errorf("Expected invalid book ID error, got: %v", err) 441 + } 442 + }) 443 444 + t.Run("fails with progress out of range", func(t *testing.T) { 445 + testCases := []int{-1, 101, 150} 446 447 + for _, progress := range testCases { 448 + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), progress) 449 + if err == nil { 450 + t.Errorf("Expected error for progress %d", progress) 451 + } 452 453 + if !strings.Contains(err.Error(), "progress must be between 0 and 100") { 454 + t.Errorf("Expected range error for progress %d, got: %v", progress, err) 455 + } 456 + } 457 + }) 458 459 + t.Run("fails with non-existent book ID", func(t *testing.T) { 460 + err := handler.UpdateBookProgressByID(ctx, "99999", 50) 461 if err == nil { 462 + t.Error("Expected error for non-existent book ID") 463 } 464 465 + if !strings.Contains(err.Error(), "failed to get book") { 466 + t.Errorf("Expected book not found error, got: %v", err) 467 } 468 + }) 469 }) 470 }) 471 }) ··· 579 t.Errorf("Expected initial status 'queued', got '%s'", book.Status) 580 } 581 582 + err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 25) 583 if err != nil { 584 t.Errorf("Failed to update progress: %v", err) 585 } ··· 593 t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 594 } 595 596 + err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) 597 if err != nil { 598 t.Errorf("Failed to complete book: %v", err) 599 } ··· 634 635 go func() { 636 time.Sleep(time.Millisecond * 10) 637 + done <- handler.ListBooks(ctx, "") 638 }() 639 640 go func() { 641 time.Sleep(time.Millisecond * 15) 642 + done <- handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) 643 }() 644 645 go func() { 646 time.Sleep(time.Millisecond * 20) 647 + done <- handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") 648 }() 649 650 for i := range 3 {