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

feat: add read command for articles with glamour based markdown rendering

+600 -99
+18
cmd/commands.go
··· 479 } 480 root.AddCommand(viewCmd) 481 482 removeCmd := &cobra.Command{ 483 Use: "remove <id>", 484 Short: "Remove article and associated files",
··· 479 } 480 root.AddCommand(viewCmd) 481 482 + readCmd := &cobra.Command{ 483 + Use: "read <id>", 484 + Short: "Read article content with formatted markdown", 485 + Long: `Read the full markdown content of an article with beautiful formatting. 486 + 487 + This displays the complete article content using syntax highlighting and proper formatting.`, 488 + Args: cobra.ExactArgs(1), 489 + RunE: func(cmd *cobra.Command, args []string) error { 490 + if articleID, err := parseID("article", args); err != nil { 491 + return err 492 + } else { 493 + defer c.handler.Close() 494 + return c.handler.Read(cmd.Context(), articleID) 495 + } 496 + }, 497 + } 498 + root.AddCommand(readCmd) 499 + 500 removeCmd := &cobra.Command{ 501 Use: "remove <id>", 502 Short: "Remove article and associated files",
+18
cmd/commands_test.go
··· 734 } 735 }) 736 737 t.Run("remove command with non-existent article ID", func(t *testing.T) { 738 cmd := NewArticleCommand(handler).Create() 739 cmd.SetArgs([]string{"remove", "999"})
··· 734 } 735 }) 736 737 + t.Run("read command with non-existent article ID", func(t *testing.T) { 738 + cmd := NewArticleCommand(handler).Create() 739 + cmd.SetArgs([]string{"read", "999"}) 740 + err := cmd.Execute() 741 + if err == nil { 742 + t.Error("expected article read command to fail with non-existent ID") 743 + } 744 + }) 745 + 746 + t.Run("read command with non-numeric ID", func(t *testing.T) { 747 + cmd := NewArticleCommand(handler).Create() 748 + cmd.SetArgs([]string{"read", "invalid"}) 749 + err := cmd.Execute() 750 + if err == nil { 751 + t.Error("expected article read command to fail with non-numeric ID") 752 + } 753 + }) 754 + 755 t.Run("remove command with non-existent article ID", func(t *testing.T) { 756 cmd := NewArticleCommand(handler).Create() 757 cmd.SetArgs([]string{"remove", "999"})
+30 -39
docs/testing.md
··· 1 # Testing Documentation 2 3 - This document outlines the testing patterns and practices used in the noteleaf application. 4 5 - ## Testing Principles 6 7 - The codebase follows Go's standard testing practices without external libraries. Tests use only the standard library `testing` package and avoid mock frameworks or assertion libraries. This keeps dependencies minimal and tests readable using standard Go patterns. 8 9 - ## Test File Organization 10 11 - Test files follow the standard Go convention of `*_test.go` naming. Each package contains its own test files alongside the source code. Test files are organized by functionality and mirror the structure of the source code they test. 12 13 - ## Testing Patterns 14 15 ### Handler Creation Pattern 16 ··· 30 31 Tests use `t.Fatal` for setup errors that prevent test execution and `t.Error` for test assertion failures. Fatal errors stop test execution while errors allow tests to continue checking other conditions. 32 33 ### Command Structure Testing 34 35 Command group tests verify cobra command structure including use strings, aliases, short descriptions, and subcommand presence. Tests check that commands are properly configured without executing their logic. ··· 38 39 Tests verify interface compliance using compile-time checks with blank identifier assignments. This ensures structs implement expected interfaces without runtime overhead. 40 41 - ```go 42 - var _ CommandGroup = NewTaskCommands(handler) 43 - ``` 44 - 45 ## Test Organization Patterns 46 47 - ### Single Root Test Pattern 48 49 - The preferred test organization pattern uses a single root test function with nested subtests using `t.Run`. This provides clear hierarchical organization and allows running specific test sections while maintaining shared setup and context. 50 51 - ```go 52 - func TestCommandGroup(t *testing.T) { 53 - t.Run("Interface Implementations", func(t *testing.T) { 54 - // Test interface compliance 55 - }) 56 57 - t.Run("Create", func(t *testing.T) { 58 - t.Run("TaskCommand", func(t *testing.T) { 59 - // Test task command creation 60 - }) 61 - t.Run("MovieCommand", func(t *testing.T) { 62 - // Test movie command creation 63 - }) 64 - }) 65 - } 66 - ``` 67 68 - This pattern offers several advantages: clear test hierarchy with logical grouping, ability to run specific test sections with `go test -run TestCommandGroup/Create/TaskCommand`, consistent test structure across the codebase, and shared setup that can be inherited by subtests. 69 70 - ### Integration vs Unit Testing 71 72 - The codebase emphasizes integration testing over heavy mocking. Tests use real handlers and services to verify actual behavior rather than mocked interactions. This approach catches integration issues while maintaining test reliability. 73 - 74 - ### Static Output Testing 75 76 - UI components support static output modes for testing. Tests capture output using bytes.Buffer and verify content using string contains checks rather than exact string matching for better test maintainability. 77 78 - ## Test Utilities 79 80 - ### Helper Functions 81 82 Test files include helper functions for creating test data and finding elements in collections. These utilities reduce code duplication and improve test readability. 83 84 - ### Mock Data Creation 85 86 - Tests create realistic mock data using factory functions that return properly initialized structs with sensible defaults. This approach provides consistent test data across different test cases. 87 88 ## Testing CLI Commands 89 ··· 101 102 The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases. 103 104 - ## Best Practices Summary 105 106 - Use factory functions for test handler creation with proper cleanup patterns. Organize tests using single root test functions with nested subtests for clear hierarchy. Manage resources with cleanup functions returned by factory methods. Prefer integration testing over mocking for real-world behavior validation. Verify interface compliance at compile time within dedicated subtests. Focus command tests on structure verification rather than execution testing. Leverage the single root test pattern for logical grouping and selective test execution. Use realistic test data with factory functions for consistent test scenarios.
··· 1 # Testing Documentation 2 3 + This document outlines the testing patterns and practices used in the `noteleaf` application. 4 5 + ## Overview 6 7 + The codebase follows Go's standard testing practices without external libraries. Tests use only the standard library package and avoid mock frameworks or assertion libraries. This is to keep dependencies minimal and tests readable using standard Go patterns. 8 9 + ### Organization 10 11 + Each package contains its own test files alongside the source code. Test files are organized by functionality and mirror the structure of the source code they test. 12 13 + ## Patterns 14 15 ### Handler Creation Pattern 16 ··· 30 31 Tests use `t.Fatal` for setup errors that prevent test execution and `t.Error` for test assertion failures. Fatal errors stop test execution while errors allow tests to continue checking other conditions. 32 33 + ### Context Cancellation Testing Pattern 34 + 35 + Error case testing frequently uses context cancellation to simulate database and network failures. The pattern creates a context, immediately cancels it, then calls the function under test to verify error handling. This provides a reliable way to test error paths without requiring complex mock setups or external failure injection. 36 + 37 ### Command Structure Testing 38 39 Command group tests verify cobra command structure including use strings, aliases, short descriptions, and subcommand presence. Tests check that commands are properly configured without executing their logic. ··· 42 43 Tests verify interface compliance using compile-time checks with blank identifier assignments. This ensures structs implement expected interfaces without runtime overhead. 44 45 ## Test Organization Patterns 46 47 + ### Single Root Test 48 49 + The preferred test organization pattern uses a single root test function with nested subtests using `t.Run`. This provides clear hierarchical organization and allows running specific test sections while maintaining shared setup and context. This pattern offers several advantages: clear test hierarchy with logical grouping, ability to run specific test sections, consistent test structure across the codebase, and shared setup that can be inherited by subtests. 50 51 + ### Integration vs Unit Testing 52 53 + The codebase emphasizes integration testing over heavy mocking by using real handlers and services to verify actual behavior rather than mocked interactions. The goal is to catch integration issues while maintaining test reliability. 54 55 + ### Static Output 56 57 + UI components support static output modes for testing. Tests capture output using bytes.Buffer and verify content using string contains checks rather than exact string matching for better test maintainability. 58 59 + ### Standard Output Redirection 60 61 + For testing functions that write to stdout, tests use a pipe redirection pattern with goroutines to capture output. The pattern saves the original stdout, redirects to a pipe, captures output in a separate goroutine, and restores stdout after the test. This ensures clean output capture without interfering with the testing framework. 62 63 + ## Utilities 64 65 + ### Helpers 66 67 Test files include helper functions for creating test data and finding elements in collections. These utilities reduce code duplication and improve test readability. 68 69 + ### Mock Data 70 71 + Tests create realistic mock data using factory functions (powered by faker) that return properly initialized structs with sensible defaults. 72 73 ## Testing CLI Commands 74 ··· 86 87 The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases. 88 89 + ## Errors 90 + 91 + Error coverage follows a systematic approach to identify and test failure scenarios: 92 93 + 1. **Context Cancellation** - Primary method for testing database and network timeout scenarios 94 + 2. **Invalid Input** - Malformed data, empty inputs, boundary conditions 95 + 3. **Resource Exhaustion** - Database connection failures, memory limits 96 + 4. **Constraint Violations** - Duplicate keys, foreign key failures 97 + 5. **State Validation** - Testing functions with invalid system states
+25
internal/handlers/articles.go
··· 267 return nil 268 } 269 270 // TODO: Try to get from config first (could be added later) 271 // For now, use default ~/Documents/Leaf/ 272 func (h *ArticleHandler) getStorageDirectory() (string, error) {
··· 267 return nil 268 } 269 270 + // Read displays an article's content with formatted markdown rendering 271 + func (h *ArticleHandler) Read(ctx context.Context, id int64) error { 272 + article, err := h.repos.Articles.Get(ctx, id) 273 + if err != nil { 274 + return fmt.Errorf("failed to get article: %w", err) 275 + } 276 + 277 + if _, err := os.Stat(article.MarkdownPath); os.IsNotExist(err) { 278 + return fmt.Errorf("markdown file not found: %s", article.MarkdownPath) 279 + } 280 + 281 + content, err := os.ReadFile(article.MarkdownPath) 282 + if err != nil { 283 + return fmt.Errorf("failed to read markdown file: %w", err) 284 + } 285 + 286 + if rendered, err := renderMarkdown(string(content)); err != nil { 287 + return err 288 + } else { 289 + fmt.Print(rendered) 290 + return nil 291 + } 292 + 293 + } 294 + 295 // TODO: Try to get from config first (could be added later) 296 // For now, use default ~/Documents/Leaf/ 297 func (h *ArticleHandler) getStorageDirectory() (string, error) {
+49 -9
internal/handlers/articles_test.go
··· 329 }) 330 }) 331 332 t.Run("Remove", func(t *testing.T) { 333 t.Run("removes article successfully", func(t *testing.T) { 334 helper := NewArticleTestHelper(t) 335 ctx := context.Background() 336 - 337 id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01") 338 - 339 Expect.AssertArticleExists(t, helper, id) 340 341 err := helper.Remove(ctx, id) 342 Expect.AssertNoError(t, err, "Remove should succeed") 343 - 344 Expect.AssertArticleNotExists(t, helper, id) 345 }) 346 347 t.Run("handles non-existent article", func(t *testing.T) { 348 helper := NewArticleTestHelper(t) 349 ctx := context.Background() 350 - 351 err := helper.Remove(ctx, 99999) 352 Expect.AssertError(t, err, "failed to get article", "Remove should fail with non-existent article ID") 353 }) ··· 379 t.Run("handles database error", func(t *testing.T) { 380 helper := NewArticleTestHelper(t) 381 ctx := context.Background() 382 - 383 id := helper.CreateTestArticle(t, "https://example.com/db-error", "DB Error Test", "Author", "2024-01-01") 384 385 helper.db.Exec("DROP TABLE articles") ··· 392 t.Run("Help", func(t *testing.T) { 393 t.Run("shows supported domains", func(t *testing.T) { 394 helper := NewArticleTestHelper(t) 395 - 396 err := helper.Help() 397 Expect.AssertNoError(t, err, "Help should succeed") 398 }) ··· 419 t.Run("Close", func(t *testing.T) { 420 t.Run("closes successfully", func(t *testing.T) { 421 helper := NewArticleTestHelper(t) 422 - 423 err := helper.Close() 424 Expect.AssertNoError(t, err, "Close should succeed") 425 }) ··· 427 t.Run("handles nil database gracefully", func(t *testing.T) { 428 helper := NewArticleTestHelper(t) 429 helper.db = nil 430 - 431 err := helper.Close() 432 Expect.AssertNoError(t, err, "Close should succeed with nil database") 433 }) ··· 436 t.Run("getStorageDirectory", func(t *testing.T) { 437 t.Run("returns storage directory successfully", func(t *testing.T) { 438 helper := NewArticleTestHelper(t) 439 - 440 dir, err := helper.getStorageDirectory() 441 Expect.AssertNoError(t, err, "getStorageDirectory should succeed") 442
··· 329 }) 330 }) 331 332 + t.Run("Read", func(t *testing.T) { 333 + t.Run("read renders article successfully", func(t *testing.T) { 334 + helper := NewArticleTestHelper(t) 335 + ctx := context.Background() 336 + id := helper.CreateTestArticle(t, "https://example.com/read", "Read Test Article", "Test Author", "2024-01-01") 337 + err := helper.Read(ctx, id) 338 + Expect.AssertNoError(t, err, "Read should succeed with valid article ID") 339 + }) 340 + 341 + t.Run("handles non-existent article", func(t *testing.T) { 342 + helper := NewArticleTestHelper(t) 343 + ctx := context.Background() 344 + err := helper.Read(ctx, 99999) 345 + Expect.AssertError(t, err, "failed to get article", "Read should fail with non-existent article ID") 346 + }) 347 + 348 + t.Run("handles missing markdown file", func(t *testing.T) { 349 + helper := NewArticleTestHelper(t) 350 + ctx := context.Background() 351 + 352 + article := &models.Article{ 353 + URL: "https://example.com/missing-md", 354 + Title: "Missing Markdown Article", 355 + Author: "Test Author", 356 + Date: "2024-01-01", 357 + MarkdownPath: "/non/existent/path.md", 358 + HTMLPath: "/some/existent/path.html", 359 + Created: time.Now(), 360 + Modified: time.Now(), 361 + } 362 + 363 + id, err := helper.repos.Articles.Create(ctx, article) 364 + if err != nil { 365 + t.Fatalf("Failed to create article with missing markdown file: %v", err) 366 + } 367 + 368 + err = helper.Read(ctx, id) 369 + Expect.AssertError(t, err, "markdown file not found", "Read should fail when markdown file is missing") 370 + }) 371 + 372 + t.Run("handles database error", func(t *testing.T) { 373 + helper := NewArticleTestHelper(t) 374 + ctx := context.Background() 375 + helper.db.Exec("DROP TABLE articles") 376 + err := helper.Read(ctx, 1) 377 + Expect.AssertError(t, err, "failed to get article", "Read should fail when database is corrupted") 378 + }) 379 + }) 380 + 381 t.Run("Remove", func(t *testing.T) { 382 t.Run("removes article successfully", func(t *testing.T) { 383 helper := NewArticleTestHelper(t) 384 ctx := context.Background() 385 id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01") 386 Expect.AssertArticleExists(t, helper, id) 387 388 err := helper.Remove(ctx, id) 389 Expect.AssertNoError(t, err, "Remove should succeed") 390 Expect.AssertArticleNotExists(t, helper, id) 391 }) 392 393 t.Run("handles non-existent article", func(t *testing.T) { 394 helper := NewArticleTestHelper(t) 395 ctx := context.Background() 396 err := helper.Remove(ctx, 99999) 397 Expect.AssertError(t, err, "failed to get article", "Remove should fail with non-existent article ID") 398 }) ··· 424 t.Run("handles database error", func(t *testing.T) { 425 helper := NewArticleTestHelper(t) 426 ctx := context.Background() 427 id := helper.CreateTestArticle(t, "https://example.com/db-error", "DB Error Test", "Author", "2024-01-01") 428 429 helper.db.Exec("DROP TABLE articles") ··· 436 t.Run("Help", func(t *testing.T) { 437 t.Run("shows supported domains", func(t *testing.T) { 438 helper := NewArticleTestHelper(t) 439 err := helper.Help() 440 Expect.AssertNoError(t, err, "Help should succeed") 441 }) ··· 462 t.Run("Close", func(t *testing.T) { 463 t.Run("closes successfully", func(t *testing.T) { 464 helper := NewArticleTestHelper(t) 465 err := helper.Close() 466 Expect.AssertNoError(t, err, "Close should succeed") 467 }) ··· 469 t.Run("handles nil database gracefully", func(t *testing.T) { 470 helper := NewArticleTestHelper(t) 471 helper.db = nil 472 err := helper.Close() 473 Expect.AssertNoError(t, err, "Close should succeed with nil database") 474 }) ··· 477 t.Run("getStorageDirectory", func(t *testing.T) { 478 t.Run("returns storage directory successfully", func(t *testing.T) { 479 helper := NewArticleTestHelper(t) 480 dir, err := helper.getStorageDirectory() 481 Expect.AssertNoError(t, err, "getStorageDirectory should succeed") 482
+14
internal/handlers/books_test.go
··· 485 t.Errorf("Close failed: %v", err) 486 } 487 }) 488 }) 489 490 t.Run("Print", func(t *testing.T) {
··· 485 t.Errorf("Close failed: %v", err) 486 } 487 }) 488 + 489 + t.Run("handles service close gracefully", func(t *testing.T) { 490 + _, cleanup := setupBookTest(t) 491 + defer cleanup() 492 + 493 + handler, err := NewBookHandler() 494 + if err != nil { 495 + t.Fatalf("NewBookHandler failed: %v", err) 496 + } 497 + 498 + if err = handler.Close(); err != nil { 499 + t.Errorf("Close should succeed: %v", err) 500 + } 501 + }) 502 }) 503 504 t.Run("Print", func(t *testing.T) {
+33 -43
internal/handlers/notes.go
··· 8 "path/filepath" 9 "strings" 10 11 - "github.com/charmbracelet/glamour" 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/repo" 14 "github.com/stormlightlabs/noteleaf/internal/store" ··· 275 return "" 276 } 277 278 - func defaultOpenInEditor(editor, filePath string) error { 279 - cmd := exec.Command(editor, filePath) 280 - cmd.Stdin = os.Stdin 281 - cmd.Stdout = os.Stdout 282 - cmd.Stderr = os.Stderr 283 - return cmd.Run() 284 - } 285 - 286 func (h *NoteHandler) openInEditor(editor, filePath string) error { 287 if h.openInEditorFunc != nil { 288 return h.openInEditorFunc(editor, filePath) 289 } 290 - return defaultOpenInEditor(editor, filePath) 291 } 292 293 func (h *NoteHandler) parseNoteContent(content string) (title, noteContent string, tags []string) { ··· 324 return title, noteContent, tags 325 } 326 327 - func (h *NoteHandler) formatNoteForEdit(note *models.Note) string { 328 - var content strings.Builder 329 - 330 - if !strings.Contains(note.Content, "# "+note.Title) { 331 - content.WriteString("# " + note.Title + "\n\n") 332 - } 333 - 334 - content.WriteString(note.Content) 335 - 336 - if len(note.Tags) > 0 { 337 - if !strings.HasSuffix(note.Content, "\n") { 338 - content.WriteString("\n") 339 - } 340 - content.WriteString("\n<!-- Tags: " + strings.Join(note.Tags, ", ") + " -->\n") 341 - } 342 - 343 - return content.String() 344 - } 345 - 346 // View displays a note with formatted markdown content 347 func (h *NoteHandler) View(ctx context.Context, id int64) error { 348 note, err := h.repos.Notes.Get(ctx, id) ··· 350 return fmt.Errorf("failed to get note: %w", err) 351 } 352 353 - renderer, err := glamour.NewTermRenderer( 354 - glamour.WithAutoStyle(), 355 - glamour.WithWordWrap(80), 356 - ) 357 - if err != nil { 358 - return fmt.Errorf("failed to create markdown renderer: %w", err) 359 - } 360 - 361 content := h.formatNoteForView(note) 362 - rendered, err := renderer.Render(content) 363 - if err != nil { 364 - return fmt.Errorf("failed to render markdown: %w", err) 365 } 366 - 367 - fmt.Print(rendered) 368 - return nil 369 } 370 371 // List opens either an interactive TUI browser for navigating and viewing notes or a static list ··· 430 431 return content.String() 432 }
··· 8 "path/filepath" 9 "strings" 10 11 "github.com/stormlightlabs/noteleaf/internal/models" 12 "github.com/stormlightlabs/noteleaf/internal/repo" 13 "github.com/stormlightlabs/noteleaf/internal/store" ··· 274 return "" 275 } 276 277 func (h *NoteHandler) openInEditor(editor, filePath string) error { 278 if h.openInEditorFunc != nil { 279 return h.openInEditorFunc(editor, filePath) 280 } 281 + return openInDefaultEditor(editor, filePath) 282 } 283 284 func (h *NoteHandler) parseNoteContent(content string) (title, noteContent string, tags []string) { ··· 315 return title, noteContent, tags 316 } 317 318 // View displays a note with formatted markdown content 319 func (h *NoteHandler) View(ctx context.Context, id int64) error { 320 note, err := h.repos.Notes.Get(ctx, id) ··· 322 return fmt.Errorf("failed to get note: %w", err) 323 } 324 325 content := h.formatNoteForView(note) 326 + if rendered, err := renderMarkdown(content); err != nil { 327 + return err 328 + } else { 329 + fmt.Print(rendered) 330 + return nil 331 } 332 } 333 334 // List opens either an interactive TUI browser for navigating and viewing notes or a static list ··· 393 394 return content.String() 395 } 396 + 397 + func (h *NoteHandler) formatNoteForEdit(note *models.Note) string { 398 + var content strings.Builder 399 + 400 + if !strings.Contains(note.Content, "# "+note.Title) { 401 + content.WriteString("# " + note.Title + "\n\n") 402 + } 403 + 404 + content.WriteString(note.Content) 405 + 406 + if len(note.Tags) > 0 { 407 + if !strings.HasSuffix(note.Content, "\n") { 408 + content.WriteString("\n") 409 + } 410 + content.WriteString("\n<!-- Tags: " + strings.Join(note.Tags, ", ") + " -->\n") 411 + } 412 + 413 + return content.String() 414 + } 415 + 416 + func openInDefaultEditor(editor, filePath string) error { 417 + cmd := exec.Command(editor, filePath) 418 + cmd.Stdin = os.Stdin 419 + cmd.Stdout = os.Stdout 420 + cmd.Stderr = os.Stderr 421 + return cmd.Run() 422 + }
+200 -8
internal/handlers/notes_test.go
··· 8 "runtime" 9 "strings" 10 "testing" 11 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/store" ··· 717 }) 718 719 t.Run("Close", func(t *testing.T) { 720 - testHandler, err := NewNoteHandler() 721 - if err != nil { 722 - t.Fatalf("Failed to create test handler: %v", err) 723 - } 724 725 - err = testHandler.Close() 726 - if err != nil { 727 - t.Errorf("Close should succeed: %v", err) 728 - } 729 }) 730 731 t.Run("Helper Methods", func(t *testing.T) { ··· 868 869 err := handler.createInteractive(ctx) 870 Expect.AssertError(t, err, "failed to read edited content", "createInteractive should fail when temp file is deleted") 871 }) 872 }) 873 }
··· 8 "runtime" 9 "strings" 10 "testing" 11 + "time" 12 13 "github.com/stormlightlabs/noteleaf/internal/models" 14 "github.com/stormlightlabs/noteleaf/internal/store" ··· 718 }) 719 720 t.Run("Close", func(t *testing.T) { 721 + t.Run("closes handler resources successfully", func(t *testing.T) { 722 + testHandler, err := NewNoteHandler() 723 + if err != nil { 724 + t.Fatalf("Failed to create test handler: %v", err) 725 + } 726 + 727 + if err = testHandler.Close(); err != nil { 728 + t.Errorf("Close should succeed: %v", err) 729 + } 730 + }) 731 + 732 + t.Run("handles nil database", func(t *testing.T) { 733 + testHandler, err := NewNoteHandler() 734 + if err != nil { 735 + t.Fatalf("Failed to create test handler: %v", err) 736 + } 737 + testHandler.db = nil 738 739 + if err = testHandler.Close(); err != nil { 740 + t.Errorf("Close should succeed with nil database: %v", err) 741 + } 742 + }) 743 }) 744 745 t.Run("Helper Methods", func(t *testing.T) { ··· 882 883 err := handler.createInteractive(ctx) 884 Expect.AssertError(t, err, "failed to read edited content", "createInteractive should fail when temp file is deleted") 885 + }) 886 + }) 887 + 888 + t.Run("CreateWithOptions", func(t *testing.T) { 889 + ctx := context.Background() 890 + 891 + t.Run("creates note successfully without editor prompt", func(t *testing.T) { 892 + handler := NewHandlerTestHelper(t) 893 + err := handler.CreateWithOptions(ctx, "Test Note", "Test content", "", false, false) 894 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed") 895 + }) 896 + 897 + t.Run("creates note successfully with editor prompt disabled", func(t *testing.T) { 898 + handler := NewHandlerTestHelper(t) 899 + err := handler.CreateWithOptions(ctx, "Another Test Note", "More content", "", false, false) 900 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed") 901 + }) 902 + 903 + t.Run("handles database error during creation", func(t *testing.T) { 904 + handler := NewHandlerTestHelper(t) 905 + cancelCtx, cancel := context.WithCancel(ctx) 906 + cancel() 907 + 908 + err := handler.CreateWithOptions(cancelCtx, "Test Note", "Test content", "", false, false) 909 + Expect.AssertError(t, err, "failed to create note", "CreateWithOptions should fail with cancelled context") 910 + }) 911 + 912 + t.Run("creates note with empty content", func(t *testing.T) { 913 + handler := NewHandlerTestHelper(t) 914 + err := handler.CreateWithOptions(ctx, "Empty Content Note", "", "", false, false) 915 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed with empty content") 916 + }) 917 + 918 + t.Run("creates note with empty title", func(t *testing.T) { 919 + handler := NewHandlerTestHelper(t) 920 + err := handler.CreateWithOptions(ctx, "", "Content without title", "", false, false) 921 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed with empty title") 922 + }) 923 + 924 + t.Run("handles editor prompt with no editor available", func(t *testing.T) { 925 + handler := NewHandlerTestHelper(t) 926 + envHelper := NewEnvironmentTestHelper() 927 + defer envHelper.RestoreEnv() 928 + 929 + envHelper.UnsetEnv("EDITOR") 930 + envHelper.SetEnv("PATH", "") 931 + 932 + err := handler.CreateWithOptions(ctx, "Test Note", "Test content", "", false, true) 933 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed even when no editor is available") 934 + }) 935 + }) 936 + 937 + t.Run("formatNoteForView", func(t *testing.T) { 938 + t.Run("formats note with title and content", func(t *testing.T) { 939 + note := &models.Note{ 940 + Title: "Test Note", 941 + Content: "This is test content", 942 + Tags: []string{}, 943 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 944 + Modified: time.Date(2023, 1, 2, 11, 0, 0, 0, time.UTC), 945 + } 946 + 947 + result := handler.formatNoteForView(note) 948 + 949 + if !strings.Contains(result, "# Test Note") { 950 + t.Error("Formatted note should contain title") 951 + } 952 + if !strings.Contains(result, "This is test content") { 953 + t.Error("Formatted note should contain content") 954 + } 955 + if !strings.Contains(result, "**Created:**") { 956 + t.Error("Formatted note should contain created timestamp") 957 + } 958 + if !strings.Contains(result, "**Modified:**") { 959 + t.Error("Formatted note should contain modified timestamp") 960 + } 961 + }) 962 + 963 + t.Run("formats note with tags", func(t *testing.T) { 964 + note := &models.Note{ 965 + Title: "Tagged Note", 966 + Content: "Content with tags", 967 + Tags: []string{"work", "important", "personal"}, 968 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 969 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 970 + } 971 + 972 + result := handler.formatNoteForView(note) 973 + 974 + if !strings.Contains(result, "**Tags:**") { 975 + t.Error("Formatted note should contain tags section") 976 + } 977 + if !strings.Contains(result, "`work`") { 978 + t.Error("Formatted note should contain work tag") 979 + } 980 + if !strings.Contains(result, "`important`") { 981 + t.Error("Formatted note should contain important tag") 982 + } 983 + if !strings.Contains(result, "`personal`") { 984 + t.Error("Formatted note should contain personal tag") 985 + } 986 + }) 987 + 988 + t.Run("formats note with no tags", func(t *testing.T) { 989 + note := &models.Note{ 990 + Title: "Untagged Note", 991 + Content: "Content without tags", 992 + Tags: []string{}, 993 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 994 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 995 + } 996 + 997 + result := handler.formatNoteForView(note) 998 + 999 + if strings.Contains(result, "**Tags:**") { 1000 + t.Error("Formatted note should not contain tags section when no tags exist") 1001 + } 1002 + }) 1003 + 1004 + t.Run("handles content with existing title", func(t *testing.T) { 1005 + note := &models.Note{ 1006 + Title: "Note Title", 1007 + Content: "# Duplicate Title\nContent after title", 1008 + Tags: []string{}, 1009 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1010 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1011 + } 1012 + 1013 + result := handler.formatNoteForView(note) 1014 + 1015 + if !strings.Contains(result, "Content after title") { 1016 + t.Error("Formatted note should contain content after duplicate title removal") 1017 + } 1018 + contentLines := strings.Split(result, "\n") 1019 + duplicateTitleCount := 0 1020 + for _, line := range contentLines { 1021 + if strings.Contains(line, "# Duplicate Title") { 1022 + duplicateTitleCount++ 1023 + } 1024 + } 1025 + if duplicateTitleCount > 0 { 1026 + t.Error("Formatted note should not contain duplicate title from content") 1027 + } 1028 + }) 1029 + 1030 + t.Run("handles empty content", func(t *testing.T) { 1031 + note := &models.Note{ 1032 + Title: "Empty Content Note", 1033 + Content: "", 1034 + Tags: []string{}, 1035 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1036 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1037 + } 1038 + 1039 + result := handler.formatNoteForView(note) 1040 + 1041 + if !strings.Contains(result, "# Empty Content Note") { 1042 + t.Error("Formatted note should contain title even with empty content") 1043 + } 1044 + if !strings.Contains(result, "---") { 1045 + t.Error("Formatted note should contain separator") 1046 + } 1047 + }) 1048 + 1049 + t.Run("handles content with only title line", func(t *testing.T) { 1050 + note := &models.Note{ 1051 + Title: "Single Line", 1052 + Content: "# Single Line", 1053 + Tags: []string{}, 1054 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1055 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1056 + } 1057 + 1058 + result := handler.formatNoteForView(note) 1059 + 1060 + if !strings.Contains(result, "# Single Line") { 1061 + t.Error("Formatted note should contain title") 1062 + } 1063 }) 1064 }) 1065 }
+21
internal/handlers/shared.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/charmbracelet/glamour" 7 + ) 8 + 9 + func renderMarkdown(content string) (string, error) { 10 + renderer, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(80)) 11 + if err != nil { 12 + return "", fmt.Errorf("failed to create markdown renderer: %w", err) 13 + } 14 + 15 + rendered, err := renderer.Render(content) 16 + if err != nil { 17 + return "", fmt.Errorf("failed to render markdown: %w", err) 18 + } 19 + 20 + return rendered, nil 21 + }
+82
internal/handlers/shared_test.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + type renderMarkdownTC struct { 9 + name string 10 + content string 11 + err bool 12 + contains []string 13 + } 14 + 15 + func TestRenderMarkdown(t *testing.T) { 16 + tt := []renderMarkdownTC{ 17 + {name: "simple text", content: "Hello, world!", err: false, contains: []string{"Hello, world!"}}, 18 + {name: "markdown heading", content: "# Main Title", err: false, contains: []string{"Main Title"}}, 19 + {name: "markdown with emphasis", content: "This is **bold** and *italic* text", err: false, contains: []string{"bold", "italic"}}, 20 + {name: "markdown list", content: "- Item 1\n- Item 2\n- Item 3", err: false, contains: []string{"Item 1", "Item 2", "Item 3"}}, 21 + {name: "code block", content: "```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```", err: false, contains: []string{"main", "fmt.Println"}}, 22 + {name: "empty string", content: "", err: false, contains: []string{}}, 23 + {name: "only whitespace", content: " \n\t \n ", err: false, contains: []string{}}, 24 + {name: "mixed content", content: "# Title\n\nSome **bold** text and a [link](https://example.com)\n\n- List item", err: false, 25 + contains: []string{"Title", "bold", "example.com", "List item"}}} 26 + 27 + for _, tt := range tt { 28 + t.Run(tt.name, func(t *testing.T) { 29 + result, err := renderMarkdown(tt.content) 30 + if tt.err && err == nil { 31 + t.Fatalf("expected error, got nil") 32 + } 33 + 34 + if err != nil { 35 + t.Fatalf("unexpected error: %v", err) 36 + } 37 + 38 + for _, want := range tt.contains { 39 + if strings.Contains(result, want) { 40 + continue 41 + } 42 + t.Fatalf("result should contain %q, got:\n%s", want, result) 43 + } 44 + }) 45 + } 46 + 47 + t.Run("WordWrap", func(t *testing.T) { 48 + text := strings.Repeat("This is a very long line that should be wrapped at 80 characters. ", 5) 49 + result, err := renderMarkdown(text) 50 + if err != nil { 51 + t.Fatalf("unexpected error: %v", err) 52 + } 53 + 54 + lines := strings.Split(result, "\n") 55 + for i, line := range lines { 56 + cleaned := removeANSI(line) 57 + if len(cleaned) > 85 { 58 + t.Fatalf("Line at index %d is too long (%d chars): %q", i, len(cleaned), cleaned) 59 + } 60 + } 61 + }) 62 + } 63 + 64 + func removeANSI(s string) string { 65 + result := "" 66 + inEscapeCh := false 67 + for i := 0; i < len(s); i++ { 68 + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { 69 + inEscapeCh = true 70 + i++ 71 + continue 72 + } 73 + if inEscapeCh { 74 + if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') { 75 + inEscapeCh = false 76 + } 77 + continue 78 + } 79 + result += string(s[i]) 80 + } 81 + return result 82 + }
+110
internal/handlers/tasks_test.go
··· 1141 }) 1142 }) 1143 }) 1144 }
··· 1141 }) 1142 }) 1143 }) 1144 + 1145 + t.Run("ListContexts", func(t *testing.T) { 1146 + _, cleanup := setupTaskTest(t) 1147 + defer cleanup() 1148 + 1149 + ctx := context.Background() 1150 + 1151 + handler, err := NewTaskHandler() 1152 + if err != nil { 1153 + t.Fatalf("Failed to create handler: %v", err) 1154 + } 1155 + defer handler.Close() 1156 + 1157 + tasks := []*models.Task{ 1158 + { 1159 + UUID: uuid.New().String(), 1160 + Description: "Task with context 1", 1161 + Status: "pending", 1162 + Context: "test-context", 1163 + }, 1164 + { 1165 + UUID: uuid.New().String(), 1166 + Description: "Task with context 2", 1167 + Status: "pending", 1168 + Context: "work-context", 1169 + }, 1170 + { 1171 + UUID: uuid.New().String(), 1172 + Description: "Task without context", 1173 + Status: "pending", 1174 + }, 1175 + } 1176 + 1177 + for _, task := range tasks { 1178 + _, err := handler.repos.Tasks.Create(ctx, task) 1179 + if err != nil { 1180 + t.Fatalf("Failed to create task: %v", err) 1181 + } 1182 + } 1183 + 1184 + t.Run("lists contexts in static mode", func(t *testing.T) { 1185 + err := handler.ListContexts(ctx, true) 1186 + if err != nil { 1187 + t.Errorf("ListContexts static mode failed: %v", err) 1188 + } 1189 + }) 1190 + 1191 + t.Run("lists contexts in interactive mode (falls back to static)", func(t *testing.T) { 1192 + err := handler.ListContexts(ctx, false) 1193 + if err != nil { 1194 + t.Errorf("ListContexts interactive mode failed: %v", err) 1195 + } 1196 + }) 1197 + 1198 + t.Run("lists contexts with todoTxt flag true", func(t *testing.T) { 1199 + err := handler.ListContexts(ctx, true, true) 1200 + if err != nil { 1201 + t.Errorf("ListContexts with todoTxt=true failed: %v", err) 1202 + } 1203 + }) 1204 + 1205 + t.Run("lists contexts with todoTxt flag false", func(t *testing.T) { 1206 + err := handler.ListContexts(ctx, true, false) 1207 + if err != nil { 1208 + t.Errorf("ListContexts with todoTxt=false failed: %v", err) 1209 + } 1210 + }) 1211 + 1212 + t.Run("handles database error in static mode", func(t *testing.T) { 1213 + cancelCtx, cancel := context.WithCancel(ctx) 1214 + cancel() 1215 + 1216 + err := handler.ListContexts(cancelCtx, true) 1217 + if err == nil { 1218 + t.Error("Expected error with cancelled context in static mode") 1219 + } 1220 + if !strings.Contains(err.Error(), "failed to list tasks for contexts") { 1221 + t.Errorf("Expected specific error message, got: %v", err) 1222 + } 1223 + }) 1224 + 1225 + t.Run("handles database error in interactive mode", func(t *testing.T) { 1226 + cancelCtx, cancel := context.WithCancel(ctx) 1227 + cancel() 1228 + 1229 + err := handler.ListContexts(cancelCtx, false) 1230 + if err == nil { 1231 + t.Error("Expected error with cancelled context in interactive mode") 1232 + } 1233 + if !strings.Contains(err.Error(), "failed to list tasks for contexts") { 1234 + t.Errorf("Expected specific error message, got: %v", err) 1235 + } 1236 + }) 1237 + 1238 + t.Run("returns no contexts when none exist", func(t *testing.T) { 1239 + _, cleanup_ := setupTaskTest(t) 1240 + defer cleanup_() 1241 + 1242 + handler_, err := NewTaskHandler() 1243 + if err != nil { 1244 + t.Fatalf("Failed to create handler: %v", err) 1245 + } 1246 + defer handler_.Close() 1247 + 1248 + err = handler_.ListContexts(ctx, true) 1249 + if err != nil { 1250 + t.Errorf("ListContexts with no contexts failed: %v", err) 1251 + } 1252 + }) 1253 + }) 1254 }