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

Configure Feed

Select the types of activity you want to include in your feed.

build: update test infrastructure refactor: remove redundant handler functions

+926 -783
+24
ROADMAP.md
··· 197 197 - [ ] Complete README/documentation 198 198 - [ ] Installation instructions 199 199 - [ ] Usage examples 200 + 201 + ## Tech Debt 202 + 203 + ### Signatures 204 + 205 + We've got inconsistent argument parsing and sanitization leading to calls to strconv.Atoi in tests & handler funcs. 206 + This is only done correctly in the note command -> handler sequence 207 + 208 + ### Movie Commands - Missing Tests 209 + 210 + - movie watched [id] - marks movie as watched 211 + 212 + ### TV Commands - Missing Tests 213 + 214 + - tv watching [id] - marks TV show as watching 215 + - tv watched [id] - marks TV show as watched 216 + 217 + ### Book Commands - Missing Tests 218 + 219 + - book add [search query...] - search and add book 220 + - book reading `<id>` - marks book as reading 221 + - book finished `<id>` - marks book as finished 222 + - book progress `<id>` `<percentage>` - updates reading progress 223 +
+39 -30
cmd/commands.go
··· 12 12 "github.com/stormlightlabs/noteleaf/internal/handlers" 13 13 ) 14 14 15 + func parseID(k string, args []string) (int64, error) { 16 + id, err := strconv.ParseInt(args[0], 10, 64) 17 + if err != nil { 18 + return id, fmt.Errorf("invalid %v ID: %s", k, args[0]) 19 + } 20 + 21 + return id, err 22 + } 23 + 15 24 // CommandGroup represents a group of related CLI commands 16 25 type CommandGroup interface { 17 26 Create() *cobra.Command ··· 44 53 interactive, _ := cmd.Flags().GetBool("interactive") 45 54 query := strings.Join(args, " ") 46 55 47 - return c.handler.SearchAndAddMovie(cmd.Context(), query, interactive) 56 + return c.handler.SearchAndAdd(cmd.Context(), query, interactive) 48 57 }, 49 58 } 50 59 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") ··· 68 77 } 69 78 } 70 79 71 - return c.handler.ListMovies(cmd.Context(), status) 80 + return c.handler.List(cmd.Context(), status) 72 81 }, 73 82 }) 74 83 ··· 78 87 Aliases: []string{"seen"}, 79 88 Args: cobra.ExactArgs(1), 80 89 RunE: func(cmd *cobra.Command, args []string) error { 81 - return c.handler.MarkMovieWatched(cmd.Context(), args[0]) 90 + return c.handler.MarkWatched(cmd.Context(), args[0]) 82 91 }, 83 92 }) 84 93 ··· 88 97 Aliases: []string{"rm"}, 89 98 Args: cobra.ExactArgs(1), 90 99 RunE: func(cmd *cobra.Command, args []string) error { 91 - return c.handler.RemoveMovie(cmd.Context(), args[0]) 100 + return c.handler.Remove(cmd.Context(), args[0]) 92 101 }, 93 102 }) 94 103 ··· 122 131 interactive, _ := cmd.Flags().GetBool("interactive") 123 132 query := strings.Join(args, " ") 124 133 125 - return c.handler.SearchAndAddTV(cmd.Context(), query, interactive) 134 + return c.handler.SearchAndAdd(cmd.Context(), query, interactive) 126 135 }, 127 136 } 128 137 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for TV show selection") ··· 148 157 } 149 158 } 150 159 151 - return c.handler.ListTVShows(cmd.Context(), status) 160 + return c.handler.List(cmd.Context(), status) 152 161 }, 153 162 }) 154 163 ··· 167 176 Aliases: []string{"seen"}, 168 177 Args: cobra.ExactArgs(1), 169 178 RunE: func(cmd *cobra.Command, args []string) error { 170 - return c.handler.MarkTVShowWatched(cmd.Context(), args[0]) 179 + return c.handler.MarkWatched(cmd.Context(), args[0]) 171 180 }, 172 181 }) 173 182 ··· 177 186 Aliases: []string{"rm"}, 178 187 Args: cobra.ExactArgs(1), 179 188 RunE: func(cmd *cobra.Command, args []string) error { 180 - return c.handler.RemoveTVShow(cmd.Context(), args[0]) 189 + return c.handler.Remove(cmd.Context(), args[0]) 181 190 }, 182 191 }) 183 192 ··· 206 215 Use the -i flag for an interactive interface with navigation keys.`, 207 216 RunE: func(cmd *cobra.Command, args []string) error { 208 217 interactive, _ := cmd.Flags().GetBool("interactive") 209 - return c.handler.SearchAndAddBook(cmd.Context(), args, interactive) 218 + return c.handler.SearchAndAdd(cmd.Context(), args, interactive) 210 219 }, 211 220 } 212 221 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") ··· 231 240 return fmt.Errorf("invalid status filter: %s (use: --all, --reading, --finished, --queued)", args[0]) 232 241 } 233 242 } 234 - return c.handler.ListBooks(cmd.Context(), status) 243 + return c.handler.List(cmd.Context(), status) 235 244 }, 236 245 }) 237 246 ··· 240 249 Short: "Mark book as currently reading", 241 250 Args: cobra.ExactArgs(1), 242 251 RunE: func(cmd *cobra.Command, args []string) error { 243 - return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "reading") 252 + return c.handler.UpdateStatus(cmd.Context(), args[0], "reading") 244 253 }, 245 254 }) 246 255 ··· 250 259 Aliases: []string{"read"}, 251 260 Args: cobra.ExactArgs(1), 252 261 RunE: func(cmd *cobra.Command, args []string) error { 253 - return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "finished") 262 + return c.handler.UpdateStatus(cmd.Context(), args[0], "finished") 254 263 }, 255 264 }) 256 265 ··· 260 269 Aliases: []string{"rm"}, 261 270 Args: cobra.ExactArgs(1), 262 271 RunE: func(cmd *cobra.Command, args []string) error { 263 - return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], "removed") 272 + return c.handler.UpdateStatus(cmd.Context(), args[0], "removed") 264 273 }, 265 274 }) 266 275 ··· 273 282 if err != nil { 274 283 return fmt.Errorf("invalid progress percentage: %s", args[1]) 275 284 } 276 - return c.handler.UpdateBookProgressByID(cmd.Context(), args[0], progress) 285 + return c.handler.UpdateProgress(cmd.Context(), args[0], progress) 277 286 }, 278 287 }) 279 288 ··· 282 291 Short: "Update book status (queued|reading|finished|removed)", 283 292 Args: cobra.ExactArgs(2), 284 293 RunE: func(cmd *cobra.Command, args []string) error { 285 - return c.handler.UpdateBookStatusByID(cmd.Context(), args[0], args[1]) 294 + return c.handler.UpdateStatus(cmd.Context(), args[0], args[1]) 286 295 }, 287 296 }) 288 297 ··· 360 369 Aliases: []string{"view"}, 361 370 Args: cobra.ExactArgs(1), 362 371 RunE: func(cmd *cobra.Command, args []string) error { 363 - noteID, err := strconv.ParseInt(args[0], 10, 64) 364 - if err != nil { 365 - return fmt.Errorf("invalid note ID: %s", args[0]) 372 + if noteID, err := parseID("note", args); err != nil { 373 + return err 374 + } else { 375 + defer c.handler.Close() 376 + return c.handler.View(cmd.Context(), noteID) 366 377 } 367 - defer c.handler.Close() 368 - return c.handler.View(cmd.Context(), noteID) 369 378 }, 370 379 }) 371 380 ··· 374 383 Short: "Edit note in configured editor", 375 384 Args: cobra.ExactArgs(1), 376 385 RunE: func(cmd *cobra.Command, args []string) error { 377 - noteID, err := strconv.ParseInt(args[0], 10, 64) 378 - if err != nil { 379 - return fmt.Errorf("invalid note ID: %s", args[0]) 386 + if noteID, err := parseID("note", args); err != nil { 387 + return err 388 + } else { 389 + defer c.handler.Close() 390 + return c.handler.Edit(cmd.Context(), noteID) 380 391 } 381 - defer c.handler.Close() 382 - return c.handler.Edit(cmd.Context(), noteID) 383 392 }, 384 393 }) 385 394 ··· 389 398 Aliases: []string{"rm", "delete", "del"}, 390 399 Args: cobra.ExactArgs(1), 391 400 RunE: func(cmd *cobra.Command, args []string) error { 392 - noteID, err := strconv.ParseInt(args[0], 10, 64) 393 - if err != nil { 394 - return fmt.Errorf("invalid note ID: %s", args[0]) 401 + if noteID, err := parseID("note", args); err != nil { 402 + return err 403 + } else { 404 + defer c.handler.Close() 405 + return c.handler.Delete(cmd.Context(), noteID) 395 406 } 396 - defer c.handler.Close() 397 - return c.handler.Delete(cmd.Context(), noteID) 398 407 }, 399 408 }) 400 409
+246 -1
cmd/commands_test.go
··· 4 4 "context" 5 5 "os" 6 6 "slices" 7 + "strings" 7 8 "testing" 8 9 9 10 "github.com/stormlightlabs/noteleaf/internal/handlers" 11 + "github.com/stormlightlabs/noteleaf/internal/services" 10 12 ) 11 13 12 14 func setupCommandTest(t *testing.T) func() { ··· 377 379 t.Error("expected movie add command to fail with empty args") 378 380 } 379 381 }) 382 + 383 + t.Run("add command with valid args - successful search", func(t *testing.T) { 384 + cleanup := services.SetupSuccessfulMovieMocks(t) 385 + defer cleanup() 386 + 387 + cmd := NewMovieCommand(handler).Create() 388 + cmd.SetArgs([]string{"add", "Fantastic Four"}) 389 + err := cmd.Execute() 390 + 391 + // NOTE: The command will find results but fail due to no user input in test environment 392 + if err == nil { 393 + t.Error("expected movie add command to fail due to no user input in test environment") 394 + } 395 + if !strings.Contains(err.Error(), "invalid input") { 396 + t.Errorf("expected 'invalid input' error, got: %v", err) 397 + } 398 + }) 399 + 400 + t.Run("add command with valid args - search failure", func(t *testing.T) { 401 + cleanup := services.SetupFailureMocks(t, "search failed") 402 + defer cleanup() 403 + 404 + cmd := NewMovieCommand(handler).Create() 405 + cmd.SetArgs([]string{"add", "some movie"}) 406 + err := cmd.Execute() 407 + if err == nil { 408 + t.Error("expected movie add command to fail when search fails") 409 + } 410 + services.AssertErrorContains(t, err, "search failed") 411 + }) 412 + 413 + t.Run("remove command with non-existent movie ID", func(t *testing.T) { 414 + cmd := NewMovieCommand(handler).Create() 415 + cmd.SetArgs([]string{"remove", "999"}) 416 + err := cmd.Execute() 417 + if err == nil { 418 + t.Error("expected movie remove command to fail with non-existent ID") 419 + } 420 + }) 421 + 422 + t.Run("remove command with non-numeric ID", func(t *testing.T) { 423 + cmd := NewMovieCommand(handler).Create() 424 + cmd.SetArgs([]string{"remove", "invalid"}) 425 + err := cmd.Execute() 426 + if err == nil { 427 + t.Error("expected movie remove command to fail with non-numeric ID") 428 + } 429 + }) 380 430 }) 381 431 382 432 t.Run("TV Commands", func(t *testing.T) { ··· 400 450 t.Error("expected tv add command to fail with empty args") 401 451 } 402 452 }) 453 + 454 + t.Run("add command with valid args - successful search", func(t *testing.T) { 455 + cleanup := services.SetupSuccessfulTVMocks(t) 456 + defer cleanup() 457 + 458 + cmd := NewTVCommand(handler).Create() 459 + cmd.SetArgs([]string{"add", "Peacemaker"}) 460 + err := cmd.Execute() 461 + 462 + // NOTE: The command will find results but fail due to no user input in test environment 463 + if err == nil { 464 + t.Error("expected tv add command to fail due to no user input in test environment") 465 + } 466 + if !strings.Contains(err.Error(), "invalid input") { 467 + t.Errorf("expected 'invalid input' error, got: %v", err) 468 + } 469 + }) 470 + 471 + t.Run("add command with valid args - search failure", func(t *testing.T) { 472 + cleanup := services.SetupFailureMocks(t, "tv search failed") 473 + defer cleanup() 474 + 475 + cmd := NewTVCommand(handler).Create() 476 + cmd.SetArgs([]string{"add", "some show"}) 477 + err := cmd.Execute() 478 + if err == nil { 479 + t.Error("expected tv add command to fail when search fails") 480 + } 481 + services.AssertErrorContains(t, err, "tv search failed") 482 + }) 483 + 484 + t.Run("remove command with non-existent TV show ID", func(t *testing.T) { 485 + cmd := NewTVCommand(handler).Create() 486 + cmd.SetArgs([]string{"remove", "999"}) 487 + err := cmd.Execute() 488 + if err == nil { 489 + t.Error("expected tv remove command to fail with non-existent ID") 490 + } 491 + }) 492 + 493 + t.Run("remove command with non-numeric ID", func(t *testing.T) { 494 + cmd := NewTVCommand(handler).Create() 495 + cmd.SetArgs([]string{"remove", "invalid"}) 496 + err := cmd.Execute() 497 + if err == nil { 498 + t.Error("expected tv remove command to fail with non-numeric ID") 499 + } 500 + }) 403 501 }) 404 502 405 503 t.Run("Book Commands", func(t *testing.T) { ··· 414 512 t.Errorf("book list command failed: %v", err) 415 513 } 416 514 }) 515 + 516 + t.Run("remove command with non-existent book ID", func(t *testing.T) { 517 + cmd := NewBookCommand(handler).Create() 518 + cmd.SetArgs([]string{"remove", "999"}) 519 + err := cmd.Execute() 520 + if err == nil { 521 + t.Error("expected book remove command to fail with non-existent ID") 522 + } 523 + }) 524 + 525 + t.Run("remove command with non-numeric ID", func(t *testing.T) { 526 + cmd := NewBookCommand(handler).Create() 527 + cmd.SetArgs([]string{"remove", "invalid"}) 528 + err := cmd.Execute() 529 + if err == nil { 530 + t.Error("expected book remove command to fail with non-numeric ID") 531 + } 532 + }) 533 + 534 + t.Run("update command with removed status", func(t *testing.T) { 535 + cmd := NewBookCommand(handler).Create() 536 + cmd.SetArgs([]string{"update", "999", "removed"}) 537 + err := cmd.Execute() 538 + if err == nil { 539 + t.Error("expected book update command to fail with non-existent ID") 540 + } 541 + }) 542 + 543 + t.Run("update command with invalid status", func(t *testing.T) { 544 + cmd := NewBookCommand(handler).Create() 545 + cmd.SetArgs([]string{"update", "1", "invalid_status"}) 546 + err := cmd.Execute() 547 + if err == nil { 548 + t.Error("expected book update command to fail with invalid status") 549 + } 550 + }) 417 551 }) 418 552 419 553 t.Run("Note Commands", func(t *testing.T) { 420 - 421 554 t.Run("create command - non-interactive", func(t *testing.T) { 422 555 handler, cleanup := createTestNoteHandler(t) 423 556 defer cleanup() ··· 439 572 err := cmd.Execute() 440 573 if err != nil { 441 574 t.Errorf("note list command failed: %v", err) 575 + } 576 + }) 577 + 578 + t.Run("read command with valid note ID", func(t *testing.T) { 579 + handler, cleanup := createTestNoteHandler(t) 580 + defer cleanup() 581 + 582 + err := handler.CreateWithOptions(context.Background(), "test note", "test content", "", false, false) 583 + if err != nil { 584 + t.Fatalf("failed to create test note: %v", err) 585 + } 586 + 587 + cmd := NewNoteCommand(handler).Create() 588 + cmd.SetArgs([]string{"read", "1"}) 589 + err = cmd.Execute() 590 + if err != nil { 591 + t.Errorf("note read command failed: %v", err) 592 + } 593 + }) 594 + 595 + t.Run("edit command with valid note ID", func(t *testing.T) { 596 + t.Skip("edit command requires interactive editor") 597 + }) 598 + 599 + t.Run("remove command with valid note ID", func(t *testing.T) { 600 + handler, cleanup := createTestNoteHandler(t) 601 + defer cleanup() 602 + 603 + err := handler.CreateWithOptions(context.Background(), "test note", "test content", "", false, false) 604 + if err != nil { 605 + t.Fatalf("failed to create test note: %v", err) 606 + } 607 + 608 + cmd := NewNoteCommand(handler).Create() 609 + cmd.SetArgs([]string{"remove", "1"}) 610 + err = cmd.Execute() 611 + if err != nil { 612 + t.Errorf("note remove command failed: %v", err) 613 + } 614 + }) 615 + }) 616 + 617 + t.Run("Task Commands", func(t *testing.T) { 618 + t.Run("list command - static", func(t *testing.T) { 619 + handler, cleanup := createTestTaskHandler(t) 620 + defer cleanup() 621 + 622 + cmd := NewTaskCommand(handler).Create() 623 + cmd.SetArgs([]string{"list", "--static"}) 624 + err := cmd.Execute() 625 + if err != nil { 626 + t.Errorf("task list command failed: %v", err) 627 + } 628 + }) 629 + 630 + t.Run("add command with valid args", func(t *testing.T) { 631 + handler, cleanup := createTestTaskHandler(t) 632 + defer cleanup() 633 + 634 + cmd := NewTaskCommand(handler).Create() 635 + cmd.SetArgs([]string{"add", "test task"}) 636 + err := cmd.Execute() 637 + if err != nil { 638 + t.Errorf("task add command failed: %v", err) 639 + } 640 + }) 641 + 642 + t.Run("projects command - static", func(t *testing.T) { 643 + handler, cleanup := createTestTaskHandler(t) 644 + defer cleanup() 645 + 646 + cmd := NewTaskCommand(handler).Create() 647 + cmd.SetArgs([]string{"projects", "--static"}) 648 + err := cmd.Execute() 649 + if err != nil { 650 + t.Errorf("task projects command failed: %v", err) 651 + } 652 + }) 653 + 654 + t.Run("tags command - static", func(t *testing.T) { 655 + handler, cleanup := createTestTaskHandler(t) 656 + defer cleanup() 657 + 658 + cmd := NewTaskCommand(handler).Create() 659 + cmd.SetArgs([]string{"tags", "--static"}) 660 + err := cmd.Execute() 661 + if err != nil { 662 + t.Errorf("task tags command failed: %v", err) 663 + } 664 + }) 665 + 666 + t.Run("contexts command - static", func(t *testing.T) { 667 + handler, cleanup := createTestTaskHandler(t) 668 + defer cleanup() 669 + 670 + cmd := NewTaskCommand(handler).Create() 671 + cmd.SetArgs([]string{"contexts", "--static"}) 672 + err := cmd.Execute() 673 + if err != nil { 674 + t.Errorf("task contexts command failed: %v", err) 675 + } 676 + }) 677 + 678 + t.Run("timesheet command", func(t *testing.T) { 679 + handler, cleanup := createTestTaskHandler(t) 680 + defer cleanup() 681 + 682 + cmd := NewTaskCommand(handler).Create() 683 + cmd.SetArgs([]string{"timesheet"}) 684 + err := cmd.Execute() 685 + if err != nil { 686 + t.Errorf("task timesheet command failed: %v", err) 442 687 } 443 688 }) 444 689 })
+1 -2
codecov.yml
··· 21 21 - "**/testdata/**" 22 22 - "**/vendor/**" 23 23 - "cmd/main.go" 24 - - "internal/repo/test_utilities.go" 25 - - "internal/handlers/test_utilities.go" 24 + - "internal/**/*/test_utilities.go"
+9 -9
internal/handlers/books.go
··· 36 36 } 37 37 38 38 repos := repo.NewRepositories(db.DB) 39 - service := services.NewBookService() 39 + service := services.NewBookService(services.OpenLibraryBaseURL) 40 40 41 41 return &BookHandler{ 42 42 db: db, ··· 86 86 fmt.Println() 87 87 } 88 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 { 89 + // SearchAndAdd searches for books and allows user to select and add to queue 90 + func (h *BookHandler) SearchAndAdd(ctx context.Context, args []string, interactive bool) error { 91 91 if len(args) == 0 { 92 92 return fmt.Errorf("usage: book add <search query>") 93 93 } ··· 173 173 return nil 174 174 } 175 175 176 - // ListBooks lists all books with status filtering 177 - func (h *BookHandler) ListBooks(ctx context.Context, status string) error { 176 + // List lists all books with status filtering 177 + func (h *BookHandler) List(ctx context.Context, status string) error { 178 178 var books []*models.Book 179 179 var err error 180 180 ··· 214 214 return nil 215 215 } 216 216 217 - // UpdateBookStatusByID changes the status of a book 218 - func (h *BookHandler) UpdateBookStatusByID(ctx context.Context, id, status string) error { 217 + // UpdateStatus changes the status of a [models.Book] 218 + func (h *BookHandler) UpdateStatus(ctx context.Context, id, status string) error { 219 219 bookID, err := strconv.ParseInt(id, 10, 64) 220 220 if err != nil { 221 221 return fmt.Errorf("invalid book ID: %s", id) ··· 250 250 return nil 251 251 } 252 252 253 - // UpdateBookProgressByID updates a book's reading progress percentage 254 - func (h *BookHandler) UpdateBookProgressByID(ctx context.Context, id string, progress int) error { 253 + // UpdateProgress updates a [models.Book]'s reading progress percentage 254 + func (h *BookHandler) UpdateProgress(ctx context.Context, id string, progress int) error { 255 255 bookID, err := strconv.ParseInt(id, 10, 64) 256 256 if err != nil { 257 257 return fmt.Errorf("invalid book ID: %s", id)
+27 -27
internal/handlers/books_test.go
··· 118 118 ctx := context.Background() 119 119 t.Run("fails with empty args", func(t *testing.T) { 120 120 args := []string{} 121 - err := handler.SearchAndAddBook(ctx, args, false) 121 + err := handler.SearchAndAdd(ctx, args, false) 122 122 if err == nil { 123 123 t.Error("Expected error for empty args") 124 124 } ··· 130 130 131 131 t.Run("handles empty search", func(t *testing.T) { 132 132 args := []string{""} 133 - err := handler.SearchAndAddBook(ctx, args, false) 133 + err := handler.SearchAndAdd(ctx, args, false) 134 134 if err != nil && !strings.Contains(err.Error(), "No books found") { 135 135 t.Errorf("Expected no error or 'No books found', got: %v", err) 136 136 } ··· 140 140 ctx := context.Background() 141 141 t.Run("fails with empty args", func(t *testing.T) { 142 142 args := []string{} 143 - err := handler.SearchAndAddBook(ctx, args, false) 143 + err := handler.SearchAndAdd(ctx, args, false) 144 144 if err == nil { 145 145 t.Error("Expected error for empty args") 146 146 } ··· 152 152 153 153 t.Run("handles search service errors", func(t *testing.T) { 154 154 args := []string{"test", "book"} 155 - err := handler.SearchAndAddBook(ctx, args, false) 155 + err := handler.SearchAndAdd(ctx, args, false) 156 156 if err == nil { 157 157 t.Error("Expected error due to mocked service") 158 158 } ··· 194 194 book3.ID = id3 195 195 196 196 t.Run("lists queued books by default", func(t *testing.T) { 197 - err := handler.ListBooks(ctx, "queued") 197 + err := handler.List(ctx, "queued") 198 198 if err != nil { 199 199 t.Errorf("ListBooks failed: %v", err) 200 200 } 201 201 }) 202 202 203 203 t.Run("filters by status - all", func(t *testing.T) { 204 - err := handler.ListBooks(ctx, "") 204 + err := handler.List(ctx, "") 205 205 if err != nil { 206 206 t.Errorf("ListBooks with status all failed: %v", err) 207 207 } 208 208 }) 209 209 210 210 t.Run("filters by status - reading", func(t *testing.T) { 211 - err := handler.ListBooks(ctx, "reading") 211 + err := handler.List(ctx, "reading") 212 212 if err != nil { 213 213 t.Errorf("ListBooks with status reading failed: %v", err) 214 214 } 215 215 }) 216 216 217 217 t.Run("filters by status - finished", func(t *testing.T) { 218 - err := handler.ListBooks(ctx, "finished") 218 + err := handler.List(ctx, "finished") 219 219 if err != nil { 220 220 t.Errorf("ListBooks with status finished failed: %v", err) 221 221 } 222 222 }) 223 223 224 224 t.Run("filters by status - queued", func(t *testing.T) { 225 - err := handler.ListBooks(ctx, "queued") 225 + err := handler.List(ctx, "queued") 226 226 if err != nil { 227 227 t.Errorf("ListBooks with status queued failed: %v", err) 228 228 } ··· 237 237 } 238 238 239 239 for flag, status := range statusVariants { 240 - err := handler.ListBooks(ctx, status) 240 + err := handler.List(ctx, status) 241 241 if err != nil { 242 242 t.Errorf("ListBooks with flag %s (status %s) failed: %v", flag, status, err) 243 243 } ··· 251 251 book := createTestBook(t, handler, ctx) 252 252 253 253 t.Run("updates book status successfully", func(t *testing.T) { 254 - err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "reading") 254 + err := handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), "reading") 255 255 if err != nil { 256 256 t.Errorf("UpdateBookStatusByID failed: %v", err) 257 257 } ··· 271 271 }) 272 272 273 273 t.Run("updates to finished status", func(t *testing.T) { 274 - err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") 274 + err := handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), "finished") 275 275 if err != nil { 276 276 t.Errorf("UpdateBookStatusByID failed: %v", err) 277 277 } ··· 295 295 }) 296 296 297 297 t.Run("fails with invalid book ID", func(t *testing.T) { 298 - err := handler.UpdateBookStatusByID(ctx, "invalid-id", "reading") 298 + err := handler.UpdateStatus(ctx, "invalid-id", "reading") 299 299 if err == nil { 300 300 t.Error("Expected error for invalid book ID") 301 301 } ··· 306 306 }) 307 307 308 308 t.Run("fails with invalid status", func(t *testing.T) { 309 - err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "invalid-status") 309 + err := handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), "invalid-status") 310 310 if err == nil { 311 311 t.Error("Expected error for invalid status") 312 312 } ··· 317 317 }) 318 318 319 319 t.Run("fails with non-existent book ID", func(t *testing.T) { 320 - err := handler.UpdateBookStatusByID(ctx, "99999", "reading") 320 + err := handler.UpdateStatus(ctx, "99999", "reading") 321 321 if err == nil { 322 322 t.Error("Expected error for non-existent book ID") 323 323 } ··· 331 331 validStatuses := []string{"queued", "reading", "finished", "removed"} 332 332 333 333 for _, status := range validStatuses { 334 - err := handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), status) 334 + err := handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), status) 335 335 if err != nil { 336 336 t.Errorf("UpdateBookStatusByID with status %s failed: %v", status, err) 337 337 } ··· 354 354 book := createTestBook(t, handler, ctx) 355 355 356 356 t.Run("updates progress successfully", func(t *testing.T) { 357 - err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) 357 + err := handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), 50) 358 358 if err != nil { 359 359 t.Errorf("UpdateBookProgressByID failed: %v", err) 360 360 } ··· 378 378 }) 379 379 380 380 t.Run("auto-completes book at 100%", func(t *testing.T) { 381 - err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) 381 + err := handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), 100) 382 382 if err != nil { 383 383 t.Errorf("UpdateBookProgressByID failed: %v", err) 384 384 } ··· 407 407 book.Started = &now 408 408 handler.repos.Books.Update(ctx, book) 409 409 410 - err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 0) 410 + err := handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), 0) 411 411 if err != nil { 412 412 t.Errorf("UpdateBookProgressByID failed: %v", err) 413 413 } ··· 431 431 }) 432 432 433 433 t.Run("fails with invalid book ID", func(t *testing.T) { 434 - err := handler.UpdateBookProgressByID(ctx, "invalid-id", 50) 434 + err := handler.UpdateProgress(ctx, "invalid-id", 50) 435 435 if err == nil { 436 436 t.Error("Expected error for invalid book ID") 437 437 } ··· 445 445 testCases := []int{-1, 101, 150} 446 446 447 447 for _, progress := range testCases { 448 - err := handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), progress) 448 + err := handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), progress) 449 449 if err == nil { 450 450 t.Errorf("Expected error for progress %d", progress) 451 451 } ··· 457 457 }) 458 458 459 459 t.Run("fails with non-existent book ID", func(t *testing.T) { 460 - err := handler.UpdateBookProgressByID(ctx, "99999", 50) 460 + err := handler.UpdateProgress(ctx, "99999", 50) 461 461 if err == nil { 462 462 t.Error("Expected error for non-existent book ID") 463 463 } ··· 579 579 t.Errorf("Expected initial status 'queued', got '%s'", book.Status) 580 580 } 581 581 582 - err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 25) 582 + err = handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), 25) 583 583 if err != nil { 584 584 t.Errorf("Failed to update progress: %v", err) 585 585 } ··· 593 593 t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status) 594 594 } 595 595 596 - err = handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 100) 596 + err = handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), 100) 597 597 if err != nil { 598 598 t.Errorf("Failed to complete book: %v", err) 599 599 } ··· 634 634 635 635 go func() { 636 636 time.Sleep(time.Millisecond * 10) 637 - done <- handler.ListBooks(ctx, "") 637 + done <- handler.List(ctx, "") 638 638 }() 639 639 640 640 go func() { 641 641 time.Sleep(time.Millisecond * 15) 642 - done <- handler.UpdateBookProgressByID(ctx, strconv.FormatInt(book.ID, 10), 50) 642 + done <- handler.UpdateProgress(ctx, strconv.FormatInt(book.ID, 10), 50) 643 643 }() 644 644 645 645 go func() { 646 646 time.Sleep(time.Millisecond * 20) 647 - done <- handler.UpdateBookStatusByID(ctx, strconv.FormatInt(book.ID, 10), "finished") 647 + done <- handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), "finished") 648 648 }() 649 649 650 650 for i := range 3 {
+13 -50
internal/handlers/movies.go
··· 131 131 return nil 132 132 } 133 133 134 - // List movies with status filtering 134 + // List lists all movies in the queue with status filtering 135 135 func (h *MovieHandler) List(ctx context.Context, status string) error { 136 136 var movies []*models.Movie 137 137 var err error ··· 231 231 } 232 232 233 233 // MarkWatched marks a movie as watched 234 - func (h *MovieHandler) MarkWatched(ctx context.Context, movieID int64) error { 234 + func (h *MovieHandler) MarkWatched(ctx context.Context, id string) error { 235 + movieID, err := strconv.ParseInt(id, 10, 64) 236 + if err != nil { 237 + return fmt.Errorf("invalid movie ID: %s", id) 238 + } 239 + 235 240 return h.UpdateStatus(ctx, movieID, "watched") 236 241 } 237 242 238 243 // Remove removes a movie from the queue 239 - func (h *MovieHandler) Remove(ctx context.Context, movieID int64) error { 244 + func (h *MovieHandler) Remove(ctx context.Context, id string) error { 245 + movieID, err := strconv.ParseInt(id, 10, 64) 246 + if err != nil { 247 + return fmt.Errorf("invalid movie ID: %s", id) 248 + } 249 + 240 250 movie, err := h.repos.Movies.Get(ctx, movieID) 241 251 if err != nil { 242 252 return fmt.Errorf("movie %d not found: %w", movieID, err) ··· 268 278 } 269 279 fmt.Println() 270 280 } 271 - 272 - // SearchAndAddMovie searches for movies and allows user to select and add to queue 273 - func (h *MovieHandler) SearchAndAddMovie(ctx context.Context, query string, interactive bool) error { 274 - return h.SearchAndAdd(ctx, query, interactive) 275 - } 276 - 277 - // ListMovies lists all movies in the queue with status filtering 278 - func (h *MovieHandler) ListMovies(ctx context.Context, status string) error { 279 - return h.List(ctx, status) 280 - } 281 - 282 - // ViewMovie displays detailed information about a specific movie 283 - func (h *MovieHandler) ViewMovie(ctx context.Context, id string) error { 284 - movieID, err := strconv.ParseInt(id, 10, 64) 285 - if err != nil { 286 - return fmt.Errorf("invalid movie ID: %s", id) 287 - } 288 - return h.View(ctx, movieID) 289 - } 290 - 291 - // UpdateMovieStatus changes the status of a movie 292 - func (h *MovieHandler) UpdateMovieStatus(ctx context.Context, id, status string) error { 293 - movieID, err := strconv.ParseInt(id, 10, 64) 294 - if err != nil { 295 - return fmt.Errorf("invalid movie ID: %s", id) 296 - } 297 - return h.UpdateStatus(ctx, movieID, status) 298 - } 299 - 300 - // MarkMovieWatched marks a movie as watched 301 - func (h *MovieHandler) MarkMovieWatched(ctx context.Context, id string) error { 302 - movieID, err := strconv.ParseInt(id, 10, 64) 303 - if err != nil { 304 - return fmt.Errorf("invalid movie ID: %s", id) 305 - } 306 - return h.MarkWatched(ctx, movieID) 307 - } 308 - 309 - // RemoveMovie removes a movie from the queue 310 - func (h *MovieHandler) RemoveMovie(ctx context.Context, id string) error { 311 - movieID, err := strconv.ParseInt(id, 10, 64) 312 - if err != nil { 313 - return fmt.Errorf("invalid movie ID: %s", id) 314 - } 315 - 316 - return h.Remove(ctx, movieID) 317 - }
+11 -61
internal/handlers/movies_test.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "strconv" 6 7 "testing" 7 8 "time" 8 9 ··· 122 123 handler := createTestMovieHandler(t) 123 124 defer handler.Close() 124 125 125 - // Test with empty status (all movies) 126 126 err := handler.List(context.Background(), "") 127 127 if err != nil { 128 128 t.Errorf("Expected no error for listing all movies, got: %v", err) ··· 162 162 } 163 163 }) 164 164 165 - t.Run("Invalid ID", func(t *testing.T) { 166 - handler := createTestMovieHandler(t) 167 - defer handler.Close() 168 - 169 - err := handler.ViewMovie(context.Background(), "invalid") 170 - if err == nil { 171 - t.Error("Expected error for invalid movie ID") 172 - } 173 - if err.Error() != "invalid movie ID: invalid" { 174 - t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err) 175 - } 176 - }) 177 165 }) 178 166 179 167 t.Run("Update", func(t *testing.T) { ··· 207 195 handler := createTestMovieHandler(t) 208 196 defer handler.Close() 209 197 210 - err := handler.MarkWatched(context.Background(), 999) 198 + err := handler.MarkWatched(context.Background(), "999") 211 199 if err == nil { 212 200 t.Error("Expected error for non-existent movie") 213 201 } 214 202 }) 215 203 216 - t.Run("Remove_MovieNotFound", func(t *testing.T) { 204 + t.Run("RemoveMovie_MovieNotFound", func(t *testing.T) { 217 205 handler := createTestMovieHandler(t) 218 206 defer handler.Close() 219 207 220 - err := handler.Remove(context.Background(), 999) 208 + err := handler.Remove(context.Background(), "999") 221 209 if err == nil { 222 210 t.Error("Expected error for non-existent movie") 223 211 } 224 212 }) 225 213 226 - t.Run("UpdateMovieStatus_InvalidID", func(t *testing.T) { 214 + t.Run("MarkWatched_InvalidID", func(t *testing.T) { 227 215 handler := createTestMovieHandler(t) 228 216 defer handler.Close() 229 217 230 - err := handler.UpdateMovieStatus(context.Background(), "invalid", "watched") 231 - if err == nil { 232 - t.Error("Expected error for invalid movie ID") 233 - } 234 - if err.Error() != "invalid movie ID: invalid" { 235 - t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err) 236 - } 237 - }) 238 - 239 - t.Run("MarkMovieWatched_InvalidID", func(t *testing.T) { 240 - handler := createTestMovieHandler(t) 241 - defer handler.Close() 242 - 243 - err := handler.MarkMovieWatched(context.Background(), "invalid") 218 + err := handler.MarkWatched(context.Background(), "invalid") 244 219 if err == nil { 245 220 t.Error("Expected error for invalid movie ID") 246 221 } ··· 253 228 handler := createTestMovieHandler(t) 254 229 defer handler.Close() 255 230 256 - err := handler.RemoveMovie(context.Background(), "invalid") 231 + err := handler.Remove(context.Background(), "invalid") 257 232 if err == nil { 258 233 t.Error("Expected error for invalid movie ID") 259 234 } ··· 286 261 handler.printMovie(watchedMovie) 287 262 }) 288 263 289 - t.Run("SearchAndAddMovie", func(t *testing.T) { 290 - handler := createTestMovieHandler(t) 291 - defer handler.Close() 292 - 293 - err := handler.SearchAndAddMovie(context.Background(), "", false) 294 - if err == nil { 295 - t.Error("Expected error for empty query") 296 - } 297 - }) 298 - 299 - t.Run("List Movies", func(t *testing.T) { 300 - handler := createTestMovieHandler(t) 301 - defer handler.Close() 302 - 303 - err := handler.ListMovies(context.Background(), "") 304 - if err != nil { 305 - t.Errorf("Expected no error for listing all movies, got: %v", err) 306 - } 307 - 308 - err = handler.ListMovies(context.Background(), "invalid") 309 - if err == nil { 310 - t.Error("Expected error for invalid status") 311 - } 312 - }) 313 - 314 264 t.Run("Integration", func(t *testing.T) { 315 265 t.Run("CreateAndRetrieve", func(t *testing.T) { 316 266 handler := createTestMovieHandler(t) ··· 335 285 t.Errorf("Failed to update movie status: %v", err) 336 286 } 337 287 338 - err = handler.MarkWatched(context.Background(), id) 288 + err = handler.MarkWatched(context.Background(), strconv.Itoa(int(id))) 339 289 if err != nil { 340 290 t.Errorf("Failed to mark movie as watched: %v", err) 341 291 } 342 292 343 - err = handler.Remove(context.Background(), id) 293 + err = handler.Remove(context.Background(), strconv.Itoa(int(id))) 344 294 if err != nil { 345 295 t.Errorf("Failed to remove movie: %v", err) 346 296 } ··· 406 356 }, 407 357 { 408 358 name: "Mark non-existent movie as watched", 409 - fn: func() error { return handler.MarkWatched(ctx, nonExistentID) }, 359 + fn: func() error { return handler.MarkWatched(ctx, strconv.Itoa(int(nonExistentID))) }, 410 360 }, 411 361 { 412 362 name: "Remove non-existent movie", 413 - fn: func() error { return handler.Remove(ctx, nonExistentID) }, 363 + fn: func() error { return handler.Remove(ctx, strconv.Itoa(int(nonExistentID))) }, 414 364 }, 415 365 } 416 366
-4
internal/handlers/notes.go
··· 179 179 return nil 180 180 } 181 181 182 - func (h *NoteHandler) createFromArgs(ctx context.Context, title, content string) error { 183 - return h.createFromArgsWithOptions(ctx, title, content, false) 184 - } 185 - 186 182 func (h *NoteHandler) createFromArgsWithOptions(ctx context.Context, title, content string, promptEditor bool) error { 187 183 note := &models.Note{ 188 184 Title: title,
+20 -42
internal/handlers/tv.go
··· 172 172 173 173 fmt.Printf("Found %d TV show(s):\n\n", len(shows)) 174 174 for _, show := range shows { 175 - h.printTVShow(show) 175 + h.print(show) 176 176 } 177 177 178 178 return nil 179 179 } 180 180 181 181 // View displays detailed information about a specific TV show 182 - func (h *TVHandler) View(ctx context.Context, showID int64) error { 182 + func (h *TVHandler) View(ctx context.Context, id string) error { 183 + showID, err := strconv.ParseInt(id, 10, 64) 184 + if err != nil { 185 + return fmt.Errorf("invalid TV show ID: %s", id) 186 + } 187 + 183 188 show, err := h.repos.TV.Get(ctx, showID) 184 189 if err != nil { 185 190 return fmt.Errorf("failed to get TV show %d: %w", showID, err) ··· 245 250 } 246 251 247 252 // MarkWatched marks a TV show as watched 248 - func (h *TVHandler) MarkWatched(ctx context.Context, showID int64) error { 253 + func (h *TVHandler) MarkWatched(ctx context.Context, id string) error { 254 + showID, err := strconv.ParseInt(id, 10, 64) 255 + if err != nil { 256 + return fmt.Errorf("invalid TV show ID: %s", id) 257 + } 258 + 249 259 return h.UpdateStatus(ctx, showID, "watched") 250 260 } 251 261 252 262 // Remove removes a TV show from the queue 253 - func (h *TVHandler) Remove(ctx context.Context, showID int64) error { 263 + func (h *TVHandler) Remove(ctx context.Context, id string) error { 264 + showID, err := strconv.ParseInt(id, 10, 64) 265 + if err != nil { 266 + return fmt.Errorf("invalid TV show ID: %s", id) 267 + } 268 + 254 269 show, err := h.repos.TV.Get(ctx, showID) 255 270 if err != nil { 256 271 return fmt.Errorf("TV show %d not found: %w", showID, err) ··· 269 284 return nil 270 285 } 271 286 272 - func (h *TVHandler) printTVShow(show *models.TVShow) { 287 + func (h *TVHandler) print(show *models.TVShow) { 273 288 fmt.Printf("[%d] %s", show.ID, show.Title) 274 289 if show.Season > 0 { 275 290 fmt.Printf(" (Season %d", show.Season) ··· 287 302 fmt.Println() 288 303 } 289 304 290 - // SearchAndAddTV searches for TV shows and allows user to select and add to queue 291 - func (h *TVHandler) SearchAndAddTV(ctx context.Context, query string, interactive bool) error { 292 - return h.SearchAndAdd(ctx, query, interactive) 293 - } 294 - 295 - // ListTVShows lists all TV shows in the queue with status filtering 296 - func (h *TVHandler) ListTVShows(ctx context.Context, status string) error { 297 - return h.List(ctx, status) 298 - } 299 - 300 - // ViewTVShow displays detailed information about a specific TV show 301 - func (h *TVHandler) ViewTVShow(ctx context.Context, id string) error { 302 - showID, err := strconv.ParseInt(id, 10, 64) 303 - if err != nil { 304 - return fmt.Errorf("invalid TV show ID: %s", id) 305 - } 306 - return h.View(ctx, showID) 307 - } 308 - 309 305 // UpdateTVShowStatus changes the status of a TV show 310 306 func (h *TVHandler) UpdateTVShowStatus(ctx context.Context, id, status string) error { 311 307 showID, err := strconv.ParseInt(id, 10, 64) ··· 323 319 } 324 320 return h.MarkWatching(ctx, showID) 325 321 } 326 - 327 - // MarkTVShowWatched marks a TV show as watched 328 - func (h *TVHandler) MarkTVShowWatched(ctx context.Context, id string) error { 329 - showID, err := strconv.ParseInt(id, 10, 64) 330 - if err != nil { 331 - return fmt.Errorf("invalid TV show ID: %s", id) 332 - } 333 - return h.MarkWatched(ctx, showID) 334 - } 335 - 336 - // RemoveTVShow removes a TV show from the queue 337 - func (h *TVHandler) RemoveTVShow(ctx context.Context, id string) error { 338 - showID, err := strconv.ParseInt(id, 10, 64) 339 - if err != nil { 340 - return fmt.Errorf("invalid TV show ID: %s", id) 341 - } 342 - return h.Remove(ctx, showID) 343 - }
+19 -43
internal/handlers/tv_test.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "strconv" 6 7 "testing" 7 8 "time" 8 9 ··· 153 154 handler := createTestTVHandler(t) 154 155 defer handler.Close() 155 156 156 - err := handler.View(context.Background(), 999) 157 + err := handler.View(context.Background(), strconv.Itoa(int(999))) 157 158 if err == nil { 158 159 t.Error("Expected error for non-existent TV show") 159 160 } ··· 163 164 handler := createTestTVHandler(t) 164 165 defer handler.Close() 165 166 166 - err := handler.ViewTVShow(context.Background(), "invalid") 167 + err := handler.View(context.Background(), "invalid") 167 168 if err == nil { 168 169 t.Error("Expected error for invalid TV show ID") 169 170 } ··· 214 215 handler := createTestTVHandler(t) 215 216 defer handler.Close() 216 217 217 - err := handler.MarkWatched(context.Background(), 999) 218 + err := handler.MarkWatched(context.Background(), strconv.Itoa(int(999))) 218 219 if err == nil { 219 220 t.Error("Expected error for non-existent TV show") 220 221 } ··· 224 225 handler := createTestTVHandler(t) 225 226 defer handler.Close() 226 227 227 - err := handler.Remove(context.Background(), 999) 228 + err := handler.Remove(context.Background(), strconv.Itoa(int(999))) 228 229 if err == nil { 229 230 t.Error("Expected error for non-existent TV show") 230 231 } ··· 256 257 } 257 258 }) 258 259 259 - t.Run("MarkTVShowWatched_InvalidID", func(t *testing.T) { 260 + t.Run("MarkWatched_InvalidID", func(t *testing.T) { 260 261 handler := createTestTVHandler(t) 261 262 defer handler.Close() 262 263 263 - err := handler.MarkTVShowWatched(context.Background(), "invalid") 264 + err := handler.MarkWatched(context.Background(), "invalid") 264 265 if err == nil { 265 266 t.Error("Expected error for invalid TV show ID") 266 267 } ··· 269 270 } 270 271 }) 271 272 272 - t.Run("RemoveTVShow_InvalidID", func(t *testing.T) { 273 + t.Run("Remove_InvalidID", func(t *testing.T) { 273 274 handler := createTestTVHandler(t) 274 275 defer handler.Close() 275 276 276 - err := handler.RemoveTVShow(context.Background(), "invalid") 277 + err := handler.Remove(context.Background(), "invalid") 277 278 if err == nil { 278 279 t.Error("Expected error for invalid TV show ID") 279 280 } ··· 282 283 } 283 284 }) 284 285 285 - t.Run("printTVShow", func(t *testing.T) { 286 + t.Run("print", func(t *testing.T) { 286 287 handler := createTestTVHandler(t) 287 288 defer handler.Close() 288 289 289 290 show := createTestTVShow() 290 291 291 - handler.printTVShow(show) 292 + handler.print(show) 292 293 293 294 minimalShow := &models.TVShow{ 294 295 ID: 2, 295 296 Title: "Minimal Show", 296 297 } 297 - handler.printTVShow(minimalShow) 298 + handler.print(minimalShow) 298 299 299 300 watchedShow := &models.TVShow{ 300 301 ID: 3, ··· 304 305 Status: "watched", 305 306 Rating: 3.5, 306 307 } 307 - handler.printTVShow(watchedShow) 308 - }) 309 - 310 - t.Run("SearchAndAddTV", func(t *testing.T) { 311 - handler := createTestTVHandler(t) 312 - defer handler.Close() 313 - 314 - err := handler.SearchAndAddTV(context.Background(), "", false) 315 - if err == nil { 316 - t.Error("Expected error for empty query") 317 - } 318 - }) 319 - 320 - t.Run("List TV Shows", func(t *testing.T) { 321 - handler := createTestTVHandler(t) 322 - defer handler.Close() 323 - 324 - err := handler.ListTVShows(context.Background(), "") 325 - if err != nil { 326 - t.Errorf("Expected no error for listing all TV shows, got: %v", err) 327 - } 328 - 329 - err = handler.ListTVShows(context.Background(), "invalid") 330 - if err == nil { 331 - t.Error("Expected error for invalid status") 332 - } 308 + handler.print(watchedShow) 333 309 }) 334 310 335 311 t.Run("Integration", func(t *testing.T) { ··· 346 322 return 347 323 } 348 324 349 - err = handler.View(context.Background(), id) 325 + err = handler.View(context.Background(), strconv.Itoa(int(id))) 350 326 if err != nil { 351 327 t.Errorf("Failed to view created TV show: %v", err) 352 328 } ··· 356 332 t.Errorf("Failed to update TV show status: %v", err) 357 333 } 358 334 359 - err = handler.MarkWatched(context.Background(), id) 335 + err = handler.MarkWatched(context.Background(), strconv.Itoa(int(id))) 360 336 if err != nil { 361 337 t.Errorf("Failed to mark TV show as watched: %v", err) 362 338 } ··· 366 342 t.Errorf("Failed to mark TV show as watching: %v", err) 367 343 } 368 344 369 - err = handler.Remove(context.Background(), id) 345 + err = handler.Remove(context.Background(), strconv.Itoa(int(id))) 370 346 if err != nil { 371 347 t.Errorf("Failed to remove TV show: %v", err) 372 348 } ··· 436 412 }{ 437 413 { 438 414 name: "View non-existent show", 439 - fn: func() error { return handler.View(ctx, nonExistentID) }, 415 + fn: func() error { return handler.View(ctx, strconv.Itoa(int(nonExistentID))) }, 440 416 }, 441 417 { 442 418 name: "Update status of non-existent show", ··· 448 424 }, 449 425 { 450 426 name: "Mark non-existent show as watched", 451 - fn: func() error { return handler.MarkWatched(ctx, nonExistentID) }, 427 + fn: func() error { return handler.MarkWatched(ctx, strconv.Itoa(int(nonExistentID))) }, 452 428 }, 453 429 { 454 430 name: "Remove non-existent show", 455 - fn: func() error { return handler.Remove(ctx, nonExistentID) }, 431 + fn: func() error { return handler.Remove(ctx, strconv.Itoa(int(nonExistentID))) }, 456 432 }, 457 433 } 458 434
+196 -309
internal/services/media_test.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 - _ "embed" 7 - "errors" 8 6 "strings" 9 7 "testing" 10 8 11 9 "github.com/stormlightlabs/noteleaf/internal/models" 12 10 ) 13 11 14 - // From: https://www.rottentomatoes.com/m/the_fantastic_four_first_steps 15 - // 16 - //go:embed samples/movie.html 17 - var MovieSample []byte 12 + func TestMediaServices(t *testing.T) { 13 + t.Run("MovieService", func(t *testing.T) { 14 + t.Run("Search", func(t *testing.T) { 15 + t.Run("successful search", func(t *testing.T) { 16 + cleanup := SetupSuccessfulMovieMocks(t) 17 + defer cleanup() 18 18 19 - // From: https://www.rottentomatoes.com/search?search=peacemaker 20 - // 21 - //go:embed samples/search.html 22 - var SearchSample []byte 19 + service := CreateMovieService() 20 + TestMovieSearch(t, service, "Fantastic Four", "Fantastic Four") 21 + }) 23 22 24 - // From: https://www.rottentomatoes.com/tv/peacemaker_2022 25 - // 26 - //go:embed samples/series_overview.html 27 - var SeriesSample []byte 23 + t.Run("search returns error", func(t *testing.T) { 24 + cleanup := SetupFailureMocks(t, "search error") 25 + defer cleanup() 26 + 27 + service := CreateMovieService() 28 + _, err := service.Search(context.Background(), "error", 1, 10) 29 + AssertErrorContains(t, err, "search error") 30 + }) 31 + }) 32 + 33 + t.Run("Get", func(t *testing.T) { 34 + t.Run("successful get", func(t *testing.T) { 35 + cleanup := SetupSuccessfulMovieMocks(t) 36 + defer cleanup() 37 + 38 + service := CreateMovieService() 39 + result, err := service.Get(context.Background(), "some-url") 40 + if err != nil { 41 + t.Fatalf("Get failed: %v", err) 42 + } 43 + movie, ok := (*result).(*models.Movie) 44 + if !ok { 45 + t.Fatalf("expected a movie model, got %T", *result) 46 + } 47 + if movie.Title == "" { 48 + t.Error("expected non-empty movie title") 49 + } 50 + }) 51 + 52 + t.Run("get returns error", func(t *testing.T) { 53 + cleanup := SetupFailureMocks(t, "fetch error") 54 + defer cleanup() 55 + 56 + service := CreateMovieService() 57 + _, err := service.Get(context.Background(), "error") 58 + AssertErrorContains(t, err, "fetch error") 59 + }) 60 + }) 61 + 62 + t.Run("Check", func(t *testing.T) { 63 + t.Run("successful check", func(t *testing.T) { 64 + cleanup := SetupSuccessfulMovieMocks(t) 65 + defer cleanup() 28 66 29 - // From: https://www.rottentomatoes.com/tv/peacemaker_2022/s02 30 - // 31 - //go:embed samples/series_season.html 32 - var SeasonSample []byte 67 + service := CreateMovieService() 68 + err := service.Check(context.Background()) 69 + if err != nil { 70 + t.Fatalf("Check failed: %v", err) 71 + } 72 + }) 33 73 34 - // From: https://www.rottentomatoes.com/search?search=Fantastic%20Four 35 - // 36 - //go:embed samples/movie_search.html 37 - var MovieSearchSample []byte 74 + t.Run("check returns error", func(t *testing.T) { 75 + cleanup := SetupFailureMocks(t, "html fetch error") 76 + defer cleanup() 38 77 39 - func TestMovieService(t *testing.T) { 40 - t.Run("Search", func(t *testing.T) { 41 - originalSearch := SearchRottenTomatoes 42 - defer func() { SearchRottenTomatoes = originalSearch }() 78 + service := CreateMovieService() 79 + err := service.Check(context.Background()) 80 + AssertErrorContains(t, err, "html fetch error") 81 + }) 82 + }) 43 83 44 - SearchRottenTomatoes = func(q string) ([]Media, error) { 45 - if q == "error" { 46 - return nil, errors.New("search error") 84 + t.Run("Parse Search results", func(t *testing.T) { 85 + results, err := ParseSearch(bytes.NewReader(SearchSample)) 86 + if err != nil { 87 + t.Fatalf("ParseSearch failed: %v", err) 47 88 } 48 - if q == "Fantastic Four" { 49 - return ParseSearch(bytes.NewReader(MovieSearchSample)) 89 + if len(results) == 0 { 90 + t.Fatal("expected non-empty search results") 50 91 } 51 - return nil, errors.New("unexpected query") 52 - } 53 - 54 - service := NewMovieService() 92 + }) 55 93 56 - t.Run("successful search", func(t *testing.T) { 57 - results, err := service.Search(context.Background(), "Fantastic Four", 1, 10) 94 + t.Run("Parse Search error", func(t *testing.T) { 95 + results, err := ParseSearch(strings.NewReader("\x00bad html")) 58 96 if err != nil { 59 - t.Fatalf("Search failed: %v", err) 97 + t.Fatalf("unexpected error for malformed HTML: %v", err) 60 98 } 61 - if len(results) == 0 { 62 - t.Fatal("expected search results, got none") 99 + if len(results) != 0 { 100 + t.Errorf("expected 0 results for malformed HTML, got %d", len(results)) 63 101 } 64 102 65 - var movieFound bool 66 - for _, r := range results { 67 - m, ok := (*r).(*models.Movie) 68 - if !ok { 69 - continue 70 - } 71 - if strings.Contains(m.Title, "Fantastic Four") { 72 - movieFound = true 73 - break 74 - } 103 + html := `<a class="score-list-item"><span>Test</span></a>` 104 + results, err = ParseSearch(strings.NewReader(html)) 105 + if err != nil { 106 + t.Fatalf("unexpected error: %v", err) 75 107 } 76 - if !movieFound { 77 - t.Error("expected to find a movie in search results") 108 + if len(results) != 0 { 109 + t.Errorf("expected 0 results, got %d", len(results)) 78 110 } 79 111 }) 80 112 81 - t.Run("search returns error", func(t *testing.T) { 82 - _, err := service.Search(context.Background(), "error", 1, 10) 83 - if err == nil { 84 - t.Fatal("expected error from search, got nil") 113 + t.Run("Extract Metadata", func(t *testing.T) { 114 + movie, err := ExtractMovieMetadata(bytes.NewReader(MovieSample)) 115 + if err != nil { 116 + t.Fatalf("ExtractMovieMetadata failed: %v", err) 85 117 } 86 - if !strings.Contains(err.Error(), "search error") { 87 - t.Errorf("expected error to contain 'search error', got %v", err) 118 + if movie.Type != "Movie" { 119 + t.Errorf("expected Type=Movie, got %s", movie.Type) 120 + } 121 + if movie.Name == "" { 122 + t.Error("expected non-empty Name") 88 123 } 89 124 }) 90 - }) 91 125 92 - t.Run("Get", func(t *testing.T) { 93 - originalFetch := FetchMovie 94 - defer func() { FetchMovie = originalFetch }() 126 + t.Run("Extract Metadata Errors", func(t *testing.T) { 127 + if _, err := ExtractMovieMetadata(strings.NewReader("not html")); err == nil { 128 + t.Error("expected error for invalid HTML") 129 + } 95 130 96 - FetchMovie = func(url string) (*Movie, error) { 97 - if url == "error" { 98 - return nil, errors.New("fetch error") 131 + html := `<script type="application/ld+json">{"@type":"Other"}</script>` 132 + if _, err := ExtractMovieMetadata(strings.NewReader(html)); err == nil || !strings.Contains(err.Error(), "no Movie JSON-LD") { 133 + t.Errorf("expected 'no Movie JSON-LD', got %v", err) 99 134 } 100 - return ExtractMovieMetadata(bytes.NewReader(MovieSample)) 101 - } 102 135 103 - service := NewMovieService() 136 + html = `<script type="application/ld+json">{oops}</script>` 137 + if _, err := ExtractMovieMetadata(strings.NewReader(html)); err == nil { 138 + t.Error("expected error for invalid JSON") 139 + } 140 + }) 104 141 105 - t.Run("successful get", func(t *testing.T) { 106 - result, err := service.Get(context.Background(), "some-url") 142 + t.Run("Extract TV Series Metadata", func(t *testing.T) { 143 + series, err := ExtractTVSeriesMetadata(bytes.NewReader(SeriesSample)) 107 144 if err != nil { 108 - t.Fatalf("Get failed: %v", err) 145 + t.Fatalf("ExtractTVSeriesMetadata failed: %v", err) 109 146 } 110 - movie, ok := (*result).(*models.Movie) 111 - if !ok { 112 - t.Fatalf("expected a movie model, got %T", *result) 147 + if series.Type != "TVSeries" { 148 + t.Errorf("expected Type=TVSeries, got %s", series.Type) 113 149 } 114 - if movie.Title != "The Fantastic Four: First Steps" { 115 - t.Errorf("expected title 'The Fantastic Four: First Steps', got '%s'", movie.Title) 150 + if series.NumberOfSeasons <= 0 { 151 + t.Error("expected NumberOfSeasons > 0") 116 152 } 117 153 }) 118 154 119 - t.Run("get returns error", func(t *testing.T) { 120 - _, err := service.Get(context.Background(), "error") 121 - if err == nil { 122 - t.Fatal("expected error from get, got nil") 155 + t.Run("Extract TV Series Metadata Errors", func(t *testing.T) { 156 + if _, err := ExtractTVSeriesMetadata(strings.NewReader("not html")); err == nil { 157 + t.Error("expected error for invalid HTML") 123 158 } 124 - if !strings.Contains(err.Error(), "fetch error") { 125 - t.Errorf("expected error to contain 'fetch error', got %v", err) 159 + 160 + html := `<script type="application/ld+json">{"@type":"Other"}</script>` 161 + if _, err := ExtractTVSeriesMetadata(strings.NewReader(html)); err == nil || !strings.Contains(err.Error(), "no TVSeries JSON-LD") { 162 + t.Errorf("expected 'no TVSeries JSON-LD', got %v", err) 126 163 } 127 164 }) 128 - }) 129 - 130 - t.Run("Check", func(t *testing.T) { 131 - originalFetchHTML := FetchHTML 132 - defer func() { FetchHTML = originalFetchHTML }() 133 165 134 - service := NewMovieService() 135 - 136 - t.Run("successful check", func(t *testing.T) { 137 - FetchHTML = func(url string) (string, error) { 138 - return "ok", nil 139 - } 140 - err := service.Check(context.Background()) 166 + t.Run("Extract TV Series Season metadata", func(t *testing.T) { 167 + season, err := ExtractTVSeasonMetadata(bytes.NewReader(SeasonSample)) 141 168 if err != nil { 142 - t.Fatalf("Check failed: %v", err) 169 + t.Fatalf("ExtractTVSeasonMetadata failed: %v", err) 143 170 } 144 - }) 145 - 146 - t.Run("check returns error", func(t *testing.T) { 147 - FetchHTML = func(url string) (string, error) { 148 - return "", errors.New("html fetch error") 171 + if season.Type != "TVSeason" { 172 + t.Errorf("expected Type=TVSeason, got %s", season.Type) 149 173 } 150 - err := service.Check(context.Background()) 151 - if err == nil { 152 - t.Fatal("expected error from check, got nil") 174 + if season.SeasonNumber <= 0 { 175 + t.Error("expected SeasonNumber > 0") 153 176 } 154 - if !strings.Contains(err.Error(), "html fetch error") { 155 - t.Errorf("expected error to contain 'html fetch error', got %v", err) 177 + if season.PartOfSeries.Name == "" { 178 + t.Error("expected non-empty PartOfSeries.Name") 156 179 } 157 180 }) 158 - }) 159 181 160 - t.Run("Parse Search results", func(t *testing.T) { 161 - results, err := ParseSearch(bytes.NewReader(SearchSample)) 162 - if err != nil { 163 - t.Fatalf("ParseSearch failed: %v", err) 164 - } 165 - if len(results) == 0 { 166 - t.Fatal("expected non-empty search results") 167 - } 168 - }) 182 + t.Run("Extract TV Series Season errors", func(t *testing.T) { 183 + if _, err := ExtractTVSeasonMetadata(strings.NewReader("not html")); err == nil { 184 + t.Error("expected error for invalid HTML") 185 + } 169 186 170 - t.Run("Parse Search error", func(t *testing.T) { 171 - results, err := ParseSearch(strings.NewReader("\x00bad html")) 172 - if err != nil { 173 - t.Fatalf("unexpected error for malformed HTML: %v", err) 174 - } 175 - if len(results) != 0 { 176 - t.Errorf("expected 0 results for malformed HTML, got %d", len(results)) 177 - } 187 + html := `<script type="application/ld+json">{"@type":"Other"}</script>` 188 + if _, err := ExtractTVSeasonMetadata(strings.NewReader(html)); err == nil || !strings.Contains(err.Error(), "no TVSeason JSON-LD") { 189 + t.Errorf("expected 'no TVSeason JSON-LD', got %v", err) 190 + } 191 + }) 178 192 179 - html := `<a class="score-list-item"><span>Test</span></a>` 180 - results, err = ParseSearch(strings.NewReader(html)) 181 - if err != nil { 182 - t.Fatalf("unexpected error: %v", err) 183 - } 184 - if len(results) != 0 { 185 - t.Errorf("expected 0 results, got %d", len(results)) 186 - } 187 - }) 188 - 189 - t.Run("Extract Metadata", func(t *testing.T) { 190 - movie, err := ExtractMovieMetadata(bytes.NewReader(MovieSample)) 191 - if err != nil { 192 - t.Fatalf("ExtractMovieMetadata failed: %v", err) 193 - } 194 - if movie.Type != "Movie" { 195 - t.Errorf("expected Type=Movie, got %s", movie.Type) 196 - } 197 - if movie.Name == "" { 198 - t.Error("expected non-empty Name") 199 - } 200 - }) 201 - 202 - t.Run("Extract Metadata Errors", func(t *testing.T) { 203 - if _, err := ExtractMovieMetadata(strings.NewReader("not html")); err == nil { 204 - t.Error("expected error for invalid HTML") 205 - } 206 - 207 - html := `<script type="application/ld+json">{"@type":"Other"}</script>` 208 - if _, err := ExtractMovieMetadata(strings.NewReader(html)); err == nil || !strings.Contains(err.Error(), "no Movie JSON-LD") { 209 - t.Errorf("expected 'no Movie JSON-LD', got %v", err) 210 - } 211 - 212 - html = `<script type="application/ld+json">{oops}</script>` 213 - if _, err := ExtractMovieMetadata(strings.NewReader(html)); err == nil { 214 - t.Error("expected error for invalid JSON") 215 - } 216 - }) 217 - 218 - t.Run("Extract TV Series Metadata", func(t *testing.T) { 219 - series, err := ExtractTVSeriesMetadata(bytes.NewReader(SeriesSample)) 220 - if err != nil { 221 - t.Fatalf("ExtractTVSeriesMetadata failed: %v", err) 222 - } 223 - if series.Type != "TVSeries" { 224 - t.Errorf("expected Type=TVSeries, got %s", series.Type) 225 - } 226 - if series.NumberOfSeasons <= 0 { 227 - t.Error("expected NumberOfSeasons > 0") 228 - } 229 - }) 230 - 231 - t.Run("Extract TV Series Metadata Errors", func(t *testing.T) { 232 - if _, err := ExtractTVSeriesMetadata(strings.NewReader("not html")); err == nil { 233 - t.Error("expected error for invalid HTML") 234 - } 193 + t.Run("Fetch HTML errors", func(t *testing.T) { 194 + if _, err := FetchHTML("://bad-url"); err == nil { 195 + t.Error("expected error for invalid URL") 196 + } 197 + }) 235 198 236 - html := `<script type="application/ld+json">{"@type":"Other"}</script>` 237 - if _, err := ExtractTVSeriesMetadata(strings.NewReader(html)); err == nil || !strings.Contains(err.Error(), "no TVSeries JSON-LD") { 238 - t.Errorf("expected 'no TVSeries JSON-LD', got %v", err) 239 - } 240 199 }) 241 200 242 - t.Run("Extract TV Series Season metadata", func(t *testing.T) { 243 - season, err := ExtractTVSeasonMetadata(bytes.NewReader(SeasonSample)) 244 - if err != nil { 245 - t.Fatalf("ExtractTVSeasonMetadata failed: %v", err) 246 - } 247 - if season.Type != "TVSeason" { 248 - t.Errorf("expected Type=TVSeason, got %s", season.Type) 249 - } 250 - if season.SeasonNumber <= 0 { 251 - t.Error("expected SeasonNumber > 0") 252 - } 253 - if season.PartOfSeries.Name != "Peacemaker" { 254 - t.Errorf("expected PartOfSeries.Name=Peacemaker, got %s", season.PartOfSeries.Name) 255 - } 256 - }) 201 + t.Run("TVService", func(t *testing.T) { 202 + t.Run("Search", func(t *testing.T) { 203 + t.Run("successful search", func(t *testing.T) { 204 + cleanup := SetupSuccessfulTVMocks(t) 205 + defer cleanup() 257 206 258 - t.Run("Extract TV Series Season errors", func(t *testing.T) { 259 - if _, err := ExtractTVSeasonMetadata(strings.NewReader("not html")); err == nil { 260 - t.Error("expected error for invalid HTML") 261 - } 207 + service := CreateTVService() 208 + TestTVSearch(t, service, "peacemaker", "Peacemaker") 209 + }) 262 210 263 - html := `<script type="application/ld+json">{"@type":"Other"}</script>` 264 - if _, err := ExtractTVSeasonMetadata(strings.NewReader(html)); err == nil || !strings.Contains(err.Error(), "no TVSeason JSON-LD") { 265 - t.Errorf("expected 'no TVSeason JSON-LD', got %v", err) 266 - } 267 - }) 268 - 269 - t.Run("Fetch HTML errors", func(t *testing.T) { 270 - if _, err := FetchHTML("://bad-url"); err == nil { 271 - t.Error("expected error for invalid URL") 272 - } 273 - }) 274 - 275 - } 276 - 277 - func TestTVService(t *testing.T) { 278 - t.Run("Search", func(t *testing.T) { 279 - originalSearch := SearchRottenTomatoes 280 - defer func() { SearchRottenTomatoes = originalSearch }() 281 - 282 - SearchRottenTomatoes = func(q string) ([]Media, error) { 283 - if q == "error" { 284 - return nil, errors.New("search error") 285 - } 286 - return ParseSearch(bytes.NewReader(SearchSample)) 287 - } 211 + t.Run("search returns error", func(t *testing.T) { 212 + cleanup := SetupFailureMocks(t, "search error") 213 + defer cleanup() 288 214 289 - service := NewTVService() 215 + service := CreateTVService() 216 + _, err := service.Search(context.Background(), "error", 1, 10) 217 + AssertErrorContains(t, err, "search error") 218 + }) 219 + }) 290 220 291 - t.Run("successful search", func(t *testing.T) { 292 - results, err := service.Search(context.Background(), "peacemaker", 1, 10) 293 - if err != nil { 294 - t.Fatalf("Search failed: %v", err) 295 - } 296 - if len(results) == 0 { 297 - t.Fatal("expected search results, got none") 298 - } 221 + t.Run("Get", func(t *testing.T) { 222 + t.Run("successful get", func(t *testing.T) { 223 + cleanup := SetupSuccessfulTVMocks(t) 224 + defer cleanup() 299 225 300 - var tvFound bool 301 - for _, r := range results { 302 - s, ok := (*r).(*models.TVShow) 226 + service := CreateTVService() 227 + result, err := service.Get(context.Background(), "some-url") 228 + if err != nil { 229 + t.Fatalf("Get failed: %v", err) 230 + } 231 + show, ok := (*result).(*models.TVShow) 303 232 if !ok { 304 - continue 233 + t.Fatalf("expected a tv show model, got %T", *result) 305 234 } 306 - if strings.Contains(s.Title, "Peacemaker") { 307 - tvFound = true 308 - break 235 + if show.Title == "" { 236 + t.Error("expected non-empty TV show title") 309 237 } 310 - } 311 - if !tvFound { 312 - t.Error("expected to find a tv show in search results") 313 - } 314 - }) 238 + }) 315 239 316 - t.Run("search returns error", func(t *testing.T) { 317 - _, err := service.Search(context.Background(), "error", 1, 10) 318 - if err == nil { 319 - t.Fatal("expected error from search, got nil") 320 - } 321 - }) 322 - }) 323 - 324 - t.Run("Get", func(t *testing.T) { 325 - originalFetch := FetchTVSeries 326 - defer func() { FetchTVSeries = originalFetch }() 327 - 328 - FetchTVSeries = func(url string) (*TVSeries, error) { 329 - if url == "error" { 330 - return nil, errors.New("fetch error") 331 - } 332 - return ExtractTVSeriesMetadata(bytes.NewReader(SeriesSample)) 333 - } 334 - 335 - service := NewTVService() 336 - 337 - t.Run("successful get", func(t *testing.T) { 338 - result, err := service.Get(context.Background(), "some-url") 339 - if err != nil { 340 - t.Fatalf("Get failed: %v", err) 341 - } 342 - show, ok := (*result).(*models.TVShow) 343 - if !ok { 344 - t.Fatalf("expected a tv show model, got %T", *result) 345 - } 346 - if !strings.Contains(show.Title, "Peacemaker") { 347 - t.Errorf("expected title to contain 'Peacemaker', got '%s'", show.Title) 348 - } 349 - }) 240 + t.Run("get returns error", func(t *testing.T) { 241 + cleanup := SetupFailureMocks(t, "fetch error") 242 + defer cleanup() 350 243 351 - t.Run("get returns error", func(t *testing.T) { 352 - _, err := service.Get(context.Background(), "error") 353 - if err == nil { 354 - t.Fatal("expected error from get, got nil") 355 - } 244 + service := CreateTVService() 245 + _, err := service.Get(context.Background(), "error") 246 + AssertErrorContains(t, err, "fetch error") 247 + }) 356 248 }) 357 - }) 358 249 359 - t.Run("Check", func(t *testing.T) { 360 - originalFetchHTML := FetchHTML 361 - defer func() { FetchHTML = originalFetchHTML }() 250 + t.Run("Check", func(t *testing.T) { 251 + t.Run("successful check", func(t *testing.T) { 252 + cleanup := SetupSuccessfulTVMocks(t) 253 + defer cleanup() 362 254 363 - service := NewTVService() 255 + service := CreateTVService() 256 + err := service.Check(context.Background()) 257 + if err != nil { 258 + t.Fatalf("Check failed: %v", err) 259 + } 260 + }) 364 261 365 - t.Run("successful check", func(t *testing.T) { 366 - FetchHTML = func(url string) (string, error) { 367 - return "ok", nil 368 - } 369 - err := service.Check(context.Background()) 370 - if err != nil { 371 - t.Fatalf("Check failed: %v", err) 372 - } 373 - }) 262 + t.Run("check returns error", func(t *testing.T) { 263 + cleanup := SetupFailureMocks(t, "html fetch error") 264 + defer cleanup() 374 265 375 - t.Run("check returns error", func(t *testing.T) { 376 - FetchHTML = func(url string) (string, error) { 377 - return "", errors.New("html fetch error") 378 - } 379 - err := service.Check(context.Background()) 380 - if err == nil { 381 - t.Fatal("expected error from check, got nil") 382 - } 266 + service := CreateTVService() 267 + err := service.Check(context.Background()) 268 + AssertErrorContains(t, err, "html fetch error") 269 + }) 383 270 }) 384 271 }) 385 272 }
+18 -33
internal/services/services.go
··· 21 21 22 22 const ( 23 23 // Open Library API endpoints 24 - openLibraryBaseURL = "https://openlibrary.org" 25 - openLibrarySearch = openLibraryBaseURL + "/search.json" 24 + OpenLibraryBaseURL string = "https://openlibrary.org" 25 + openLibrarySearch string = OpenLibraryBaseURL + "/search.json" 26 26 27 27 // Rate limiting: 180 requests per minute = 3 requests per second 28 - requestsPerSecond = 3 29 - burstLimit = 5 28 + requestsPerSecond int = 3 29 + burstLimit int = 5 30 30 31 31 // User agent 32 32 // TODO: See https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications ··· 45 45 type BookService struct { 46 46 client *http.Client 47 47 limiter *rate.Limiter 48 - baseURL string // Allow configurable base URL for testing 48 + baseURL string // Allows configurable base URL for testing 49 49 } 50 50 51 51 // NewBookService creates a new book service with rate limiting 52 - func NewBookService() *BookService { 53 - return &BookService{ 54 - client: &http.Client{ 55 - Timeout: 30 * time.Second, 56 - }, 57 - limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burstLimit), 58 - baseURL: openLibraryBaseURL, 59 - } 60 - } 61 - 62 - // NewBookServiceWithBaseURL creates a book service with custom base URL (for testing) 63 - func NewBookServiceWithBaseURL(baseURL string) *BookService { 52 + func NewBookService(baseURL string) *BookService { 64 53 return &BookService{ 65 54 client: &http.Client{ 66 55 Timeout: 30 * time.Second, ··· 125 114 Key string `json:"key"` 126 115 } 127 116 117 + func (bs *BookService) buildSearchURL(query string, page, limit int) string { 118 + params := url.Values{} 119 + params.Add("q", query) 120 + params.Add("offset", strconv.Itoa((page-1)*limit)) 121 + params.Add("limit", strconv.Itoa(limit)) 122 + params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext") 123 + return bs.baseURL + "/search.json?" + params.Encode() 124 + } 125 + 128 126 // Search searches for books using the Open Library API 129 127 func (bs *BookService) Search(ctx context.Context, query string, page, limit int) ([]*models.Model, error) { 130 128 if err := bs.limiter.Wait(ctx); err != nil { 131 129 return nil, fmt.Errorf("rate limit wait failed: %w", err) 132 130 } 133 131 134 - // Build search URL 135 - params := url.Values{} 136 - params.Add("q", query) 137 - params.Add("offset", strconv.Itoa((page-1)*limit)) 138 - params.Add("limit", strconv.Itoa(limit)) 139 - params.Add("fields", "key,title,author_name,first_publish_year,edition_count,isbn,publisher,subject,cover_i,has_fulltext") 140 - 141 - searchURL := bs.baseURL + "/search.json?" + params.Encode() 132 + searchURL := bs.buildSearchURL(query, page, limit) 142 133 143 134 req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) 144 135 if err != nil { ··· 163 154 return nil, fmt.Errorf("failed to decode response: %w", err) 164 155 } 165 156 166 - // Convert to models 167 157 var books []*models.Model 168 158 for _, doc := range searchResp.Docs { 169 159 book := bs.searchDocToBook(doc) ··· 180 170 return nil, fmt.Errorf("rate limit wait failed: %w", err) 181 171 } 182 172 183 - // Ensure id starts with /works/ 184 173 workKey := id 185 174 if !strings.HasPrefix(workKey, "/works/") { 186 175 workKey = "/works/" + id ··· 252 241 func (bs *BookService) Close() error { 253 242 return nil 254 243 } 255 - 256 - // Helper functions 257 244 258 245 func (bs *BookService) searchDocToBook(doc OpenLibrarySearchDoc) *models.Book { 259 246 book := &models.Book{ ··· 266 253 book.Author = strings.Join(doc.AuthorName, ", ") 267 254 } 268 255 269 - // Set publication year as pages (approximation) 270 256 if doc.FirstPublishYear > 0 { 271 257 // We don't have page count, so we'll leave it as 0 272 - // Could potentially estimate based on edition count or other factors 258 + // TODO: Could potentially estimate based on edition count or other factors 273 259 } 274 260 275 261 var notes []string ··· 297 283 Added: time.Now(), 298 284 } 299 285 300 - // Extract author names (would need additional API calls to get full names) 286 + // TODO: Extract author names (would need additional API calls to get full names) 301 287 if len(work.Authors) > 0 { 302 - // For now, just use the keys 303 288 var authorKeys []string 304 289 for _, author := range work.Authors { 305 290 key := strings.TrimPrefix(author.Author.Key, "/authors/")
+30 -41
internal/services/services_test.go
··· 8 8 "strings" 9 9 "testing" 10 10 "time" 11 + 12 + "golang.org/x/time/rate" 11 13 ) 12 14 13 15 func TestBookService(t *testing.T) { 14 16 t.Run("NewBookService", func(t *testing.T) { 15 - service := NewBookService() 17 + service := NewBookService(OpenLibraryBaseURL) 16 18 17 19 if service == nil { 18 20 t.Fatal("NewBookService should return a non-nil service") ··· 26 28 t.Error("BookService should have a non-nil rate limiter") 27 29 } 28 30 29 - if service.limiter.Limit() != requestsPerSecond { 31 + if service.limiter.Limit() != rate.Limit(requestsPerSecond) { 30 32 t.Errorf("Expected rate limit of %v, got %v", requestsPerSecond, service.limiter.Limit()) 31 33 } 32 34 }) ··· 84 86 })) 85 87 defer server.Close() 86 88 87 - service := NewBookServiceWithBaseURL(server.URL) 89 + service := NewBookService(server.URL) 88 90 ctx := context.Background() 89 91 results, err := service.Search(ctx, "roald dahl", 1, 10) 90 92 ··· 107 109 })) 108 110 defer server.Close() 109 111 110 - service := NewBookServiceWithBaseURL(server.URL) 112 + service := NewBookService(server.URL) 111 113 ctx := context.Background() 112 114 113 115 _, err := service.Search(ctx, "test", 1, 10) ··· 115 117 t.Error("Search should return error for API failure") 116 118 } 117 119 118 - if !strings.Contains(err.Error(), "API returned status 500") { 119 - t.Errorf("Error should mention status code, got: %v", err) 120 - } 120 + AssertErrorContains(t, err, "API returned status 500") 121 121 }) 122 122 123 123 t.Run("handles malformed JSON", func(t *testing.T) { ··· 127 127 })) 128 128 defer server.Close() 129 129 130 - service := NewBookServiceWithBaseURL(server.URL) 130 + service := NewBookService(server.URL) 131 131 ctx := context.Background() 132 132 133 133 _, err := service.Search(ctx, "test", 1, 10) ··· 135 135 t.Error("Search should return error for malformed JSON") 136 136 } 137 137 138 - if !strings.Contains(err.Error(), "failed to decode response") { 139 - t.Errorf("Error should mention decode failure, got: %v", err) 140 - } 138 + AssertErrorContains(t, err, "failed to decode response") 141 139 }) 142 140 143 141 t.Run("handles context cancellation", func(t *testing.T) { 144 - service := NewBookService() 142 + service := NewBookService(OpenLibraryBaseURL) 145 143 ctx, cancel := context.WithCancel(context.Background()) 146 144 cancel() 147 145 ··· 152 150 }) 153 151 154 152 t.Run("respects pagination", func(t *testing.T) { 155 - service := NewBookService() 153 + service := NewBookService(OpenLibraryBaseURL) 156 154 ctx := context.Background() 157 155 158 156 _, err := service.Search(ctx, "test", 2, 5) ··· 194 192 })) 195 193 defer server.Close() 196 194 197 - service := NewBookServiceWithBaseURL(server.URL) 195 + service := NewBookService(server.URL) 198 196 ctx := context.Background() 199 197 200 198 result, err := service.Get(ctx, "OL45804W") ··· 208 206 }) 209 207 210 208 t.Run("handles work key with /works/ prefix", func(t *testing.T) { 211 - service := NewBookService() 209 + service := NewBookService(OpenLibraryBaseURL) 212 210 ctx := context.Background() 213 211 214 212 _, err1 := service.Get(ctx, "OL45804W") ··· 225 223 })) 226 224 defer server.Close() 227 225 228 - service := NewBookServiceWithBaseURL(server.URL) 226 + service := NewBookService(server.URL) 229 227 ctx := context.Background() 230 228 231 229 _, err := service.Get(ctx, "nonexistent") ··· 233 231 t.Error("Get should return error for non-existent work") 234 232 } 235 233 236 - if !strings.Contains(err.Error(), "book not found") { 237 - t.Errorf("Error should mention book not found, got: %v", err) 238 - } 234 + AssertErrorContains(t, err, "book not found") 239 235 }) 240 236 241 237 t.Run("handles API error", func(t *testing.T) { ··· 244 240 })) 245 241 defer server.Close() 246 242 247 - service := NewBookServiceWithBaseURL(server.URL) 243 + service := NewBookService(server.URL) 248 244 ctx := context.Background() 249 245 250 246 _, err := service.Get(ctx, "test") ··· 252 248 t.Error("Get should return error for API failure") 253 249 } 254 250 255 - if !strings.Contains(err.Error(), "API returned status 500") { 256 - t.Errorf("Error should mention status code, got: %v", err) 257 - } 251 + AssertErrorContains(t, err, "API returned status 500") 258 252 }) 259 253 }) 260 254 261 255 t.Run("Check", func(t *testing.T) { 262 256 t.Run("successful check", func(t *testing.T) { 263 257 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 264 - // Verify it's a search request with test query 265 258 if r.URL.Path != "/search.json" { 266 259 t.Errorf("Expected path /search.json, got %s", r.URL.Path) 267 260 } ··· 274 267 t.Errorf("Expected limit '1', got %s", query.Get("limit")) 275 268 } 276 269 277 - // Verify User-Agent 278 270 if r.Header.Get("User-Agent") != userAgent { 279 271 t.Errorf("Expected User-Agent %s, got %s", userAgent, r.Header.Get("User-Agent")) 280 272 } ··· 284 276 })) 285 277 defer server.Close() 286 278 287 - service := NewBookServiceWithBaseURL(server.URL) 279 + service := NewBookService(server.URL) 288 280 ctx := context.Background() 289 281 290 - // Test with mock server 291 282 err := service.Check(ctx) 292 283 if err != nil { 293 284 t.Errorf("Check should not return error for healthy API: %v", err) ··· 300 291 })) 301 292 defer server.Close() 302 293 303 - service := NewBookServiceWithBaseURL(server.URL) 294 + service := NewBookService(server.URL) 304 295 ctx := context.Background() 305 296 306 297 err := service.Check(ctx) ··· 308 299 t.Error("Check should return error for API failure") 309 300 } 310 301 311 - if !strings.Contains(err.Error(), "open Library API returned status 503") { 312 - t.Errorf("Error should mention API status, got: %v", err) 313 - } 302 + AssertErrorContains(t, err, "open Library API returned status 503") 314 303 }) 315 304 316 305 t.Run("handles network error", func(t *testing.T) { 317 - service := NewBookService() 306 + service := NewBookService(OpenLibraryBaseURL) 318 307 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) 319 308 defer cancel() 320 309 ··· 326 315 }) 327 316 328 317 t.Run("Close", func(t *testing.T) { 329 - service := NewBookService() 318 + service := NewBookService(OpenLibraryBaseURL) 330 319 err := service.Close() 331 320 if err != nil { 332 321 t.Errorf("Close should not return error: %v", err) ··· 335 324 336 325 t.Run("RateLimiting", func(t *testing.T) { 337 326 t.Run("respects rate limits", func(t *testing.T) { 338 - service := NewBookService() 327 + service := NewBookService(OpenLibraryBaseURL) 339 328 ctx := context.Background() 340 329 341 330 start := time.Now() ··· 368 357 369 358 t.Run("Conversion Functions", func(t *testing.T) { 370 359 t.Run("searchDocToBook conversion", func(t *testing.T) { 371 - service := NewBookService() 360 + service := NewBookService(OpenLibraryBaseURL) 372 361 doc := OpenLibrarySearchDoc{ 373 362 Key: "/works/OL45804W", 374 363 Title: "Test Book", ··· 403 392 }) 404 393 405 394 t.Run("workToBook conversion with string description", func(t *testing.T) { 406 - service := NewBookService() 395 + service := NewBookService(OpenLibraryBaseURL) 407 396 work := OpenLibraryWork{ 408 397 Key: "/works/OL45804W", 409 398 Title: "Test Work", ··· 431 420 }) 432 421 433 422 t.Run("workToBook conversion with object description", func(t *testing.T) { 434 - service := NewBookService() 423 + service := NewBookService(OpenLibraryBaseURL) 435 424 work := OpenLibraryWork{ 436 425 Title: "Test Work", 437 426 Description: map[string]any{ ··· 448 437 }) 449 438 450 439 t.Run("workToBook uses subjects when no description", func(t *testing.T) { 451 - service := NewBookService() 440 + service := NewBookService(OpenLibraryBaseURL) 452 441 work := OpenLibraryWork{ 453 442 Title: "Test Work", 454 443 Subjects: []string{"Fiction", "Adventure", "Classic", "Literature", "Drama", "Extra"}, ··· 474 463 t.Run("Interface Compliance", func(t *testing.T) { 475 464 t.Run("implements APIService interface", func(t *testing.T) { 476 465 var _ APIService = &BookService{} 477 - var _ APIService = NewBookService() 466 + var _ APIService = NewBookService(OpenLibraryBaseURL) 478 467 }) 479 468 }) 480 469 ··· 487 476 488 477 t.Run("Constants", func(t *testing.T) { 489 478 t.Run("API endpoints are correct", func(t *testing.T) { 490 - if openLibraryBaseURL != "https://openlibrary.org" { 491 - t.Errorf("Base URL should be https://openlibrary.org, got %s", openLibraryBaseURL) 479 + if OpenLibraryBaseURL != "https://openlibrary.org" { 480 + t.Errorf("Base URL should be https://openlibrary.org, got %s", OpenLibraryBaseURL) 492 481 } 493 482 494 483 if openLibrarySearch != "https://openlibrary.org/search.json" {
+273
internal/services/test_utilities.go
··· 1 + package services 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + _ "embed" 7 + "errors" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + ) 13 + 14 + // From: https://www.rottentomatoes.com/m/the_fantastic_four_first_steps 15 + // 16 + //go:embed samples/movie.html 17 + var MovieSample []byte 18 + 19 + // From: https://www.rottentomatoes.com/search?search=peacemaker 20 + // 21 + //go:embed samples/search.html 22 + var SearchSample []byte 23 + 24 + // From: https://www.rottentomatoes.com/tv/peacemaker_2022 25 + // 26 + //go:embed samples/series_overview.html 27 + var SeriesSample []byte 28 + 29 + // From: https://www.rottentomatoes.com/tv/peacemaker_2022/s02 30 + // 31 + //go:embed samples/series_season.html 32 + var SeasonSample []byte 33 + 34 + // From: https://www.rottentomatoes.com/search?search=Fantastic%20Four 35 + // 36 + //go:embed samples/movie_search.html 37 + var MovieSearchSample []byte 38 + 39 + // MockConfig holds configuration for mocking media services 40 + type MockConfig struct { 41 + SearchResults []Media 42 + SearchError error 43 + MovieResult *Movie 44 + MovieError error 45 + TVSeriesResult *TVSeries 46 + TVSeriesError error 47 + TVSeasonResult *TVSeason 48 + TVSeasonError error 49 + HTMLResult string 50 + HTMLError error 51 + } 52 + 53 + // MockSetup contains the original function variables for restoration 54 + type MockSetup struct { 55 + originalSearchRottenTomatoes func(string) ([]Media, error) 56 + originalFetchMovie func(string) (*Movie, error) 57 + originalFetchTVSeries func(string) (*TVSeries, error) 58 + originalFetchTVSeason func(string) (*TVSeason, error) 59 + originalFetchHTML func(string) (string, error) 60 + } 61 + 62 + // SetupMediaMocks configures mock functions for media services testing 63 + func SetupMediaMocks(t *testing.T, config MockConfig) func() { 64 + t.Helper() 65 + 66 + setup := &MockSetup{ 67 + originalSearchRottenTomatoes: SearchRottenTomatoes, 68 + originalFetchMovie: FetchMovie, 69 + originalFetchTVSeries: FetchTVSeries, 70 + originalFetchTVSeason: FetchTVSeason, 71 + originalFetchHTML: FetchHTML, 72 + } 73 + 74 + SearchRottenTomatoes = func(q string) ([]Media, error) { 75 + if config.SearchError != nil { 76 + return nil, config.SearchError 77 + } 78 + return config.SearchResults, nil 79 + } 80 + 81 + FetchMovie = func(url string) (*Movie, error) { 82 + if config.MovieError != nil { 83 + return nil, config.MovieError 84 + } 85 + return config.MovieResult, nil 86 + } 87 + 88 + FetchTVSeries = func(url string) (*TVSeries, error) { 89 + if config.TVSeriesError != nil { 90 + return nil, config.TVSeriesError 91 + } 92 + return config.TVSeriesResult, nil 93 + } 94 + 95 + FetchTVSeason = func(url string) (*TVSeason, error) { 96 + if config.TVSeasonError != nil { 97 + return nil, config.TVSeasonError 98 + } 99 + return config.TVSeasonResult, nil 100 + } 101 + 102 + FetchHTML = func(url string) (string, error) { 103 + if config.HTMLError != nil { 104 + return "", config.HTMLError 105 + } 106 + return config.HTMLResult, nil 107 + } 108 + 109 + return func() { 110 + SearchRottenTomatoes = setup.originalSearchRottenTomatoes 111 + FetchMovie = setup.originalFetchMovie 112 + FetchTVSeries = setup.originalFetchTVSeries 113 + FetchTVSeason = setup.originalFetchTVSeason 114 + FetchHTML = setup.originalFetchHTML 115 + } 116 + } 117 + 118 + // Sample data access helpers - these use the embedded samples 119 + func GetSampleMovieSearchResults() ([]Media, error) { 120 + return ParseSearch(bytes.NewReader(MovieSearchSample)) 121 + } 122 + 123 + func GetSampleSearchResults() ([]Media, error) { 124 + return ParseSearch(bytes.NewReader(SearchSample)) 125 + } 126 + 127 + func GetSampleMovie() (*Movie, error) { 128 + return ExtractMovieMetadata(bytes.NewReader(MovieSample)) 129 + } 130 + 131 + func GetSampleTVSeries() (*TVSeries, error) { 132 + return ExtractTVSeriesMetadata(bytes.NewReader(SeriesSample)) 133 + } 134 + 135 + func GetSampleTVSeason() (*TVSeason, error) { 136 + return ExtractTVSeasonMetadata(bytes.NewReader(SeasonSample)) 137 + } 138 + 139 + // SetupSuccessfulMovieMocks configures mocks for successful movie operations 140 + func SetupSuccessfulMovieMocks(t *testing.T) func() { 141 + t.Helper() 142 + 143 + movieResults, err := GetSampleMovieSearchResults() 144 + if err != nil { 145 + t.Fatalf("failed to get sample movie results: %v", err) 146 + } 147 + 148 + movie, err := GetSampleMovie() 149 + if err != nil { 150 + t.Fatalf("failed to get sample movie: %v", err) 151 + } 152 + 153 + return SetupMediaMocks(t, MockConfig{ 154 + SearchResults: movieResults, 155 + MovieResult: movie, 156 + HTMLResult: "ok", 157 + }) 158 + } 159 + 160 + // SetupSuccessfulTVMocks configures mocks for successful TV operations 161 + func SetupSuccessfulTVMocks(t *testing.T) func() { 162 + t.Helper() 163 + 164 + searchResults, err := GetSampleSearchResults() 165 + if err != nil { 166 + t.Fatalf("failed to get sample search results: %v", err) 167 + } 168 + 169 + series, err := GetSampleTVSeries() 170 + if err != nil { 171 + t.Fatalf("failed to get sample TV series: %v", err) 172 + } 173 + 174 + return SetupMediaMocks(t, MockConfig{ 175 + SearchResults: searchResults, 176 + TVSeriesResult: series, 177 + HTMLResult: "ok", 178 + }) 179 + } 180 + 181 + // SetupFailureMocks configures mocks that return errors 182 + func SetupFailureMocks(t *testing.T, errorMsg string) func() { 183 + t.Helper() 184 + 185 + err := errors.New(errorMsg) 186 + return SetupMediaMocks(t, MockConfig{ 187 + SearchError: err, 188 + MovieError: err, 189 + TVSeriesError: err, 190 + TVSeasonError: err, 191 + HTMLError: err, 192 + }) 193 + } 194 + 195 + // AssertMovieInResults checks if a movie with the given title exists in results 196 + func AssertMovieInResults(t *testing.T, results []*models.Model, expectedTitle string) { 197 + t.Helper() 198 + 199 + for _, result := range results { 200 + if movie, ok := (*result).(*models.Movie); ok { 201 + if strings.Contains(movie.Title, expectedTitle) { 202 + return 203 + } 204 + } 205 + } 206 + t.Errorf("expected to find movie containing '%s' in results", expectedTitle) 207 + } 208 + 209 + // AssertTVShowInResults checks if a TV show with the given title exists in results 210 + func AssertTVShowInResults(t *testing.T, results []*models.Model, expectedTitle string) { 211 + t.Helper() 212 + 213 + for _, result := range results { 214 + if show, ok := (*result).(*models.TVShow); ok { 215 + if strings.Contains(show.Title, expectedTitle) { 216 + return // Found it 217 + } 218 + } 219 + } 220 + t.Errorf("expected to find TV show containing '%s' in results", expectedTitle) 221 + } 222 + 223 + // AssertErrorContains checks that an error contains the expected message 224 + func AssertErrorContains(t *testing.T, err error, expectedMsg string) { 225 + t.Helper() 226 + 227 + if err == nil { 228 + t.Fatalf("expected error containing '%s', got nil", expectedMsg) 229 + } 230 + if !strings.Contains(err.Error(), expectedMsg) { 231 + t.Errorf("expected error to contain '%s', got '%v'", expectedMsg, err) 232 + } 233 + } 234 + 235 + // CreateMovieService returns a new movie service for testing 236 + func CreateMovieService() *MovieService { 237 + return NewMovieService() 238 + } 239 + 240 + // CreateTVService returns a new TV service for testing 241 + func CreateTVService() *TVService { 242 + return NewTVService() 243 + } 244 + 245 + // TestMovieSearch runs a standard movie search test 246 + func TestMovieSearch(t *testing.T, service *MovieService, query string, expectedTitleFragment string) { 247 + t.Helper() 248 + 249 + results, err := service.Search(context.Background(), query, 1, 10) 250 + if err != nil { 251 + t.Fatalf("Search failed: %v", err) 252 + } 253 + if len(results) == 0 { 254 + t.Fatal("expected search results, got none") 255 + } 256 + 257 + AssertMovieInResults(t, results, expectedTitleFragment) 258 + } 259 + 260 + // TestTVSearch runs a standard TV search test 261 + func TestTVSearch(t *testing.T, service *TVService, query string, expectedTitleFragment string) { 262 + t.Helper() 263 + 264 + results, err := service.Search(context.Background(), query, 1, 10) 265 + if err != nil { 266 + t.Fatalf("Search failed: %v", err) 267 + } 268 + if len(results) == 0 { 269 + t.Fatal("expected search results, got none") 270 + } 271 + 272 + AssertTVShowInResults(t, results, expectedTitleFragment) 273 + }
-131
media.md
··· 1 - # MEDIA management feature 2 - 3 - ## Current State Analysis 4 - 5 - - Existing Media Service (`/internal/services/media.go`): 6 - - Rotten Tomatoes scraping with colly for movies/TV search 7 - - Rich metadata extraction (Movie, TVSeries, TVSeason structs) 8 - - Search functionality via SearchRottenTomatoes() 9 - - Detailed metadata fetching via FetchMovie(), FetchTVSeries(), FetchTVSeason() 10 - 11 - - Book Search Pattern (`/internal/handlers/books.go`): 12 - - Uses APIService interface with `Search()`, `Get()`, `Check()`, `Close()` methods 13 - - Interactive and static search modes 14 - - Number-based selection UX 15 - - Converts API results to models.Book via interface 16 - 17 - - Models (`/internal/models/models.go`): 18 - - Movie and TVShow structs already implement Model interface 19 - - Both have proper status tracking (queued, watched, etc.) 20 - 21 - ## Media Service Refactor 22 - 23 - ### Create MovieService that implement APIService (โœ“) 24 - 25 - ```go 26 - // MovieService implements APIService for Rotten Tomatoes movies 27 - type MovieService struct { 28 - client *http.Client 29 - limiter *rate.Limiter 30 - } 31 - ``` 32 - 33 - ### Create TVService that implement APIService (โœ“) 34 - 35 - ```go 36 - // TVService implements APIService for Rotten Tomatoes TV shows 37 - type TVService struct { 38 - client *http.Client 39 - limiter *rate.Limiter 40 - } 41 - ``` 42 - 43 - ### Implement APIService (โœ“) 44 - 45 - - `Search(ctx, query, page, limit)` - Use existing SearchRottenTomatoes() and convert results to []*models.Model 46 - - `Get(ctx, id)` - Use existing FetchMovie() / FetchTVSeries() with Rotten Tomatoes URLs 47 - - `Check(ctx)` - Simple connectivity test to Rotten Tomatoes 48 - - `Close()` - Cleanup resources 49 - 50 - ### Result Conversion (โœ“) 51 - 52 - - Convert services.Media search results to models.Movie / models.TVShow 53 - - Convert detailed metadata structs to models with proper status defaults 54 - - Extract key information (title, year, rating, description) into notes field 55 - 56 - ## Handler Implementation (โœ“) 57 - 58 - ### Create MovieHandler similar to BookHandler 59 - 60 - ```go 61 - type MovieHandler struct { 62 - db *store.Database 63 - config *store.Config 64 - repos *repo.Repositories 65 - service *services.MovieService 66 - } 67 - ``` 68 - 69 - - `SearchAndAddMovie(ctx, args, interactive)` - Mirror book search UX 70 - - `SearchAndAddTV(ctx, args, interactive)` - Same pattern for TV shows 71 - - Number-based selection interface identical to books 72 - - Add movie/TV repositories if not already present 73 - - Ensure proper CRUD operations for queue management 74 - 75 - ## Commands 76 - 77 - ### Update definitions 78 - 79 - - Replace stubbed movie commands with real implementations 80 - - Replace stubbed TV commands with real implementations 81 - - Connect to new handlers with proper error handling 82 - 83 - ### Structure 84 - 85 - ```sh 86 - # Movies 87 - 88 - media movie add [search query...] [-i for interactive] 89 - media movie list [--all|--watched|--queued] 90 - media movie watched <id> 91 - media movie remove <id> 92 - 93 - # TV Shows 94 - 95 - media tv add [search query...] [-i for interactive] 96 - media tv list [--all|--watched|--queued] 97 - media tv watched <id> 98 - media tv remove <id> 99 - ``` 100 - 101 - ## UX Consistency 102 - 103 - ### Search 104 - 105 - 1. Parse search query from args 106 - 2. Show "Loading..." progress indicator 107 - 3. Display numbered results with title, year, rating 108 - 4. Prompt for selection (1-N or 0 to cancel) 109 - 5. Add selected item to queue with "queued" status 110 - 6. Confirm addition to user 111 - 112 - ### Interactivity 113 - 114 - - Use existing TUI patterns from book/task lists 115 - - Browse search results with keyboard navigation 116 - - Preview detailed metadata before adding 117 - 118 - ## Key Implementation Details 119 - 120 - _Rate Limiting_: Add rate limiter to media services (Rotten Tomatoes likely has limits) 121 - 122 - _Error Handling_: Robust handling of scraping failures, network issues, parsing errors 123 - 124 - _Data Mapping_: 125 - - Map Rotten Tomatoes critic scores to model rating fields 126 - - Extract genres, cast, descriptions into notes field 127 - - Handle missing or incomplete metadata gracefully 128 - 129 - _Caching_: Consider caching search results to reduce API calls during selection 130 - 131 - _Status_: Default new items to "queued" status, provide commands to update