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