···8687The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases.
88000000000000000089## Errors
9091Error coverage follows a systematic approach to identify and test failure scenarios:
···953. **Resource Exhaustion** - Database connection failures, memory limits
964. **Constraint Violations** - Duplicate keys, foreign key failures
975. **State Validation** - Testing functions with invalid system states
0
···8687The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases.
8889+## Interactive Component Testing
90+91+Interactive components that use `fmt.Scanf` for user input require special testing infrastructure to prevent tests from hanging while waiting for stdin.
92+93+### Testing Success Scenarios
94+95+Interactive handlers should test both success and error paths:
96+97+- **Valid user selections** - User chooses valid menu options
98+- **Cancellation** - User chooses to cancel (option 0)
99+- **Invalid choices** - User selects out-of-range options
100+- **Empty results** - Search returns no results
101+- **Network errors** - Service calls fail
102+103+This ensures tests run reliably in automated environments while maintaining coverage of the non-interactive code paths.
104+105## Errors
106107Error coverage follows a systematic approach to identify and test failure scenarios:
···1113. **Resource Exhaustion** - Database connection failures, memory limits
1124. **Constraint Violations** - Duplicate keys, foreign key failures
1135. **State Validation** - Testing functions with invalid system states
114+6. **Interactive Input** - Invalid user choices, cancellation handling, input simulation errors
+15-2
internal/handlers/books.go
···3import (
4 "context"
5 "fmt"
06 "os"
7 "slices"
8 "strconv"
···21 config *store.Config
22 repos *repo.Repositories
23 service *services.BookService
024}
2526// NewBookHandler creates a new book handler
···52 return fmt.Errorf("failed to close service: %w", err)
53 }
54 return h.db.Close()
0000055}
5657func (h *BookHandler) printBook(book *models.Book) {
···142 fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ")
143144 var choice int
145- if _, err := fmt.Scanf("%d", &choice); err != nil {
146- return fmt.Errorf("invalid input")
000000147 }
148149 if choice == 0 {
···3import (
4 "context"
5 "fmt"
6+ "io"
7 "os"
8 "slices"
9 "strconv"
···22 config *store.Config
23 repos *repo.Repositories
24 service *services.BookService
25+ reader io.Reader
26}
2728// NewBookHandler creates a new book handler
···54 return fmt.Errorf("failed to close service: %w", err)
55 }
56 return h.db.Close()
57+}
58+59+// SetInputReader sets the input reader
60+func (h *BookHandler) SetInputReader(reader io.Reader) {
61+ h.reader = reader
62}
6364func (h *BookHandler) printBook(book *models.Book) {
···149 fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ")
150151 var choice int
152+ if h.reader != nil {
153+ if _, err := fmt.Fscanf(h.reader, "%d", &choice); err != nil {
154+ return fmt.Errorf("invalid input")
155+ }
156+ } else {
157+ if _, err := fmt.Scanf("%d", &choice); err != nil {
158+ return fmt.Errorf("invalid input")
159+ }
160 }
161162 if choice == 0 {
+187-34
internal/handlers/books_test.go
···9 "time"
1011 "github.com/stormlightlabs/noteleaf/internal/models"
0012)
1314func setupBookTest(t *testing.T) (string, func()) {
···128 }
129 })
130131- t.Run("handles empty search", func(t *testing.T) {
132- args := []string{""}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000133 err := handler.SearchAndAdd(ctx, args, false)
134- if err != nil && !strings.Contains(err.Error(), "No books found") {
135- t.Errorf("Expected no error or 'No books found', got: %v", err)
00000000000136 }
137 })
138139- t.Run("with options", func(t *testing.T) {
140- ctx := context.Background()
141- t.Run("fails with empty args", func(t *testing.T) {
142- args := []string{}
143- err := handler.SearchAndAdd(ctx, args, false)
144- if err == nil {
145- t.Error("Expected error for empty args")
146- }
147148- if !strings.Contains(err.Error(), "usage: book add") {
149- t.Errorf("Expected usage error, got: %v", err)
150- }
151- })
152153- t.Run("handles search service errors", func(t *testing.T) {
154- args := []string{"test", "book"}
155- err := handler.SearchAndAdd(ctx, args, false)
156- if err == nil {
157- t.Error("Expected error due to mocked service")
158- }
159- if strings.Contains(err.Error(), "usage:") {
160- t.Error("Should not show usage error for valid args")
161- }
162- })
0000000000000000000000000163 })
0164 })
165166 t.Run("List", func(t *testing.T) {
167-168 ctx := context.Background()
169-170 _ = createTestBook(t, handler, ctx)
171172 book2 := &models.Book{
···237 }
238239 for flag, status := range statusVariants {
240- err := handler.List(ctx, status)
241- if err != nil {
242 t.Errorf("ListBooks with flag %s (status %s) failed: %v", flag, status, err)
243 }
244 }
···251 book := createTestBook(t, handler, ctx)
252253 t.Run("updates book status successfully", func(t *testing.T) {
254- err := handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), "reading")
255- if err != nil {
256 t.Errorf("UpdateBookStatusByID failed: %v", err)
257 }
258···331 validStatuses := []string{"queued", "reading", "finished", "removed"}
332333 for _, status := range validStatuses {
334- err := handler.UpdateStatus(ctx, strconv.FormatInt(book.ID, 10), status)
335- if err != nil {
336 t.Errorf("UpdateBookStatusByID with status %s failed: %v", status, err)
337 }
338 }
···3import (
4 "context"
5 "fmt"
06 "slices"
7 "strconv"
8 "strings"
···20 config *store.Config
21 repos *repo.Repositories
22 service *services.MovieService
023}
2425// NewMovieHandler creates a new movie handler
···53 return h.db.Close()
54}
550000056// SearchAndAdd searches for movies and allows user to select and add to queue
57func (h *MovieHandler) SearchAndAdd(ctx context.Context, query string, interactive bool) error {
58 if query == "" {
···100 fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ")
101102 var choice int
103- if _, err := fmt.Scanf("%d", &choice); err != nil {
104- return fmt.Errorf("invalid input")
000000105 }
106107 if choice == 0 {
···3import (
4 "context"
5 "fmt"
6+ "io"
7 "slices"
8 "strconv"
9 "strings"
···21 config *store.Config
22 repos *repo.Repositories
23 service *services.MovieService
24+ reader io.Reader
25}
2627// NewMovieHandler creates a new movie handler
···55 return h.db.Close()
56}
5758+// SetInputReader sets the input reader
59+func (h *MovieHandler) SetInputReader(reader io.Reader) {
60+ h.reader = reader
61+}
62+63// SearchAndAdd searches for movies and allows user to select and add to queue
64func (h *MovieHandler) SearchAndAdd(ctx context.Context, query string, interactive bool) error {
65 if query == "" {
···107 fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ")
108109 var choice int
110+ if h.reader != nil {
111+ if _, err := fmt.Fscanf(h.reader, "%d", &choice); err != nil {
112+ return fmt.Errorf("invalid input")
113+ }
114+ } else {
115+ if _, err := fmt.Scanf("%d", &choice); err != nil {
116+ return fmt.Errorf("invalid input")
117+ }
118 }
119120 if choice == 0 {
+147-21
internal/handlers/movies_test.go
···4 "context"
5 "fmt"
6 "strconv"
07 "testing"
8 "time"
910 "github.com/stormlightlabs/noteleaf/internal/models"
0011)
1213func createTestMovieHandler(t *testing.T) *MovieHandler {
···73 }
74 })
7576- t.Run("Search Error", func(t *testing.T) {
77 handler := createTestMovieHandler(t)
78 defer handler.Close()
7980- // Test with malformed search that should cause network error
000000000000000000081 err := handler.SearchAndAdd(context.Background(), "test movie", false)
82- // We expect this to work with the actual service, so we test for successful completion
83- // or a specific network error - this tests the error handling path in the code
00000000000000000000000000000000000000000000000000000000000000084 if err != nil {
85- // This is expected - the search might fail due to network issues in test environment
86- if err.Error() != "search query cannot be empty" {
87- // We got a search error, which tests our error handling path
88- t.Logf("Search failed as expected in test environment: %v", err)
89- }
0090 }
91 })
9293- t.Run("Network Error", func(t *testing.T) {
094 handler := createTestMovieHandler(t)
95 defer handler.Close()
9697- // Test search with a query that will likely fail due to network issues in test env
98- // This tests the error handling path
99- err := handler.SearchAndAdd(context.Background(), "unlikely_to_find_this_movie_12345", false)
100- // We don't expect a specific error, but this tests the error handling path
000000101 if err != nil {
102- t.Logf("Network error encountered (expected in test environment): %v", err)
00000000000000000000000000000000103 }
104 })
105···325 }
326 defer handler.repos.Movies.Delete(context.Background(), id2)
327328- testCases := []string{"", "queued", "watched"}
329- for _, status := range testCases {
330- err = handler.List(context.Background(), status)
331 if err != nil {
332- t.Errorf("Failed to list movies with status '%s': %v", status, err)
333 }
334 }
335 })
···342 ctx := context.Background()
343 nonExistentID := int64(999999)
344345- testCases := []struct {
346 name string
347 fn func() error
348 }{
···364 },
365 }
366367- for _, tc := range testCases {
368 t.Run(tc.name, func(t *testing.T) {
369 err := tc.fn()
370 if err == nil {