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

build: add tests for shared utilities & main

+595 -85
+60
cmd/commands_test.go
··· 117 117 } 118 118 } 119 119 120 + func createTestConfigHandler(t *testing.T) (*handlers.ConfigHandler, func()) { 121 + cleanup := setupCommandTest(t) 122 + handler, err := handlers.NewConfigHandler() 123 + if err != nil { 124 + cleanup() 125 + t.Fatalf("failed to create test config handler: %v", err) 126 + } 127 + return handler, cleanup 128 + } 129 + 120 130 func findSubcommand(commands []string, target string) bool { 121 131 return slices.Contains(commands, target) 122 132 } ··· 1029 1039 err := cmd.Execute() 1030 1040 if err == nil { 1031 1041 t.Error("expected task done command to fail with non-existent ID") 1042 + } 1043 + }) 1044 + }) 1045 + 1046 + t.Run("Config Command", func(t *testing.T) { 1047 + handler, cleanup := createTestConfigHandler(t) 1048 + defer cleanup() 1049 + 1050 + cmd := NewConfigCommand(handler).Create() 1051 + 1052 + if cmd.Use != "config" { 1053 + t.Errorf("expected Use 'config', got %s", cmd.Use) 1054 + } 1055 + if cmd.Short == "" { 1056 + t.Errorf("expected Short description to be set") 1057 + } 1058 + if len(cmd.Commands()) == 0 { 1059 + t.Errorf("expected subcommands to be registered") 1060 + } 1061 + 1062 + t.Run("path command", func(t *testing.T) { 1063 + cmd.SetArgs([]string{"path"}) 1064 + if err := cmd.Execute(); err != nil { 1065 + t.Errorf("config path failed: %v", err) 1066 + } 1067 + }) 1068 + 1069 + t.Run("get with no args", func(t *testing.T) { 1070 + cmd.SetArgs([]string{"get"}) 1071 + if err := cmd.Execute(); err != nil { 1072 + t.Errorf("config get with no args failed: %v", err) 1073 + } 1074 + }) 1075 + 1076 + t.Run("set and get roundtrip", func(t *testing.T) { 1077 + cmd.SetArgs([]string{"set", "editor", "vim"}) 1078 + if err := cmd.Execute(); err != nil { 1079 + t.Fatalf("config set failed: %v", err) 1080 + } 1081 + 1082 + cmd.SetArgs([]string{"get", "editor"}) 1083 + if err := cmd.Execute(); err != nil { 1084 + t.Errorf("config get after set failed: %v", err) 1085 + } 1086 + }) 1087 + 1088 + t.Run("reset command", func(t *testing.T) { 1089 + cmd.SetArgs([]string{"reset"}) 1090 + if err := cmd.Execute(); err != nil { 1091 + t.Errorf("config reset failed: %v", err) 1032 1092 } 1033 1093 }) 1034 1094 })
+46 -25
cmd/main.go
··· 15 15 "github.com/stormlightlabs/noteleaf/internal/utils" 16 16 ) 17 17 18 + var ( 19 + newTaskHandler = handlers.NewTaskHandler 20 + newMovieHandler = handlers.NewMovieHandler 21 + newTVHandler = handlers.NewTVHandler 22 + newNoteHandler = handlers.NewNoteHandler 23 + newBookHandler = handlers.NewBookHandler 24 + newArticleHandler = handlers.NewArticleHandler 25 + exc = fang.Execute 26 + ) 27 + 18 28 // App represents the main CLI application 19 29 type App struct { 20 30 db *store.Database 21 31 config *store.Config 22 32 } 23 33 24 - // NewApp creates a new CLI application instance 34 + // NewApp creates a new CLI application instance ([App]) 25 35 func NewApp() (*App, error) { 26 36 db, err := store.NewDatabase() 27 37 if err != nil { 28 38 return nil, fmt.Errorf("failed to initialize database: %w", err) 29 39 } 30 40 31 - if config, err := store.LoadConfig(); err != nil { 41 + config, err := store.LoadConfig() 42 + if err != nil { 32 43 return nil, fmt.Errorf("failed to load configuration: %w", err) 33 - } else { 34 - return &App{db, config}, nil 35 44 } 45 + 46 + return &App{db, config}, nil 36 47 } 37 48 38 49 // Close cleans up application resources ··· 48 59 Use: "status", 49 60 Short: "Show application status and configuration", 50 61 RunE: func(cmd *cobra.Command, args []string) error { 51 - return handlers.Status(cmd.Context(), args) 62 + return handlers.Status(cmd.Context(), args, cmd.OutOrStdout()) 52 63 }, 53 64 } 54 65 } ··· 74 85 } 75 86 76 87 output := strings.Join(args, " ") 77 - fmt.Println(output) 88 + fmt.Fprintln(c.OutOrStdout(), output) 78 89 return nil 79 90 }, 80 91 } ··· 124 135 return NewConfigCommand(handler).Create() 125 136 } 126 137 127 - func main() { 138 + func run() int { 128 139 logger := utils.NewLogger("info", "text") 129 140 utils.Logger = logger 130 141 131 142 app, err := NewApp() 132 143 if err != nil { 133 - logger.Fatal("Failed to initialize application", "error", err) 144 + logger.Error("Failed to initialize application", "error", err) 145 + return 1 134 146 } 135 147 defer app.Close() 136 148 137 - taskHandler, err := handlers.NewTaskHandler() 149 + taskHandler, err := newTaskHandler() 138 150 if err != nil { 139 - log.Fatalf("failed to create task handler: %v", err) 151 + log.Error("failed to create task handler", "err", err) 152 + return 1 140 153 } 141 154 142 - movieHandler, err := handlers.NewMovieHandler() 155 + movieHandler, err := newMovieHandler() 143 156 if err != nil { 144 - log.Fatalf("failed to create movie handler: %v", err) 157 + log.Error("failed to create movie handler", "err", err) 158 + return 1 145 159 } 146 160 147 - tvHandler, err := handlers.NewTVHandler() 161 + tvHandler, err := newTVHandler() 148 162 if err != nil { 149 - log.Fatalf("failed to create TV handler: %v", err) 163 + log.Error("failed to create TV handler", "err", err) 164 + return 1 150 165 } 151 166 152 - noteHandler, err := handlers.NewNoteHandler() 167 + noteHandler, err := newNoteHandler() 153 168 if err != nil { 154 - log.Fatalf("failed to create note handler: %v", err) 169 + log.Error("failed to create note handler", "err", err) 170 + return 1 155 171 } 156 172 157 - bookHandler, err := handlers.NewBookHandler() 173 + bookHandler, err := newBookHandler() 158 174 if err != nil { 159 - log.Fatalf("failed to create book handler: %v", err) 175 + log.Error("failed to create book handler", "err", err) 176 + return 1 160 177 } 161 178 162 - articleHandler, err := handlers.NewArticleHandler() 179 + articleHandler, err := newArticleHandler() 163 180 if err != nil { 164 - log.Fatalf("failed to create article handler: %v", err) 181 + log.Error("failed to create article handler", "err", err) 182 + return 1 165 183 } 166 184 167 185 root := rootCmd() 168 186 169 187 coreGroups := []CommandGroup{ 170 - NewTaskCommand(taskHandler), 171 - NewNoteCommand(noteHandler), 172 - NewArticleCommand(articleHandler), 188 + NewTaskCommand(taskHandler), NewNoteCommand(noteHandler), NewArticleCommand(articleHandler), 173 189 } 174 190 175 191 for _, group := range coreGroups { ··· 198 214 fang.WithColorSchemeFunc(ui.NoteleafColorScheme), 199 215 } 200 216 201 - if err := fang.Execute(context.Background(), root, opts...); err != nil { 202 - os.Exit(1) 217 + if err := exc(context.Background(), root, opts...); err != nil { 218 + return 1 203 219 } 220 + return 0 221 + } 222 + 223 + func main() { 224 + os.Exit(run()) 204 225 }
+204
cmd/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/charmbracelet/fang" 11 + "github.com/spf13/cobra" 12 + "github.com/stormlightlabs/noteleaf/internal/handlers" 13 + "github.com/stormlightlabs/noteleaf/internal/store" 14 + ) 15 + 16 + func executeCommand(t *testing.T, cmd *cobra.Command, args ...string) string { 17 + buf := &bytes.Buffer{} 18 + cmd.SetOut(buf) 19 + cmd.SetArgs(args) 20 + if err := cmd.Execute(); err != nil { 21 + t.Fatalf("command %q failed: %v", args, err) 22 + } 23 + return buf.String() 24 + } 25 + 26 + func TestNewApp(t *testing.T) { 27 + t.Run("Success", func(t *testing.T) { 28 + origDB := store.NewDatabase 29 + origConfig := store.LoadConfig 30 + defer func() { 31 + store.NewDatabase = origDB 32 + store.LoadConfig = origConfig 33 + }() 34 + 35 + store.NewDatabase = func() (*store.Database, error) { return &store.Database{}, nil } 36 + store.LoadConfig = func() (*store.Config, error) { return &store.Config{}, nil } 37 + 38 + app, err := NewApp() 39 + if err != nil { 40 + t.Fatalf("expected no error, got %v", err) 41 + } 42 + if app.db == nil || app.config == nil { 43 + t.Fatalf("expected db and config to be initialized") 44 + } 45 + }) 46 + 47 + t.Run("DBError", func(t *testing.T) { 48 + origDB := store.NewDatabase 49 + defer func() { store.NewDatabase = origDB }() 50 + store.NewDatabase = func() (*store.Database, error) { return nil, errors.New("db boom") } 51 + 52 + _, err := NewApp() 53 + if err == nil || !strings.Contains(err.Error(), "failed to initialize database") { 54 + t.Errorf("expected db init error, got %v", err) 55 + } 56 + }) 57 + 58 + t.Run("ConfigError", func(t *testing.T) { 59 + origDB := store.NewDatabase 60 + origConfig := store.LoadConfig 61 + defer func() { 62 + store.NewDatabase = origDB 63 + store.LoadConfig = origConfig 64 + }() 65 + store.NewDatabase = func() (*store.Database, error) { return &store.Database{}, nil } 66 + store.LoadConfig = func() (*store.Config, error) { return nil, errors.New("config boom") } 67 + 68 + _, err := NewApp() 69 + if err == nil || !strings.Contains(err.Error(), "failed to load configuration") { 70 + t.Errorf("expected config load error, got %v", err) 71 + } 72 + }) 73 + } 74 + 75 + func TestRootCmd(t *testing.T) { 76 + t.Run("Help", func(t *testing.T) { 77 + root := rootCmd() 78 + root.SetArgs([]string{}) 79 + if err := root.Execute(); err != nil { 80 + t.Fatalf("expected no error, got %v", err) 81 + } 82 + }) 83 + 84 + t.Run("PrintArgs", func(t *testing.T) { 85 + root := rootCmd() 86 + root.SetArgs([]string{"hello", "world"}) 87 + buf := &bytes.Buffer{} 88 + root.SetOut(buf) 89 + 90 + if err := root.Execute(); err != nil { 91 + t.Fatalf("unexpected error: %v", err) 92 + } 93 + if got := buf.String(); !strings.Contains(got, "hello world") { 94 + t.Errorf("expected output to contain 'hello world', got %q", got) 95 + } 96 + }) 97 + } 98 + 99 + func TestStatusCmd(t *testing.T) { 100 + output := executeCommand(t, statusCmd(), nil...) 101 + if output == "" { 102 + t.Errorf("expected some status output, got empty string") 103 + } 104 + } 105 + 106 + func TestResetCmd(t *testing.T) { 107 + _ = executeCommand(t, resetCmd(), nil...) 108 + } 109 + 110 + func TestSetupCmd(t *testing.T) { 111 + _ = executeCommand(t, setupCmd(), nil...) 112 + } 113 + 114 + func TestConfCmd(t *testing.T) { 115 + cmd := confCmd() 116 + buf := &bytes.Buffer{} 117 + cmd.SetOut(buf) 118 + cmd.SetArgs([]string{"path"}) 119 + 120 + if err := cmd.Execute(); err != nil { 121 + t.Errorf("config path command failed: %v", err) 122 + } 123 + } 124 + 125 + func TestRun(t *testing.T) { 126 + t.Run("Success", func(t *testing.T) { 127 + code := run() 128 + if code != 0 { 129 + t.Errorf("expected exit code 0, got %d", code) 130 + } 131 + }) 132 + 133 + t.Run("TaskHandlerError", func(t *testing.T) { 134 + orig := newTaskHandler 135 + defer func() { newTaskHandler = orig }() 136 + newTaskHandler = func() (*handlers.TaskHandler, error) { return nil, errors.New("boom") } 137 + 138 + if code := run(); code != 1 { 139 + t.Errorf("expected exit code 1, got %d", code) 140 + } 141 + }) 142 + 143 + t.Run("MovieHandlerError", func(t *testing.T) { 144 + orig := newMovieHandler 145 + defer func() { newMovieHandler = orig }() 146 + newMovieHandler = func() (*handlers.MovieHandler, error) { return nil, errors.New("boom") } 147 + 148 + if code := run(); code != 1 { 149 + t.Errorf("expected exit code 1, got %d", code) 150 + } 151 + }) 152 + 153 + t.Run("TVHandlerError", func(t *testing.T) { 154 + orig := newTVHandler 155 + defer func() { newTVHandler = orig }() 156 + newTVHandler = func() (*handlers.TVHandler, error) { return nil, errors.New("boom") } 157 + 158 + if code := run(); code != 1 { 159 + t.Errorf("expected exit code 1, got %d", code) 160 + } 161 + }) 162 + 163 + t.Run("NoteHandlerError", func(t *testing.T) { 164 + orig := newNoteHandler 165 + defer func() { newNoteHandler = orig }() 166 + newNoteHandler = func() (*handlers.NoteHandler, error) { return nil, errors.New("boom") } 167 + 168 + if code := run(); code != 1 { 169 + t.Errorf("expected exit code 1, got %d", code) 170 + } 171 + }) 172 + 173 + t.Run("BookHandlerError", func(t *testing.T) { 174 + orig := newBookHandler 175 + defer func() { newBookHandler = orig }() 176 + newBookHandler = func() (*handlers.BookHandler, error) { return nil, errors.New("boom") } 177 + 178 + if code := run(); code != 1 { 179 + t.Errorf("expected exit code 1, got %d", code) 180 + } 181 + }) 182 + 183 + t.Run("ArticleHandlerError", func(t *testing.T) { 184 + orig := newArticleHandler 185 + defer func() { newArticleHandler = orig }() 186 + newArticleHandler = func() (*handlers.ArticleHandler, error) { return nil, errors.New("boom") } 187 + 188 + if code := run(); code != 1 { 189 + t.Errorf("expected exit code 1, got %d", code) 190 + } 191 + }) 192 + 193 + t.Run("FangExecuteError", func(t *testing.T) { 194 + orig := exc 195 + defer func() { exc = orig }() 196 + exc = func(ctx context.Context, cmd *cobra.Command, opts ...fang.Option) error { 197 + return errors.New("fang failed") 198 + } 199 + 200 + if code := run(); code != 1 { 201 + t.Errorf("expected exit code 1, got %d", code) 202 + } 203 + }) 204 + }
-1
codecov.yml
··· 20 20 - "**/*_test.go" 21 21 - "**/testdata/**" 22 22 - "**/vendor/**" 23 - - "cmd/main.go" 24 23 - "internal/**/test_utilities.go"
+3 -13
internal/handlers/config.go
··· 8 8 "github.com/stormlightlabs/noteleaf/internal/store" 9 9 ) 10 10 11 - // ConfigHandler handles configuration-related operations 11 + // ConfigHandler handles [store.Config]-related operations 12 12 type ConfigHandler struct { 13 13 config *store.Config 14 14 } 15 15 16 - // NewConfigHandler creates a new ConfigHandler 16 + // NewConfigHandler creates a new [ConfigHandler] 17 17 func NewConfigHandler() (*ConfigHandler, error) { 18 18 config, err := store.LoadConfig() 19 19 if err != nil { 20 20 return nil, fmt.Errorf("failed to load config: %w", err) 21 21 } 22 22 23 - return &ConfigHandler{ 24 - config: config, 25 - }, nil 23 + return &ConfigHandler{config: config}, nil 26 24 } 27 25 28 26 // Get displays one or all configuration values 29 27 func (h *ConfigHandler) Get(key string) error { 30 28 if key == "" { 31 - // Display all configuration values 32 29 return h.displayAll() 33 30 } 34 31 35 - // Display specific key 36 32 value, err := h.getConfigValue(key) 37 33 if err != nil { 38 34 return err ··· 87 83 field := t.Field(i) 88 84 value := v.Field(i) 89 85 90 - // Get the TOML tag name 91 86 tomlTag := field.Tag.Get("toml") 92 87 if tomlTag == "" { 93 88 continue 94 89 } 95 90 96 - // Remove ",omitempty" suffix if present 97 91 tagName := strings.Split(tomlTag, ",")[0] 98 92 99 - // Format the output based on field type 100 93 switch value.Kind() { 101 94 case reflect.String: 102 95 if value.String() != "" { ··· 118 111 v := reflect.ValueOf(*h.config) 119 112 t := reflect.TypeOf(*h.config) 120 113 121 - // Find the field with matching TOML tag 122 114 for i := 0; i < v.NumField(); i++ { 123 115 field := t.Field(i) 124 116 tomlTag := field.Tag.Get("toml") ··· 139 131 v := reflect.ValueOf(h.config).Elem() 140 132 t := reflect.TypeOf(*h.config) 141 133 142 - // Find the field with matching TOML tag 143 134 for i := 0; i < v.NumField(); i++ { 144 135 field := t.Field(i) 145 136 tomlTag := field.Tag.Get("toml") ··· 151 142 if tagName == key { 152 143 fieldValue := v.Field(i) 153 144 154 - // Set the value based on field type 155 145 switch fieldValue.Kind() { 156 146 case reflect.String: 157 147 fieldValue.SetString(value)
+8 -9
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "io" 6 7 "os" 7 8 "path/filepath" 8 9 ··· 130 131 } 131 132 132 133 // Status shows the current application status 133 - func Status(ctx context.Context, args []string) error { 134 + func Status(ctx context.Context, args []string, w io.Writer) error { 134 135 configDir, err := store.GetConfigDir() 135 136 if err != nil { 136 137 return fmt.Errorf("failed to get config directory: %w", err) ··· 138 139 139 140 fmt.Printf("Config directory: %s\n", configDir) 140 141 141 - // Load config to determine the actual database path 142 142 config, err := store.LoadConfig() 143 143 if err != nil { 144 144 return fmt.Errorf("failed to load configuration: %w", err) 145 145 } 146 146 147 - // Determine database path using the same logic as NewDatabase 148 147 var dbPath string 149 148 if config.DatabasePath != "" { 150 149 dbPath = config.DatabasePath ··· 159 158 } 160 159 161 160 if _, err := os.Stat(dbPath); err != nil { 162 - fmt.Println("Database: Not found") 163 - fmt.Println("Run 'noteleaf setup' to initialize.") 161 + fmt.Fprintln(w, "Database: Not found") 162 + fmt.Fprintln(w, "Run 'noteleaf setup' to initialize.") 164 163 return nil 165 164 } 166 165 167 - fmt.Printf("Database: %s\n", dbPath) 166 + fmt.Fprintf(w, "Database: %s\n", dbPath) 168 167 169 168 configPath, err := store.GetConfigPath() 170 169 if err != nil { ··· 172 171 } 173 172 174 173 if _, err := os.Stat(configPath); err != nil { 175 - fmt.Println("Configuration: Not found") 174 + fmt.Fprintln(w, "Configuration: Not found") 176 175 } else { 177 - fmt.Printf("Configuration: %s\n", configPath) 176 + fmt.Fprintf(w, "Configuration: %s\n", configPath) 178 177 } 179 178 180 179 db, err := store.NewDatabase() ··· 194 193 return fmt.Errorf("failed to get available migrations: %w", err) 195 194 } 196 195 197 - fmt.Printf("Migrations: %d/%d applied\n", len(applied), len(available)) 196 + fmt.Fprintf(w, "Migrations: %d/%d applied\n", len(applied), len(available)) 198 197 199 198 return nil 200 199 }
+25 -16
internal/handlers/handlers_test.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "os" 6 7 "path/filepath" ··· 197 198 t.Run("reports status when setup", func(t *testing.T) { 198 199 _ = createTestDir(t) 199 200 ctx := context.Background() 201 + var buf bytes.Buffer 200 202 201 203 err := Setup(ctx, []string{}) 202 204 if err != nil { 203 205 t.Fatalf("Setup failed: %v", err) 204 206 } 205 207 206 - err = Status(ctx, []string{}) 208 + err = Status(ctx, []string{}, &buf) 207 209 if err != nil { 208 210 t.Errorf("Status failed: %v", err) 209 211 } ··· 213 215 t.Run("reports status when not setup", func(t *testing.T) { 214 216 _ = createTestDir(t) 215 217 ctx := context.Background() 218 + var buf bytes.Buffer 216 219 217 - err := Status(ctx, []string{}) 220 + err := Status(ctx, []string{}, &buf) 218 221 if err != nil { 219 222 t.Errorf("Status should not fail when not setup: %v", err) 220 223 } ··· 224 227 t.Run("shows migration information", func(t *testing.T) { 225 228 _ = createTestDir(t) 226 229 ctx := context.Background() 230 + var buf bytes.Buffer 227 231 228 232 err := Setup(ctx, []string{}) 229 233 if err != nil { 230 234 t.Fatalf("Setup failed: %v", err) 231 235 } 232 236 233 - err = Status(ctx, []string{}) 237 + err = Status(ctx, []string{}, &buf) 234 238 if err != nil { 235 239 t.Errorf("Status failed: %v", err) 236 240 } ··· 245 249 originalNoteleafConfig := os.Getenv("NOTELEAF_CONFIG") 246 250 originalNoteleafDataDir := os.Getenv("NOTELEAF_DATA_DIR") 247 251 248 - // Unset all environment variables so GetConfigDir fails 249 252 os.Unsetenv("NOTELEAF_CONFIG") 250 253 os.Unsetenv("NOTELEAF_DATA_DIR") 251 254 ··· 431 434 os.Setenv("HOME", originalHome) 432 435 }() 433 436 437 + var buf bytes.Buffer 434 438 ctx := context.Background() 435 - err := Status(ctx, []string{}) 439 + err := Status(ctx, []string{}, &buf) 436 440 437 441 if err == nil { 438 442 t.Error("Status should fail when GetConfigDir fails") ··· 467 471 dbPath = filepath.Join(dataDir, "noteleaf.db") 468 472 } 469 473 470 - // Remove the database file 471 474 os.Remove(dbPath) 472 475 473 - // Create a directory with the same name as the database file 474 - // This will cause database connection to fail 475 476 if err := os.MkdirAll(dbPath, 0755); err != nil { 476 477 t.Fatalf("Failed to create directory: %v", err) 477 478 } 478 479 479 - err = Status(ctx, []string{}) 480 + var buf bytes.Buffer 481 + err = Status(ctx, []string{}, &buf) 480 482 if err == nil { 481 483 t.Error("Status should fail when database connection fails") 482 484 } else if !strings.Contains(err.Error(), "failed to connect to database") && !strings.Contains(err.Error(), "failed to open database") && !strings.Contains(err.Error(), "failed to load config") { ··· 510 512 } 511 513 db.Close() 512 514 513 - err = Status(ctx, []string{}) 515 + var buf bytes.Buffer 516 + err = Status(ctx, []string{}, &buf) 514 517 if err == nil { 515 518 t.Error("Status should fail when migration queries fail") 516 519 } ··· 590 593 } 591 594 } 592 595 }, 593 - handlerFunc: Status, 596 + handlerFunc: func(ctx context.Context, args []string) error { 597 + var buf bytes.Buffer 598 + return Status(ctx, args, &buf) 599 + }, 594 600 expectError: true, 595 601 errorSubstr: "config directory", 596 602 }, ··· 620 626 _ = createTestDir(t) 621 627 ctx := context.Background() 622 628 623 - err := Status(ctx, []string{}) 629 + var buf bytes.Buffer 630 + err := Status(ctx, []string{}, &buf) 624 631 if err != nil { 625 632 t.Errorf("Initial status failed: %v", err) 626 633 } ··· 630 637 t.Errorf("Setup failed: %v", err) 631 638 } 632 639 633 - err = Status(ctx, []string{}) 640 + buf.Reset() 641 + err = Status(ctx, []string{}, &buf) 634 642 if err != nil { 635 643 t.Errorf("Status after setup failed: %v", err) 636 644 } 637 645 638 - // Determine database path using the same logic as Setup 639 646 config, _ := store.LoadConfig() 640 647 var dbPath string 641 648 if config.DatabasePath != "" { ··· 660 667 t.Error("Database should not exist after reset") 661 668 } 662 669 663 - err = Status(ctx, []string{}) 670 + buf.Reset() 671 + err = Status(ctx, []string{}, &buf) 664 672 if err != nil { 665 673 t.Errorf("Status after reset failed: %v", err) 666 674 } ··· 678 686 679 687 done := make(chan error, 3) 680 688 689 + var buf bytes.Buffer 681 690 for range 3 { 682 691 go func() { 683 692 time.Sleep(time.Millisecond * 10) 684 - done <- Status(ctx, []string{}) 693 + done <- Status(ctx, []string{}, &buf) 685 694 }() 686 695 } 687 696
+9 -2
internal/handlers/shared.go
··· 6 6 "github.com/charmbracelet/glamour" 7 7 ) 8 8 9 + type MarkdownRenderer interface { 10 + Render(string) (string, error) 11 + } 12 + 13 + var newRenderer = func() (MarkdownRenderer, error) { 14 + return glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(80)) 15 + } 16 + 9 17 func renderMarkdown(content string) (string, error) { 10 - renderer, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(80)) 18 + renderer, err := newRenderer() 11 19 if err != nil { 12 20 return "", fmt.Errorf("failed to create markdown renderer: %w", err) 13 21 } ··· 16 24 if err != nil { 17 25 return "", fmt.Errorf("failed to render markdown: %w", err) 18 26 } 19 - 20 27 return rendered, nil 21 28 }
+48 -12
internal/handlers/shared_test.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "errors" 4 5 "strings" 5 6 "testing" 6 7 ) ··· 12 13 contains []string 13 14 } 14 15 16 + type fakeRenderer struct { 17 + fail bool 18 + } 19 + 20 + func (f fakeRenderer) Render(s string) (string, error) { 21 + if f.fail { 22 + return "", errors.New("render error") 23 + } 24 + return "fake:" + s, nil 25 + } 26 + 27 + var defaultRenderer = newRenderer 28 + 15 29 func TestRenderMarkdown(t *testing.T) { 16 30 tt := []renderMarkdownTC{ 17 31 {name: "simple text", content: "Hello, world!", err: false, contains: []string{"Hello, world!"}}, ··· 21 35 {name: "code block", content: "```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```", err: false, contains: []string{"main", "fmt.Println"}}, 22 36 {name: "empty string", content: "", err: false, contains: []string{}}, 23 37 {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"}}} 38 + {name: "mixed content", content: "# Title\n\nSome **bold** text and a [link](https://example.com)\n\n- List item", 39 + err: false, contains: []string{"Title", "bold", "example.com", "List item"}}, 40 + } 41 + 42 + for _, tc := range tt { 43 + t.Run(tc.name, func(t *testing.T) { 44 + defer func() { newRenderer = defaultRenderer }() 26 45 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 { 46 + result, err := renderMarkdown(tc.content) 47 + if tc.err && err == nil { 31 48 t.Fatalf("expected error, got nil") 32 49 } 33 - 34 - if err != nil { 50 + if !tc.err && err != nil { 35 51 t.Fatalf("unexpected error: %v", err) 36 52 } 37 53 38 - for _, want := range tt.contains { 39 - if strings.Contains(result, want) { 40 - continue 54 + for _, want := range tc.contains { 55 + if !strings.Contains(result, want) { 56 + t.Fatalf("result should contain %q, got:\n%s", want, result) 41 57 } 42 - t.Fatalf("result should contain %q, got:\n%s", want, result) 43 58 } 44 59 }) 45 60 } 46 61 47 62 t.Run("WordWrap", func(t *testing.T) { 63 + defer func() { newRenderer = defaultRenderer }() 48 64 text := strings.Repeat("This is a very long line that should be wrapped at 80 characters. ", 5) 49 65 result, err := renderMarkdown(text) 50 66 if err != nil { ··· 57 73 if len(cleaned) > 85 { 58 74 t.Fatalf("Line at index %d is too long (%d chars): %q", i, len(cleaned), cleaned) 59 75 } 76 + } 77 + }) 78 + 79 + t.Run("RendererCreationFails", func(t *testing.T) { 80 + newRenderer = func() (MarkdownRenderer, error) { 81 + return nil, errors.New("forced renderer creation error") 82 + } 83 + _, err := renderMarkdown("test") 84 + if err == nil || !strings.Contains(err.Error(), "failed to create markdown renderer") { 85 + t.Fatalf("expected creation error, got %v", err) 86 + } 87 + }) 88 + 89 + t.Run("RenderFails", func(t *testing.T) { 90 + newRenderer = func() (MarkdownRenderer, error) { 91 + return fakeRenderer{fail: true}, nil 92 + } 93 + _, err := renderMarkdown("test") 94 + if err == nil || !strings.Contains(err.Error(), "failed to render markdown") { 95 + t.Fatalf("expected render error, got %v", err) 60 96 } 61 97 }) 62 98 }
+186
internal/services/http_test.go
··· 1 + package services 2 + 3 + import ( 4 + "errors" 5 + "io" 6 + "testing" 7 + ) 8 + 9 + func TestHTTPFuncs(t *testing.T) { 10 + origFetch := FetchHTML 11 + origMovie := ExtractMovieMetadata 12 + origTV := ExtractTVSeriesMetadata 13 + origSeason := ExtractTVSeasonMetadata 14 + origSearch := ParseSearch 15 + 16 + defer func() { 17 + FetchHTML = origFetch 18 + ExtractMovieMetadata = origMovie 19 + ExtractTVSeriesMetadata = origTV 20 + ExtractTVSeasonMetadata = origSeason 21 + ParseSearch = origSearch 22 + }() 23 + 24 + tests := []struct { 25 + name string 26 + setup func() 27 + call func() error 28 + expectErr bool 29 + }{ 30 + { 31 + name: "FetchMovie success", 32 + setup: func() { 33 + FetchHTML = func(url string) (string, error) { 34 + return "<html>movie</html>", nil 35 + } 36 + ExtractMovieMetadata = func(r io.Reader) (*Movie, error) { 37 + return &Movie{Name: "Fake Movie"}, nil 38 + } 39 + }, 40 + call: func() error { 41 + m, err := FetchMovie("http://fake") 42 + if err != nil { 43 + return err 44 + } 45 + if m.Name != "Fake Movie" { 46 + return errors.New("unexpected movie title") 47 + } 48 + return nil 49 + }, 50 + }, 51 + { 52 + name: "FetchMovie error from FetchHTML", 53 + setup: func() { 54 + FetchHTML = func(url string) (string, error) { 55 + return "", errors.New("boom") 56 + } 57 + }, 58 + call: func() error { 59 + _, err := FetchMovie("http://fake") 60 + return err 61 + }, 62 + expectErr: true, 63 + }, 64 + { 65 + name: "FetchTVSeries success", 66 + setup: func() { 67 + FetchHTML = func(url string) (string, error) { 68 + return "<html>tv</html>", nil 69 + } 70 + ExtractTVSeriesMetadata = func(r io.Reader) (*TVSeries, error) { 71 + return &TVSeries{Name: "Fake Series"}, nil 72 + } 73 + }, 74 + call: func() error { 75 + tv, err := FetchTVSeries("http://fake") 76 + if err != nil { 77 + return err 78 + } 79 + if tv.Name != "Fake Series" { 80 + return errors.New("unexpected series name") 81 + } 82 + return nil 83 + }, 84 + }, 85 + { 86 + name: "FetchTVSeries error from FetchHTML", 87 + setup: func() { 88 + FetchHTML = func(url string) (string, error) { 89 + return "", errors.New("boom") 90 + } 91 + }, 92 + call: func() error { 93 + _, err := FetchTVSeries("http://fake") 94 + return err 95 + }, 96 + expectErr: true, 97 + }, 98 + { 99 + name: "FetchTVSeason success", 100 + setup: func() { 101 + FetchHTML = func(url string) (string, error) { 102 + return "<html>season</html>", nil 103 + } 104 + ExtractTVSeasonMetadata = func(r io.Reader) (*TVSeason, error) { 105 + return &TVSeason{SeasonNumber: 1}, nil 106 + } 107 + }, 108 + call: func() error { 109 + season, err := FetchTVSeason("http://fake") 110 + if err != nil { 111 + return err 112 + } 113 + if season.SeasonNumber != 1 { 114 + return errors.New("unexpected season number") 115 + } 116 + return nil 117 + }, 118 + }, 119 + { 120 + name: "FetchTVSeason error from FetchHTML", 121 + setup: func() { 122 + FetchHTML = func(url string) (string, error) { 123 + return "", errors.New("boom") 124 + } 125 + }, 126 + call: func() error { 127 + _, err := FetchTVSeason("http://fake") 128 + return err 129 + }, 130 + expectErr: true, 131 + }, 132 + { 133 + name: "SearchRottenTomatoes success", 134 + setup: func() { 135 + FetchHTML = func(url string) (string, error) { 136 + return "<html>search</html>", nil 137 + } 138 + ParseSearch = func(r io.Reader) ([]Media, error) { 139 + return []Media{{Title: "Fake Result"}}, nil 140 + } 141 + }, 142 + call: func() error { 143 + results, err := SearchRottenTomatoes("query") 144 + if err != nil { 145 + return err 146 + } 147 + if len(results) != 1 || results[0].Title != "Fake Result" { 148 + return errors.New("unexpected search results") 149 + } 150 + return nil 151 + }, 152 + }, 153 + { 154 + name: "SearchRottenTomatoes error from FetchHTML", 155 + setup: func() { 156 + FetchHTML = func(url string) (string, error) { 157 + return "", errors.New("boom") 158 + } 159 + }, 160 + call: func() error { 161 + _, err := SearchRottenTomatoes("query") 162 + return err 163 + }, 164 + expectErr: true, 165 + }, 166 + } 167 + 168 + for _, tc := range tests { 169 + t.Run(tc.name, func(t *testing.T) { 170 + FetchHTML = origFetch 171 + ExtractMovieMetadata = origMovie 172 + ExtractTVSeriesMetadata = origTV 173 + ExtractTVSeasonMetadata = origSeason 174 + ParseSearch = origSearch 175 + 176 + tc.setup() 177 + err := tc.call() 178 + if tc.expectErr && err == nil { 179 + t.Fatalf("expected error, got nil") 180 + } 181 + if !tc.expectErr && err != nil { 182 + t.Fatalf("unexpected error: %v", err) 183 + } 184 + }) 185 + } 186 + }
+4 -4
internal/services/media.go
··· 115 115 } 116 116 117 117 // ParseSearch parses Rotten Tomatoes search results HTML into Media entries. 118 - func ParseSearch(r io.Reader) ([]Media, error) { 118 + var ParseSearch = func(r io.Reader) ([]Media, error) { 119 119 doc, err := goquery.NewDocumentFromReader(r) 120 120 if err != nil { 121 121 return nil, err ··· 173 173 return results, nil 174 174 } 175 175 176 - func ExtractTVSeriesMetadata(r io.Reader) (*TVSeries, error) { 176 + var ExtractTVSeriesMetadata = func(r io.Reader) (*TVSeries, error) { 177 177 doc, err := goquery.NewDocumentFromReader(r) 178 178 if err != nil { 179 179 return nil, err ··· 196 196 return &series, nil 197 197 } 198 198 199 - func ExtractMovieMetadata(r io.Reader) (*Movie, error) { 199 + var ExtractMovieMetadata = func(r io.Reader) (*Movie, error) { 200 200 doc, err := goquery.NewDocumentFromReader(r) 201 201 if err != nil { 202 202 return nil, err ··· 219 219 return &movie, nil 220 220 } 221 221 222 - func ExtractTVSeasonMetadata(r io.Reader) (*TVSeason, error) { 222 + var ExtractTVSeasonMetadata = func(r io.Reader) (*TVSeason, error) { 223 223 doc, err := goquery.NewDocumentFromReader(r) 224 224 if err != nil { 225 225 return nil, err
+1 -2
internal/store/config.go
··· 41 41 } 42 42 43 43 // LoadConfig loads configuration from the config directory or NOTELEAF_CONFIG path 44 - func LoadConfig() (*Config, error) { 44 + var LoadConfig = func() (*Config, error) { 45 45 var configPath string 46 46 47 - // Check for NOTELEAF_CONFIG environment variable 48 47 if envConfigPath := os.Getenv("NOTELEAF_CONFIG"); envConfigPath != "" { 49 48 configPath = envConfigPath 50 49 } else {
+1 -1
internal/store/database.go
··· 100 100 } 101 101 102 102 // NewDatabase creates and initializes a new database connection 103 - func NewDatabase() (*Database, error) { 103 + var NewDatabase = func() (*Database, error) { 104 104 return NewDatabaseWithConfig(nil) 105 105 } 106 106