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 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 2 TODO: Implement config management 11 3 */ 12 4 package main ··· 16 8 "strconv" 17 9 "strings" 18 10 19 - "github.com/charmbracelet/log" 20 - 21 11 "github.com/spf13/cobra" 22 12 "github.com/stormlightlabs/noteleaf/internal/handlers" 23 - "github.com/stormlightlabs/noteleaf/internal/ui" 24 13 ) 25 14 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 15 + // CommandGroup represents a group of related CLI commands 16 + type CommandGroup interface { 17 + Create() *cobra.Command 48 18 } 49 19 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 20 + // MovieCommand implements CommandGroup for movie-related commands 21 + type MovieCommand struct { 22 + handler *handlers.MovieHandler 68 23 } 69 24 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 25 + // NewMovieCommand creates a new MovieCommands with the given handler 26 + func NewMovieCommand(handler *handlers.MovieHandler) *MovieCommand { 27 + return &MovieCommand{handler: handler} 76 28 } 77 29 78 - func movieMediaCmd() *cobra.Command { 30 + func (c *MovieCommand) Create() *cobra.Command { 79 31 root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} 80 32 81 - root.AddCommand(&cobra.Command{ 82 - Use: "add [title]", 83 - Short: "Add movie to watch queue", 84 - Args: cobra.MinimumNArgs(1), 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.`, 85 40 RunE: func(cmd *cobra.Command, args []string) error { 86 - title := args[0] 87 - fmt.Printf("Adding movie: %s\n", title) 88 - return nil 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) 89 48 }, 90 - }) 49 + } 50 + addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") 51 + root.AddCommand(addCmd) 91 52 92 53 root.AddCommand(&cobra.Command{ 93 - Use: "list", 94 - Short: "List movies in queue", 54 + Use: "list [--all|--watched|--queued]", 55 + Short: "List movies in queue with status filtering", 95 56 RunE: func(cmd *cobra.Command, args []string) error { 96 - fmt.Println("Listing movies...") 97 - return nil 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) 98 72 }, 99 73 }) 100 74 ··· 104 78 Aliases: []string{"seen"}, 105 79 Args: cobra.ExactArgs(1), 106 80 RunE: func(cmd *cobra.Command, args []string) error { 107 - fmt.Printf("Marking movie %s as watched\n", args[0]) 108 - return nil 81 + return c.handler.MarkMovieWatched(cmd.Context(), args[0]) 109 82 }, 110 83 }) 111 84 ··· 115 88 Aliases: []string{"rm"}, 116 89 Args: cobra.ExactArgs(1), 117 90 RunE: func(cmd *cobra.Command, args []string) error { 118 - fmt.Printf("Removing movie %s from queue\n", args[0]) 119 - return nil 91 + return c.handler.RemoveMovie(cmd.Context(), args[0]) 120 92 }, 121 93 }) 122 94 123 95 return root 124 96 } 125 97 126 - func tvMediaCmd() *cobra.Command { 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 { 127 109 root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} 128 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 + 129 131 root.AddCommand(&cobra.Command{ 130 - Use: "add [title]", 131 - Short: "Add TV show to watch queue", 132 - Args: cobra.MinimumNArgs(1), 132 + Use: "list [--all|--queued|--watching|--watched]", 133 + Short: "List TV shows in queue with status filtering", 133 134 RunE: func(cmd *cobra.Command, args []string) error { 134 - title := args[0] 135 - fmt.Printf("Adding TV show: %s\n", title) 136 - return nil 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) 137 152 }, 138 153 }) 139 154 140 155 root.AddCommand(&cobra.Command{ 141 - Use: "list", 142 - Short: "List TV shows in queue", 156 + Use: "watching [id]", 157 + Short: "Mark TV show as currently watching", 158 + Args: cobra.ExactArgs(1), 143 159 RunE: func(cmd *cobra.Command, args []string) error { 144 - fmt.Println("Listing TV shows...") 145 - return nil 160 + return c.handler.MarkTVShowWatching(cmd.Context(), args[0]) 146 161 }, 147 162 }) 148 163 ··· 152 167 Aliases: []string{"seen"}, 153 168 Args: cobra.ExactArgs(1), 154 169 RunE: func(cmd *cobra.Command, args []string) error { 155 - fmt.Printf("Marking TV show %s as watched\n", args[0]) 156 - return nil 170 + return c.handler.MarkTVShowWatched(cmd.Context(), args[0]) 157 171 }, 158 172 }) 159 173 ··· 163 177 Aliases: []string{"rm"}, 164 178 Args: cobra.ExactArgs(1), 165 179 RunE: func(cmd *cobra.Command, args []string) error { 166 - fmt.Printf("Removing TV show %s from queue\n", args[0]) 167 - return nil 180 + return c.handler.RemoveTVShow(cmd.Context(), args[0]) 168 181 }, 169 182 }) 170 183 171 184 return root 172 185 } 173 186 174 - func bookMediaCmd() *cobra.Command { 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 { 175 198 root := &cobra.Command{Use: "book", Short: "Manage reading list"} 176 199 177 200 addCmd := &cobra.Command{ ··· 183 206 Use the -i flag for an interactive interface with navigation keys.`, 184 207 RunE: func(cmd *cobra.Command, args []string) error { 185 208 interactive, _ := cmd.Flags().GetBool("interactive") 186 - return handlers.SearchAndAddWithOptions(cmd.Context(), args, interactive) 209 + return c.handler.SearchAndAddBook(cmd.Context(), args, interactive) 187 210 }, 188 211 } 189 212 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") ··· 193 216 Use: "list [--all|--reading|--finished|--queued]", 194 217 Short: "Show reading queue with progress", 195 218 RunE: func(cmd *cobra.Command, args []string) error { 196 - return handlers.ListBooks(cmd.Context(), args) 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) 197 235 }, 198 236 }) 199 237 ··· 202 240 Short: "Mark book as currently reading", 203 241 Args: cobra.ExactArgs(1), 204 242 RunE: func(cmd *cobra.Command, args []string) error { 205 - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "reading"}) 243 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "reading") 206 244 }, 207 245 }) 208 246 ··· 212 250 Aliases: []string{"read"}, 213 251 Args: cobra.ExactArgs(1), 214 252 RunE: func(cmd *cobra.Command, args []string) error { 215 - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "finished"}) 253 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "finished") 216 254 }, 217 255 }) 218 256 ··· 222 260 Aliases: []string{"rm"}, 223 261 Args: cobra.ExactArgs(1), 224 262 RunE: func(cmd *cobra.Command, args []string) error { 225 - return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "removed"}) 263 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "removed") 226 264 }, 227 265 }) 228 266 ··· 231 269 Short: "Update reading progress percentage (0-100)", 232 270 Args: cobra.ExactArgs(2), 233 271 RunE: func(cmd *cobra.Command, args []string) error { 234 - return handlers.UpdateBookProgress(cmd.Context(), args) 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) 235 277 }, 236 278 }) 237 279 ··· 240 282 Short: "Update book status (queued|reading|finished|removed)", 241 283 Args: cobra.ExactArgs(2), 242 284 RunE: func(cmd *cobra.Command, args []string) error { 243 - return handlers.UpdateBookStatus(cmd.Context(), args) 285 + return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], args[1]) 244 286 }, 245 287 }) 246 288 247 289 return root 248 290 } 249 291 250 - func noteCmd() *cobra.Command { 251 - root := &cobra.Command{Use: "note", Short: "Manage notes"} 292 + // NoteCommand implements [CommandGroup] for note-related commands 293 + type NoteCommand struct { 294 + handler *handlers.NoteHandler 295 + } 252 296 253 - handler, err := handlers.NewNoteHandler() 254 - if err != nil { 255 - log.Fatalf("failed to instantiate note handler: %v", err) 256 - } 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"} 257 304 258 305 createCmd := &cobra.Command{ 259 306 Use: "create [title] [content...]", ··· 271 318 content = strings.Join(args[1:], " ") 272 319 } 273 320 274 - if err != nil { 275 - return err 276 - } 277 - defer handler.Close() 278 - return handler.Create(cmd.Context(), title, content, filePath, interactive) 321 + defer c.handler.Close() 322 + return c.handler.Create(cmd.Context(), title, content, filePath, interactive) 279 323 }, 280 324 } 281 325 createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor") ··· 298 342 } 299 343 } 300 344 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) 345 + defer c.handler.Close() 346 + return c.handler.List(cmd.Context(), false, archived, tags) 307 347 }, 308 348 } 309 349 listCmd.Flags().BoolP("archived", "a", false, "Show archived notes") ··· 320 360 if err != nil { 321 361 return fmt.Errorf("invalid note ID: %s", args[0]) 322 362 } 323 - handler, err := handlers.NewNoteHandler() 324 - if err != nil { 325 - return err 326 - } 327 - defer handler.Close() 328 - return handler.View(cmd.Context(), noteID) 363 + defer c.handler.Close() 364 + return c.handler.View(cmd.Context(), noteID) 329 365 }, 330 366 }) 331 367 ··· 338 374 if err != nil { 339 375 return fmt.Errorf("invalid note ID: %s", args[0]) 340 376 } 341 - handler, err := handlers.NewNoteHandler() 342 - if err != nil { 343 - return err 344 - } 345 - defer handler.Close() 346 - return handler.Edit(cmd.Context(), noteID) 377 + defer c.handler.Close() 378 + return c.handler.Edit(cmd.Context(), noteID) 347 379 }, 348 380 }) 349 381 ··· 357 389 if err != nil { 358 390 return fmt.Errorf("invalid note ID: %s", args[0]) 359 391 } 360 - handler, err := handlers.NewNoteHandler() 361 - if err != nil { 362 - return err 363 - } 364 - defer handler.Close() 365 - return handler.Delete(cmd.Context(), noteID) 392 + defer c.handler.Close() 393 + return c.handler.Delete(cmd.Context(), noteID) 366 394 }, 367 395 }) 368 396 369 397 return root 370 398 } 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 - }
+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 4 "context" 5 5 "fmt" 6 6 "os" 7 + "strings" 7 8 8 9 "github.com/charmbracelet/fang" 10 + "github.com/charmbracelet/log" 9 11 "github.com/spf13/cobra" 12 + "github.com/stormlightlabs/noteleaf/internal/handlers" 10 13 "github.com/stormlightlabs/noteleaf/internal/store" 11 14 "github.com/stormlightlabs/noteleaf/internal/ui" 12 15 "github.com/stormlightlabs/noteleaf/internal/utils" ··· 28 31 if config, err := store.LoadConfig(); err != nil { 29 32 return nil, fmt.Errorf("failed to load configuration: %w", err) 30 33 } else { 31 - return &App{db: db, config: config}, nil 34 + return &App{db, config}, nil 32 35 } 33 36 } 34 37 ··· 40 43 return nil 41 44 } 42 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 + 43 132 func main() { 44 133 logger := utils.NewLogger("info", "text") 45 134 utils.Logger = logger ··· 50 139 } 51 140 defer app.Close() 52 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 + 53 167 root := rootCmd() 54 - core := []func() *cobra.Command{todoCmd, mediaCmd, noteCmd} 55 - mgmt := []func() *cobra.Command{statusCmd, confCmd, setupCmd, resetCmd} 168 + 169 + coreGroups := []CommandGroup{ 170 + NewTaskCommand(taskHandler), 171 + NewNoteCommand(noteHandler), 172 + } 56 173 57 - for _, cmdFunc := range core { 58 - cmd := cmdFunc() 174 + for _, group := range coreGroups { 175 + cmd := group.Create() 59 176 cmd.GroupID = "core" 60 177 root.AddCommand(cmd) 61 178 } 62 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} 63 188 for _, cmdFunc := range mgmt { 64 189 cmd := cmdFunc() 65 190 cmd.GroupID = "management"
+25
cmd/task_commands.go
··· 5 5 "github.com/stormlightlabs/noteleaf/internal/handlers" 6 6 ) 7 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 + 8 33 func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { 9 34 cmd := &cobra.Command{ 10 35 Use: "add [description]",
+1 -1
codecov.yml
··· 20 20 - "**/*_test.go" 21 21 - "**/testdata/**" 22 22 - "**/vendor/**" 23 - - "cmd/*.go" 23 + - "cmd/main.go" 24 24 - "internal/repo/test_utilities.go" 25 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 4 5 5 require ( 6 6 github.com/BurntSushi/toml v1.5.0 7 - github.com/charmbracelet/fang v0.3.0 7 + github.com/charmbracelet/fang v0.4.2 8 8 github.com/mattn/go-sqlite3 v1.14.32 9 9 github.com/spf13/cobra v1.9.1 10 10 ) ··· 48 48 github.com/muesli/reflow v0.3.0 // indirect 49 49 github.com/yuin/goldmark v1.7.8 // indirect 50 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 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 54 ) 55 55 56 56 require ( 57 57 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 58 58 github.com/charmbracelet/bubbles v0.21.0 59 - github.com/charmbracelet/colorprofile v0.3.1 // indirect 59 + github.com/charmbracelet/colorprofile v0.3.2 // indirect 60 60 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 61 61 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 62 62 github.com/charmbracelet/log v0.4.2 63 - github.com/charmbracelet/x/ansi v0.9.3 // indirect 63 + github.com/charmbracelet/x/ansi v0.10.1 // indirect 64 64 github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 65 65 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect 66 66 github.com/charmbracelet/x/term v0.2.1 // indirect 67 67 github.com/go-logfmt/logfmt v0.6.0 // indirect 68 68 github.com/gocolly/colly/v2 v2.2.0 69 69 github.com/inconshreveable/mousetrap v1.1.0 // indirect 70 - github.com/lucasb-eyer/go-colorful v1.2.0 70 + github.com/lucasb-eyer/go-colorful v1.3.0 71 71 github.com/mattn/go-isatty v0.0.20 // indirect 72 72 github.com/mattn/go-runewidth v0.0.16 // indirect 73 73 github.com/muesli/cancelreader v0.2.2 // indirect ··· 80 80 github.com/spf13/pflag v1.0.6 // indirect 81 81 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 82 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 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 85 86 )
+20 -18
go.sum
··· 31 31 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 32 32 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 33 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= 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 38 github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 39 39 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 40 40 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= ··· 43 43 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 44 44 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 45 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= 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 48 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 49 49 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 50 50 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= ··· 89 89 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 90 90 github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 91 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= 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 94 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 95 95 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 96 96 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= ··· 171 171 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 172 172 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 173 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= 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 176 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 177 177 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 178 178 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 180 180 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 181 181 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 182 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= 183 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 184 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 185 185 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 186 186 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 187 187 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 196 196 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 197 197 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 198 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= 199 + golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 200 + golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 201 201 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 202 202 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 203 203 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= ··· 208 208 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 209 209 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 210 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= 211 + golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= 212 + golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 213 213 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 214 214 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 215 215 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= ··· 220 220 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 221 221 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 222 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= 223 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 224 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 225 225 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 226 226 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 227 227 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 230 230 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 231 231 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 232 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= 233 235 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 234 236 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 235 237 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+32 -136
internal/handlers/books.go
··· 5 5 "fmt" 6 6 "os" 7 7 "slices" 8 + "strconv" 8 9 "time" 9 10 10 11 "github.com/stormlightlabs/noteleaf/internal/models" ··· 53 54 return h.db.Close() 54 55 } 55 56 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() 57 + func (h *BookHandler) printBook(book *models.Book) { 58 + fmt.Printf("[%d] %s", book.ID, book.Title) 63 59 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) 60 + if book.Author != "" { 61 + fmt.Printf(" by %s", book.Author) 72 62 } 73 - defer handler.Close() 74 63 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]") 64 + if book.Status != "queued" { 65 + fmt.Printf(" (%s)", book.Status) 81 66 } 82 67 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] 68 + if book.Progress > 0 { 69 + fmt.Printf(" [%d%%]", book.Progress) 88 70 } 89 71 90 - if len(searchArgs) == 0 { 91 - return fmt.Errorf("search query cannot be empty") 72 + if book.Rating > 0 { 73 + fmt.Printf(" โ˜…%.1f", book.Rating) 92 74 } 93 75 94 - query := searchArgs[0] 95 - if len(searchArgs) > 1 { 96 - for _, arg := range searchArgs[1:] { 97 - query += " " + arg 76 + fmt.Println() 77 + 78 + if book.Notes != "" { 79 + notes := book.Notes 80 + if len(notes) > 80 { 81 + notes = notes[:77] + "..." 98 82 } 83 + fmt.Printf(" %s\n", notes) 99 84 } 100 85 101 - return h.searchAndAddWithOptions(ctx, searchArgs, interactive) 86 + fmt.Println() 102 87 } 103 88 104 - func (h *BookHandler) searchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error { 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 { 105 91 if len(args) == 0 { 106 92 return fmt.Errorf("usage: book add <search query>") 107 93 } ··· 187 173 return nil 188 174 } 189 175 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 - 176 + // ListBooks lists all books with status filtering 177 + func (h *BookHandler) ListBooks(ctx context.Context, status string) error { 216 178 var books []*models.Book 217 179 var err error 218 180 ··· 252 214 return nil 253 215 } 254 216 255 - func UpdateBookStatus(ctx context.Context, args []string) error { 256 - handler, err := NewBookHandler() 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) 257 220 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]) 221 + return fmt.Errorf("invalid book ID: %s", id) 273 222 } 274 223 275 - status := args[1] 276 224 validStatuses := []string{"queued", "reading", "finished", "removed"} 277 - valid := slices.Contains(validStatuses, status) 278 - if !valid { 225 + if !slices.Contains(validStatuses, status) { 279 226 return fmt.Errorf("invalid status: %s (valid: %v)", status, validStatuses) 280 227 } 281 228 ··· 303 250 return nil 304 251 } 305 252 306 - // UpdateBookProgress updates a book's reading progress percentage 307 - func UpdateBookProgress(ctx context.Context, args []string) error { 308 - handler, err := NewBookHandler() 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) 309 256 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]) 257 + return fmt.Errorf("invalid book ID: %s", id) 330 258 } 331 259 332 260 if progress < 0 || progress > 100 { ··· 368 296 fmt.Println() 369 297 return nil 370 298 } 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 - }
+261 -358
internal/handlers/books_test.go
··· 104 104 }) 105 105 }) 106 106 107 - t.Run("Search & Add", func(t *testing.T) { 107 + t.Run("BookHandler instance methods", func(t *testing.T) { 108 108 _, cleanup := setupBookTest(t) 109 109 defer cleanup() 110 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 - } 111 + handler, err := NewBookHandler() 112 + if err != nil { 113 + t.Fatalf("Failed to create handler: %v", err) 114 + } 115 + defer handler.Close() 119 116 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) { 117 + t.Run("Search & Add", func(t *testing.T) { 126 118 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 119 t.Run("fails with empty args", func(t *testing.T) { 144 - ctx := context.Background() 145 120 args := []string{} 146 - 147 - err := SearchAndAddWithOptions(ctx, args, false) 121 + err := handler.SearchAndAddBook(ctx, args, false) 148 122 if err == nil { 149 123 t.Error("Expected error for empty args") 150 124 } ··· 154 128 } 155 129 }) 156 130 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") 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) 167 136 } 168 137 }) 169 138 170 - }) 171 - }) 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 + } 172 147 173 - t.Run("List", func(t *testing.T) { 174 - _, cleanup := setupBookTest(t) 175 - defer cleanup() 148 + if !strings.Contains(err.Error(), "usage: book add") { 149 + t.Errorf("Expected usage error, got: %v", err) 150 + } 151 + }) 176 152 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 - } 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 + }) 218 164 }) 219 165 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"} 166 + t.Run("List", func(t *testing.T) { 231 167 232 - err := ListBooks(ctx, args) 233 - if err != nil { 234 - t.Errorf("ListBooks with status reading failed: %v", err) 235 - } 236 - }) 168 + ctx := context.Background() 237 169 238 - t.Run("filters by status - finished", func(t *testing.T) { 239 - args := []string{"finished"} 170 + _ = createTestBook(t, handler, ctx) 240 171 241 - err := ListBooks(ctx, args) 242 - if err != nil { 243 - t.Errorf("ListBooks with status finished failed: %v", err) 172 + book2 := &models.Book{ 173 + Title: "Reading Book", 174 + Author: "Reading Author", 175 + Status: "reading", 176 + Added: time.Now(), 244 177 } 245 - }) 246 - 247 - t.Run("filters by status - queued", func(t *testing.T) { 248 - args := []string{"queued"} 249 - 250 - err := ListBooks(ctx, args) 178 + id2, err := handler.repos.Books.Create(ctx, book2) 251 179 if err != nil { 252 - t.Errorf("ListBooks with status queued failed: %v", err) 180 + t.Fatalf("Failed to create book2: %v", err) 253 181 } 254 - }) 182 + book2.ID = id2 255 183 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"}, 184 + book3 := &models.Book{ 185 + Title: "Finished Book", 186 + Author: "Finished Author", 187 + Status: "finished", 188 + Added: time.Now(), 262 189 } 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() 190 + id3, err := handler.repos.Books.Create(ctx, book3) 281 191 if err != nil { 282 - t.Fatalf("Failed to create handler: %v", err) 192 + t.Fatalf("Failed to create book3: %v", err) 283 193 } 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"} 194 + book3.ID = id3 290 195 291 - err := UpdateBookStatus(ctx, args) 196 + t.Run("lists queued books by default", func(t *testing.T) { 197 + err := handler.ListBooks(ctx, "queued") 292 198 if err != nil { 293 - t.Errorf("UpdateBookStatus failed: %v", err) 199 + t.Errorf("ListBooks failed: %v", err) 294 200 } 201 + }) 295 202 296 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 203 + t.Run("filters by status - all", func(t *testing.T) { 204 + err := handler.ListBooks(ctx, "") 297 205 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") 206 + t.Errorf("ListBooks with status all failed: %v", err) 307 207 } 308 208 }) 309 209 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) 210 + t.Run("filters by status - reading", func(t *testing.T) { 211 + err := handler.ListBooks(ctx, "reading") 314 212 if err != nil { 315 - t.Errorf("UpdateBookStatus failed: %v", err) 213 + t.Errorf("ListBooks with status reading failed: %v", err) 316 214 } 215 + }) 317 216 318 - updatedBook, err := handler.repos.Books.Get(ctx, book.ID) 217 + t.Run("filters by status - finished", func(t *testing.T) { 218 + err := handler.ListBooks(ctx, "finished") 319 219 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) 220 + t.Errorf("ListBooks with status finished failed: %v", err) 333 221 } 334 222 }) 335 223 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) 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) 346 228 } 347 229 }) 348 230 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") 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", 355 237 } 356 238 357 - if !strings.Contains(err.Error(), "invalid book ID") { 358 - t.Errorf("Expected invalid book ID error, got: %v", err) 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 + } 359 244 } 360 245 }) 246 + }) 361 247 362 - t.Run("fails with invalid status", func(t *testing.T) { 363 - args := []string{strconv.FormatInt(book.ID, 10), "invalid-status"} 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) 364 252 365 - err := UpdateBookStatus(ctx, args) 366 - if err == nil { 367 - t.Error("Expected error for invalid status") 368 - } 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 + } 369 258 370 - if !strings.Contains(err.Error(), "invalid status") { 371 - t.Errorf("Expected invalid status error, got: %v", err) 372 - } 373 - }) 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 + } 374 263 375 - t.Run("fails with non-existent book ID", func(t *testing.T) { 376 - args := []string{"99999", "reading"} 264 + if updatedBook.Status != "reading" { 265 + t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 266 + } 377 267 378 - err := UpdateBookStatus(ctx, args) 379 - if err == nil { 380 - t.Error("Expected error for non-existent book ID") 381 - } 268 + if updatedBook.Started == nil { 269 + t.Error("Expected started time to be set") 270 + } 271 + }) 382 272 383 - if !strings.Contains(err.Error(), "failed to get book") { 384 - t.Errorf("Expected book not found error, got: %v", err) 385 - } 386 - }) 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 + } 387 278 388 - t.Run("validates all status options", func(t *testing.T) { 389 - validStatuses := []string{"queued", "reading", "finished", "removed"} 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 + } 390 283 391 - for _, status := range validStatuses { 392 - args := []string{strconv.FormatInt(book.ID, 10), status} 284 + if updatedBook.Status != "finished" { 285 + t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 286 + } 393 287 394 - err := UpdateBookStatus(ctx, args) 395 - if err != nil { 396 - t.Errorf("UpdateBookStatus with status %s failed: %v", status, err) 288 + if updatedBook.Finished == nil { 289 + t.Error("Expected finished time to be set") 397 290 } 398 - } 399 - }) 400 - }) 401 291 402 - t.Run("progress", func(t *testing.T) { 403 - _, cleanup := setupBookTest(t) 404 - defer cleanup() 292 + if updatedBook.Progress != 100 { 293 + t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 294 + } 295 + }) 405 296 406 - ctx := context.Background() 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 + } 407 302 408 - handler, err := NewBookHandler() 409 - if err != nil { 410 - t.Fatalf("Failed to create handler: %v", err) 411 - } 412 - defer handler.Close() 303 + if !strings.Contains(err.Error(), "invalid book ID") { 304 + t.Errorf("Expected invalid book ID error, got: %v", err) 305 + } 306 + }) 413 307 414 - book := createTestBook(t, handler, ctx) 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 + } 415 313 416 - t.Run("updates progress successfully", func(t *testing.T) { 417 - args := []string{strconv.FormatInt(book.ID, 10), "50"} 314 + if !strings.Contains(err.Error(), "invalid status") { 315 + t.Errorf("Expected invalid status error, got: %v", err) 316 + } 317 + }) 418 318 419 - err := UpdateBookProgress(ctx, args) 420 - if err != nil { 421 - t.Errorf("UpdateBookProgress failed: %v", err) 422 - } 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 + } 423 324 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 - } 325 + if !strings.Contains(err.Error(), "failed to get book") { 326 + t.Errorf("Expected book not found error, got: %v", err) 327 + } 328 + }) 428 329 429 - if updatedBook.Progress != 50 { 430 - t.Errorf("Expected progress 50, got %d", updatedBook.Progress) 431 - } 330 + t.Run("validates all status options", func(t *testing.T) { 331 + validStatuses := []string{"queued", "reading", "finished", "removed"} 432 332 433 - if updatedBook.Status != "reading" { 434 - t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 435 - } 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 + }) 436 341 437 - if updatedBook.Started == nil { 438 - t.Error("Expected started time to be set") 439 - } 440 - }) 342 + t.Run("progress", func(t *testing.T) { 343 + _, cleanup := setupBookTest(t) 344 + defer cleanup() 441 345 442 - t.Run("auto-completes book at 100%", func(t *testing.T) { 443 - args := []string{strconv.FormatInt(book.ID, 10), "100"} 346 + ctx := context.Background() 444 347 445 - err := UpdateBookProgress(ctx, args) 348 + handler, err := NewBookHandler() 446 349 if err != nil { 447 - t.Errorf("UpdateBookProgress failed: %v", err) 350 + t.Fatalf("Failed to create handler: %v", err) 448 351 } 352 + defer handler.Close() 449 353 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 - } 354 + book := createTestBook(t, handler, ctx) 454 355 455 - if updatedBook.Progress != 100 { 456 - t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 457 - } 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 + } 458 361 459 - if updatedBook.Status != "finished" { 460 - t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 461 - } 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 + } 462 366 463 - if updatedBook.Finished == nil { 464 - t.Error("Expected finished time to be set") 465 - } 466 - }) 367 + if updatedBook.Progress != 50 { 368 + t.Errorf("Expected progress 50, got %d", updatedBook.Progress) 369 + } 467 370 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) 371 + if updatedBook.Status != "reading" { 372 + t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 373 + } 473 374 474 - args := []string{strconv.FormatInt(book.ID, 10), "0"} 375 + if updatedBook.Started == nil { 376 + t.Error("Expected started time to be set") 377 + } 378 + }) 475 379 476 - err := UpdateBookProgress(ctx, args) 477 - if err != nil { 478 - t.Errorf("UpdateBookProgress failed: %v", err) 479 - } 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 + } 480 385 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 - } 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 + } 485 390 486 - if updatedBook.Progress != 0 { 487 - t.Errorf("Expected progress 0, got %d", updatedBook.Progress) 488 - } 391 + if updatedBook.Progress != 100 { 392 + t.Errorf("Expected progress 100, got %d", updatedBook.Progress) 393 + } 489 394 490 - if updatedBook.Status != "queued" { 491 - t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status) 492 - } 395 + if updatedBook.Status != "finished" { 396 + t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status) 397 + } 493 398 494 - if updatedBook.Started != nil { 495 - t.Error("Expected started time to be nil") 496 - } 497 - }) 399 + if updatedBook.Finished == nil { 400 + t.Error("Expected finished time to be set") 401 + } 402 + }) 498 403 499 - t.Run("fails with insufficient arguments", func(t *testing.T) { 500 - args := []string{strconv.FormatInt(book.ID, 10)} 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) 501 409 502 - err := UpdateBookProgress(ctx, args) 503 - if err == nil { 504 - t.Error("Expected error for insufficient arguments") 505 - } 410 + err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 0) 411 + if err != nil { 412 + t.Errorf("UpdateBookProgressByID failed: %v", err) 413 + } 506 414 507 - if !strings.Contains(err.Error(), "usage: book progress") { 508 - t.Errorf("Expected usage error, got: %v", err) 509 - } 510 - }) 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 + } 511 419 512 - t.Run("fails with invalid book ID", func(t *testing.T) { 513 - args := []string{"invalid-id", "50"} 420 + if updatedBook.Progress != 0 { 421 + t.Errorf("Expected progress 0, got %d", updatedBook.Progress) 422 + } 514 423 515 - err := UpdateBookProgress(ctx, args) 516 - if err == nil { 517 - t.Error("Expected error for invalid book ID") 518 - } 424 + if updatedBook.Status != "queued" { 425 + t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status) 426 + } 519 427 520 - if !strings.Contains(err.Error(), "invalid book ID") { 521 - t.Errorf("Expected invalid book ID error, got: %v", err) 522 - } 523 - }) 428 + if updatedBook.Started != nil { 429 + t.Error("Expected started time to be nil") 430 + } 431 + }) 524 432 525 - t.Run("fails with invalid progress percentage", func(t *testing.T) { 526 - args := []string{strconv.FormatInt(book.ID, 10), "invalid-progress"} 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 + } 527 438 528 - err := UpdateBookProgress(ctx, args) 529 - if err == nil { 530 - t.Error("Expected error for invalid progress percentage") 531 - } 439 + if !strings.Contains(err.Error(), "invalid book ID") { 440 + t.Errorf("Expected invalid book ID error, got: %v", err) 441 + } 442 + }) 532 443 533 - if !strings.Contains(err.Error(), "invalid progress percentage") { 534 - t.Errorf("Expected invalid progress percentage error, got: %v", err) 535 - } 536 - }) 444 + t.Run("fails with progress out of range", func(t *testing.T) { 445 + testCases := []int{-1, 101, 150} 537 446 538 - t.Run("fails with progress out of range", func(t *testing.T) { 539 - testCases := []string{"-1", "101", "150"} 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 + } 540 452 541 - for _, progress := range testCases { 542 - args := []string{strconv.FormatInt(book.ID, 10), progress} 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 + }) 543 458 544 - err := UpdateBookProgress(ctx, args) 459 + t.Run("fails with non-existent book ID", func(t *testing.T) { 460 + err := handler.UpdateBookProgressByID(ctx, "99999", 50) 545 461 if err == nil { 546 - t.Errorf("Expected error for progress %s", progress) 462 + t.Error("Expected error for non-existent book ID") 547 463 } 548 464 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) 465 + if !strings.Contains(err.Error(), "failed to get book") { 466 + t.Errorf("Expected book not found error, got: %v", err) 551 467 } 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 - } 468 + }) 566 469 }) 567 470 }) 568 471 }) ··· 676 579 t.Errorf("Expected initial status 'queued', got '%s'", book.Status) 677 580 } 678 581 679 - err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "25"}) 582 + err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 25) 680 583 if err != nil { 681 584 t.Errorf("Failed to update progress: %v", err) 682 585 } ··· 690 593 t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 691 594 } 692 595 693 - err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "100"}) 596 + err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) 694 597 if err != nil { 695 598 t.Errorf("Failed to complete book: %v", err) 696 599 } ··· 731 634 732 635 go func() { 733 636 time.Sleep(time.Millisecond * 10) 734 - done <- ListBooks(ctx, []string{}) 637 + done <- handler.ListBooks(ctx, "") 735 638 }() 736 639 737 640 go func() { 738 641 time.Sleep(time.Millisecond * 15) 739 - done <- UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "50"}) 642 + done <- handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) 740 643 }() 741 644 742 645 go func() { 743 646 time.Sleep(time.Millisecond * 20) 744 - done <- UpdateBookStatus(ctx, []string{strconv.FormatInt(book.ID, 10), "finished"}) 647 + done <- handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") 745 648 }() 746 649 747 650 for i := range 3 {