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

refactor: test utility cleanup & query builders

+1128 -1221
+1
codecov.yml
··· 21 - "**/testdata/**" 22 - "**/vendor/**" 23 - "internal/**/test_utilities.go"
··· 21 - "**/testdata/**" 22 - "**/vendor/**" 23 - "internal/**/test_utilities.go" 24 + - "internal/handlers/handler_test_suite.go"
+9 -9
internal/articles/parser_test.go
··· 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 ) 14 15 // ExampleParser_Convert demonstrates parsing a local HTML file using Wikipedia rules. 16 func ExampleParser_Convert() { 17 parser, err := NewArticleParser(http.DefaultClient) ··· 636 </body> 637 </html>` 638 639 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 640 - w.WriteHeader(http.StatusOK) 641 - w.Write([]byte(wikipediaHTML)) 642 - })) 643 defer server.Close() 644 645 // Create a custom parser with localhost rule for testing ··· 746 </body> 747 </html>` 748 749 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 750 - w.WriteHeader(http.StatusOK) 751 - w.Write([]byte(contentHTML)) 752 - })) 753 defer server.Close() 754 755 - // Create a custom parser with arXiv-like rule for testing 756 parser, err := NewArticleParser(server.Client()) 757 if err != nil { 758 t.Fatalf("Failed to create parser: %v", err)
··· 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 ) 14 15 + func newServerWithHtml(h string) *httptest.Server { 16 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 + w.WriteHeader(http.StatusOK) 18 + w.Write([]byte(h)) 19 + })) 20 + } 21 + 22 // ExampleParser_Convert demonstrates parsing a local HTML file using Wikipedia rules. 23 func ExampleParser_Convert() { 24 parser, err := NewArticleParser(http.DefaultClient) ··· 643 </body> 644 </html>` 645 646 + server := newServerWithHtml(wikipediaHTML) 647 defer server.Close() 648 649 // Create a custom parser with localhost rule for testing ··· 750 </body> 751 </html>` 752 753 + server := newServerWithHtml(contentHTML) 754 defer server.Close() 755 756 parser, err := NewArticleParser(server.Client()) 757 if err != nil { 758 t.Fatalf("Failed to create parser: %v", err)
+5 -5
internal/handlers/articles_test.go
··· 224 err := helper.List(ctx, "", "", 0) 225 Expect.AssertNoError(t, err, "List should succeed") 226 227 - Expect.AssertArticleExists(t, helper, id1) 228 - Expect.AssertArticleExists(t, helper, id2) 229 }) 230 231 t.Run("lists with title filter", func(t *testing.T) { ··· 389 helper := NewArticleTestHelper(t) 390 ctx := context.Background() 391 id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01") 392 - Expect.AssertArticleExists(t, helper, id) 393 394 err := helper.Remove(ctx, id) 395 Expect.AssertNoError(t, err, "Remove should succeed") 396 - Expect.AssertArticleNotExists(t, helper, id) 397 }) 398 399 t.Run("handles non-existent article", func(t *testing.T) { ··· 581 err = helper.Remove(ctx, articleID) 582 Expect.AssertNoError(t, err, "Remove should succeed in integration test") 583 584 - Expect.AssertArticleNotExists(t, helper, articleID) 585 }) 586 }
··· 224 err := helper.List(ctx, "", "", 0) 225 Expect.AssertNoError(t, err, "List should succeed") 226 227 + AssertExists(t, helper.repos.Articles.Get, id1, "article") 228 + AssertExists(t, helper.repos.Articles.Get, id2, "article") 229 }) 230 231 t.Run("lists with title filter", func(t *testing.T) { ··· 389 helper := NewArticleTestHelper(t) 390 ctx := context.Background() 391 id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01") 392 + AssertExists(t, helper.repos.Articles.Get, id, "article") 393 394 err := helper.Remove(ctx, id) 395 Expect.AssertNoError(t, err, "Remove should succeed") 396 + AssertNotExists(t, helper.repos.Articles.Get, id, "article") 397 }) 398 399 t.Run("handles non-existent article", func(t *testing.T) { ··· 581 err = helper.Remove(ctx, articleID) 582 Expect.AssertNoError(t, err, "Remove should succeed in integration test") 583 584 + AssertNotExists(t, helper.repos.Articles.Get, articleID, "article") 585 }) 586 }
+110
internal/handlers/behaviors.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "io" 6 + ) 7 + 8 + // Core Handler Behavior Interfaces 9 + // 10 + // These interfaces define composable behaviors that handlers can implement 11 + // to provide consistent functionality across different domain handlers. 12 + 13 + // Closeable manages resource cleanup for handlers 14 + // 15 + // Implemented by all handlers that manage database connections, services, or other resources 16 + type Closeable interface { 17 + Close() error 18 + } 19 + 20 + // Viewable retrieves and displays a single item by ID (int64) 21 + // 22 + // Implemented by handlers that support viewing individual items with integer IDs 23 + type Viewable interface { 24 + View(ctx context.Context, id int64) error 25 + } 26 + 27 + // ViewableByString retrieves and displays a single item by ID (string) 28 + // 29 + // Implemented by handlers that support viewing items with string IDs 30 + type ViewableByString interface { 31 + View(ctx context.Context, id string) error 32 + } 33 + 34 + // Note: Listable already defined in media_handler.go 35 + // We reuse that interface: List(ctx context.Context, status string) error 36 + 37 + // Removable handles item removal (already defined in media_handler.go) 38 + // We extend it with type-specific variants for consistency 39 + 40 + // RemovableByInt64 handles removal by integer ID 41 + type RemovableByInt64 interface { 42 + Remove(ctx context.Context, id int64) error 43 + } 44 + 45 + // DeletableByInt64 handles deletion by integer ID 46 + type DeletableByInt64 interface { 47 + Delete(ctx context.Context, id int64) error 48 + } 49 + 50 + // InputReader provides testable input handling 51 + // 52 + // Handlers implementing this interface can have their input source replaced for testing 53 + type InputReader interface { 54 + SetInputReader(reader io.Reader) 55 + } 56 + 57 + // InteractiveSupport indicates handler support for interactive/TUI modes 58 + // 59 + // Handlers can check if they're running in test mode and adjust behavior accordingly 60 + type InteractiveSupport interface { 61 + SupportsInteractive() bool 62 + } 63 + 64 + // Renderable provides content rendering capabilities 65 + // 66 + // Implemented by handlers that need to render markdown or other formatted content 67 + type Renderable interface { 68 + Render(content string) (string, error) 69 + } 70 + 71 + // MediaHandler is already defined in media_handler.go 72 + // It composes: Searchable, Listable, StatusUpdatable, Removable, InputReader, Closeable 73 + 74 + // Compile-time interface checks - verifying shared behaviors across handlers 75 + // 76 + // Note: Handlers have domain-specific List() signatures, so we cannot enforce 77 + // a common Listable interface. Each handler's List method is tailored to its domain: 78 + // - ArticleHandler: List(ctx, query, author, limit) 79 + // - NoteHandler: List(ctx, static, showArchived, tags) 80 + // - TaskHandler: List(ctx, static, showAll, status, priority, project, context) 81 + // - Media handlers: List(ctx, status) via MediaHandler.Listable 82 + var ( 83 + // All handlers implement Closeable for resource cleanup 84 + _ Closeable = (*ArticleHandler)(nil) 85 + _ Closeable = (*NoteHandler)(nil) 86 + _ Closeable = (*TaskHandler)(nil) 87 + _ Closeable = (*BookHandler)(nil) 88 + _ Closeable = (*MovieHandler)(nil) 89 + _ Closeable = (*TVHandler)(nil) 90 + 91 + // Handlers with View by ID (int64) 92 + _ Viewable = (*ArticleHandler)(nil) 93 + _ Viewable = (*MovieHandler)(nil) 94 + 95 + // Handlers with Remove by ID (int64) 96 + _ RemovableByInt64 = (*ArticleHandler)(nil) 97 + 98 + // Handlers with Delete by ID (int64) 99 + _ DeletableByInt64 = (*NoteHandler)(nil) 100 + 101 + // Media handlers implement MediaHandler (defined in media_handler.go) 102 + _ MediaHandler = (*BookHandler)(nil) 103 + _ MediaHandler = (*MovieHandler)(nil) 104 + _ MediaHandler = (*TVHandler)(nil) 105 + 106 + // Media handlers support input reading for testing 107 + _ InputReader = (*BookHandler)(nil) 108 + _ InputReader = (*MovieHandler)(nil) 109 + _ InputReader = (*TVHandler)(nil) 110 + )
-1
internal/handlers/books.go
··· 27 reader io.Reader 28 } 29 30 - // Ensure BookHandler implements MediaHandler interface 31 var _ MediaHandler = (*BookHandler)(nil) 32 33 // NewBookHandler creates a new book handler
··· 27 reader io.Reader 28 } 29 30 var _ MediaHandler = (*BookHandler)(nil) 31 32 // NewBookHandler creates a new book handler
+76 -47
internal/handlers/books_test.go
··· 3 import ( 4 "context" 5 "os" 6 - "path/filepath" 7 "strconv" 8 "strings" 9 "testing" ··· 14 "github.com/stormlightlabs/noteleaf/internal/services" 15 ) 16 17 - func setupBookTest(t *testing.T) (string, func()) { 18 - tempDir, err := os.MkdirTemp("", "noteleaf-book-test-*") 19 - if err != nil { 20 - t.Fatalf("Failed to create temp dir: %v", err) 21 - } 22 - 23 - oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 24 - oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 25 - os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 26 - os.Setenv("NOTELEAF_DATA_DIR", tempDir) 27 - 28 - cleanup := func() { 29 - os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 30 - os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 31 - os.RemoveAll(tempDir) 32 - } 33 - 34 - ctx := context.Background() 35 - err = Setup(ctx, []string{}) 36 - if err != nil { 37 - cleanup() 38 - t.Fatalf("Failed to setup database: %v", err) 39 - } 40 - 41 - return tempDir, cleanup 42 - } 43 44 func createTestBook(t *testing.T, handler *BookHandler, ctx context.Context) *models.Book { 45 t.Helper() ··· 63 func TestBookHandler(t *testing.T) { 64 t.Run("New", func(t *testing.T) { 65 t.Run("creates handler successfully", func(t *testing.T) { 66 - _, cleanup := setupBookTest(t) 67 - defer cleanup() 68 69 handler, err := NewBookHandler() 70 if err != nil { ··· 111 }) 112 113 t.Run("BookHandler instance methods", func(t *testing.T) { 114 - _, cleanup := setupBookTest(t) 115 - defer cleanup() 116 117 handler, err := NewBookHandler() 118 if err != nil { ··· 223 }) 224 225 t.Run("handles interactive mode", func(t *testing.T) { 226 - _, cleanup := setupBookTest(t) 227 - defer cleanup() 228 229 handler, err := NewBookHandler() 230 if err != nil { ··· 251 }) 252 253 t.Run("interactive mode path", func(t *testing.T) { 254 - _, cleanup := setupBookTest(t) 255 - defer cleanup() 256 257 handler, err := NewBookHandler() 258 if err != nil { ··· 528 }) 529 530 t.Run("progress", func(t *testing.T) { 531 - _, cleanup := setupBookTest(t) 532 - defer cleanup() 533 534 ctx := context.Background() 535 ··· 659 660 t.Run("Close", func(t *testing.T) { 661 t.Run("closes handler resources", func(t *testing.T) { 662 - _, cleanup := setupBookTest(t) 663 - defer cleanup() 664 665 handler, err := NewBookHandler() 666 if err != nil { ··· 674 }) 675 676 t.Run("handles service close gracefully", func(t *testing.T) { 677 - _, cleanup := setupBookTest(t) 678 - defer cleanup() 679 680 handler, err := NewBookHandler() 681 if err != nil { ··· 689 }) 690 691 t.Run("Print", func(t *testing.T) { 692 - _, cleanup := setupBookTest(t) 693 - defer cleanup() 694 695 handler, err := NewBookHandler() 696 if err != nil { ··· 763 764 t.Run("Integration", func(t *testing.T) { 765 t.Run("full book lifecycle", func(t *testing.T) { 766 - _, cleanup := setupBookTest(t) 767 - defer cleanup() 768 769 ctx := context.Background() 770 ··· 818 }) 819 820 t.Run("concurrent book operations", func(t *testing.T) { 821 - _, cleanup := setupBookTest(t) 822 - defer cleanup() 823 824 ctx := context.Background() 825 ··· 856 }) 857 }) 858 }
··· 3 import ( 4 "context" 5 "os" 6 "strconv" 7 "strings" 8 "testing" ··· 13 "github.com/stormlightlabs/noteleaf/internal/services" 14 ) 15 16 + // setupBookTest removed - use NewHandlerTestSuite(t) instead 17 18 func createTestBook(t *testing.T, handler *BookHandler, ctx context.Context) *models.Book { 19 t.Helper() ··· 37 func TestBookHandler(t *testing.T) { 38 t.Run("New", func(t *testing.T) { 39 t.Run("creates handler successfully", func(t *testing.T) { 40 + _ = NewHandlerTestSuite(t) 41 42 handler, err := NewBookHandler() 43 if err != nil { ··· 84 }) 85 86 t.Run("BookHandler instance methods", func(t *testing.T) { 87 + _ = NewHandlerTestSuite(t) 88 89 handler, err := NewBookHandler() 90 if err != nil { ··· 195 }) 196 197 t.Run("handles interactive mode", func(t *testing.T) { 198 + _ = NewHandlerTestSuite(t) 199 200 handler, err := NewBookHandler() 201 if err != nil { ··· 222 }) 223 224 t.Run("interactive mode path", func(t *testing.T) { 225 + _ = NewHandlerTestSuite(t) 226 227 handler, err := NewBookHandler() 228 if err != nil { ··· 498 }) 499 500 t.Run("progress", func(t *testing.T) { 501 + _ = NewHandlerTestSuite(t) 502 503 ctx := context.Background() 504 ··· 628 629 t.Run("Close", func(t *testing.T) { 630 t.Run("closes handler resources", func(t *testing.T) { 631 + _ = NewHandlerTestSuite(t) 632 633 handler, err := NewBookHandler() 634 if err != nil { ··· 642 }) 643 644 t.Run("handles service close gracefully", func(t *testing.T) { 645 + _ = NewHandlerTestSuite(t) 646 647 handler, err := NewBookHandler() 648 if err != nil { ··· 656 }) 657 658 t.Run("Print", func(t *testing.T) { 659 + _ = NewHandlerTestSuite(t) 660 661 handler, err := NewBookHandler() 662 if err != nil { ··· 729 730 t.Run("Integration", func(t *testing.T) { 731 t.Run("full book lifecycle", func(t *testing.T) { 732 + _ = NewHandlerTestSuite(t) 733 734 ctx := context.Background() 735 ··· 783 }) 784 785 t.Run("concurrent book operations", func(t *testing.T) { 786 + _ = NewHandlerTestSuite(t) 787 788 ctx := context.Background() 789 ··· 820 }) 821 }) 822 } 823 + 824 + // TestBookHandlerWithSuite demonstrates using HandlerTestSuite for cleaner tests 825 + func TestBookHandlerWithSuite(t *testing.T) { 826 + // Example: Using HandlerTestSuite for lifecycle testing 827 + t.Run("Lifecycle", func(t *testing.T) { 828 + suite := NewMediaHandlerTestSuite(t) 829 + 830 + handler, err := NewBookHandler() 831 + suite.AssertNoError(err, "NewBookHandler") 832 + defer handler.Close() 833 + 834 + // Test basic behaviors using reusable patterns 835 + InputReaderTest(t, handler) 836 + }) 837 + 838 + // Example: Using generic CreateHandler 839 + t.Run("GenericHandlerCreation", func(t *testing.T) { 840 + _ = NewHandlerTestSuite(t) 841 + 842 + // Generic CreateHandler replaces CreateBookHandler 843 + handler := CreateHandler(t, NewBookHandler) 844 + 845 + // Handler automatically cleaned up via t.Cleanup 846 + _ = handler 847 + }) 848 + 849 + // Example: Using HandlerTestSuite for media operations 850 + t.Run("MediaOperations", func(t *testing.T) { 851 + suite := NewMediaHandlerTestSuite(t) 852 + 853 + handler, err := NewBookHandler() 854 + suite.AssertNoError(err, "NewBookHandler") 855 + defer handler.Close() 856 + 857 + // Create a test book first 858 + book := &models.Book{ 859 + Title: "Suite Test Book", 860 + Author: "Suite Test Author", 861 + Status: "queued", 862 + Added: time.Now(), 863 + } 864 + id, err := handler.repos.Books.Create(suite.Context(), book) 865 + suite.AssertNoError(err, "Create test book") 866 + 867 + // Test list operation 868 + suite.TestList(handler, "") 869 + 870 + // Test status update 871 + suite.TestUpdateStatus(handler, strconv.FormatInt(id, 10), "reading", true) 872 + 873 + // Test invalid status update 874 + suite.TestUpdateStatus(handler, strconv.FormatInt(id, 10), "invalid", false) 875 + 876 + // Test remove operation 877 + suite.TestRemove(handler, strconv.FormatInt(id, 10), true) 878 + }) 879 + 880 + // Example: Generic lifecycle test 881 + t.Run("GenericLifecycle", func(t *testing.T) { 882 + _ = NewHandlerTestSuite(t) 883 + 884 + // Demonstrates using generic HandlerLifecycleTest 885 + HandlerLifecycleTest(t, NewBookHandler) 886 + }) 887 + }
+1 -1
internal/handlers/config.go
··· 107 return nil 108 } 109 110 - func (h *ConfigHandler) getConfigValue(key string) (interface{}, error) { 111 v := reflect.ValueOf(*h.config) 112 t := reflect.TypeOf(*h.config) 113
··· 107 return nil 108 } 109 110 + func (h *ConfigHandler) getConfigValue(key string) (any, error) { 111 v := reflect.ValueOf(*h.config) 112 t := reflect.TypeOf(*h.config) 113
+267
internal/handlers/handler_test_suite.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/repo" 10 + "github.com/stormlightlabs/noteleaf/internal/store" 11 + ) 12 + 13 + // HandlerTestSuite provides reusable test infrastructure for handlers 14 + type HandlerTestSuite struct { 15 + t *testing.T 16 + tempDir string 17 + ctx context.Context 18 + cleanup func() 19 + } 20 + 21 + // NewHandlerTestSuite creates a new test suite with isolated environment 22 + func NewHandlerTestSuite(t *testing.T) *HandlerTestSuite { 23 + t.Helper() 24 + 25 + tempDir, err := os.MkdirTemp("", "noteleaf-handler-suite-*") 26 + if err != nil { 27 + t.Fatalf("Failed to create temp dir: %v", err) 28 + } 29 + 30 + oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 31 + oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 32 + os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 33 + os.Setenv("NOTELEAF_DATA_DIR", tempDir) 34 + 35 + cleanup := func() { 36 + os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 37 + os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 38 + os.RemoveAll(tempDir) 39 + } 40 + 41 + ctx := context.Background() 42 + if err := Setup(ctx, []string{}); err != nil { 43 + cleanup() 44 + t.Fatalf("Failed to setup database: %v", err) 45 + } 46 + 47 + suite := &HandlerTestSuite{ 48 + t: t, 49 + tempDir: tempDir, 50 + ctx: ctx, 51 + cleanup: cleanup, 52 + } 53 + 54 + t.Cleanup(suite.Cleanup) 55 + 56 + return suite 57 + } 58 + 59 + // Context returns the test context 60 + func (s *HandlerTestSuite) Context() context.Context { 61 + return s.ctx 62 + } 63 + 64 + // TempDir returns the temporary directory for this test 65 + func (s *HandlerTestSuite) TempDir() string { 66 + return s.tempDir 67 + } 68 + 69 + // Cleanup performs test cleanup 70 + func (s *HandlerTestSuite) Cleanup() { 71 + if s.cleanup != nil { 72 + s.cleanup() 73 + } 74 + } 75 + 76 + // AssertNoError fails the test if err is not nil 77 + func (s *HandlerTestSuite) AssertNoError(err error, msg string) { 78 + s.t.Helper() 79 + if err != nil { 80 + s.t.Fatalf("%s: unexpected error: %v", msg, err) 81 + } 82 + } 83 + 84 + // AssertError fails the test if err is nil 85 + func (s *HandlerTestSuite) AssertError(err error, msg string) { 86 + s.t.Helper() 87 + if err == nil { 88 + s.t.Fatalf("%s: expected error but got nil", msg) 89 + } 90 + } 91 + 92 + // CreateTestDatabase creates an isolated test database 93 + func (s *HandlerTestSuite) CreateTestDatabase() (*store.Database, *repo.Repositories, error) { 94 + db, err := store.NewDatabase() 95 + if err != nil { 96 + return nil, nil, err 97 + } 98 + 99 + repos := repo.NewRepositories(db.DB) 100 + return db, repos, nil 101 + } 102 + 103 + // HandlerLifecycleTest tests basic handler lifecycle (create, close) 104 + // 105 + // This is a reusable test pattern for any handler implementing Closeable 106 + func HandlerLifecycleTest[H Closeable](t *testing.T, createHandler func() (H, error)) { 107 + t.Helper() 108 + 109 + t.Run("creates handler successfully", func(t *testing.T) { 110 + handler, err := createHandler() 111 + if err != nil { 112 + t.Fatalf("Failed to create handler: %v", err) 113 + } 114 + 115 + if err := handler.Close(); err != nil { 116 + t.Errorf("Close failed: %v", err) 117 + } 118 + }) 119 + 120 + t.Run("handles close gracefully", func(t *testing.T) { 121 + handler, err := createHandler() 122 + if err != nil { 123 + t.Fatalf("Failed to create handler: %v", err) 124 + } 125 + 126 + if err := handler.Close(); err != nil { 127 + t.Errorf("First close should succeed: %v", err) 128 + } 129 + 130 + _ = handler.Close() 131 + }) 132 + } 133 + 134 + // ViewableTest tests View functionality for handlers 135 + func ViewableTest[H Viewable](t *testing.T, handler H, validID, invalidID int64) { 136 + t.Helper() 137 + ctx := context.Background() 138 + 139 + t.Run("views valid item", func(t *testing.T) { 140 + err := handler.View(ctx, validID) 141 + if err != nil { 142 + t.Errorf("View with valid ID should succeed: %v", err) 143 + } 144 + }) 145 + 146 + t.Run("fails with invalid ID", func(t *testing.T) { 147 + err := handler.View(ctx, invalidID) 148 + if err == nil { 149 + t.Error("View with invalid ID should fail") 150 + } 151 + }) 152 + } 153 + 154 + // InputReaderTest tests SetInputReader functionality 155 + func InputReaderTest[H InputReader](t *testing.T, handler H) { 156 + t.Helper() 157 + 158 + t.Run("sets input reader", func(t *testing.T) { 159 + sim := NewInputSimulator("test") 160 + handler.SetInputReader(sim) 161 + // If we get here without panic, the test passes 162 + }) 163 + 164 + t.Run("handles nil reader", func(t *testing.T) { 165 + handler.SetInputReader(nil) 166 + // If we get here without panic, the test passes 167 + }) 168 + } 169 + 170 + // MediaHandlerTestSuite provides specialized test utilities for media handlers 171 + type MediaHandlerTestSuite struct { 172 + *HandlerTestSuite 173 + } 174 + 175 + // NewMediaHandlerTestSuite creates a test suite for media handlers 176 + func NewMediaHandlerTestSuite(t *testing.T) *MediaHandlerTestSuite { 177 + return &MediaHandlerTestSuite{ 178 + HandlerTestSuite: NewHandlerTestSuite(t), 179 + } 180 + } 181 + 182 + // TestSearchAndAdd is a reusable test pattern for SearchAndAdd operations 183 + func (s *MediaHandlerTestSuite) TestSearchAndAdd(handler MediaHandler, query string, shouldSucceed bool) { 184 + s.t.Helper() 185 + 186 + err := handler.SearchAndAdd(s.ctx, query, false) 187 + if shouldSucceed && err != nil { 188 + s.t.Errorf("SearchAndAdd should succeed: %v", err) 189 + } 190 + if !shouldSucceed && err == nil { 191 + s.t.Error("SearchAndAdd should fail") 192 + } 193 + } 194 + 195 + // TestList is a reusable test pattern for List operations 196 + func (s *MediaHandlerTestSuite) TestList(handler MediaHandler, status string) { 197 + s.t.Helper() 198 + 199 + err := handler.List(s.ctx, status) 200 + if err != nil { 201 + s.t.Errorf("List should succeed: %v", err) 202 + } 203 + } 204 + 205 + // TestUpdateStatus is a reusable test pattern for UpdateStatus operations 206 + func (s *MediaHandlerTestSuite) TestUpdateStatus(handler MediaHandler, id, status string, shouldSucceed bool) { 207 + s.t.Helper() 208 + 209 + err := handler.UpdateStatus(s.ctx, id, status) 210 + if shouldSucceed && err != nil { 211 + s.t.Errorf("UpdateStatus should succeed: %v", err) 212 + } 213 + if !shouldSucceed && err == nil { 214 + s.t.Error("UpdateStatus should fail") 215 + } 216 + } 217 + 218 + // TestRemove is a reusable test pattern for Remove operations 219 + func (s *MediaHandlerTestSuite) TestRemove(handler MediaHandler, id string, shouldSucceed bool) { 220 + s.t.Helper() 221 + 222 + err := handler.Remove(s.ctx, id) 223 + if shouldSucceed && err != nil { 224 + s.t.Errorf("Remove should succeed: %v", err) 225 + } 226 + if !shouldSucceed && err == nil { 227 + s.t.Error("Remove should fail") 228 + } 229 + } 230 + 231 + // CreateHandler creates a handler with automatic cleanup 232 + func CreateHandler[H Closeable](t *testing.T, factory func() (H, error)) H { 233 + t.Helper() 234 + 235 + handler, err := factory() 236 + if err != nil { 237 + t.Fatalf("Failed to create handler: %v", err) 238 + } 239 + 240 + t.Cleanup(func() { 241 + handler.Close() 242 + }) 243 + 244 + return handler 245 + } 246 + 247 + // AssertExists verifies that an item exists using a getter 248 + func AssertExists[T any](t *testing.T, getter func(context.Context, int64) (*T, error), id int64, itemType string) { 249 + t.Helper() 250 + 251 + ctx := context.Background() 252 + _, err := getter(ctx, id) 253 + if err != nil { 254 + t.Errorf("%s %d should exist but got error: %v", itemType, id, err) 255 + } 256 + } 257 + 258 + // AssertNotExists verifies that an item does not exist using a getter 259 + func AssertNotExists[T any](t *testing.T, getter func(context.Context, int64) (*T, error), id int64, itemType string) { 260 + t.Helper() 261 + 262 + ctx := context.Background() 263 + _, err := getter(ctx, id) 264 + if err == nil { 265 + t.Errorf("%s %d should not exist but was found", itemType, id) 266 + } 267 + }
+18 -37
internal/handlers/handlers_test.go
··· 33 return tempDir 34 } 35 36 func TestSetup(t *testing.T) { 37 t.Run("creates database and config files", func(t *testing.T) { 38 _ = createTestDir(t) ··· 43 t.Errorf("Setup failed: %v", err) 44 } 45 46 - // Determine database path using the same logic as Setup 47 config, err := store.LoadConfig() 48 if err != nil { 49 t.Fatalf("Failed to load config: %v", err) 50 } 51 52 - var dbPath string 53 - if config.DatabasePath != "" { 54 - dbPath = config.DatabasePath 55 - } else if config.DataDir != "" { 56 - dbPath = filepath.Join(config.DataDir, "noteleaf.db") 57 - } else { 58 - dataDir, _ := store.GetDataDir() 59 - dbPath = filepath.Join(dataDir, "noteleaf.db") 60 - } 61 62 if _, err := os.Stat(dbPath); os.IsNotExist(err) { 63 t.Error("Database file was not created") ··· 144 t.Fatalf("Failed to load config: %v", err) 145 } 146 147 - var dbPath string 148 - if config.DatabasePath != "" { 149 - dbPath = config.DatabasePath 150 - } else if config.DataDir != "" { 151 - dbPath = filepath.Join(config.DataDir, "noteleaf.db") 152 - } else { 153 - dataDir, _ := store.GetDataDir() 154 - dbPath = filepath.Join(dataDir, "noteleaf.db") 155 - } 156 157 configPath, err := store.GetConfigPath() 158 if err != nil { ··· 461 t.Fatalf("Failed to load config: %v", err) 462 } 463 464 - var dbPath string 465 - if config.DatabasePath != "" { 466 - dbPath = config.DatabasePath 467 - } else if config.DataDir != "" { 468 - dbPath = filepath.Join(config.DataDir, "noteleaf.db") 469 - } else { 470 - dataDir, _ := store.GetDataDir() 471 - dbPath = filepath.Join(dataDir, "noteleaf.db") 472 - } 473 474 os.Remove(dbPath) 475 ··· 644 } 645 646 config, _ := store.LoadConfig() 647 - var dbPath string 648 - if config.DatabasePath != "" { 649 - dbPath = config.DatabasePath 650 - } else if config.DataDir != "" { 651 - dbPath = filepath.Join(config.DataDir, "noteleaf.db") 652 - } else { 653 - dataDir, _ := store.GetDataDir() 654 - dbPath = filepath.Join(dataDir, "noteleaf.db") 655 - } 656 657 if _, err := os.Stat(dbPath); os.IsNotExist(err) { 658 t.Error("Database should exist after setup")
··· 33 return tempDir 34 } 35 36 + func getDbPath(config *store.Config) string { 37 + var dbPath string 38 + if config.DatabasePath != "" { 39 + dbPath = config.DatabasePath 40 + } else if config.DataDir != "" { 41 + dbPath = filepath.Join(config.DataDir, "noteleaf.db") 42 + } else { 43 + dataDir, _ := store.GetDataDir() 44 + dbPath = filepath.Join(dataDir, "noteleaf.db") 45 + } 46 + 47 + return dbPath 48 + } 49 + 50 func TestSetup(t *testing.T) { 51 t.Run("creates database and config files", func(t *testing.T) { 52 _ = createTestDir(t) ··· 57 t.Errorf("Setup failed: %v", err) 58 } 59 60 config, err := store.LoadConfig() 61 if err != nil { 62 t.Fatalf("Failed to load config: %v", err) 63 } 64 65 + dbPath := getDbPath(config) 66 67 if _, err := os.Stat(dbPath); os.IsNotExist(err) { 68 t.Error("Database file was not created") ··· 149 t.Fatalf("Failed to load config: %v", err) 150 } 151 152 + dbPath := getDbPath(config) 153 154 configPath, err := store.GetConfigPath() 155 if err != nil { ··· 458 t.Fatalf("Failed to load config: %v", err) 459 } 460 461 + dbPath := getDbPath(config) 462 463 os.Remove(dbPath) 464 ··· 633 } 634 635 config, _ := store.LoadConfig() 636 + dbPath := getDbPath(config) 637 638 if _, err := os.Stat(dbPath); os.IsNotExist(err) { 639 t.Error("Database should exist after setup")
+7 -15
internal/handlers/media_handler.go
··· 5 "io" 6 ) 7 8 - // MediaHandler defines common operations for media handlers 9 - // 10 - // This interface captures the shared behavior across media handlers for polymorphic handling of different media types. 11 type MediaHandler interface { 12 - // SearchAndAdd searches for media and allows user to select and add to queue 13 - SearchAndAdd(ctx context.Context, query string, interactive bool) error 14 - // List lists all media items with optional status filtering 15 - List(ctx context.Context, status string) error 16 - // UpdateStatus changes the status of a media item 17 - UpdateStatus(ctx context.Context, id, status string) error 18 - // Remove removes a media item from the queue 19 - Remove(ctx context.Context, id string) error 20 - // SetInputReader sets the input reader for interactive prompts 21 - SetInputReader(reader io.Reader) 22 - // Close cleans up resources 23 - Close() error 24 } 25 26 // Searchable defines search behavior for media handlers
··· 5 "io" 6 ) 7 8 + // MediaHandler defines common operations for media handlers and captures the shared behavior across media handlers for polymorphic handling of different media types. 9 type MediaHandler interface { 10 + SearchAndAdd(ctx context.Context, query string, interactive bool) error // SearchAndAdd searches for media and allows user to select and add to queue 11 + List(ctx context.Context, status string) error // List lists all media items with optional status filtering 12 + UpdateStatus(ctx context.Context, id, status string) error // UpdateStatus changes the status of a media item 13 + Remove(ctx context.Context, id string) error // Remove removes a media item from the queue 14 + SetInputReader(reader io.Reader) // SetInputReader sets the input reader for interactive prompts 15 + Close() error // Close cleans up resources 16 } 17 18 // Searchable defines search behavior for media handlers
+9 -39
internal/handlers/notes_test.go
··· 14 "github.com/stormlightlabs/noteleaf/internal/store" 15 ) 16 17 - func setupNoteTest(t *testing.T) (string, func()) { 18 - tempDir, err := os.MkdirTemp("", "noteleaf-note-test-*") 19 - if err != nil { 20 - t.Fatalf("Failed to create temp dir: %v", err) 21 - } 22 - 23 - oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 24 - oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 25 - os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 26 - os.Setenv("NOTELEAF_DATA_DIR", tempDir) 27 - 28 - cleanup := func() { 29 - os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 30 - os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 31 - os.RemoveAll(tempDir) 32 - } 33 - 34 - ctx := context.Background() 35 - err = Setup(ctx, []string{}) 36 - if err != nil { 37 - cleanup() 38 - t.Fatalf("Failed to setup database: %v", err) 39 - } 40 - 41 - return tempDir, cleanup 42 - } 43 44 func createTestMarkdownFile(t *testing.T, dir, filename, content string) string { 45 filePath := filepath.Join(dir, filename) ··· 51 } 52 53 func TestNoteHandler(t *testing.T) { 54 - tempDir, cleanup := setupNoteTest(t) 55 - defer cleanup() 56 57 handler, err := NewNoteHandler() 58 if err != nil { ··· 116 <!-- tags: personal, work --> 117 118 This is the content of my note.` 119 - filePath := createTestMarkdownFile(t, tempDir, "test.md", content) 120 121 err := handler.Create(ctx, "", "", filePath, false) 122 Expect.AssertNoError(t, err, "Create from file should succeed") ··· 378 err := handler.Edit(ctx, noteID) 379 380 Expect.AssertNoError(t, err, "Edit should succeed") 381 - Expect.AssertNoteExists(t, handler, noteID) 382 }) 383 384 t.Run("handles no changes made", func(t *testing.T) { ··· 477 }) 478 479 t.Run("handles empty note list", func(t *testing.T) { 480 - _, emptyCleanup := setupNoteTest(t) 481 - defer emptyCleanup() 482 483 emptyHandler, err := NewNoteHandler() 484 if err != nil { ··· 493 }) 494 495 t.Run("interactive mode path", func(t *testing.T) { 496 - _, cleanup := setupNoteTest(t) 497 - defer cleanup() 498 499 testHandler, err := NewNoteHandler() 500 if err != nil { ··· 516 }) 517 518 t.Run("interactive mode path with filters", func(t *testing.T) { 519 - _, cleanup := setupNoteTest(t) 520 - defer cleanup() 521 522 testHandler, err := NewNoteHandler() 523 if err != nil { ··· 578 }) 579 580 t.Run("deletes note with file path", func(t *testing.T) { 581 - testTempDir, testCleanup := setupNoteTest(t) 582 - defer testCleanup() 583 584 testHandler, err := NewNoteHandler() 585 if err != nil { ··· 587 } 588 defer testHandler.Close() 589 590 - filePath := createTestMarkdownFile(t, testTempDir, "delete-test.md", "# Test Note\n\nTest content") 591 592 err = testHandler.Create(ctx, "", "", filePath, false) 593 if err != nil {
··· 14 "github.com/stormlightlabs/noteleaf/internal/store" 15 ) 16 17 + // setupNoteTest removed - use NewHandlerTestSuite(t) instead 18 19 func createTestMarkdownFile(t *testing.T, dir, filename, content string) string { 20 filePath := filepath.Join(dir, filename) ··· 26 } 27 28 func TestNoteHandler(t *testing.T) { 29 + suite := NewHandlerTestSuite(t) 30 31 handler, err := NewNoteHandler() 32 if err != nil { ··· 90 <!-- tags: personal, work --> 91 92 This is the content of my note.` 93 + filePath := createTestMarkdownFile(t, suite.TempDir(), "test.md", content) 94 95 err := handler.Create(ctx, "", "", filePath, false) 96 Expect.AssertNoError(t, err, "Create from file should succeed") ··· 352 err := handler.Edit(ctx, noteID) 353 354 Expect.AssertNoError(t, err, "Edit should succeed") 355 + AssertExists(t, handler.repos.Notes.Get, noteID, "note") 356 }) 357 358 t.Run("handles no changes made", func(t *testing.T) { ··· 451 }) 452 453 t.Run("handles empty note list", func(t *testing.T) { 454 + _ = NewHandlerTestSuite(t) 455 456 emptyHandler, err := NewNoteHandler() 457 if err != nil { ··· 466 }) 467 468 t.Run("interactive mode path", func(t *testing.T) { 469 + _ = NewHandlerTestSuite(t) 470 471 testHandler, err := NewNoteHandler() 472 if err != nil { ··· 488 }) 489 490 t.Run("interactive mode path with filters", func(t *testing.T) { 491 + _ = NewHandlerTestSuite(t) 492 493 testHandler, err := NewNoteHandler() 494 if err != nil { ··· 549 }) 550 551 t.Run("deletes note with file path", func(t *testing.T) { 552 + testSuite := NewHandlerTestSuite(t) 553 554 testHandler, err := NewNoteHandler() 555 if err != nil { ··· 557 } 558 defer testHandler.Close() 559 560 + filePath := createTestMarkdownFile(t, testSuite.TempDir(), "delete-test.md", "# Test Note\n\nTest content") 561 562 err = testHandler.Create(ctx, "", "", filePath, false) 563 if err != nil {
+2 -29
internal/handlers/seed_test.go
··· 3 import ( 4 "context" 5 "os" 6 - "path/filepath" 7 "runtime" 8 "strings" 9 "testing" ··· 11 "github.com/stormlightlabs/noteleaf/internal/store" 12 ) 13 14 - func setupSeedTest(t *testing.T) (string, func()) { 15 - tempDir, err := os.MkdirTemp("", "noteleaf-seed-test-*") 16 - if err != nil { 17 - t.Fatalf("Failed to create temp dir: %v", err) 18 - } 19 - 20 - oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 21 - oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 22 - os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 23 - os.Setenv("NOTELEAF_DATA_DIR", tempDir) 24 - 25 - cleanup := func() { 26 - os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 27 - os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 28 - os.RemoveAll(tempDir) 29 - } 30 - 31 - ctx := context.Background() 32 - err = Setup(ctx, []string{}) 33 - if err != nil { 34 - cleanup() 35 - t.Fatalf("Failed to setup database: %v", err) 36 - } 37 - 38 - return tempDir, cleanup 39 - } 40 41 func countRecords(t *testing.T, db *store.Database, table string) int { 42 t.Helper() ··· 73 } 74 75 func TestSeedHandler(t *testing.T) { 76 - _, cleanup := setupSeedTest(t) 77 - defer cleanup() 78 79 handler, err := NewSeedHandler() 80 if err != nil {
··· 3 import ( 4 "context" 5 "os" 6 "runtime" 7 "strings" 8 "testing" ··· 10 "github.com/stormlightlabs/noteleaf/internal/store" 11 ) 12 13 + // setupSeedTest removed - use NewHandlerTestSuite(t) instead 14 15 func countRecords(t *testing.T, db *store.Database, table string) int { 16 t.Helper() ··· 47 } 48 49 func TestSeedHandler(t *testing.T) { 50 + _ = NewHandlerTestSuite(t) 51 52 handler, err := NewSeedHandler() 53 if err != nil {
+48 -49
internal/handlers/tasks_test.go
··· 21 ctx := context.Background() 22 t.Run("New", func(t *testing.T) { 23 t.Run("creates handler successfully", func(t *testing.T) { 24 - _, cleanup := SetupHandlerTest(t) 25 - defer cleanup() 26 27 handler, err := NewTaskHandler() 28 if err != nil { ··· 70 }) 71 72 t.Run("Create", func(t *testing.T) { 73 - _, cleanup := SetupHandlerTest(t) 74 - defer cleanup() 75 76 - handler := CreateTaskHandler(t) 77 78 t.Run("creates task successfully", func(t *testing.T) { 79 desc := "Buy groceries and cook dinner" ··· 197 }) 198 199 t.Run("List", func(t *testing.T) { 200 - _, cleanup := SetupHandlerTest(t) 201 - defer cleanup() 202 203 handler, err := NewTaskHandler() 204 if err != nil { ··· 283 }) 284 285 t.Run("Update", func(t *testing.T) { 286 - _, cleanup := SetupHandlerTest(t) 287 - defer cleanup() 288 - 289 handler, err := NewTaskHandler() 290 if err != nil { 291 t.Fatalf("Failed to create handler: %v", err) ··· 461 }) 462 463 t.Run("Delete", func(t *testing.T) { 464 - _, cleanup := SetupHandlerTest(t) 465 - defer cleanup() 466 467 - handler := CreateTaskHandler(t) 468 469 task := &models.Task{ 470 UUID: uuid.New().String(), ··· 542 }) 543 544 t.Run("View", func(t *testing.T) { 545 - _, cleanup := SetupHandlerTest(t) 546 - defer cleanup() 547 548 handler, err := NewTaskHandler() 549 if err != nil { ··· 637 }) 638 639 t.Run("Done", func(t *testing.T) { 640 - _, cleanup := SetupHandlerTest(t) 641 - defer cleanup() 642 643 handler, err := NewTaskHandler() 644 if err != nil { ··· 776 }) 777 778 t.Run("Print", func(t *testing.T) { 779 - _, cleanup := SetupHandlerTest(t) 780 - defer cleanup() 781 782 handler, err := NewTaskHandler() 783 if err != nil { ··· 823 }) 824 825 t.Run("ListProjects", func(t *testing.T) { 826 - _, cleanup := SetupHandlerTest(t) 827 - defer cleanup() 828 829 handler, err := NewTaskHandler() 830 if err != nil { ··· 854 }) 855 856 t.Run("returns no projects when none exist", func(t *testing.T) { 857 - _, cleanup2 := SetupHandlerTest(t) 858 - defer cleanup2() 859 860 err := handler.ListProjects(ctx, true) 861 if err != nil { ··· 879 }) 880 881 t.Run("ListTags", func(t *testing.T) { 882 - _, cleanup := SetupHandlerTest(t) 883 - defer cleanup() 884 885 handler, err := NewTaskHandler() 886 if err != nil { ··· 910 }) 911 912 t.Run("returns no tags when none exist", func(t *testing.T) { 913 - _, cleanup2 := SetupHandlerTest(t) 914 - defer cleanup2() 915 916 err := handler.ListTags(ctx, true) 917 if err != nil { ··· 961 }) 962 963 t.Run("InteractiveComponentsStatic", func(t *testing.T) { 964 - _, cleanup := SetupHandlerTest(t) 965 - defer cleanup() 966 967 handler, err := NewTaskHandler() 968 if err != nil { ··· 1107 }) 1108 1109 t.Run("handles no contexts", func(t *testing.T) { 1110 - _, cleanup2 := SetupHandlerTest(t) 1111 - defer cleanup2() 1112 1113 handler2, err := NewTaskHandler() 1114 if err != nil { ··· 1174 }) 1175 1176 t.Run("ListContexts", func(t *testing.T) { 1177 - _, cleanup := SetupHandlerTest(t) 1178 - defer cleanup() 1179 1180 handler, err := NewTaskHandler() 1181 if err != nil { ··· 1251 }) 1252 1253 t.Run("returns no contexts when none exist", func(t *testing.T) { 1254 - _, cleanup_ := SetupHandlerTest(t) 1255 - defer cleanup_() 1256 1257 handler_, err := NewTaskHandler() 1258 if err != nil { ··· 1268 }) 1269 1270 t.Run("SetRecur", func(t *testing.T) { 1271 - _, cleanup := SetupHandlerTest(t) 1272 - defer cleanup() 1273 1274 handler, err := NewTaskHandler() 1275 if err != nil { ··· 1347 }) 1348 1349 t.Run("ClearRecur", func(t *testing.T) { 1350 - _, cleanup := SetupHandlerTest(t) 1351 - defer cleanup() 1352 1353 handler, err := NewTaskHandler() 1354 if err != nil { ··· 1421 }) 1422 1423 t.Run("ShowRecur", func(t *testing.T) { 1424 - _, cleanup := SetupHandlerTest(t) 1425 - defer cleanup() 1426 1427 handler, err := NewTaskHandler() 1428 if err != nil { ··· 1463 }) 1464 1465 t.Run("AddDep", func(t *testing.T) { 1466 - _, cleanup := SetupHandlerTest(t) 1467 - defer cleanup() 1468 1469 handler, err := NewTaskHandler() 1470 if err != nil { ··· 1508 }) 1509 1510 t.Run("RemoveDep", func(t *testing.T) { 1511 - _, cleanup := SetupHandlerTest(t) 1512 - defer cleanup() 1513 1514 handler, err := NewTaskHandler() 1515 if err != nil { ··· 1553 }) 1554 1555 t.Run("ListDeps", func(t *testing.T) { 1556 - _, cleanup := SetupHandlerTest(t) 1557 - defer cleanup() 1558 1559 handler, err := NewTaskHandler() 1560 if err != nil { ··· 1589 }) 1590 1591 t.Run("BlockedByDep", func(t *testing.T) { 1592 - _, cleanup := SetupHandlerTest(t) 1593 - defer cleanup() 1594 1595 handler, err := NewTaskHandler() 1596 if err != nil {
··· 21 ctx := context.Background() 22 t.Run("New", func(t *testing.T) { 23 t.Run("creates handler successfully", func(t *testing.T) { 24 + suite := NewHandlerTestSuite(t) 25 + defer suite.cleanup() 26 27 handler, err := NewTaskHandler() 28 if err != nil { ··· 70 }) 71 72 t.Run("Create", func(t *testing.T) { 73 + suite := NewHandlerTestSuite(t) 74 + defer suite.cleanup() 75 76 + handler := CreateHandler(t, NewTaskHandler) 77 78 t.Run("creates task successfully", func(t *testing.T) { 79 desc := "Buy groceries and cook dinner" ··· 197 }) 198 199 t.Run("List", func(t *testing.T) { 200 + suite := NewHandlerTestSuite(t) 201 + defer suite.cleanup() 202 203 handler, err := NewTaskHandler() 204 if err != nil { ··· 283 }) 284 285 t.Run("Update", func(t *testing.T) { 286 + suite := NewHandlerTestSuite(t) 287 + defer suite.cleanup() 288 handler, err := NewTaskHandler() 289 if err != nil { 290 t.Fatalf("Failed to create handler: %v", err) ··· 460 }) 461 462 t.Run("Delete", func(t *testing.T) { 463 + suite := NewHandlerTestSuite(t) 464 + defer suite.cleanup() 465 466 + handler := CreateHandler(t, NewTaskHandler) 467 468 task := &models.Task{ 469 UUID: uuid.New().String(), ··· 541 }) 542 543 t.Run("View", func(t *testing.T) { 544 + suite := NewHandlerTestSuite(t) 545 + defer suite.cleanup() 546 547 handler, err := NewTaskHandler() 548 if err != nil { ··· 636 }) 637 638 t.Run("Done", func(t *testing.T) { 639 + suite := NewHandlerTestSuite(t) 640 + defer suite.cleanup() 641 642 handler, err := NewTaskHandler() 643 if err != nil { ··· 775 }) 776 777 t.Run("Print", func(t *testing.T) { 778 + suite := NewHandlerTestSuite(t) 779 + defer suite.cleanup() 780 781 handler, err := NewTaskHandler() 782 if err != nil { ··· 822 }) 823 824 t.Run("ListProjects", func(t *testing.T) { 825 + suite := NewHandlerTestSuite(t) 826 + defer suite.cleanup() 827 828 handler, err := NewTaskHandler() 829 if err != nil { ··· 853 }) 854 855 t.Run("returns no projects when none exist", func(t *testing.T) { 856 + suite := NewHandlerTestSuite(t) 857 + defer suite.cleanup() 858 859 err := handler.ListProjects(ctx, true) 860 if err != nil { ··· 878 }) 879 880 t.Run("ListTags", func(t *testing.T) { 881 + suite := NewHandlerTestSuite(t) 882 + defer suite.cleanup() 883 884 handler, err := NewTaskHandler() 885 if err != nil { ··· 909 }) 910 911 t.Run("returns no tags when none exist", func(t *testing.T) { 912 + suite := NewHandlerTestSuite(t) 913 + defer suite.cleanup() 914 915 err := handler.ListTags(ctx, true) 916 if err != nil { ··· 960 }) 961 962 t.Run("InteractiveComponentsStatic", func(t *testing.T) { 963 + suite := NewHandlerTestSuite(t) 964 + defer suite.cleanup() 965 966 handler, err := NewTaskHandler() 967 if err != nil { ··· 1106 }) 1107 1108 t.Run("handles no contexts", func(t *testing.T) { 1109 + suite := NewHandlerTestSuite(t) 1110 + defer suite.cleanup() 1111 1112 handler2, err := NewTaskHandler() 1113 if err != nil { ··· 1173 }) 1174 1175 t.Run("ListContexts", func(t *testing.T) { 1176 + suite := NewHandlerTestSuite(t) 1177 + defer suite.cleanup() 1178 1179 handler, err := NewTaskHandler() 1180 if err != nil { ··· 1250 }) 1251 1252 t.Run("returns no contexts when none exist", func(t *testing.T) { 1253 + suite := NewHandlerTestSuite(t) 1254 + defer suite.cleanup() 1255 1256 handler_, err := NewTaskHandler() 1257 if err != nil { ··· 1267 }) 1268 1269 t.Run("SetRecur", func(t *testing.T) { 1270 + suite := NewHandlerTestSuite(t) 1271 + defer suite.cleanup() 1272 1273 handler, err := NewTaskHandler() 1274 if err != nil { ··· 1346 }) 1347 1348 t.Run("ClearRecur", func(t *testing.T) { 1349 + suite := NewHandlerTestSuite(t) 1350 + defer suite.cleanup() 1351 1352 handler, err := NewTaskHandler() 1353 if err != nil { ··· 1420 }) 1421 1422 t.Run("ShowRecur", func(t *testing.T) { 1423 + suite := NewHandlerTestSuite(t) 1424 + defer suite.cleanup() 1425 1426 handler, err := NewTaskHandler() 1427 if err != nil { ··· 1462 }) 1463 1464 t.Run("AddDep", func(t *testing.T) { 1465 + suite := NewHandlerTestSuite(t) 1466 + defer suite.cleanup() 1467 1468 handler, err := NewTaskHandler() 1469 if err != nil { ··· 1507 }) 1508 1509 t.Run("RemoveDep", func(t *testing.T) { 1510 + suite := NewHandlerTestSuite(t) 1511 + defer suite.cleanup() 1512 1513 handler, err := NewTaskHandler() 1514 if err != nil { ··· 1552 }) 1553 1554 t.Run("ListDeps", func(t *testing.T) { 1555 + suite := NewHandlerTestSuite(t) 1556 + defer suite.cleanup() 1557 1558 handler, err := NewTaskHandler() 1559 if err != nil { ··· 1588 }) 1589 1590 t.Run("BlockedByDep", func(t *testing.T) { 1591 + suite := NewHandlerTestSuite(t) 1592 + defer suite.cleanup() 1593 1594 handler, err := NewTaskHandler() 1595 if err != nil {
+14 -241
internal/handlers/test_utilities.go
··· 25 ) 26 27 // HandlerTestHelper wraps NoteHandler with test-specific functionality 28 type HandlerTestHelper struct { 29 *NoteHandler 30 - tempDir string 31 - cleanup func() 32 } 33 34 // NewHandlerTestHelper creates a NoteHandler with isolated test database 35 func NewHandlerTestHelper(t *testing.T) *HandlerTestHelper { 36 - tempDir, err := os.MkdirTemp("", "noteleaf-handler-test-*") 37 - if err != nil { 38 - t.Fatalf("Failed to create temp dir: %v", err) 39 - } 40 - 41 - oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 42 - oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 43 - os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 44 - os.Setenv("NOTELEAF_DATA_DIR", tempDir) 45 - 46 - cleanup := func() { 47 - os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 48 - os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 49 - os.RemoveAll(tempDir) 50 - } 51 - 52 - ctx := context.Background() 53 - err = Setup(ctx, []string{}) 54 - if err != nil { 55 - cleanup() 56 - t.Fatalf("Failed to setup database: %v", err) 57 - } 58 59 handler, err := NewNoteHandler() 60 if err != nil { 61 - cleanup() 62 t.Fatalf("Failed to create note handler: %v", err) 63 } 64 65 testHandler := &HandlerTestHelper{ 66 NoteHandler: handler, 67 - tempDir: tempDir, 68 - cleanup: cleanup, 69 } 70 71 t.Cleanup(func() { 72 testHandler.Close() 73 - testHandler.cleanup() 74 }) 75 76 return testHandler ··· 96 97 // CreateTestFile creates a temporary markdown file with content 98 func (th *HandlerTestHelper) CreateTestFile(t *testing.T, filename, content string) string { 99 - filePath := filepath.Join(th.tempDir, filename) 100 err := os.WriteFile(filePath, []byte(content), 0644) 101 if err != nil { 102 t.Fatalf("Failed to create test file: %v", err) ··· 238 } 239 } 240 241 - // AssertNoteExists checks that a note exists in the database 242 - func (ah AssertionHelpers) AssertNoteExists(t *testing.T, handler *HandlerTestHelper, id int64) { 243 - t.Helper() 244 - ctx := context.Background() 245 - _, err := handler.repos.Notes.Get(ctx, id) 246 - if err != nil { 247 - t.Errorf("Note %d should exist but got error: %v", id, err) 248 - } 249 - } 250 - 251 - // AssertNoteNotExists checks that a note does not exist in the database 252 - func (ah AssertionHelpers) AssertNoteNotExists(t *testing.T, handler *HandlerTestHelper, id int64) { 253 - t.Helper() 254 - ctx := context.Background() 255 - _, err := handler.repos.Notes.Get(ctx, id) 256 - if err == nil { 257 - t.Errorf("Note %d should not exist but was found", id) 258 - } 259 - } 260 - 261 - // AssertArticleExists checks that an article exists in the database 262 - func (ah AssertionHelpers) AssertArticleExists(t *testing.T, handler *ArticleTestHelper, id int64) { 263 - t.Helper() 264 - ctx := context.Background() 265 - _, err := handler.repos.Articles.Get(ctx, id) 266 - if err != nil { 267 - t.Errorf("Article %d should exist but got error: %v", id, err) 268 - } 269 - } 270 - 271 - // AssertArticleNotExists checks that an article does not exist in the database 272 - func (ah AssertionHelpers) AssertArticleNotExists(t *testing.T, handler *ArticleTestHelper, id int64) { 273 - t.Helper() 274 - ctx := context.Background() 275 - _, err := handler.repos.Articles.Get(ctx, id) 276 - if err == nil { 277 - t.Errorf("Article %d should not exist but was found", id) 278 - } 279 - } 280 - 281 // EnvironmentTestHelper provides environment manipulation utilities for testing 282 type EnvironmentTestHelper struct { 283 originalVars map[string]string 284 } ··· 363 // ArticleTestHelper wraps ArticleHandler with test-specific functionality 364 type ArticleTestHelper struct { 365 *ArticleHandler 366 - tempDir string 367 - cleanup func() 368 } 369 370 // NewArticleTestHelper creates an ArticleHandler with isolated test database 371 func NewArticleTestHelper(t *testing.T) *ArticleTestHelper { 372 - tempDir, err := os.MkdirTemp("", "noteleaf-article-test-*") 373 - if err != nil { 374 - t.Fatalf("Failed to create temp dir: %v", err) 375 - } 376 - 377 - oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 378 - oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 379 - os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 380 - os.Setenv("NOTELEAF_DATA_DIR", tempDir) 381 - 382 - cleanup := func() { 383 - os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 384 - os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 385 - os.RemoveAll(tempDir) 386 - } 387 - 388 - ctx := context.Background() 389 - err = Setup(ctx, []string{}) 390 - if err != nil { 391 - cleanup() 392 - t.Fatalf("Failed to setup database: %v", err) 393 - } 394 395 handler, err := NewArticleHandler() 396 if err != nil { 397 - cleanup() 398 t.Fatalf("Failed to create article handler: %v", err) 399 } 400 401 testHelper := &ArticleTestHelper{ 402 ArticleHandler: handler, 403 - tempDir: tempDir, 404 - cleanup: cleanup, 405 } 406 407 t.Cleanup(func() { 408 testHelper.Close() 409 - testHelper.cleanup() 410 }) 411 412 return testHelper ··· 416 func (ath *ArticleTestHelper) CreateTestArticle(t *testing.T, url, title, author, date string) int64 { 417 ctx := context.Background() 418 419 - mdPath := filepath.Join(ath.tempDir, fmt.Sprintf("%s.md", title)) 420 - htmlPath := filepath.Join(ath.tempDir, fmt.Sprintf("%s.html", title)) 421 422 mdContent := fmt.Sprintf("# %s\n\n**Author:** %s\n**Date:** %s\n\nTest content", title, author, date) 423 err := os.WriteFile(mdPath, []byte(mdContent), 0644) ··· 828 return ith.sim 829 } 830 831 - // SetupHandlerWithInput creates a handler and sets up input simulation in one call 832 - func SetupBookHandlerWithInput(t *testing.T, inputs ...string) (*BookHandler, func()) { 833 - _, cleanup := SetupHandlerTest(t) 834 - 835 - handler, err := NewBookHandler() 836 - if err != nil { 837 - cleanup() 838 - t.Fatalf("Failed to create book handler: %v", err) 839 - } 840 - 841 - if len(inputs) > 0 { 842 - handler.SetInputReader(NewInputSimulator(inputs...)) 843 - } 844 - 845 - fullCleanup := func() { 846 - handler.Close() 847 - cleanup() 848 - } 849 - 850 - return handler, fullCleanup 851 - } 852 - 853 - // SetupMovieHandlerWithInput creates a movie handler and sets up input simulation 854 - func SetupMovieHandlerWithInput(t *testing.T, inputs ...string) (*MovieHandler, func()) { 855 - _, cleanup := SetupHandlerTest(t) 856 - 857 - handler, err := NewMovieHandler() 858 - if err != nil { 859 - cleanup() 860 - t.Fatalf("Failed to create movie handler: %v", err) 861 - } 862 - 863 - if len(inputs) > 0 { 864 - handler.SetInputReader(NewInputSimulator(inputs...)) 865 - } 866 - 867 - fullCleanup := func() { 868 - handler.Close() 869 - cleanup() 870 - } 871 - 872 - return handler, fullCleanup 873 - } 874 - 875 - // SetupTVHandlerWithInput creates a TV handler and sets up input simulation 876 - func SetupTVHandlerWithInput(t *testing.T, inputs ...string) (*TVHandler, func()) { 877 - _, cleanup := SetupHandlerTest(t) 878 - 879 - handler, err := NewTVHandler() 880 - if err != nil { 881 - cleanup() 882 - t.Fatalf("Failed to create TV handler: %v", err) 883 - } 884 - 885 - if len(inputs) > 0 { 886 - handler.SetInputReader(NewInputSimulator(inputs...)) 887 - } 888 - 889 - fullCleanup := func() { 890 - handler.Close() 891 - cleanup() 892 - } 893 - 894 - return handler, fullCleanup 895 - } 896 - 897 - func SetupHandlerTest(t *testing.T) (string, func()) { 898 - tempDir, err := os.MkdirTemp("", "noteleaf-interactive-test-*") 899 - if err != nil { 900 - t.Fatalf("Failed to create temp dir: %v", err) 901 - } 902 - 903 - oldNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 904 - oldNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 905 - os.Setenv("NOTELEAF_CONFIG", filepath.Join(tempDir, ".noteleaf.conf.toml")) 906 - os.Setenv("NOTELEAF_DATA_DIR", tempDir) 907 - 908 - cleanup := func() { 909 - os.Setenv("NOTELEAF_CONFIG", oldNoteleafConfig) 910 - os.Setenv("NOTELEAF_DATA_DIR", oldNoteleafDataDir) 911 - os.RemoveAll(tempDir) 912 - } 913 - 914 - ctx := context.Background() 915 - err = Setup(ctx, []string{}) 916 - if err != nil { 917 - cleanup() 918 - t.Fatalf("Failed to setup database: %v", err) 919 - } 920 - 921 - return tempDir, cleanup 922 - } 923 - 924 // TUICapableHandler interface for handlers that can expose TUI models for testing 925 type TUICapableHandler interface { 926 GetTUIModel(ctx context.Context, opts TUITestOptions) (tea.Model, error) ··· 1192 } 1193 } 1194 1195 - // CreateTaskHandler creates a TaskHandler for testing with automatic cleanup 1196 - func CreateTaskHandler(t *testing.T) *TaskHandler { 1197 - t.Helper() 1198 - 1199 - handler, err := NewTaskHandler() 1200 - if err != nil { 1201 - t.Fatalf("Failed to create task handler: %v", err) 1202 - } 1203 - 1204 - t.Cleanup(func() { 1205 - handler.Close() 1206 - }) 1207 - 1208 - return handler 1209 - } 1210 - 1211 // AssertTaskHasUUID verifies that a task has a non-empty UUID 1212 func AssertTaskHasUUID(t *testing.T, task *models.Task) { 1213 t.Helper() ··· 1226 t.Error("Task Modified timestamp should be set") 1227 } 1228 } 1229 - 1230 - // CreateBookHandler creates a [BookHandler] for testing with automatic cleanup 1231 - func CreateBookHandler(t *testing.T) *BookHandler { 1232 - t.Helper() 1233 - handler, err := NewBookHandler() 1234 - if err != nil { 1235 - t.Fatalf("Failed to create book handler: %v", err) 1236 - } 1237 - t.Cleanup(func() { handler.Close() }) 1238 - return handler 1239 - } 1240 - 1241 - // CreateMovieHandler creates a [MovieHandler] for testing with automatic cleanup 1242 - func CreateMovieHandler(t *testing.T) *MovieHandler { 1243 - t.Helper() 1244 - handler, err := NewMovieHandler() 1245 - if err != nil { 1246 - t.Fatalf("Failed to create movie handler: %v", err) 1247 - } 1248 - t.Cleanup(func() { handler.Close() }) 1249 - return handler 1250 - } 1251 - 1252 - // CreateTVHandler creates a [TVHandler] for testing with automatic cleanup 1253 - func CreateTVHandler(t *testing.T) *TVHandler { 1254 - t.Helper() 1255 - handler, err := NewTVHandler() 1256 - if err != nil { 1257 - t.Fatalf("Failed to create TV handler: %v", err) 1258 - } 1259 - t.Cleanup(func() { handler.Close() }) 1260 - return handler 1261 - }
··· 25 ) 26 27 // HandlerTestHelper wraps NoteHandler with test-specific functionality 28 + // 29 + // Uses HandlerTestSuite internally to avoid code duplication 30 type HandlerTestHelper struct { 31 *NoteHandler 32 + suite *HandlerTestSuite 33 } 34 35 // NewHandlerTestHelper creates a NoteHandler with isolated test database 36 func NewHandlerTestHelper(t *testing.T) *HandlerTestHelper { 37 + suite := NewHandlerTestSuite(t) 38 39 handler, err := NewNoteHandler() 40 if err != nil { 41 t.Fatalf("Failed to create note handler: %v", err) 42 } 43 44 testHandler := &HandlerTestHelper{ 45 NoteHandler: handler, 46 + suite: suite, 47 } 48 49 t.Cleanup(func() { 50 testHandler.Close() 51 }) 52 53 return testHandler ··· 73 74 // CreateTestFile creates a temporary markdown file with content 75 func (th *HandlerTestHelper) CreateTestFile(t *testing.T, filename, content string) string { 76 + filePath := filepath.Join(th.suite.TempDir(), filename) 77 err := os.WriteFile(filePath, []byte(content), 0644) 78 if err != nil { 79 t.Fatalf("Failed to create test file: %v", err) ··· 215 } 216 } 217 218 // EnvironmentTestHelper provides environment manipulation utilities for testing 219 + // 220 + // Use this for tests requiring fine-grained environment control beyond HandlerTestSuite. 221 + // Examples: testing missing EDITOR, invalid PATH, corrupt TMPDIR, etc. 222 type EnvironmentTestHelper struct { 223 originalVars map[string]string 224 } ··· 303 // ArticleTestHelper wraps ArticleHandler with test-specific functionality 304 type ArticleTestHelper struct { 305 *ArticleHandler 306 + suite *HandlerTestSuite 307 } 308 309 // NewArticleTestHelper creates an ArticleHandler with isolated test database 310 func NewArticleTestHelper(t *testing.T) *ArticleTestHelper { 311 + suite := NewHandlerTestSuite(t) 312 313 handler, err := NewArticleHandler() 314 if err != nil { 315 t.Fatalf("Failed to create article handler: %v", err) 316 } 317 318 testHelper := &ArticleTestHelper{ 319 ArticleHandler: handler, 320 + suite: suite, 321 } 322 323 t.Cleanup(func() { 324 testHelper.Close() 325 }) 326 327 return testHelper ··· 331 func (ath *ArticleTestHelper) CreateTestArticle(t *testing.T, url, title, author, date string) int64 { 332 ctx := context.Background() 333 334 + mdPath := filepath.Join(ath.suite.TempDir(), fmt.Sprintf("%s.md", title)) 335 + htmlPath := filepath.Join(ath.suite.TempDir(), fmt.Sprintf("%s.html", title)) 336 337 mdContent := fmt.Sprintf("# %s\n\n**Author:** %s\n**Date:** %s\n\nTest content", title, author, date) 338 err := os.WriteFile(mdPath, []byte(mdContent), 0644) ··· 743 return ith.sim 744 } 745 746 // TUICapableHandler interface for handlers that can expose TUI models for testing 747 type TUICapableHandler interface { 748 GetTUIModel(ctx context.Context, opts TUITestOptions) (tea.Model, error) ··· 1014 } 1015 } 1016 1017 // AssertTaskHasUUID verifies that a task has a non-empty UUID 1018 func AssertTaskHasUUID(t *testing.T, task *models.Task) { 1019 t.Helper() ··· 1032 t.Error("Task Modified timestamp should be set") 1033 } 1034 }
+1 -4
internal/handlers/tv.go
··· 15 "github.com/stormlightlabs/noteleaf/internal/store" 16 ) 17 18 - // TVHandler handles all TV show-related commands 19 - // 20 - // Implements MediaHandler interface for polymorphic media handling 21 type TVHandler struct { 22 db *store.Database 23 config *store.Config ··· 26 reader io.Reader 27 } 28 29 - // Ensure TVHandler implements MediaHandler interface 30 var _ MediaHandler = (*TVHandler)(nil) 31 32 // NewTVHandler creates a new TV handler
··· 15 "github.com/stormlightlabs/noteleaf/internal/store" 16 ) 17 18 + // TVHandler handles all TV show-related commands. Implements [MediaHandler] for polymorphic media handling 19 type TVHandler struct { 20 db *store.Database 21 config *store.Config ··· 24 reader io.Reader 25 } 26 27 var _ MediaHandler = (*TVHandler)(nil) 28 29 // NewTVHandler creates a new TV handler
-52
internal/models/behaviors.go
··· 1 - package models 2 - 3 - import "time" 4 - 5 - // Stateful represents entities with status management behavior 6 - // 7 - // Implemented by: [Book], [Movie], [TVShow], [Task] 8 - type Stateful interface { 9 - GetStatus() string 10 - ValidStatuses() []string 11 - } 12 - 13 - // Queueable represents media that can be queued for later consumption 14 - // 15 - // Implemented by: [Book], [Movie], [TVShow] 16 - type Queueable interface { 17 - Stateful 18 - IsQueued() bool 19 - } 20 - 21 - // Completable represents media that can be marked as completed/finished/watched. It tracks completion timestamps for media consumption. 22 - // 23 - // Implemented by: [Book] (finished), [Movie] (watched), [TVShow] (watched) 24 - type Completable interface { 25 - Stateful 26 - IsCompleted() bool 27 - GetCompletionTime() *time.Time 28 - } 29 - 30 - // Progressable represents media with measurable progress tracking 31 - // 32 - // Implemented by: [Book] (percentage-based reading progress) 33 - type Progressable interface { 34 - Completable 35 - GetProgress() int 36 - SetProgress(progress int) error 37 - } 38 - 39 - // Compile-time interface checks 40 - var ( 41 - _ Stateful = (*Task)(nil) 42 - _ Stateful = (*Book)(nil) 43 - _ Stateful = (*Movie)(nil) 44 - _ Stateful = (*TVShow)(nil) 45 - _ Queueable = (*Book)(nil) 46 - _ Queueable = (*Movie)(nil) 47 - _ Queueable = (*TVShow)(nil) 48 - _ Completable = (*Book)(nil) 49 - _ Completable = (*Movie)(nil) 50 - _ Completable = (*TVShow)(nil) 51 - _ Progressable = (*Book)(nil) 52 - )
···
+70 -61
internal/models/models.go
··· 43 44 // Model defines the common interface that all domain models must implement 45 type Model interface { 46 - // GetID returns the primary key identifier 47 - GetID() int64 48 - // SetID sets the primary key identifier 49 - SetID(id int64) 50 - // GetTableName returns the database table name for this model 51 - GetTableName() string 52 - // GetCreatedAt returns when the model was created 53 - GetCreatedAt() time.Time 54 - // SetCreatedAt sets when the model was created 55 - SetCreatedAt(t time.Time) 56 - // GetUpdatedAt returns when the model was last updated 57 - GetUpdatedAt() time.Time 58 - // SetUpdatedAt sets when the model was last updated 59 - SetUpdatedAt(t time.Time) 60 } 61 62 // Task represents a task item with TaskWarrior-inspired fields 63 type Task struct { 64 ID int64 `json:"id"` ··· 210 } 211 212 // IsCompleted returns true if the task is marked as completed 213 - func (t *Task) IsCompleted() bool { 214 - return t.Status == "completed" 215 - } 216 217 // IsPending returns true if the task is pending 218 - func (t *Task) IsPending() bool { 219 - return t.Status == "pending" 220 - } 221 222 // IsDeleted returns true if the task is deleted 223 - func (t *Task) IsDeleted() bool { 224 - return t.Status == "deleted" 225 - } 226 227 // HasPriority returns true if the task has a priority set 228 - func (t *Task) HasPriority() bool { 229 - return t.Priority != "" 230 - } 231 232 - // New status tracking methods 233 - func (t *Task) IsTodo() bool { 234 - return t.Status == StatusTodo 235 - } 236 - 237 - func (t *Task) IsInProgress() bool { 238 - return t.Status == StatusInProgress 239 - } 240 - 241 - func (t *Task) IsBlocked() bool { 242 - return t.Status == StatusBlocked 243 - } 244 - 245 - func (t *Task) IsDone() bool { 246 - return t.Status == StatusDone 247 - } 248 - 249 - func (t *Task) IsAbandoned() bool { 250 - return t.Status == StatusAbandoned 251 - } 252 253 // IsValidStatus returns true if the status is one of the defined valid statuses 254 func (t *Task) IsValidStatus() bool { ··· 282 return false 283 } 284 285 - // GetPriorityWeight returns a numeric weight for sorting priorities 286 - // 287 - // Higher numbers = higher priority 288 func (t *Task) GetPriorityWeight() int { 289 switch t.Priority { 290 case PriorityHigh, "5": ··· 524 } 525 526 // HasRating returns true if the album has a rating set 527 - func (a *Album) HasRating() bool { 528 - return a.Rating > 0 529 - } 530 531 // IsValidRating returns true if the rating is between 1 and 5 532 - func (a *Album) IsValidRating() bool { 533 - return a.Rating >= 1 && a.Rating <= 5 534 - } 535 536 func (a *Album) GetID() int64 { return a.ID } 537 func (a *Album) SetID(id int64) { a.ID = id } ··· 585 } 586 587 // HasAuthor returns true if the article has an author 588 - func (a *Article) HasAuthor() bool { 589 - return a.Author != "" 590 - } 591 592 // HasDate returns true if the article has a date 593 - func (a *Article) HasDate() bool { 594 - return a.Date != "" 595 - }
··· 43 44 // Model defines the common interface that all domain models must implement 45 type Model interface { 46 + GetID() int64 // GetID returns the primary key identifier 47 + SetID(id int64) // SetID sets the primary key identifier 48 + GetTableName() string // GetTableName returns the database table name for this model 49 + GetCreatedAt() time.Time // GetCreatedAt returns when the model was created 50 + SetCreatedAt(t time.Time) // SetCreatedAt sets when the model was created 51 + GetUpdatedAt() time.Time // GetUpdatedAt returns when the model was last updated 52 + SetUpdatedAt(t time.Time) // SetUpdatedAt sets when the model was last updated 53 + } 54 + 55 + // Stateful represents entities with status management behavior 56 + // 57 + // Implemented by: [Book], [Movie], [TVShow], [Task] 58 + type Stateful interface { 59 + GetStatus() string 60 + ValidStatuses() []string 61 } 62 63 + // Queueable represents media that can be queued for later consumption 64 + // 65 + // Implemented by: [Book], [Movie], [TVShow] 66 + type Queueable interface { 67 + Stateful 68 + IsQueued() bool 69 + } 70 + 71 + // Completable represents media that can be marked as completed/finished/watched. It tracks completion timestamps for media consumption. 72 + // 73 + // Implemented by: [Book] (finished), [Movie] (watched), [TVShow] (watched) 74 + type Completable interface { 75 + Stateful 76 + IsCompleted() bool 77 + GetCompletionTime() *time.Time 78 + } 79 + 80 + // Progressable represents media with measurable progress tracking 81 + // 82 + // Implemented by: [Book] (percentage-based reading progress) 83 + type Progressable interface { 84 + Completable 85 + GetProgress() int 86 + SetProgress(progress int) error 87 + } 88 + 89 + // Compile-time interface checks 90 + var ( 91 + _ Stateful = (*Task)(nil) 92 + _ Stateful = (*Book)(nil) 93 + _ Stateful = (*Movie)(nil) 94 + _ Stateful = (*TVShow)(nil) 95 + _ Queueable = (*Book)(nil) 96 + _ Queueable = (*Movie)(nil) 97 + _ Queueable = (*TVShow)(nil) 98 + _ Completable = (*Book)(nil) 99 + _ Completable = (*Movie)(nil) 100 + _ Completable = (*TVShow)(nil) 101 + _ Progressable = (*Book)(nil) 102 + ) 103 + 104 // Task represents a task item with TaskWarrior-inspired fields 105 type Task struct { 106 ID int64 `json:"id"` ··· 252 } 253 254 // IsCompleted returns true if the task is marked as completed 255 + func (t *Task) IsCompleted() bool { return t.Status == "completed" } 256 257 // IsPending returns true if the task is pending 258 + func (t *Task) IsPending() bool { return t.Status == "pending" } 259 260 // IsDeleted returns true if the task is deleted 261 + func (t *Task) IsDeleted() bool { return t.Status == "deleted" } 262 263 // HasPriority returns true if the task has a priority set 264 + func (t *Task) HasPriority() bool { return t.Priority != "" } 265 266 + func (t *Task) IsTodo() bool { return t.Status == StatusTodo } 267 + func (t *Task) IsInProgress() bool { return t.Status == StatusInProgress } 268 + func (t *Task) IsBlocked() bool { return t.Status == StatusBlocked } 269 + func (t *Task) IsDone() bool { return t.Status == StatusDone } 270 + func (t *Task) IsAbandoned() bool { return t.Status == StatusAbandoned } 271 272 // IsValidStatus returns true if the status is one of the defined valid statuses 273 func (t *Task) IsValidStatus() bool { ··· 301 return false 302 } 303 304 + // GetPriorityWeight returns a numeric weight for sorting priorities. A higher number = higher priority 305 func (t *Task) GetPriorityWeight() int { 306 switch t.Priority { 307 case PriorityHigh, "5": ··· 541 } 542 543 // HasRating returns true if the album has a rating set 544 + func (a *Album) HasRating() bool { return a.Rating > 0 } 545 546 // IsValidRating returns true if the rating is between 1 and 5 547 + func (a *Album) IsValidRating() bool { return a.Rating >= 1 && a.Rating <= 5 } 548 549 func (a *Album) GetID() int64 { return a.ID } 550 func (a *Album) SetID(id int64) { a.ID = id } ··· 598 } 599 600 // HasAuthor returns true if the article has an author 601 + func (a *Article) HasAuthor() bool { return a.Author != "" } 602 603 // HasDate returns true if the article has a date 604 + func (a *Article) HasDate() bool { return a.Date != "" }
+139 -134
internal/repo/article_repository.go
··· 11 "github.com/stormlightlabs/noteleaf/internal/services" 12 ) 13 14 // ArticleRepository provides database operations for articles 15 type ArticleRepository struct { 16 db *sql.DB ··· 32 Offset int 33 } 34 35 - // Create stores a new article and returns its assigned ID 36 - func (r *ArticleRepository) Create(ctx context.Context, article *models.Article) (int64, error) { 37 - if err := r.Validate(article); err != nil { 38 - return 0, err 39 - } 40 - 41 - now := time.Now() 42 - article.Created = now 43 - article.Modified = now 44 - 45 - query := ` 46 - INSERT INTO articles (url, title, author, date, markdown_path, html_path, created, modified) 47 - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 48 - 49 - result, err := r.db.ExecContext(ctx, query, 50 - article.URL, article.Title, article.Author, article.Date, 51 - article.MarkdownPath, article.HTMLPath, article.Created, article.Modified) 52 - if err != nil { 53 - return 0, fmt.Errorf("failed to insert article: %w", err) 54 - } 55 - 56 - id, err := result.LastInsertId() 57 - if err != nil { 58 - return 0, fmt.Errorf("failed to get last insert id: %w", err) 59 - } 60 - 61 - article.ID = id 62 - return id, nil 63 - } 64 - 65 - // Get retrieves an article by its ID 66 - func (r *ArticleRepository) Get(ctx context.Context, id int64) (*models.Article, error) { 67 - query := ` 68 - SELECT id, url, title, author, date, markdown_path, html_path, created, modified 69 - FROM articles WHERE id = ?` 70 - 71 - row := r.db.QueryRowContext(ctx, query, id) 72 - 73 var article models.Article 74 - err := row.Scan(&article.ID, &article.URL, &article.Title, &article.Author, &article.Date, 75 &article.MarkdownPath, &article.HTMLPath, &article.Created, &article.Modified) 76 if err != nil { 77 - if err == sql.ErrNoRows { 78 - return nil, fmt.Errorf("article with id %d not found", id) 79 - } 80 - return nil, fmt.Errorf("failed to scan article: %w", err) 81 } 82 - 83 return &article, nil 84 } 85 86 - // GetByURL retrieves an article by its URL 87 - func (r *ArticleRepository) GetByURL(ctx context.Context, url string) (*models.Article, error) { 88 - query := ` 89 - SELECT id, url, title, author, date, markdown_path, html_path, created, modified 90 - FROM articles WHERE url = ?` 91 - 92 - row := r.db.QueryRowContext(ctx, query, url) 93 - 94 - var article models.Article 95 - err := row.Scan(&article.ID, &article.URL, &article.Title, &article.Author, &article.Date, 96 - &article.MarkdownPath, &article.HTMLPath, &article.Created, &article.Modified) 97 if err != nil { 98 if err == sql.ErrNoRows { 99 - return nil, fmt.Errorf("article with url %s not found", url) 100 } 101 return nil, fmt.Errorf("failed to scan article: %w", err) 102 } 103 - 104 - return &article, nil 105 - } 106 - 107 - // Update modifies an existing article 108 - func (r *ArticleRepository) Update(ctx context.Context, article *models.Article) error { 109 - if err := r.Validate(article); err != nil { 110 - return err 111 - } 112 - 113 - article.Modified = time.Now() 114 - 115 - query := ` 116 - UPDATE articles 117 - SET title = ?, author = ?, date = ?, markdown_path = ?, html_path = ?, modified = ? 118 - WHERE id = ?` 119 - 120 - result, err := r.db.ExecContext(ctx, query, 121 - article.Title, article.Author, article.Date, article.MarkdownPath, 122 - article.HTMLPath, article.Modified, article.ID) 123 - if err != nil { 124 - return fmt.Errorf("failed to update article: %w", err) 125 - } 126 - 127 - rowsAffected, err := result.RowsAffected() 128 - if err != nil { 129 - return fmt.Errorf("failed to get rows affected: %w", err) 130 - } 131 - 132 - if rowsAffected == 0 { 133 - return fmt.Errorf("article with id %d not found", article.ID) 134 - } 135 - 136 - return nil 137 } 138 139 - // Delete removes an article from the database 140 - func (r *ArticleRepository) Delete(ctx context.Context, id int64) error { 141 - query := "DELETE FROM articles WHERE id = ?" 142 - 143 - result, err := r.db.ExecContext(ctx, query, id) 144 if err != nil { 145 - return fmt.Errorf("failed to delete article: %w", err) 146 } 147 148 - rowsAffected, err := result.RowsAffected() 149 - if err != nil { 150 - return fmt.Errorf("failed to get rows affected: %w", err) 151 } 152 153 - if rowsAffected == 0 { 154 - return fmt.Errorf("article with id %d not found", id) 155 } 156 157 - return nil 158 } 159 160 - // List retrieves articles with optional filtering 161 - func (r *ArticleRepository) List(ctx context.Context, opts *ArticleListOptions) ([]*models.Article, error) { 162 - query := ` 163 - SELECT id, url, title, author, date, markdown_path, html_path, created, modified 164 - FROM articles` 165 - 166 var conditions []string 167 var args []any 168 ··· 204 } 205 } 206 207 - rows, err := r.db.QueryContext(ctx, query, args...) 208 - if err != nil { 209 - return nil, fmt.Errorf("failed to query articles: %w", err) 210 - } 211 - defer rows.Close() 212 - 213 - var articles []*models.Article 214 - for rows.Next() { 215 - var article models.Article 216 - err := rows.Scan(&article.ID, &article.URL, &article.Title, &article.Author, &article.Date, 217 - &article.MarkdownPath, &article.HTMLPath, &article.Created, &article.Modified) 218 - if err != nil { 219 - return nil, fmt.Errorf("failed to scan article: %w", err) 220 - } 221 - articles = append(articles, &article) 222 - } 223 - 224 - if err = rows.Err(); err != nil { 225 - return nil, fmt.Errorf("error iterating over articles: %w", err) 226 - } 227 - 228 - return articles, nil 229 } 230 231 - // Count returns the total number of articles matching the given options 232 - func (r *ArticleRepository) Count(ctx context.Context, opts *ArticleListOptions) (int64, error) { 233 - query := "SELECT COUNT(*) FROM articles" 234 - 235 var conditions []string 236 var args []any 237 ··· 261 if len(conditions) > 0 { 262 query += " WHERE " + strings.Join(conditions, " AND ") 263 } 264 265 var count int64 266 err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
··· 11 "github.com/stormlightlabs/noteleaf/internal/services" 12 ) 13 14 + func ArticleNotFoundError(id int64) error { 15 + return fmt.Errorf("article with id %d not found", id) 16 + } 17 + 18 // ArticleRepository provides database operations for articles 19 type ArticleRepository struct { 20 db *sql.DB ··· 36 Offset int 37 } 38 39 + // scanArticle scans a database row into an Article model 40 + func (r *ArticleRepository) scanArticle(s scanner) (*models.Article, error) { 41 var article models.Article 42 + err := s.Scan(&article.ID, &article.URL, &article.Title, &article.Author, &article.Date, 43 &article.MarkdownPath, &article.HTMLPath, &article.Created, &article.Modified) 44 if err != nil { 45 + return nil, err 46 } 47 return &article, nil 48 } 49 50 + // queryOne executes a query that returns a single article 51 + func (r *ArticleRepository) queryOne(ctx context.Context, query string, args ...any) (*models.Article, error) { 52 + row := r.db.QueryRowContext(ctx, query, args...) 53 + article, err := r.scanArticle(row) 54 if err != nil { 55 if err == sql.ErrNoRows { 56 + return nil, fmt.Errorf("article not found") 57 } 58 return nil, fmt.Errorf("failed to scan article: %w", err) 59 } 60 + return article, nil 61 } 62 63 + // queryMany executes a query that returns multiple articles 64 + func (r *ArticleRepository) queryMany(ctx context.Context, query string, args ...any) ([]*models.Article, error) { 65 + rows, err := r.db.QueryContext(ctx, query, args...) 66 if err != nil { 67 + return nil, fmt.Errorf("failed to query articles: %w", err) 68 } 69 + defer rows.Close() 70 71 + var articles []*models.Article 72 + for rows.Next() { 73 + article, err := r.scanArticle(rows) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to scan article: %w", err) 76 + } 77 + articles = append(articles, article) 78 } 79 80 + if err := rows.Err(); err != nil { 81 + return nil, fmt.Errorf("error iterating over articles: %w", err) 82 } 83 84 + return articles, nil 85 } 86 87 + // buildListQuery constructs a query and arguments for the List method 88 + func (r *ArticleRepository) buildListQuery(opts *ArticleListOptions) (string, []any) { 89 + query := queryArticlesList 90 var conditions []string 91 var args []any 92 ··· 128 } 129 } 130 131 + return query, args 132 } 133 134 + // buildCountQuery constructs a count query and arguments 135 + func (r *ArticleRepository) buildCountQuery(opts *ArticleListOptions) (string, []any) { 136 + query := queryArticlesCount 137 var conditions []string 138 var args []any 139 ··· 163 if len(conditions) > 0 { 164 query += " WHERE " + strings.Join(conditions, " AND ") 165 } 166 + 167 + return query, args 168 + } 169 + 170 + // Create stores a new article and returns its assigned ID 171 + func (r *ArticleRepository) Create(ctx context.Context, article *models.Article) (int64, error) { 172 + if err := r.Validate(article); err != nil { 173 + return 0, err 174 + } 175 + 176 + now := time.Now() 177 + article.Created = now 178 + article.Modified = now 179 + 180 + result, err := r.db.ExecContext(ctx, queryArticleInsert, 181 + article.URL, article.Title, article.Author, article.Date, 182 + article.MarkdownPath, article.HTMLPath, article.Created, article.Modified) 183 + if err != nil { 184 + return 0, fmt.Errorf("failed to insert article: %w", err) 185 + } 186 + 187 + id, err := result.LastInsertId() 188 + if err != nil { 189 + return 0, fmt.Errorf("failed to get last insert id: %w", err) 190 + } 191 + 192 + article.ID = id 193 + return id, nil 194 + } 195 + 196 + // Get retrieves an article by its ID 197 + func (r *ArticleRepository) Get(ctx context.Context, id int64) (*models.Article, error) { 198 + article, err := r.queryOne(ctx, queryArticleByID, id) 199 + if err != nil { 200 + return nil, ArticleNotFoundError(id) 201 + } 202 + return article, nil 203 + } 204 + 205 + // GetByURL retrieves an article by its URL 206 + func (r *ArticleRepository) GetByURL(ctx context.Context, url string) (*models.Article, error) { 207 + article, err := r.queryOne(ctx, queryArticleByURL, url) 208 + if err != nil { 209 + return nil, fmt.Errorf("article with url %s not found", url) 210 + } 211 + return article, nil 212 + } 213 + 214 + // Update modifies an existing article 215 + func (r *ArticleRepository) Update(ctx context.Context, article *models.Article) error { 216 + if err := r.Validate(article); err != nil { 217 + return err 218 + } 219 + 220 + article.Modified = time.Now() 221 + 222 + result, err := r.db.ExecContext(ctx, queryArticleUpdate, 223 + article.Title, article.Author, article.Date, article.MarkdownPath, 224 + article.HTMLPath, article.Modified, article.ID) 225 + if err != nil { 226 + return fmt.Errorf("failed to update article: %w", err) 227 + } 228 + 229 + rowsAffected, err := result.RowsAffected() 230 + if err != nil { 231 + return fmt.Errorf("failed to get rows affected: %w", err) 232 + } 233 + 234 + if rowsAffected == 0 { 235 + return ArticleNotFoundError(article.ID) 236 + } 237 + 238 + return nil 239 + } 240 + 241 + // Delete removes an article from the database 242 + func (r *ArticleRepository) Delete(ctx context.Context, id int64) error { 243 + result, err := r.db.ExecContext(ctx, queryArticleDelete, id) 244 + if err != nil { 245 + return fmt.Errorf("failed to delete article: %w", err) 246 + } 247 + 248 + rowsAffected, err := result.RowsAffected() 249 + if err != nil { 250 + return fmt.Errorf("failed to get rows affected: %w", err) 251 + } 252 + 253 + if rowsAffected == 0 { 254 + return ArticleNotFoundError(id) 255 + } 256 + 257 + return nil 258 + } 259 + 260 + // List retrieves articles with optional filtering 261 + func (r *ArticleRepository) List(ctx context.Context, opts *ArticleListOptions) ([]*models.Article, error) { 262 + query, args := r.buildListQuery(opts) 263 + return r.queryMany(ctx, query, args...) 264 + } 265 + 266 + // Count returns the total number of articles matching the given options 267 + func (r *ArticleRepository) Count(ctx context.Context, opts *ArticleListOptions) (int64, error) { 268 + query, args := r.buildCountQuery(opts) 269 270 var count int64 271 err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
+7 -7
internal/repo/article_repository_test.go
··· 395 t.Run("Create with cancelled context", func(t *testing.T) { 396 newArticle := CreateSampleArticle() 397 _, err := repo.Create(NewCanceledContext(), newArticle) 398 - AssertError(t, err, "Expected error with cancelled context") 399 }) 400 401 t.Run("Get with cancelled context", func(t *testing.T) { 402 _, err := repo.Get(NewCanceledContext(), id) 403 - AssertError(t, err, "Expected error with cancelled context") 404 }) 405 406 t.Run("GetByURL with cancelled context", func(t *testing.T) { 407 _, err := repo.GetByURL(NewCanceledContext(), article.URL) 408 - AssertError(t, err, "Expected error with cancelled context") 409 }) 410 411 t.Run("Update with cancelled context", func(t *testing.T) { 412 article.Title = "Updated" 413 err := repo.Update(NewCanceledContext(), article) 414 - AssertError(t, err, "Expected error with cancelled context") 415 }) 416 417 t.Run("Delete with cancelled context", func(t *testing.T) { 418 err := repo.Delete(NewCanceledContext(), id) 419 - AssertError(t, err, "Expected error with cancelled context") 420 }) 421 422 t.Run("List with cancelled context", func(t *testing.T) { 423 _, err := repo.List(NewCanceledContext(), nil) 424 - AssertError(t, err, "Expected error with cancelled context") 425 }) 426 427 t.Run("Count with cancelled context", func(t *testing.T) { 428 _, err := repo.Count(NewCanceledContext(), nil) 429 - AssertError(t, err, "Expected error with cancelled context") 430 }) 431 }) 432
··· 395 t.Run("Create with cancelled context", func(t *testing.T) { 396 newArticle := CreateSampleArticle() 397 _, err := repo.Create(NewCanceledContext(), newArticle) 398 + AssertCancelledContext(t, err) 399 }) 400 401 t.Run("Get with cancelled context", func(t *testing.T) { 402 _, err := repo.Get(NewCanceledContext(), id) 403 + AssertCancelledContext(t, err) 404 }) 405 406 t.Run("GetByURL with cancelled context", func(t *testing.T) { 407 _, err := repo.GetByURL(NewCanceledContext(), article.URL) 408 + AssertCancelledContext(t, err) 409 }) 410 411 t.Run("Update with cancelled context", func(t *testing.T) { 412 article.Title = "Updated" 413 err := repo.Update(NewCanceledContext(), article) 414 + AssertCancelledContext(t, err) 415 }) 416 417 t.Run("Delete with cancelled context", func(t *testing.T) { 418 err := repo.Delete(NewCanceledContext(), id) 419 + AssertCancelledContext(t, err) 420 }) 421 422 t.Run("List with cancelled context", func(t *testing.T) { 423 _, err := repo.List(NewCanceledContext(), nil) 424 + AssertCancelledContext(t, err) 425 }) 426 427 t.Run("Count with cancelled context", func(t *testing.T) { 428 _, err := repo.Count(NewCanceledContext(), nil) 429 + AssertCancelledContext(t, err) 430 }) 431 }) 432
-12
internal/repo/base_media_repository.go
··· 9 ) 10 11 // MediaConfig defines configuration for a media repository 12 - // 13 - // T should be a pointer type (*models.Book, *models.Movie, *models.TVShow) 14 type MediaConfig[T models.Model] struct { 15 TableName string // TableName is the database table name (e.g., "books", "movies", "tv_shows") 16 New func() T // New creates a new zero-value instance of T ··· 23 } 24 25 // BaseMediaRepository provides shared CRUD operations for media types 26 - // 27 - // This generic implementation eliminates duplicate code across Book, Movie, and TV repositories. 28 - // Type-specific behavior is configured via MediaConfig. 29 - // 30 - // T should be a pointer type (*models.Book, *models.Movie, *models.TVShow) 31 type BaseMediaRepository[T models.Model] struct { 32 db *sql.DB 33 config MediaConfig[T] ··· 64 } 65 66 // Get retrieves a media item by ID 67 - // 68 - // Returns T directly (which is already a pointer type like *models.Book) 69 func (r *BaseMediaRepository[T]) Get(ctx context.Context, id int64) (T, error) { 70 query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", r.config.TableName) 71 row := r.db.QueryRowContext(ctx, query, id) ··· 109 } 110 111 // ListQuery executes a custom query and scans results 112 - // 113 - // Returns []T where T is a pointer type (e.g., []*models.Book) 114 func (r *BaseMediaRepository[T]) ListQuery(ctx context.Context, query string, args ...any) ([]T, error) { 115 rows, err := r.db.QueryContext(ctx, query, args...) 116 if err != nil { ··· 140 return count, nil 141 } 142 143 - // buildPlaceholders generates "?,?,?" for SQL placeholders 144 func buildPlaceholders(values []any) string { 145 if len(values) == 0 { 146 return ""
··· 9 ) 10 11 // MediaConfig defines configuration for a media repository 12 type MediaConfig[T models.Model] struct { 13 TableName string // TableName is the database table name (e.g., "books", "movies", "tv_shows") 14 New func() T // New creates a new zero-value instance of T ··· 21 } 22 23 // BaseMediaRepository provides shared CRUD operations for media types 24 type BaseMediaRepository[T models.Model] struct { 25 db *sql.DB 26 config MediaConfig[T] ··· 57 } 58 59 // Get retrieves a media item by ID 60 func (r *BaseMediaRepository[T]) Get(ctx context.Context, id int64) (T, error) { 61 query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", r.config.TableName) 62 row := r.db.QueryRowContext(ctx, query, id) ··· 100 } 101 102 // ListQuery executes a custom query and scans results 103 func (r *BaseMediaRepository[T]) ListQuery(ctx context.Context, query string, args ...any) ([]T, error) { 104 rows, err := r.db.QueryContext(ctx, query, args...) 105 if err != nil { ··· 129 return count, nil 130 } 131 132 func buildPlaceholders(values []any) string { 133 if len(values) == 0 { 134 return ""
+13 -13
internal/repo/book_repository_test.go
··· 316 317 t.Run("Count with context cancellation", func(t *testing.T) { 318 _, err := repo.Count(NewCanceledContext(), BookListOptions{}) 319 - AssertError(t, err, "Expected error with cancelled context") 320 }) 321 }) 322 ··· 332 t.Run("Create with cancelled context", func(t *testing.T) { 333 newBook := NewBookBuilder().WithTitle("Cancelled").Build() 334 _, err := repo.Create(NewCanceledContext(), newBook) 335 - AssertError(t, err, "Expected error with cancelled context") 336 }) 337 338 t.Run("Get with cancelled context", func(t *testing.T) { 339 _, err := repo.Get(NewCanceledContext(), id) 340 - AssertError(t, err, "Expected error with cancelled context") 341 }) 342 343 t.Run("Update with cancelled context", func(t *testing.T) { 344 book.Title = "Updated" 345 err := repo.Update(NewCanceledContext(), book) 346 - AssertError(t, err, "Expected error with cancelled context") 347 }) 348 349 t.Run("Delete with cancelled context", func(t *testing.T) { 350 err := repo.Delete(NewCanceledContext(), id) 351 - AssertError(t, err, "Expected error with cancelled context") 352 }) 353 354 t.Run("List with cancelled context", func(t *testing.T) { 355 _, err := repo.List(NewCanceledContext(), BookListOptions{}) 356 - AssertError(t, err, "Expected error with cancelled context") 357 }) 358 359 t.Run("GetQueued with cancelled context", func(t *testing.T) { 360 _, err := repo.GetQueued(NewCanceledContext()) 361 - AssertError(t, err, "Expected error with cancelled context") 362 }) 363 364 t.Run("GetReading with cancelled context", func(t *testing.T) { 365 _, err := repo.GetReading(NewCanceledContext()) 366 - AssertError(t, err, "Expected error with cancelled context") 367 }) 368 369 t.Run("GetFinished with cancelled context", func(t *testing.T) { 370 _, err := repo.GetFinished(NewCanceledContext()) 371 - AssertError(t, err, "Expected error with cancelled context") 372 }) 373 374 t.Run("GetByAuthor with cancelled context", func(t *testing.T) { 375 _, err := repo.GetByAuthor(NewCanceledContext(), "Test Author") 376 - AssertError(t, err, "Expected error with cancelled context") 377 }) 378 379 t.Run("StartReading with cancelled context", func(t *testing.T) { 380 err := repo.StartReading(NewCanceledContext(), id) 381 - AssertError(t, err, "Expected error with cancelled context") 382 }) 383 384 t.Run("FinishReading with cancelled context", func(t *testing.T) { 385 err := repo.FinishReading(NewCanceledContext(), id) 386 - AssertError(t, err, "Expected error with cancelled context") 387 }) 388 389 t.Run("UpdateProgress with cancelled context", func(t *testing.T) { 390 err := repo.UpdateProgress(NewCanceledContext(), id, 50) 391 - AssertError(t, err, "Expected error with cancelled context") 392 }) 393 }) 394
··· 316 317 t.Run("Count with context cancellation", func(t *testing.T) { 318 _, err := repo.Count(NewCanceledContext(), BookListOptions{}) 319 + AssertCancelledContext(t, err) 320 }) 321 }) 322 ··· 332 t.Run("Create with cancelled context", func(t *testing.T) { 333 newBook := NewBookBuilder().WithTitle("Cancelled").Build() 334 _, err := repo.Create(NewCanceledContext(), newBook) 335 + AssertCancelledContext(t, err) 336 }) 337 338 t.Run("Get with cancelled context", func(t *testing.T) { 339 _, err := repo.Get(NewCanceledContext(), id) 340 + AssertCancelledContext(t, err) 341 }) 342 343 t.Run("Update with cancelled context", func(t *testing.T) { 344 book.Title = "Updated" 345 err := repo.Update(NewCanceledContext(), book) 346 + AssertCancelledContext(t, err) 347 }) 348 349 t.Run("Delete with cancelled context", func(t *testing.T) { 350 err := repo.Delete(NewCanceledContext(), id) 351 + AssertCancelledContext(t, err) 352 }) 353 354 t.Run("List with cancelled context", func(t *testing.T) { 355 _, err := repo.List(NewCanceledContext(), BookListOptions{}) 356 + AssertCancelledContext(t, err) 357 }) 358 359 t.Run("GetQueued with cancelled context", func(t *testing.T) { 360 _, err := repo.GetQueued(NewCanceledContext()) 361 + AssertCancelledContext(t, err) 362 }) 363 364 t.Run("GetReading with cancelled context", func(t *testing.T) { 365 _, err := repo.GetReading(NewCanceledContext()) 366 + AssertCancelledContext(t, err) 367 }) 368 369 t.Run("GetFinished with cancelled context", func(t *testing.T) { 370 _, err := repo.GetFinished(NewCanceledContext()) 371 + AssertCancelledContext(t, err) 372 }) 373 374 t.Run("GetByAuthor with cancelled context", func(t *testing.T) { 375 _, err := repo.GetByAuthor(NewCanceledContext(), "Test Author") 376 + AssertCancelledContext(t, err) 377 }) 378 379 t.Run("StartReading with cancelled context", func(t *testing.T) { 380 err := repo.StartReading(NewCanceledContext(), id) 381 + AssertCancelledContext(t, err) 382 }) 383 384 t.Run("FinishReading with cancelled context", func(t *testing.T) { 385 err := repo.FinishReading(NewCanceledContext(), id) 386 + AssertCancelledContext(t, err) 387 }) 388 389 t.Run("UpdateProgress with cancelled context", func(t *testing.T) { 390 err := repo.UpdateProgress(NewCanceledContext(), id, 50) 391 + AssertCancelledContext(t, err) 392 }) 393 }) 394
+7 -23
internal/repo/media_repository.go
··· 7 ) 8 9 // MediaRepository defines CRUD operations for media types (Books, Movies, TV) 10 - // 11 - // This interface captures the shared behavior across media repositories 12 type MediaRepository[T models.Model] interface { 13 - // Create stores a new media item and returns its assigned ID 14 - Create(ctx context.Context, item *T) (int64, error) 15 - 16 - // Get retrieves a media item by ID 17 - Get(ctx context.Context, id int64) (*T, error) 18 - 19 - // Update modifies an existing media item 20 - Update(ctx context.Context, item *T) error 21 - 22 - // Delete removes a media item by ID 23 - Delete(ctx context.Context, id int64) error 24 - 25 - // List retrieves media items with optional filtering and sorting 26 - List(ctx context.Context, opts any) ([]*T, error) 27 - 28 - // Count returns the number of media items matching conditions 29 - Count(ctx context.Context, opts any) (int64, error) 30 } 31 32 - // StatusFilterable extends MediaRepository with status-based filtering 33 - // 34 - // Media types (Books, Movies, TV) support status-based queries like "queued", "reading", "watching", "watched", "finished" 35 type StatusFilterable[T models.Model] interface { 36 MediaRepository[T] 37 - 38 // GetByStatus retrieves all items with the given status 39 GetByStatus(ctx context.Context, status string) ([]*T, error) 40 }
··· 7 ) 8 9 // MediaRepository defines CRUD operations for media types (Books, Movies, TV) 10 type MediaRepository[T models.Model] interface { 11 + Create(ctx context.Context, item *T) (int64, error) // Create stores a new media item and returns its assigned ID 12 + Get(ctx context.Context, id int64) (*T, error) // Get retrieves a media item by ID 13 + Update(ctx context.Context, item *T) error // Update modifies an existing media item 14 + Delete(ctx context.Context, id int64) error // Delete removes a media item by ID 15 + List(ctx context.Context, opts any) ([]*T, error) // List retrieves media items with optional filtering and sorting 16 + Count(ctx context.Context, opts any) (int64, error) // Count returns the number of media items matching conditions 17 } 18 19 + // StatusFilterable extends MediaRepository with status-based filtering for queries like "queued", "reading", "watching", "watched", "finished" 20 type StatusFilterable[T models.Model] interface { 21 MediaRepository[T] 22 // GetByStatus retrieves all items with the given status 23 GetByStatus(ctx context.Context, status string) ([]*T, error) 24 }
+13 -16
internal/repo/movie_repository.go
··· 10 "github.com/stormlightlabs/noteleaf/internal/models" 11 ) 12 13 // MovieRepository provides database operations for movies 14 type MovieRepository struct { 15 *BaseMediaRepository[*models.Movie] ··· 37 }, 38 } 39 40 - return &MovieRepository{ 41 - BaseMediaRepository: NewBaseMediaRepository(db, config), 42 - db: db, 43 - } 44 } 45 46 // Create stores a new movie and returns its assigned ID ··· 211 movie.Watched = &now 212 return r.Update(ctx, movie) 213 } 214 - 215 - // MovieListOptions defines options for listing movies 216 - type MovieListOptions struct { 217 - Status string 218 - Year int 219 - MinRating float64 220 - Search string 221 - SortBy string 222 - SortOrder string 223 - Limit int 224 - Offset int 225 - }
··· 10 "github.com/stormlightlabs/noteleaf/internal/models" 11 ) 12 13 + // MovieListOptions defines options for listing movies 14 + type MovieListOptions struct { 15 + Status string 16 + Year int 17 + MinRating float64 18 + Search string 19 + SortBy string 20 + SortOrder string 21 + Limit int 22 + Offset int 23 + } 24 + 25 // MovieRepository provides database operations for movies 26 type MovieRepository struct { 27 *BaseMediaRepository[*models.Movie] ··· 49 }, 50 } 51 52 + return &MovieRepository{BaseMediaRepository: NewBaseMediaRepository(db, config), db: db} 53 } 54 55 // Create stores a new movie and returns its assigned ID ··· 220 movie.Watched = &now 221 return r.Update(ctx, movie) 222 }
+9 -9
internal/repo/movie_repository_test.go
··· 231 232 t.Run("Count with context cancellation", func(t *testing.T) { 233 _, err := repo.Count(NewCanceledContext(), MovieListOptions{}) 234 - AssertError(t, err, "Expected error with cancelled context") 235 }) 236 }) 237 ··· 247 t.Run("Create with cancelled context", func(t *testing.T) { 248 newMovie := NewMovieBuilder().WithTitle("Cancelled").Build() 249 _, err := repo.Create(NewCanceledContext(), newMovie) 250 - AssertError(t, err, "Expected error with cancelled context") 251 }) 252 253 t.Run("Get with cancelled context", func(t *testing.T) { 254 _, err := repo.Get(NewCanceledContext(), id) 255 - AssertError(t, err, "Expected error with cancelled context") 256 }) 257 258 t.Run("Update with cancelled context", func(t *testing.T) { 259 movie.Title = "Updated" 260 err := repo.Update(NewCanceledContext(), movie) 261 - AssertError(t, err, "Expected error with cancelled context") 262 }) 263 264 t.Run("Delete with cancelled context", func(t *testing.T) { 265 err := repo.Delete(NewCanceledContext(), id) 266 - AssertError(t, err, "Expected error with cancelled context") 267 }) 268 269 t.Run("List with cancelled context", func(t *testing.T) { 270 _, err := repo.List(NewCanceledContext(), MovieListOptions{}) 271 - AssertError(t, err, "Expected error with cancelled context") 272 }) 273 274 t.Run("GetQueued with cancelled context", func(t *testing.T) { 275 _, err := repo.GetQueued(NewCanceledContext()) 276 - AssertError(t, err, "Expected error with cancelled context") 277 }) 278 279 t.Run("GetWatched with cancelled context", func(t *testing.T) { 280 _, err := repo.GetWatched(NewCanceledContext()) 281 - AssertError(t, err, "Expected error with cancelled context") 282 }) 283 284 t.Run("MarkWatched with cancelled context", func(t *testing.T) { 285 err := repo.MarkWatched(NewCanceledContext(), id) 286 - AssertError(t, err, "Expected error with cancelled context") 287 }) 288 }) 289
··· 231 232 t.Run("Count with context cancellation", func(t *testing.T) { 233 _, err := repo.Count(NewCanceledContext(), MovieListOptions{}) 234 + AssertCancelledContext(t, err) 235 }) 236 }) 237 ··· 247 t.Run("Create with cancelled context", func(t *testing.T) { 248 newMovie := NewMovieBuilder().WithTitle("Cancelled").Build() 249 _, err := repo.Create(NewCanceledContext(), newMovie) 250 + AssertCancelledContext(t, err) 251 }) 252 253 t.Run("Get with cancelled context", func(t *testing.T) { 254 _, err := repo.Get(NewCanceledContext(), id) 255 + AssertCancelledContext(t, err) 256 }) 257 258 t.Run("Update with cancelled context", func(t *testing.T) { 259 movie.Title = "Updated" 260 err := repo.Update(NewCanceledContext(), movie) 261 + AssertCancelledContext(t, err) 262 }) 263 264 t.Run("Delete with cancelled context", func(t *testing.T) { 265 err := repo.Delete(NewCanceledContext(), id) 266 + AssertCancelledContext(t, err) 267 }) 268 269 t.Run("List with cancelled context", func(t *testing.T) { 270 _, err := repo.List(NewCanceledContext(), MovieListOptions{}) 271 + AssertCancelledContext(t, err) 272 }) 273 274 t.Run("GetQueued with cancelled context", func(t *testing.T) { 275 _, err := repo.GetQueued(NewCanceledContext()) 276 + AssertCancelledContext(t, err) 277 }) 278 279 t.Run("GetWatched with cancelled context", func(t *testing.T) { 280 _, err := repo.GetWatched(NewCanceledContext()) 281 + AssertCancelledContext(t, err) 282 }) 283 284 t.Run("MarkWatched with cancelled context", func(t *testing.T) { 285 err := repo.MarkWatched(NewCanceledContext(), id) 286 + AssertCancelledContext(t, err) 287 }) 288 }) 289
+82 -116
internal/repo/note_repository.go
··· 11 "github.com/stormlightlabs/noteleaf/internal/models" 12 ) 13 14 // NoteRepository provides database operations for notes 15 type NoteRepository struct { 16 db *sql.DB ··· 31 Offset int 32 } 33 34 // Create stores a new note and returns its assigned ID 35 func (r *NoteRepository) Create(ctx context.Context, note *models.Note) (int64, error) { 36 now := time.Now() ··· 42 return 0, fmt.Errorf("failed to marshal tags: %w", err) 43 } 44 45 - query := ` 46 - INSERT INTO notes (title, content, tags, archived, created, modified, file_path) 47 - VALUES (?, ?, ?, ?, ?, ?, ?)` 48 - 49 - result, err := r.db.ExecContext(ctx, query, 50 note.Title, note.Content, tags, note.Archived, note.Created, note.Modified, note.FilePath) 51 if err != nil { 52 return 0, fmt.Errorf("failed to insert note: %w", err) ··· 63 64 // Get retrieves a note by its ID 65 func (r *NoteRepository) Get(ctx context.Context, id int64) (*models.Note, error) { 66 - query := ` 67 - SELECT id, title, content, tags, archived, created, modified, file_path 68 - FROM notes WHERE id = ?` 69 - 70 - row := r.db.QueryRowContext(ctx, query, id) 71 - 72 - var note models.Note 73 - var tags string 74 - err := row.Scan(&note.ID, &note.Title, &note.Content, &tags, &note.Archived, 75 - &note.Created, &note.Modified, &note.FilePath) 76 if err != nil { 77 - if err == sql.ErrNoRows { 78 - return nil, fmt.Errorf("note with id %d not found", id) 79 - } 80 - return nil, fmt.Errorf("failed to scan note: %w", err) 81 - } 82 - 83 - if err := note.UnmarshalTags(tags); err != nil { 84 - return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 85 } 86 - 87 - return &note, nil 88 } 89 90 // Update modifies an existing note ··· 96 return fmt.Errorf("failed to marshal tags: %w", err) 97 } 98 99 - query := ` 100 - UPDATE notes 101 - SET title = ?, content = ?, tags = ?, archived = ?, modified = ?, file_path = ? 102 - WHERE id = ?` 103 - 104 - result, err := r.db.ExecContext(ctx, query, 105 note.Title, note.Content, tags, note.Archived, note.Modified, note.FilePath, note.ID) 106 if err != nil { 107 return fmt.Errorf("failed to update note: %w", err) ··· 113 } 114 115 if rowsAffected == 0 { 116 - return fmt.Errorf("note with id %d not found", note.ID) 117 } 118 119 return nil ··· 121 122 // Delete removes a note by its ID 123 func (r *NoteRepository) Delete(ctx context.Context, id int64) error { 124 - query := `DELETE FROM notes WHERE id = ?` 125 - 126 - result, err := r.db.ExecContext(ctx, query, id) 127 if err != nil { 128 return fmt.Errorf("failed to delete note: %w", err) 129 } ··· 134 } 135 136 if rowsAffected == 0 { 137 - return fmt.Errorf("note with id %d not found", id) 138 } 139 140 return nil 141 } 142 143 - // List retrieves notes with optional filtering 144 - func (r *NoteRepository) List(ctx context.Context, options NoteListOptions) ([]*models.Note, error) { 145 - query := "SELECT id, title, content, tags, archived, created, modified, file_path FROM notes" 146 args := []any{} 147 conditions := []string{} 148 ··· 174 } 175 } 176 177 - rows, err := r.db.QueryContext(ctx, query, args...) 178 - if err != nil { 179 - return nil, fmt.Errorf("failed to query notes: %w", err) 180 - } 181 - defer rows.Close() 182 - 183 - var notes []*models.Note 184 - for rows.Next() { 185 - var note models.Note 186 - var tags string 187 - err := rows.Scan(&note.ID, &note.Title, &note.Content, &tags, &note.Archived, 188 - &note.Created, &note.Modified, &note.FilePath) 189 - if err != nil { 190 - return nil, fmt.Errorf("failed to scan note: %w", err) 191 - } 192 - 193 - if err := note.UnmarshalTags(tags); err != nil { 194 - return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 195 - } 196 - 197 - notes = append(notes, &note) 198 - } 199 200 - if err := rows.Err(); err != nil { 201 - return nil, fmt.Errorf("error iterating over notes: %w", err) 202 - } 203 - 204 - return notes, nil 205 } 206 207 // GetByTitle searches for notes by title pattern ··· 274 if err != nil { 275 return err 276 } 277 - 278 for i, existingTag := range note.Tags { 279 if existingTag == tag { 280 note.Tags = append(note.Tags[:i], note.Tags[i+1:]...) 281 break 282 } 283 } 284 - 285 return r.Update(ctx, note) 286 } 287 288 - // GetByTags retrieves notes that have any of the specified tags 289 - func (r *NoteRepository) GetByTags(ctx context.Context, tags []string) ([]*models.Note, error) { 290 - if len(tags) == 0 { 291 - return []*models.Note{}, nil 292 - } 293 - 294 - placeholders := make([]string, len(tags)) 295 args := make([]any, len(tags)) 296 for i, tag := range tags { 297 - placeholders[i] = "?" 298 args[i] = "%\"" + tag + "\"%" 299 } 300 301 - query := fmt.Sprintf(` 302 - SELECT id, title, content, tags, archived, created, modified, file_path 303 - FROM notes 304 - WHERE %s 305 - ORDER BY modified DESC`, 306 - strings.Join(func() []string { 307 - conditions := make([]string, len(tags)) 308 - for i := range tags { 309 - conditions[i] = "tags LIKE ?" 310 - } 311 - return conditions 312 - }(), " OR ")) 313 - 314 - rows, err := r.db.QueryContext(ctx, query, args...) 315 - if err != nil { 316 - return nil, fmt.Errorf("failed to query notes by tags: %w", err) 317 } 318 - defer rows.Close() 319 - 320 - var notes []*models.Note 321 - for rows.Next() { 322 - var note models.Note 323 - var tagsJSON string 324 - err := rows.Scan(&note.ID, &note.Title, &note.Content, &tagsJSON, &note.Archived, 325 - &note.Created, &note.Modified, &note.FilePath) 326 - if err != nil { 327 - return nil, fmt.Errorf("failed to scan note: %w", err) 328 - } 329 - 330 - if err := note.UnmarshalTags(tagsJSON); err != nil { 331 - return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 332 - } 333 - 334 - notes = append(notes, &note) 335 - } 336 - 337 - if err := rows.Err(); err != nil { 338 - return nil, fmt.Errorf("error iterating over notes: %w", err) 339 - } 340 - 341 - return notes, nil 342 }
··· 11 "github.com/stormlightlabs/noteleaf/internal/models" 12 ) 13 14 + func NoteNotFoundError(id int64) error { 15 + return fmt.Errorf("note with id %d not found", id) 16 + } 17 + 18 // NoteRepository provides database operations for notes 19 type NoteRepository struct { 20 db *sql.DB ··· 35 Offset int 36 } 37 38 + func (r *NoteRepository) scanNote(s scanner) (*models.Note, error) { 39 + var note models.Note 40 + var tags string 41 + err := s.Scan(&note.ID, &note.Title, &note.Content, &tags, &note.Archived, 42 + &note.Created, &note.Modified, &note.FilePath) 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + if err := note.UnmarshalTags(tags); err != nil { 48 + return nil, UnmarshalTagsError(err) 49 + } 50 + 51 + return &note, nil 52 + } 53 + 54 + func (r *NoteRepository) queryOne(ctx context.Context, query string, args ...any) (*models.Note, error) { 55 + row := r.db.QueryRowContext(ctx, query, args...) 56 + note, err := r.scanNote(row) 57 + if err != nil { 58 + if err == sql.ErrNoRows { 59 + return nil, fmt.Errorf("note not found") 60 + } 61 + return nil, fmt.Errorf("failed to scan note: %w", err) 62 + } 63 + return note, nil 64 + } 65 + 66 + func (r *NoteRepository) queryMany(ctx context.Context, query string, args ...any) ([]*models.Note, error) { 67 + rows, err := r.db.QueryContext(ctx, query, args...) 68 + if err != nil { 69 + return nil, fmt.Errorf("failed to query notes: %w", err) 70 + } 71 + defer rows.Close() 72 + 73 + var notes []*models.Note 74 + for rows.Next() { 75 + note, err := r.scanNote(rows) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to scan note: %w", err) 78 + } 79 + notes = append(notes, note) 80 + } 81 + 82 + if err := rows.Err(); err != nil { 83 + return nil, fmt.Errorf("error iterating over notes: %w", err) 84 + } 85 + 86 + return notes, nil 87 + } 88 + 89 // Create stores a new note and returns its assigned ID 90 func (r *NoteRepository) Create(ctx context.Context, note *models.Note) (int64, error) { 91 now := time.Now() ··· 97 return 0, fmt.Errorf("failed to marshal tags: %w", err) 98 } 99 100 + result, err := r.db.ExecContext(ctx, queryNoteInsert, 101 note.Title, note.Content, tags, note.Archived, note.Created, note.Modified, note.FilePath) 102 if err != nil { 103 return 0, fmt.Errorf("failed to insert note: %w", err) ··· 114 115 // Get retrieves a note by its ID 116 func (r *NoteRepository) Get(ctx context.Context, id int64) (*models.Note, error) { 117 + note, err := r.queryOne(ctx, queryNoteByID, id) 118 if err != nil { 119 + return nil, NoteNotFoundError(id) 120 } 121 + return note, nil 122 } 123 124 // Update modifies an existing note ··· 130 return fmt.Errorf("failed to marshal tags: %w", err) 131 } 132 133 + result, err := r.db.ExecContext(ctx, queryNoteUpdate, 134 note.Title, note.Content, tags, note.Archived, note.Modified, note.FilePath, note.ID) 135 if err != nil { 136 return fmt.Errorf("failed to update note: %w", err) ··· 142 } 143 144 if rowsAffected == 0 { 145 + return NoteNotFoundError(note.ID) 146 } 147 148 return nil ··· 150 151 // Delete removes a note by its ID 152 func (r *NoteRepository) Delete(ctx context.Context, id int64) error { 153 + result, err := r.db.ExecContext(ctx, queryNoteDelete, id) 154 if err != nil { 155 return fmt.Errorf("failed to delete note: %w", err) 156 } ··· 161 } 162 163 if rowsAffected == 0 { 164 + return NoteNotFoundError(id) 165 } 166 167 return nil 168 } 169 170 + func (r *NoteRepository) buildListQuery(options NoteListOptions) (string, []any) { 171 + query := queryNotesList 172 args := []any{} 173 conditions := []string{} 174 ··· 200 } 201 } 202 203 + return query, args 204 + } 205 206 + // List retrieves notes with optional filtering 207 + func (r *NoteRepository) List(ctx context.Context, options NoteListOptions) ([]*models.Note, error) { 208 + query, args := r.buildListQuery(options) 209 + return r.queryMany(ctx, query, args...) 210 } 211 212 // GetByTitle searches for notes by title pattern ··· 279 if err != nil { 280 return err 281 } 282 for i, existingTag := range note.Tags { 283 if existingTag == tag { 284 note.Tags = append(note.Tags[:i], note.Tags[i+1:]...) 285 break 286 } 287 } 288 return r.Update(ctx, note) 289 } 290 291 + func (r *NoteRepository) buildTagsQuery(tags []string) (string, []any) { 292 + conditions := make([]string, len(tags)) 293 args := make([]any, len(tags)) 294 for i, tag := range tags { 295 + conditions[i] = "tags LIKE ?" 296 args[i] = "%\"" + tag + "\"%" 297 } 298 + return fmt.Sprintf(`SELECT %s FROM notes WHERE %s ORDER BY modified DESC`, noteColumns, strings.Join(conditions, " OR ")), args 299 + } 300 301 + // GetByTags retrieves notes that have any of the specified tags 302 + func (r *NoteRepository) GetByTags(ctx context.Context, tags []string) ([]*models.Note, error) { 303 + if len(tags) == 0 { 304 + return []*models.Note{}, nil 305 } 306 + query, args := r.buildTagsQuery(tags) 307 + return r.queryMany(ctx, query, args...) 308 }
+15 -15
internal/repo/note_repository_test.go
··· 345 t.Run("Create with cancelled context", func(t *testing.T) { 346 newNote := NewNoteBuilder().WithTitle("Cancelled").Build() 347 _, err := repo.Create(NewCanceledContext(), newNote) 348 - AssertError(t, err, "Expected error with cancelled context") 349 }) 350 351 t.Run("Get with cancelled context", func(t *testing.T) { 352 _, err := repo.Get(NewCanceledContext(), id) 353 - AssertError(t, err, "Expected error with cancelled context") 354 }) 355 356 t.Run("Update with cancelled context", func(t *testing.T) { 357 note.Title = "Updated" 358 err := repo.Update(NewCanceledContext(), note) 359 - AssertError(t, err, "Expected error with cancelled context") 360 }) 361 362 t.Run("Delete with cancelled context", func(t *testing.T) { 363 err := repo.Delete(NewCanceledContext(), id) 364 - AssertError(t, err, "Expected error with cancelled context") 365 }) 366 367 t.Run("List with cancelled context", func(t *testing.T) { 368 _, err := repo.List(NewCanceledContext(), NoteListOptions{}) 369 - AssertError(t, err, "Expected error with cancelled context") 370 }) 371 372 t.Run("GetByTitle with cancelled context", func(t *testing.T) { 373 _, err := repo.GetByTitle(NewCanceledContext(), "Test") 374 - AssertError(t, err, "Expected error with cancelled context") 375 }) 376 377 t.Run("GetArchived with cancelled context", func(t *testing.T) { 378 _, err := repo.GetArchived(NewCanceledContext()) 379 - AssertError(t, err, "Expected error with cancelled context") 380 }) 381 382 t.Run("GetActive with cancelled context", func(t *testing.T) { 383 _, err := repo.GetActive(NewCanceledContext()) 384 - AssertError(t, err, "Expected error with cancelled context") 385 }) 386 387 t.Run("Archive with cancelled context", func(t *testing.T) { 388 err := repo.Archive(NewCanceledContext(), id) 389 - AssertError(t, err, "Expected error with cancelled context") 390 }) 391 392 t.Run("Unarchive with cancelled context", func(t *testing.T) { 393 err := repo.Unarchive(NewCanceledContext(), id) 394 - AssertError(t, err, "Expected error with cancelled context") 395 }) 396 397 t.Run("SearchContent with cancelled context", func(t *testing.T) { 398 _, err := repo.SearchContent(NewCanceledContext(), "test") 399 - AssertError(t, err, "Expected error with cancelled context") 400 }) 401 402 t.Run("GetRecent with cancelled context", func(t *testing.T) { 403 _, err := repo.GetRecent(NewCanceledContext(), 10) 404 - AssertError(t, err, "Expected error with cancelled context") 405 }) 406 407 t.Run("AddTag with cancelled context", func(t *testing.T) { 408 err := repo.AddTag(NewCanceledContext(), id, "tag") 409 - AssertError(t, err, "Expected error with cancelled context") 410 }) 411 412 t.Run("RemoveTag with cancelled context", func(t *testing.T) { 413 err := repo.RemoveTag(NewCanceledContext(), id, "tag") 414 - AssertError(t, err, "Expected error with cancelled context") 415 }) 416 417 t.Run("GetByTags with cancelled context", func(t *testing.T) { 418 _, err := repo.GetByTags(NewCanceledContext(), []string{"tag"}) 419 - AssertError(t, err, "Expected error with cancelled context") 420 }) 421 }) 422
··· 345 t.Run("Create with cancelled context", func(t *testing.T) { 346 newNote := NewNoteBuilder().WithTitle("Cancelled").Build() 347 _, err := repo.Create(NewCanceledContext(), newNote) 348 + AssertCancelledContext(t, err) 349 }) 350 351 t.Run("Get with cancelled context", func(t *testing.T) { 352 _, err := repo.Get(NewCanceledContext(), id) 353 + AssertCancelledContext(t, err) 354 }) 355 356 t.Run("Update with cancelled context", func(t *testing.T) { 357 note.Title = "Updated" 358 err := repo.Update(NewCanceledContext(), note) 359 + AssertCancelledContext(t, err) 360 }) 361 362 t.Run("Delete with cancelled context", func(t *testing.T) { 363 err := repo.Delete(NewCanceledContext(), id) 364 + AssertCancelledContext(t, err) 365 }) 366 367 t.Run("List with cancelled context", func(t *testing.T) { 368 _, err := repo.List(NewCanceledContext(), NoteListOptions{}) 369 + AssertCancelledContext(t, err) 370 }) 371 372 t.Run("GetByTitle with cancelled context", func(t *testing.T) { 373 _, err := repo.GetByTitle(NewCanceledContext(), "Test") 374 + AssertCancelledContext(t, err) 375 }) 376 377 t.Run("GetArchived with cancelled context", func(t *testing.T) { 378 _, err := repo.GetArchived(NewCanceledContext()) 379 + AssertCancelledContext(t, err) 380 }) 381 382 t.Run("GetActive with cancelled context", func(t *testing.T) { 383 _, err := repo.GetActive(NewCanceledContext()) 384 + AssertCancelledContext(t, err) 385 }) 386 387 t.Run("Archive with cancelled context", func(t *testing.T) { 388 err := repo.Archive(NewCanceledContext(), id) 389 + AssertCancelledContext(t, err) 390 }) 391 392 t.Run("Unarchive with cancelled context", func(t *testing.T) { 393 err := repo.Unarchive(NewCanceledContext(), id) 394 + AssertCancelledContext(t, err) 395 }) 396 397 t.Run("SearchContent with cancelled context", func(t *testing.T) { 398 _, err := repo.SearchContent(NewCanceledContext(), "test") 399 + AssertCancelledContext(t, err) 400 }) 401 402 t.Run("GetRecent with cancelled context", func(t *testing.T) { 403 _, err := repo.GetRecent(NewCanceledContext(), 10) 404 + AssertCancelledContext(t, err) 405 }) 406 407 t.Run("AddTag with cancelled context", func(t *testing.T) { 408 err := repo.AddTag(NewCanceledContext(), id, "tag") 409 + AssertCancelledContext(t, err) 410 }) 411 412 t.Run("RemoveTag with cancelled context", func(t *testing.T) { 413 err := repo.RemoveTag(NewCanceledContext(), id, "tag") 414 + AssertCancelledContext(t, err) 415 }) 416 417 t.Run("GetByTags with cancelled context", func(t *testing.T) { 418 _, err := repo.GetByTags(NewCanceledContext(), []string{"tag"}) 419 + AssertCancelledContext(t, err) 420 }) 421 }) 422
+45
internal/repo/queries.go
···
··· 1 + package repo 2 + 3 + const ( 4 + noteColumns = "id, title, content, tags, archived, created, modified, file_path" 5 + queryNoteByID = "SELECT " + noteColumns + " FROM notes WHERE id = ?" 6 + queryNoteInsert = `INSERT INTO notes (title, content, tags, archived, created, modified, file_path) VALUES (?, ?, ?, ?, ?, ?, ?)` 7 + queryNoteUpdate = `UPDATE notes SET title = ?, content = ?, tags = ?, archived = ?, modified = ?, file_path = ? WHERE id = ?` 8 + queryNoteDelete = "DELETE FROM notes WHERE id = ?" 9 + queryNotesList = "SELECT " + noteColumns + " FROM notes" 10 + ) 11 + const ( 12 + articleColumns = "id, url, title, author, date, markdown_path, html_path, created, modified" 13 + queryArticleByID = "SELECT " + articleColumns + " FROM articles WHERE id = ?" 14 + queryArticleByURL = "SELECT " + articleColumns + " FROM articles WHERE url = ?" 15 + queryArticleInsert = `INSERT INTO articles (url, title, author, date, markdown_path, html_path, created, modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 16 + queryArticleUpdate = `UPDATE articles SET title = ?, author = ?, date = ?, markdown_path = ?, html_path = ?, modified = ? WHERE id = ?` 17 + queryArticleDelete = "DELETE FROM articles WHERE id = ?" 18 + queryArticlesList = "SELECT " + articleColumns + " FROM articles" 19 + queryArticlesCount = "SELECT COUNT(*) FROM articles" 20 + ) 21 + 22 + const ( 23 + taskColumns = "id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations, recur, until, parent_uuid" 24 + queryTaskByID = "SELECT " + taskColumns + " FROM tasks WHERE id = ?" 25 + queryTaskByUUID = "SELECT " + taskColumns + " FROM tasks WHERE uuid = ?" 26 + queryTaskInsert = ` 27 + INSERT INTO tasks ( 28 + uuid, description, status, priority, project, context, 29 + tags, due, entry, modified, end, start, annotations, 30 + recur, until, parent_uuid 31 + ) 32 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 33 + queryTaskUpdate = ` 34 + UPDATE tasks SET 35 + uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 36 + tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, 37 + recur = ?, until = ?, parent_uuid = ? 38 + WHERE id = ?` 39 + queryTaskDelete = "DELETE FROM tasks WHERE id = ?" 40 + queryTasksList = "SELECT " + taskColumns + " FROM tasks" 41 + ) 42 + 43 + type scanner interface { 44 + Scan(dest ...any) error 45 + }
+5
internal/repo/repo.go
··· 2 3 import ( 4 "database/sql" 5 6 "github.com/stormlightlabs/noteleaf/internal/models" 7 ) ··· 33 Articles: NewArticleRepository(db), 34 } 35 }
··· 2 3 import ( 4 "database/sql" 5 + "fmt" 6 7 "github.com/stormlightlabs/noteleaf/internal/models" 8 ) ··· 34 Articles: NewArticleRepository(db), 35 } 36 } 37 + 38 + func UnmarshalTagsError(err error) error { 39 + return fmt.Errorf("failed to unmarshal tags: %w", err) 40 + }
+102 -221
internal/repo/task_repository.go
··· 1 package repo 2 3 import ( ··· 51 } 52 53 // TaskRepository provides database operations for tasks 54 - // 55 - // TODO: Implement Repository interface (Validate method) similar to ArticleRepository 56 type TaskRepository struct { 57 db *sql.DB 58 } ··· 62 return &TaskRepository{db: db} 63 } 64 65 // Create stores a new task and returns its assigned ID 66 func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, error) { 67 now := time.Now() ··· 78 return 0, fmt.Errorf("failed to marshal annotations: %w", err) 79 } 80 81 - query := ` 82 - INSERT INTO tasks ( 83 - uuid, description, status, priority, project, context, 84 - tags, due, entry, modified, end, start, annotations, 85 - recur, until, parent_uuid 86 - ) 87 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 88 - 89 - result, err := r.db.ExecContext(ctx, query, 90 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 91 tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations, 92 task.Recur, task.Until, task.ParentUUID, ··· 102 103 task.ID = id 104 105 - // Sync dependencies to task_dependencies table 106 for _, depUUID := range task.DependsOn { 107 if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil { 108 return 0, fmt.Errorf("failed to add dependency: %w", err) ··· 114 115 // Get retrieves a task by ID 116 func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) { 117 - query := ` 118 - SELECT id, uuid, description, status, priority, project, context, tags, 119 - due, entry, modified, end, start, annotations, 120 - recur, until, parent_uuid 121 - FROM tasks WHERE id = ?` 122 - 123 - task := &models.Task{} 124 - var tags, annotations sql.NullString 125 - var parentUUID sql.NullString 126 - 127 - if err := r.db.QueryRowContext(ctx, query, id).Scan( 128 - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 129 - &task.Project, &task.Context, &tags, 130 - &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 131 - &task.Recur, &task.Until, &parentUUID, 132 - ); err != nil { 133 return nil, fmt.Errorf("failed to get task: %w", err) 134 } 135 136 - if tags.Valid { 137 - if err := unmarshalTaskTags(task, tags.String); err != nil { 138 - return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 139 - } 140 - } 141 - 142 - if annotations.Valid { 143 - if err := unmarshalTaskAnnotations(task, annotations.String); err != nil { 144 - return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 145 - } 146 - } 147 - if parentUUID.Valid { 148 - task.ParentUUID = &parentUUID.String 149 - } 150 - 151 - // Populate dependencies from task_dependencies table 152 if err := r.PopulateDependencies(ctx, task); err != nil { 153 return nil, fmt.Errorf("failed to populate dependencies: %w", err) 154 } ··· 170 return fmt.Errorf("failed to marshal annotations: %w", err) 171 } 172 173 - query := ` 174 - UPDATE tasks SET 175 - uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 176 - tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, 177 - recur = ?, until = ?, parent_uuid = ? 178 - WHERE id = ?` 179 - 180 - if _, err = r.db.ExecContext(ctx, query, 181 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 182 tags, task.Due, task.Modified, task.End, task.Start, annotations, 183 task.Recur, task.Until, task.ParentUUID, ··· 186 return fmt.Errorf("failed to update task: %w", err) 187 } 188 189 - // Sync dependencies: clear existing and add new ones 190 if err := r.ClearDependencies(ctx, task.UUID); err != nil { 191 return fmt.Errorf("failed to clear dependencies: %w", err) 192 } ··· 202 203 // Delete removes a task by ID 204 func (r *TaskRepository) Delete(ctx context.Context, id int64) error { 205 - query := "DELETE FROM tasks WHERE id = ?" 206 - _, err := r.db.ExecContext(ctx, query, id) 207 if err != nil { 208 return fmt.Errorf("failed to delete task: %w", err) 209 } ··· 214 func (r *TaskRepository) List(ctx context.Context, opts TaskListOptions) ([]*models.Task, error) { 215 query := r.buildListQuery(opts) 216 args := r.buildListArgs(opts) 217 - 218 - rows, err := r.db.QueryContext(ctx, query, args...) 219 - if err != nil { 220 - return nil, fmt.Errorf("failed to list tasks: %w", err) 221 - } 222 - defer rows.Close() 223 - 224 - var tasks []*models.Task 225 - for rows.Next() { 226 - task := &models.Task{} 227 - if err := r.scanTaskRow(rows, task); err != nil { 228 - return nil, err 229 - } 230 - tasks = append(tasks, task) 231 - } 232 - 233 - return tasks, rows.Err() 234 } 235 236 func (r *TaskRepository) buildListQuery(opts TaskListOptions) string { 237 - query := ` 238 - SELECT id, uuid, description, status, priority, project, context, tags, 239 - due, entry, modified, end, start, annotations, 240 - recur, until, parent_uuid 241 - FROM tasks` 242 - 243 var conditions []string 244 245 if opts.Status != "" { ··· 325 return args 326 } 327 328 - func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error { 329 - var tags, annotations sql.NullString 330 - var parentUUID sql.NullString 331 - var priority, project, context sql.NullString 332 - 333 - if err := rows.Scan( 334 - &task.ID, &task.UUID, &task.Description, &task.Status, &priority, 335 - &project, &context, &tags, 336 - &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 337 - &task.Recur, &task.Until, &parentUUID, 338 - ); err != nil { 339 - return fmt.Errorf("failed to scan task row: %w", err) 340 - } 341 - 342 - if priority.Valid { 343 - task.Priority = priority.String 344 - } 345 - if project.Valid { 346 - task.Project = project.String 347 - } 348 - if context.Valid { 349 - task.Context = context.String 350 - } 351 - 352 - if parentUUID.Valid { 353 - task.ParentUUID = &parentUUID.String 354 - } 355 - 356 - if tags.Valid { 357 - if err := unmarshalTaskTags(task, tags.String); err != nil { 358 - return fmt.Errorf("failed to unmarshal tags: %w", err) 359 - } 360 - } 361 - 362 - if annotations.Valid { 363 - if err := unmarshalTaskAnnotations(task, annotations.String); err != nil { 364 - return fmt.Errorf("failed to unmarshal annotations: %w", err) 365 - } 366 - } 367 - 368 - return nil 369 - } 370 - 371 // Find retrieves tasks matching specific conditions 372 func (r *TaskRepository) Find(ctx context.Context, conditions TaskListOptions) ([]*models.Task, error) { 373 return r.List(ctx, conditions) ··· 432 433 // GetByUUID retrieves a task by UUID 434 func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) { 435 - query := ` 436 - SELECT id, uuid, description, status, priority, project, context, tags, 437 - due, entry, modified, end, start, annotations, 438 - recur, until, parent_uuid 439 - FROM tasks WHERE uuid = ?` 440 - 441 - task := &models.Task{} 442 - var tags, annotations sql.NullString 443 - var parentUUID sql.NullString 444 - 445 - if err := r.db.QueryRowContext(ctx, query, uuid).Scan( 446 - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 447 - &task.Project, &task.Context, &tags, 448 - &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 449 - &task.Recur, &task.Until, &parentUUID, 450 - ); err != nil { 451 return nil, fmt.Errorf("failed to get task by UUID: %w", err) 452 } 453 454 - if tags.Valid { 455 - if err := unmarshalTaskTags(task, tags.String); err != nil { 456 - return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 457 - } 458 - } 459 - 460 - if annotations.Valid { 461 - if err := unmarshalTaskAnnotations(task, annotations.String); err != nil { 462 - return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 463 - } 464 - } 465 - 466 - if parentUUID.Valid { 467 - task.ParentUUID = &parentUUID.String 468 - } 469 - 470 // Populate dependencies from task_dependencies table 471 if err := r.PopulateDependencies(ctx, task); err != nil { 472 return nil, fmt.Errorf("failed to populate dependencies: %w", err) ··· 586 WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? 587 ORDER BY t.modified DESC` 588 589 - rows, err := r.db.QueryContext(ctx, query, tag) 590 - if err != nil { 591 - return nil, fmt.Errorf("failed to get tasks by tag: %w", err) 592 - } 593 - defer rows.Close() 594 - 595 - var tasks []*models.Task 596 - for rows.Next() { 597 - task := &models.Task{} 598 - if err := r.scanTaskRow(rows, task); err != nil { 599 - return nil, err 600 - } 601 - tasks = append(tasks, task) 602 - } 603 - 604 - return tasks, rows.Err() 605 } 606 607 // GetTodo retrieves all tasks with todo status ··· 632 // GetByPriority retrieves all tasks with a specific priority with special handling for empty priority by using raw SQL 633 func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) { 634 if priority == "" { 635 - query := `SELECT id, uuid, description, status, priority, project, context, 636 - tags, due, entry, modified, end, start, annotations, recur, until, parent_uuid 637 - FROM tasks WHERE priority = '' OR priority IS NULL ORDER BY modified DESC` 638 - 639 - rows, err := r.db.QueryContext(ctx, query) 640 - if err != nil { 641 - return nil, fmt.Errorf("failed to get tasks by empty priority: %w", err) 642 - } 643 - defer rows.Close() 644 - 645 - var tasks []*models.Task 646 - for rows.Next() { 647 - task := &models.Task{} 648 - if err := r.scanTaskRow(rows, task); err != nil { 649 - return nil, err 650 - } 651 - tasks = append(tasks, task) 652 - } 653 - return tasks, rows.Err() 654 } 655 656 return r.List(ctx, TaskListOptions{Priority: priority}) ··· 781 t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 782 FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` 783 784 - rows, err := r.db.QueryContext(ctx, query, blockingUUID) 785 if err != nil { 786 return nil, fmt.Errorf("failed to get dependents: %w", err) 787 } 788 - defer rows.Close() 789 - 790 - var tasks []*models.Task 791 - for rows.Next() { 792 - task := &models.Task{} 793 - if err := r.scanTaskRow(rows, task); err != nil { 794 - return nil, err 795 - } 796 - tasks = append(tasks, task) 797 - } 798 - if err := rows.Err(); err != nil { 799 - return nil, err 800 - } 801 802 for _, task := range tasks { 803 if err := r.PopulateDependencies(ctx, task); err != nil { ··· 810 // GetBlockedTasks finds tasks that are blocked by a given UUID. 811 func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 812 query := ` 813 - SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 814 - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 815 - FROM tasks t 816 - JOIN task_dependencies d ON t.uuid = d.task_uuid 817 - WHERE d.depends_on_uuid = ?` 818 - rows, err := r.db.QueryContext(ctx, query, blockingUUID) 819 - if err != nil { 820 - return nil, err 821 - } 822 - defer rows.Close() 823 824 - var tasks []*models.Task 825 - for rows.Next() { 826 - task := &models.Task{} 827 - if err := r.scanTaskRow(rows, task); err != nil { 828 - return nil, err 829 - } 830 - tasks = append(tasks, task) 831 - } 832 - if err := rows.Err(); err != nil { 833 return nil, err 834 } 835
··· 1 + // TODO: extend queryMany composition for GetTasksBy... methods 2 package repo 3 4 import ( ··· 52 } 53 54 // TaskRepository provides database operations for tasks 55 type TaskRepository struct { 56 db *sql.DB 57 } ··· 61 return &TaskRepository{db: db} 62 } 63 64 + // scanTask scans a database row into a Task model 65 + func (r *TaskRepository) scanTask(s scanner) (*models.Task, error) { 66 + task := &models.Task{} 67 + var tags, annotations sql.NullString 68 + var parentUUID sql.NullString 69 + var priority, project, context sql.NullString 70 + 71 + if err := s.Scan( 72 + &task.ID, &task.UUID, &task.Description, &task.Status, &priority, 73 + &project, &context, &tags, 74 + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 75 + &task.Recur, &task.Until, &parentUUID, 76 + ); err != nil { 77 + return nil, err 78 + } 79 + 80 + if priority.Valid { 81 + task.Priority = priority.String 82 + } 83 + if project.Valid { 84 + task.Project = project.String 85 + } 86 + if context.Valid { 87 + task.Context = context.String 88 + } 89 + if parentUUID.Valid { 90 + task.ParentUUID = &parentUUID.String 91 + } 92 + 93 + if tags.Valid { 94 + if err := unmarshalTaskTags(task, tags.String); err != nil { 95 + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 96 + } 97 + } 98 + 99 + if annotations.Valid { 100 + if err := unmarshalTaskAnnotations(task, annotations.String); err != nil { 101 + return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 102 + } 103 + } 104 + 105 + return task, nil 106 + } 107 + 108 + // queryOne executes a query that returns a single task 109 + func (r *TaskRepository) queryOne(ctx context.Context, query string, args ...any) (*models.Task, error) { 110 + row := r.db.QueryRowContext(ctx, query, args...) 111 + task, err := r.scanTask(row) 112 + if err != nil { 113 + if err == sql.ErrNoRows { 114 + return nil, fmt.Errorf("task not found") 115 + } 116 + return nil, fmt.Errorf("failed to scan task: %w", err) 117 + } 118 + return task, nil 119 + } 120 + 121 + // queryMany executes a query that returns multiple tasks 122 + func (r *TaskRepository) queryMany(ctx context.Context, query string, args ...any) ([]*models.Task, error) { 123 + rows, err := r.db.QueryContext(ctx, query, args...) 124 + if err != nil { 125 + return nil, fmt.Errorf("failed to query tasks: %w", err) 126 + } 127 + defer rows.Close() 128 + 129 + var tasks []*models.Task 130 + for rows.Next() { 131 + task, err := r.scanTask(rows) 132 + if err != nil { 133 + return nil, fmt.Errorf("failed to scan task: %w", err) 134 + } 135 + tasks = append(tasks, task) 136 + } 137 + 138 + if err := rows.Err(); err != nil { 139 + return nil, fmt.Errorf("error iterating over tasks: %w", err) 140 + } 141 + 142 + return tasks, nil 143 + } 144 + 145 // Create stores a new task and returns its assigned ID 146 func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, error) { 147 now := time.Now() ··· 158 return 0, fmt.Errorf("failed to marshal annotations: %w", err) 159 } 160 161 + result, err := r.db.ExecContext(ctx, queryTaskInsert, 162 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 163 tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations, 164 task.Recur, task.Until, task.ParentUUID, ··· 174 175 task.ID = id 176 177 for _, depUUID := range task.DependsOn { 178 if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil { 179 return 0, fmt.Errorf("failed to add dependency: %w", err) ··· 185 186 // Get retrieves a task by ID 187 func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) { 188 + task, err := r.queryOne(ctx, queryTaskByID, id) 189 + if err != nil { 190 return nil, fmt.Errorf("failed to get task: %w", err) 191 } 192 193 if err := r.PopulateDependencies(ctx, task); err != nil { 194 return nil, fmt.Errorf("failed to populate dependencies: %w", err) 195 } ··· 211 return fmt.Errorf("failed to marshal annotations: %w", err) 212 } 213 214 + if _, err = r.db.ExecContext(ctx, queryTaskUpdate, 215 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 216 tags, task.Due, task.Modified, task.End, task.Start, annotations, 217 task.Recur, task.Until, task.ParentUUID, ··· 220 return fmt.Errorf("failed to update task: %w", err) 221 } 222 223 if err := r.ClearDependencies(ctx, task.UUID); err != nil { 224 return fmt.Errorf("failed to clear dependencies: %w", err) 225 } ··· 235 236 // Delete removes a task by ID 237 func (r *TaskRepository) Delete(ctx context.Context, id int64) error { 238 + _, err := r.db.ExecContext(ctx, queryTaskDelete, id) 239 if err != nil { 240 return fmt.Errorf("failed to delete task: %w", err) 241 } ··· 246 func (r *TaskRepository) List(ctx context.Context, opts TaskListOptions) ([]*models.Task, error) { 247 query := r.buildListQuery(opts) 248 args := r.buildListArgs(opts) 249 + return r.queryMany(ctx, query, args...) 250 } 251 252 func (r *TaskRepository) buildListQuery(opts TaskListOptions) string { 253 + query := queryTasksList 254 var conditions []string 255 256 if opts.Status != "" { ··· 336 return args 337 } 338 339 // Find retrieves tasks matching specific conditions 340 func (r *TaskRepository) Find(ctx context.Context, conditions TaskListOptions) ([]*models.Task, error) { 341 return r.List(ctx, conditions) ··· 400 401 // GetByUUID retrieves a task by UUID 402 func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) { 403 + task, err := r.queryOne(ctx, queryTaskByUUID, uuid) 404 + if err != nil { 405 return nil, fmt.Errorf("failed to get task by UUID: %w", err) 406 } 407 408 // Populate dependencies from task_dependencies table 409 if err := r.PopulateDependencies(ctx, task); err != nil { 410 return nil, fmt.Errorf("failed to populate dependencies: %w", err) ··· 524 WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? 525 ORDER BY t.modified DESC` 526 527 + return r.queryMany(ctx, query, tag) 528 } 529 530 // GetTodo retrieves all tasks with todo status ··· 555 // GetByPriority retrieves all tasks with a specific priority with special handling for empty priority by using raw SQL 556 func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) { 557 if priority == "" { 558 + query := "SELECT " + taskColumns + " FROM tasks WHERE priority = '' OR priority IS NULL ORDER BY modified DESC" 559 + return r.queryMany(ctx, query) 560 } 561 562 return r.List(ctx, TaskListOptions{Priority: priority}) ··· 687 t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 688 FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` 689 690 + tasks, err := r.queryMany(ctx, query, blockingUUID) 691 if err != nil { 692 return nil, fmt.Errorf("failed to get dependents: %w", err) 693 } 694 695 for _, task := range tasks { 696 if err := r.PopulateDependencies(ctx, task); err != nil { ··· 703 // GetBlockedTasks finds tasks that are blocked by a given UUID. 704 func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 705 query := ` 706 + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 707 + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 708 + FROM tasks t 709 + JOIN task_dependencies d ON t.uuid = d.task_uuid 710 + WHERE d.depends_on_uuid = ?` 711 712 + tasks, err := r.queryMany(ctx, query, blockingUUID) 713 + if err != nil { 714 return nil, err 715 } 716
+4 -4
internal/repo/task_repository_test.go
··· 66 t.Run("when called with context cancellation", func(t *testing.T) { 67 task := CreateSampleTask() 68 _, err := repo.Create(NewCanceledContext(), task) 69 - AssertError(t, err, "Expected error with cancelled context") 70 }) 71 }) 72 }) ··· 160 161 task.Description = "Updated" 162 err = repo.Update(NewCanceledContext(), task) 163 - AssertError(t, err, "Expected error with cancelled context") 164 }) 165 }) 166 }) ··· 334 335 t.Run("GetByUUID with context cancellation", func(t *testing.T) { 336 _, err := repo.GetByUUID(NewCanceledContext(), task1.UUID) 337 - AssertError(t, err, "Expected error with cancelled context") 338 }) 339 }) 340 ··· 387 388 t.Run("Count with context cancellation", func(t *testing.T) { 389 _, err := repo.Count(NewCanceledContext(), TaskListOptions{}) 390 - AssertError(t, err, "Expected error with cancelled context") 391 }) 392 }) 393
··· 66 t.Run("when called with context cancellation", func(t *testing.T) { 67 task := CreateSampleTask() 68 _, err := repo.Create(NewCanceledContext(), task) 69 + AssertCancelledContext(t, err) 70 }) 71 }) 72 }) ··· 160 161 task.Description = "Updated" 162 err = repo.Update(NewCanceledContext(), task) 163 + AssertCancelledContext(t, err) 164 }) 165 }) 166 }) ··· 334 335 t.Run("GetByUUID with context cancellation", func(t *testing.T) { 336 _, err := repo.GetByUUID(NewCanceledContext(), task1.UUID) 337 + AssertCancelledContext(t, err) 338 }) 339 }) 340 ··· 387 388 t.Run("Count with context cancellation", func(t *testing.T) { 389 _, err := repo.Count(NewCanceledContext(), TaskListOptions{}) 390 + AssertCancelledContext(t, err) 391 }) 392 }) 393
+6 -13
internal/repo/test_utilities.go
··· 29 t.Fatalf("Failed to enable foreign keys: %v", err) 30 } 31 32 - // if _, err := db.Exec(testSchema); err != nil { 33 - // t.Fatalf("Failed to create schema: %v", err) 34 - // } 35 - 36 mr := store.NewMigrationRunner(&store.Database{DB: db}) 37 if err := mr.RunMigrations(); err != nil { 38 t.Errorf("failed to run migrations %v", err) ··· 187 } 188 } 189 190 func AssertEqual[T comparable](t *testing.T, expected, actual T, msg string) { 191 t.Helper() 192 if expected != actual { ··· 222 } 223 } 224 225 - func AssertNil(t *testing.T, value interface{}, msg string) { 226 t.Helper() 227 if value != nil { 228 t.Fatalf("%s: expected nil, got %v", msg, value) 229 } 230 } 231 232 - func AssertNotNil(t *testing.T, value interface{}, msg string) { 233 t.Helper() 234 if value == nil { 235 t.Fatalf("%s: expected non-nil value", msg) ··· 247 t.Helper() 248 if actual >= threshold { 249 t.Fatalf("%s: expected %v < %v", msg, actual, threshold) 250 - } 251 - } 252 - 253 - func AssertStringContains(t *testing.T, str, substr, msg string) { 254 - t.Helper() 255 - if !strings.Contains(str, substr) { 256 - t.Fatalf("%s: expected string to contain '%s', got '%s'", msg, substr, str) 257 } 258 } 259
··· 29 t.Fatalf("Failed to enable foreign keys: %v", err) 30 } 31 32 mr := store.NewMigrationRunner(&store.Database{DB: db}) 33 if err := mr.RunMigrations(); err != nil { 34 t.Errorf("failed to run migrations %v", err) ··· 183 } 184 } 185 186 + func AssertCancelledContext(t *testing.T, err error) { 187 + AssertError(t, err, "Expected error with cancelled context") 188 + } 189 + 190 func AssertEqual[T comparable](t *testing.T, expected, actual T, msg string) { 191 t.Helper() 192 if expected != actual { ··· 222 } 223 } 224 225 + func AssertNil(t *testing.T, value any, msg string) { 226 t.Helper() 227 if value != nil { 228 t.Fatalf("%s: expected nil, got %v", msg, value) 229 } 230 } 231 232 + func AssertNotNil(t *testing.T, value any, msg string) { 233 t.Helper() 234 if value == nil { 235 t.Fatalf("%s: expected non-nil value", msg) ··· 247 t.Helper() 248 if actual >= threshold { 249 t.Fatalf("%s: expected %v < %v", msg, actual, threshold) 250 } 251 } 252
+9 -9
internal/repo/time_entry_repository_test.go
··· 317 task := createTestTask(t, db) 318 319 _, err := repo.Start(NewCanceledContext(), task.ID, "Cancelled") 320 - AssertError(t, err, "Expected error with cancelled context") 321 }) 322 323 t.Run("Get with cancelled context", func(t *testing.T) { 324 _, err := repo.Get(NewCanceledContext(), entry.ID) 325 - AssertError(t, err, "Expected error with cancelled context") 326 }) 327 328 t.Run("Stop with cancelled context", func(t *testing.T) { 329 _, err := repo.Stop(NewCanceledContext(), entry.ID) 330 - AssertError(t, err, "Expected error with cancelled context") 331 }) 332 333 t.Run("GetActiveByTaskID with cancelled context", func(t *testing.T) { 334 _, err := repo.GetActiveByTaskID(NewCanceledContext(), task.ID) 335 - AssertError(t, err, "Expected error with cancelled context") 336 }) 337 338 t.Run("StopActiveByTaskID with cancelled context", func(t *testing.T) { 339 _, err := repo.StopActiveByTaskID(NewCanceledContext(), task.ID) 340 - AssertError(t, err, "Expected error with cancelled context") 341 }) 342 343 t.Run("GetByTaskID with cancelled context", func(t *testing.T) { 344 _, err := repo.GetByTaskID(NewCanceledContext(), task.ID) 345 - AssertError(t, err, "Expected error with cancelled context") 346 }) 347 348 t.Run("GetTotalTimeByTaskID with cancelled context", func(t *testing.T) { 349 _, err := repo.GetTotalTimeByTaskID(NewCanceledContext(), task.ID) 350 - AssertError(t, err, "Expected error with cancelled context") 351 }) 352 353 t.Run("Delete with cancelled context", func(t *testing.T) { 354 err := repo.Delete(NewCanceledContext(), entry.ID) 355 - AssertError(t, err, "Expected error with cancelled context") 356 }) 357 358 t.Run("GetByDateRange with cancelled context", func(t *testing.T) { ··· 360 end := time.Now() 361 362 _, err := repo.GetByDateRange(NewCanceledContext(), start, end) 363 - AssertError(t, err, "Expected error with cancelled context") 364 }) 365 }) 366
··· 317 task := createTestTask(t, db) 318 319 _, err := repo.Start(NewCanceledContext(), task.ID, "Cancelled") 320 + AssertCancelledContext(t, err) 321 }) 322 323 t.Run("Get with cancelled context", func(t *testing.T) { 324 _, err := repo.Get(NewCanceledContext(), entry.ID) 325 + AssertCancelledContext(t, err) 326 }) 327 328 t.Run("Stop with cancelled context", func(t *testing.T) { 329 _, err := repo.Stop(NewCanceledContext(), entry.ID) 330 + AssertCancelledContext(t, err) 331 }) 332 333 t.Run("GetActiveByTaskID with cancelled context", func(t *testing.T) { 334 _, err := repo.GetActiveByTaskID(NewCanceledContext(), task.ID) 335 + AssertCancelledContext(t, err) 336 }) 337 338 t.Run("StopActiveByTaskID with cancelled context", func(t *testing.T) { 339 _, err := repo.StopActiveByTaskID(NewCanceledContext(), task.ID) 340 + AssertCancelledContext(t, err) 341 }) 342 343 t.Run("GetByTaskID with cancelled context", func(t *testing.T) { 344 _, err := repo.GetByTaskID(NewCanceledContext(), task.ID) 345 + AssertCancelledContext(t, err) 346 }) 347 348 t.Run("GetTotalTimeByTaskID with cancelled context", func(t *testing.T) { 349 _, err := repo.GetTotalTimeByTaskID(NewCanceledContext(), task.ID) 350 + AssertCancelledContext(t, err) 351 }) 352 353 t.Run("Delete with cancelled context", func(t *testing.T) { 354 err := repo.Delete(NewCanceledContext(), entry.ID) 355 + AssertCancelledContext(t, err) 356 }) 357 358 t.Run("GetByDateRange with cancelled context", func(t *testing.T) { ··· 360 end := time.Now() 361 362 _, err := repo.GetByDateRange(NewCanceledContext(), start, end) 363 + AssertCancelledContext(t, err) 364 }) 365 }) 366
+13 -13
internal/repo/tv_repository_test.go
··· 464 465 t.Run("Count with context cancellation", func(t *testing.T) { 466 _, err := repo.Count(NewCanceledContext(), TVListOptions{}) 467 - AssertError(t, err, "Expected error with cancelled context") 468 }) 469 }) 470 ··· 480 t.Run("Create with cancelled context", func(t *testing.T) { 481 newShow := NewTVShowBuilder().WithTitle("Cancelled").Build() 482 _, err := repo.Create(NewCanceledContext(), newShow) 483 - AssertError(t, err, "Expected error with cancelled context") 484 }) 485 486 t.Run("Get with cancelled context", func(t *testing.T) { 487 _, err := repo.Get(NewCanceledContext(), id) 488 - AssertError(t, err, "Expected error with cancelled context") 489 }) 490 491 t.Run("Update with cancelled context", func(t *testing.T) { 492 tvShow.Title = "Updated" 493 err := repo.Update(NewCanceledContext(), tvShow) 494 - AssertError(t, err, "Expected error with cancelled context") 495 }) 496 497 t.Run("Delete with cancelled context", func(t *testing.T) { 498 err := repo.Delete(NewCanceledContext(), id) 499 - AssertError(t, err, "Expected error with cancelled context") 500 }) 501 502 t.Run("List with cancelled context", func(t *testing.T) { 503 _, err := repo.List(NewCanceledContext(), TVListOptions{}) 504 - AssertError(t, err, "Expected error with cancelled context") 505 }) 506 507 t.Run("GetQueued with cancelled context", func(t *testing.T) { 508 _, err := repo.GetQueued(NewCanceledContext()) 509 - AssertError(t, err, "Expected error with cancelled context") 510 }) 511 512 t.Run("GetWatching with cancelled context", func(t *testing.T) { 513 _, err := repo.GetWatching(NewCanceledContext()) 514 - AssertError(t, err, "Expected error with cancelled context") 515 }) 516 517 t.Run("GetWatched with cancelled context", func(t *testing.T) { 518 _, err := repo.GetWatched(NewCanceledContext()) 519 - AssertError(t, err, "Expected error with cancelled context") 520 }) 521 522 t.Run("GetByTitle with cancelled context", func(t *testing.T) { 523 _, err := repo.GetByTitle(NewCanceledContext(), "Test Show") 524 - AssertError(t, err, "Expected error with cancelled context") 525 }) 526 527 t.Run("GetBySeason with cancelled context", func(t *testing.T) { 528 _, err := repo.GetBySeason(NewCanceledContext(), "Test Show", 1) 529 - AssertError(t, err, "Expected error with cancelled context") 530 }) 531 532 t.Run("MarkWatched with cancelled context", func(t *testing.T) { 533 err := repo.MarkWatched(NewCanceledContext(), id) 534 - AssertError(t, err, "Expected error with cancelled context") 535 }) 536 537 t.Run("StartWatching with cancelled context", func(t *testing.T) { 538 err := repo.StartWatching(NewCanceledContext(), id) 539 - AssertError(t, err, "Expected error with cancelled context") 540 }) 541 }) 542
··· 464 465 t.Run("Count with context cancellation", func(t *testing.T) { 466 _, err := repo.Count(NewCanceledContext(), TVListOptions{}) 467 + AssertCancelledContext(t, err) 468 }) 469 }) 470 ··· 480 t.Run("Create with cancelled context", func(t *testing.T) { 481 newShow := NewTVShowBuilder().WithTitle("Cancelled").Build() 482 _, err := repo.Create(NewCanceledContext(), newShow) 483 + AssertCancelledContext(t, err) 484 }) 485 486 t.Run("Get with cancelled context", func(t *testing.T) { 487 _, err := repo.Get(NewCanceledContext(), id) 488 + AssertCancelledContext(t, err) 489 }) 490 491 t.Run("Update with cancelled context", func(t *testing.T) { 492 tvShow.Title = "Updated" 493 err := repo.Update(NewCanceledContext(), tvShow) 494 + AssertCancelledContext(t, err) 495 }) 496 497 t.Run("Delete with cancelled context", func(t *testing.T) { 498 err := repo.Delete(NewCanceledContext(), id) 499 + AssertCancelledContext(t, err) 500 }) 501 502 t.Run("List with cancelled context", func(t *testing.T) { 503 _, err := repo.List(NewCanceledContext(), TVListOptions{}) 504 + AssertCancelledContext(t, err) 505 }) 506 507 t.Run("GetQueued with cancelled context", func(t *testing.T) { 508 _, err := repo.GetQueued(NewCanceledContext()) 509 + AssertCancelledContext(t, err) 510 }) 511 512 t.Run("GetWatching with cancelled context", func(t *testing.T) { 513 _, err := repo.GetWatching(NewCanceledContext()) 514 + AssertCancelledContext(t, err) 515 }) 516 517 t.Run("GetWatched with cancelled context", func(t *testing.T) { 518 _, err := repo.GetWatched(NewCanceledContext()) 519 + AssertCancelledContext(t, err) 520 }) 521 522 t.Run("GetByTitle with cancelled context", func(t *testing.T) { 523 _, err := repo.GetByTitle(NewCanceledContext(), "Test Show") 524 + AssertCancelledContext(t, err) 525 }) 526 527 t.Run("GetBySeason with cancelled context", func(t *testing.T) { 528 _, err := repo.GetBySeason(NewCanceledContext(), "Test Show", 1) 529 + AssertCancelledContext(t, err) 530 }) 531 532 t.Run("MarkWatched with cancelled context", func(t *testing.T) { 533 err := repo.MarkWatched(NewCanceledContext(), id) 534 + AssertCancelledContext(t, err) 535 }) 536 537 t.Run("StartWatching with cancelled context", func(t *testing.T) { 538 err := repo.StartWatching(NewCanceledContext(), id) 539 + AssertCancelledContext(t, err) 540 }) 541 }) 542
+10 -12
internal/ui/data_table_test.go
··· 119 } 120 } 121 122 func TestDataTable(t *testing.T) { 123 t.Run("TestDataTableOptions", func(t *testing.T) { 124 t.Run("default options", func(t *testing.T) { ··· 143 var buf bytes.Buffer 144 source := &MockDataSource{records: createMockRecords()} 145 opts := DataTableOptions{ 146 - Output: &buf, 147 - Static: true, 148 - Title: "Test Table", 149 - Fields: createTestFields(), 150 - ViewHandler: func(record DataRecord) string { 151 - return fmt.Sprintf("Viewing: %v", record.GetField("name")) 152 - }, 153 } 154 155 table := NewDataTable(source, opts) ··· 392 }) 393 394 t.Run("view record command", func(t *testing.T) { 395 - viewHandler := func(record DataRecord) string { 396 - return fmt.Sprintf("Viewing: %v", record.GetField("name")) 397 - } 398 - 399 model := dataTableModel{ 400 opts: DataTableOptions{ 401 - ViewHandler: viewHandler, 402 Fields: createTestFields(), 403 }, 404 }
··· 119 } 120 } 121 122 + func testViewHandler(record DataRecord) string { 123 + return fmt.Sprintf("Viewing: %v", record.GetField("name")) 124 + } 125 + 126 func TestDataTable(t *testing.T) { 127 t.Run("TestDataTableOptions", func(t *testing.T) { 128 t.Run("default options", func(t *testing.T) { ··· 147 var buf bytes.Buffer 148 source := &MockDataSource{records: createMockRecords()} 149 opts := DataTableOptions{ 150 + Output: &buf, 151 + Static: true, 152 + Title: "Test Table", 153 + Fields: createTestFields(), 154 + ViewHandler: testViewHandler, 155 } 156 157 table := NewDataTable(source, opts) ··· 394 }) 395 396 t.Run("view record command", func(t *testing.T) { 397 model := dataTableModel{ 398 opts: DataTableOptions{ 399 + ViewHandler: testViewHandler, 400 Fields: createTestFields(), 401 }, 402 }
+2 -7
internal/ui/project_list_adapter_test.go
··· 61 t.Errorf("GetTableName() = %q, want 'projects'", record.GetTableName()) 62 } 63 64 - if !record.GetCreatedAt().IsZero() { 65 - t.Error("GetCreatedAt() should return zero time") 66 - } 67 - 68 - if !record.GetUpdatedAt().IsZero() { 69 - t.Error("GetUpdatedAt() should return zero time") 70 - } 71 }) 72 }) 73
··· 61 t.Errorf("GetTableName() = %q, want 'projects'", record.GetTableName()) 62 } 63 64 + Expect.AssertZeroTime(t, record.GetCreatedAt, "GetCreatedAt") 65 + Expect.AssertZeroTime(t, record.GetUpdatedAt, "GetUpdatedAt") 66 }) 67 }) 68
+2 -7
internal/ui/tag_list_adapter_test.go
··· 61 t.Errorf("GetTableName() = %q, want 'tags'", record.GetTableName()) 62 } 63 64 - if !record.GetCreatedAt().IsZero() { 65 - t.Error("GetCreatedAt() should return zero time") 66 - } 67 - 68 - if !record.GetUpdatedAt().IsZero() { 69 - t.Error("GetUpdatedAt() should return zero time") 70 - } 71 }) 72 }) 73
··· 61 t.Errorf("GetTableName() = %q, want 'tags'", record.GetTableName()) 62 } 63 64 + Expect.AssertZeroTime(t, record.GetCreatedAt, "GetCreatedAt") 65 + Expect.AssertZeroTime(t, record.GetUpdatedAt, "GetUpdatedAt") 66 }) 67 }) 68
+7
internal/ui/test_utilities.go
··· 327 } 328 } 329 330 var Expect = AssertionHelpers{} 331 332 func containsString(haystack, needle string) bool {
··· 327 } 328 } 329 330 + func (ah *AssertionHelpers) AssertZeroTime(t *testing.T, getter func() time.Time, label string) { 331 + t.Helper() 332 + if !getter().IsZero() { 333 + t.Errorf("%v() should return zero time", label) 334 + } 335 + } 336 + 337 var Expect = AssertionHelpers{} 338 339 func containsString(haystack, needle string) bool {