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

feat: added albums table feat(wip): api service scaffold feat: crud commands for tasks

+1540 -24
+66 -15
cmd/cli/commands.go
··· 32 32 } 33 33 34 34 root.AddCommand(&cobra.Command{ 35 - Use: "add [description]", 36 - Short: "Add a new task", 37 - Args: cobra.MinimumNArgs(1), 35 + Use: "add [description]", 36 + Short: "Add a new task", 37 + Aliases: []string{"create", "new"}, 38 + Args: cobra.MinimumNArgs(1), 38 39 RunE: func(cmd *cobra.Command, args []string) error { 39 - description := args[0] 40 - fmt.Printf("Adding task: %s\n", description) 41 - // TODO: Implement task creation 42 - return nil 40 + return handlers.CreateTask(cmd.Context(), args) 43 41 }, 44 42 }) 45 43 ··· 48 46 Short: "List tasks", 49 47 Aliases: []string{"ls"}, 50 48 RunE: func(cmd *cobra.Command, args []string) error { 51 - fmt.Println("Listing tasks...") 52 - // TODO: Implement task listing 49 + return handlers.ListTasks(cmd.Context(), args) 50 + }, 51 + }) 52 + 53 + root.AddCommand(&cobra.Command{ 54 + Use: "view [task-id]", 55 + Short: "View task by ID", 56 + Args: cobra.ExactArgs(1), 57 + RunE: func(cmd *cobra.Command, args []string) error { 58 + return handlers.ViewTask(cmd.Context(), args) 59 + }, 60 + }) 61 + 62 + root.AddCommand(&cobra.Command{ 63 + Use: "update [task-id] [options...]", 64 + Short: "Update task properties", 65 + Args: cobra.MinimumNArgs(1), 66 + RunE: func(cmd *cobra.Command, args []string) error { 67 + return handlers.UpdateTask(cmd.Context(), args) 68 + }, 69 + }) 70 + 71 + root.AddCommand(&cobra.Command{ 72 + Use: "delete [task-id]", 73 + Short: "Delete a task", 74 + Args: cobra.ExactArgs(1), 75 + RunE: func(cmd *cobra.Command, args []string) error { 76 + return handlers.DeleteTask(cmd.Context(), args) 77 + }, 78 + }) 79 + 80 + root.AddCommand(&cobra.Command{ 81 + Use: "projects", 82 + Short: "List projects", 83 + Aliases: []string{"proj"}, 84 + RunE: func(cmd *cobra.Command, args []string) error { 85 + fmt.Println("Listing projects...") 53 86 return nil 54 87 }, 55 88 }) 56 89 57 90 root.AddCommand(&cobra.Command{ 58 - Use: "done [task-id]", 59 - Short: "Mark task as completed", 60 - Args: cobra.ExactArgs(1), 91 + Use: "tags", 92 + Short: "List tags", 93 + Aliases: []string{"t"}, 61 94 RunE: func(cmd *cobra.Command, args []string) error { 62 - taskID := args[0] 63 - fmt.Printf("Marking task %s as done\n", taskID) 64 - // TODO: Implement task completion 95 + fmt.Println("Listing tags...") 65 96 return nil 97 + }, 98 + }) 99 + 100 + root.AddCommand(&cobra.Command{ 101 + Use: "contexts", 102 + Short: "List contexts (locations)", 103 + Aliases: []string{"loc", "ctx", "locations"}, 104 + RunE: func(cmd *cobra.Command, args []string) error { 105 + fmt.Println("Listing task contexts...") 106 + return nil 107 + }, 108 + }) 109 + 110 + root.AddCommand(&cobra.Command{ 111 + Use: "done [task-id]", 112 + Short: "Mark task as completed", 113 + Aliases: []string{"complete"}, 114 + Args: cobra.ExactArgs(1), 115 + RunE: func(cmd *cobra.Command, args []string) error { 116 + return handlers.DoneTask(cmd.Context(), args) 66 117 }, 67 118 }) 68 119
+424
cmd/handlers/tasks.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/google/uuid" 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + "github.com/stormlightlabs/noteleaf/internal/repo" 13 + "github.com/stormlightlabs/noteleaf/internal/store" 14 + ) 15 + 16 + // TaskHandler handles all task-related commands 17 + type TaskHandler struct { 18 + db *store.Database 19 + config *store.Config 20 + repos *repo.Repositories 21 + } 22 + 23 + // NewTaskHandler creates a new task handler 24 + func NewTaskHandler() (*TaskHandler, error) { 25 + db, err := store.NewDatabase() 26 + if err != nil { 27 + return nil, fmt.Errorf("failed to initialize database: %w", err) 28 + } 29 + 30 + config, err := store.LoadConfig() 31 + if err != nil { 32 + return nil, fmt.Errorf("failed to load configuration: %w", err) 33 + } 34 + 35 + repos := repo.NewRepositories(db.DB) 36 + 37 + return &TaskHandler{ 38 + db: db, 39 + config: config, 40 + repos: repos, 41 + }, nil 42 + } 43 + 44 + // Close cleans up resources 45 + func (h *TaskHandler) Close() error { 46 + return h.db.Close() 47 + } 48 + 49 + // CreateTask creates a new task 50 + func CreateTask(ctx context.Context, args []string) error { 51 + handler, err := NewTaskHandler() 52 + if err != nil { 53 + return fmt.Errorf("failed to initialize task handler: %w", err) 54 + } 55 + defer handler.Close() 56 + 57 + return handler.createTask(ctx, args) 58 + } 59 + 60 + func (h *TaskHandler) createTask(ctx context.Context, args []string) error { 61 + if len(args) < 1 { 62 + return fmt.Errorf("task description required") 63 + } 64 + 65 + description := strings.Join(args, " ") 66 + 67 + task := &models.Task{ 68 + UUID: uuid.New().String(), 69 + Description: description, 70 + Status: "pending", 71 + } 72 + 73 + id, err := h.repos.Tasks.Create(ctx, task) 74 + if err != nil { 75 + return fmt.Errorf("failed to create task: %w", err) 76 + } 77 + 78 + fmt.Printf("Task created (ID: %d, UUID: %s): %s\n", id, task.UUID, task.Description) 79 + return nil 80 + } 81 + 82 + // ListTasks lists all tasks with optional filtering 83 + func ListTasks(ctx context.Context, args []string) error { 84 + handler, err := NewTaskHandler() 85 + if err != nil { 86 + return fmt.Errorf("failed to initialize task handler: %w", err) 87 + } 88 + defer handler.Close() 89 + 90 + return handler.listTasks(ctx, args) 91 + } 92 + 93 + func (h *TaskHandler) listTasks(ctx context.Context, args []string) error { 94 + opts := repo.TaskListOptions{} 95 + 96 + // Parse arguments for filtering 97 + for i, arg := range args { 98 + switch { 99 + case arg == "--status" && i+1 < len(args): 100 + opts.Status = args[i+1] 101 + case arg == "--priority" && i+1 < len(args): 102 + opts.Priority = args[i+1] 103 + case arg == "--project" && i+1 < len(args): 104 + opts.Project = args[i+1] 105 + case arg == "--search" && i+1 < len(args): 106 + opts.Search = args[i+1] 107 + case arg == "--limit" && i+1 < len(args): 108 + if limit, err := strconv.Atoi(args[i+1]); err == nil { 109 + opts.Limit = limit 110 + } 111 + } 112 + } 113 + 114 + // Default to showing pending tasks only 115 + if opts.Status == "" { 116 + opts.Status = "pending" 117 + } 118 + 119 + tasks, err := h.repos.Tasks.List(ctx, opts) 120 + if err != nil { 121 + return fmt.Errorf("failed to list tasks: %w", err) 122 + } 123 + 124 + if len(tasks) == 0 { 125 + fmt.Printf("No tasks found matching criteria\n") 126 + return nil 127 + } 128 + 129 + fmt.Printf("Found %d task(s):\n\n", len(tasks)) 130 + for _, task := range tasks { 131 + h.printTask(task) 132 + } 133 + 134 + return nil 135 + } 136 + 137 + // UpdateTask updates an existing task 138 + func UpdateTask(ctx context.Context, args []string) error { 139 + handler, err := NewTaskHandler() 140 + if err != nil { 141 + return fmt.Errorf("failed to initialize task handler: %w", err) 142 + } 143 + defer handler.Close() 144 + 145 + return handler.updateTask(ctx, args) 146 + } 147 + 148 + func (h *TaskHandler) updateTask(ctx context.Context, args []string) error { 149 + if len(args) < 1 { 150 + return fmt.Errorf("task ID required") 151 + } 152 + 153 + // Parse task ID (could be numeric ID or UUID) 154 + taskID := args[0] 155 + var task *models.Task 156 + var err error 157 + 158 + if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 159 + // Numeric ID 160 + task, err = h.repos.Tasks.Get(ctx, id) 161 + } else { 162 + // Assume UUID 163 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 164 + } 165 + 166 + if err != nil { 167 + return fmt.Errorf("failed to find task: %w", err) 168 + } 169 + 170 + // Parse update arguments 171 + for i := 1; i < len(args); i++ { 172 + arg := args[i] 173 + switch { 174 + case arg == "--description" && i+1 < len(args): 175 + task.Description = args[i+1] 176 + i++ 177 + case arg == "--status" && i+1 < len(args): 178 + task.Status = args[i+1] 179 + i++ 180 + case arg == "--priority" && i+1 < len(args): 181 + task.Priority = args[i+1] 182 + i++ 183 + case arg == "--project" && i+1 < len(args): 184 + task.Project = args[i+1] 185 + i++ 186 + case arg == "--due" && i+1 < len(args): 187 + if dueTime, err := time.Parse("2006-01-02", args[i+1]); err == nil { 188 + task.Due = &dueTime 189 + } 190 + i++ 191 + case strings.HasPrefix(arg, "--add-tag="): 192 + tag := strings.TrimPrefix(arg, "--add-tag=") 193 + if !contains(task.Tags, tag) { 194 + task.Tags = append(task.Tags, tag) 195 + } 196 + case strings.HasPrefix(arg, "--remove-tag="): 197 + tag := strings.TrimPrefix(arg, "--remove-tag=") 198 + task.Tags = removeString(task.Tags, tag) 199 + } 200 + } 201 + 202 + err = h.repos.Tasks.Update(ctx, task) 203 + if err != nil { 204 + return fmt.Errorf("failed to update task: %w", err) 205 + } 206 + 207 + fmt.Printf("Task updated (ID: %d): %s\n", task.ID, task.Description) 208 + return nil 209 + } 210 + 211 + // DeleteTask deletes a task 212 + func DeleteTask(ctx context.Context, args []string) error { 213 + handler, err := NewTaskHandler() 214 + if err != nil { 215 + return fmt.Errorf("failed to initialize task handler: %w", err) 216 + } 217 + defer handler.Close() 218 + 219 + return handler.deleteTask(ctx, args) 220 + } 221 + 222 + func (h *TaskHandler) deleteTask(ctx context.Context, args []string) error { 223 + if len(args) < 1 { 224 + return fmt.Errorf("task ID required") 225 + } 226 + 227 + taskID := args[0] 228 + var task *models.Task 229 + var err error 230 + 231 + if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 232 + // Get task first to show what's being deleted 233 + task, err = h.repos.Tasks.Get(ctx, id) 234 + if err != nil { 235 + return fmt.Errorf("failed to find task: %w", err) 236 + } 237 + 238 + err = h.repos.Tasks.Delete(ctx, id) 239 + } else { 240 + // Get by UUID first 241 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 242 + if err != nil { 243 + return fmt.Errorf("failed to find task: %w", err) 244 + } 245 + 246 + err = h.repos.Tasks.Delete(ctx, task.ID) 247 + } 248 + 249 + if err != nil { 250 + return fmt.Errorf("failed to delete task: %w", err) 251 + } 252 + 253 + fmt.Printf("Task deleted (ID: %d): %s\n", task.ID, task.Description) 254 + return nil 255 + } 256 + 257 + // ViewTask displays a single task 258 + func ViewTask(ctx context.Context, args []string) error { 259 + handler, err := NewTaskHandler() 260 + if err != nil { 261 + return fmt.Errorf("failed to initialize task handler: %w", err) 262 + } 263 + defer handler.Close() 264 + 265 + return handler.viewTask(ctx, args) 266 + } 267 + 268 + func (h *TaskHandler) viewTask(ctx context.Context, args []string) error { 269 + if len(args) < 1 { 270 + return fmt.Errorf("task ID required") 271 + } 272 + 273 + taskID := args[0] 274 + var task *models.Task 275 + var err error 276 + 277 + if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 278 + task, err = h.repos.Tasks.Get(ctx, id) 279 + } else { 280 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 281 + } 282 + 283 + if err != nil { 284 + return fmt.Errorf("failed to find task: %w", err) 285 + } 286 + 287 + h.printTaskDetail(task) 288 + return nil 289 + } 290 + 291 + // DoneTask marks a task as completed 292 + func DoneTask(ctx context.Context, args []string) error { 293 + handler, err := NewTaskHandler() 294 + if err != nil { 295 + return fmt.Errorf("failed to initialize task handler: %w", err) 296 + } 297 + defer handler.Close() 298 + 299 + return handler.doneTask(ctx, args) 300 + } 301 + 302 + func (h *TaskHandler) doneTask(ctx context.Context, args []string) error { 303 + if len(args) < 1 { 304 + return fmt.Errorf("task ID required") 305 + } 306 + 307 + taskID := args[0] 308 + var task *models.Task 309 + var err error 310 + 311 + if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 312 + task, err = h.repos.Tasks.Get(ctx, id) 313 + } else { 314 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 315 + } 316 + 317 + if err != nil { 318 + return fmt.Errorf("failed to find task: %w", err) 319 + } 320 + 321 + if task.Status == "completed" { 322 + fmt.Printf("Task already completed: %s\n", task.Description) 323 + return nil 324 + } 325 + 326 + now := time.Now() 327 + task.Status = "completed" 328 + task.End = &now 329 + 330 + err = h.repos.Tasks.Update(ctx, task) 331 + if err != nil { 332 + return fmt.Errorf("failed to update task: %w", err) 333 + } 334 + 335 + fmt.Printf("Task completed (ID: %d): %s\n", task.ID, task.Description) 336 + return nil 337 + } 338 + 339 + // Helper functions 340 + func (h *TaskHandler) printTask(task *models.Task) { 341 + fmt.Printf("[%d] %s", task.ID, task.Description) 342 + 343 + if task.Status != "pending" { 344 + fmt.Printf(" (%s)", task.Status) 345 + } 346 + 347 + if task.Priority != "" { 348 + fmt.Printf(" [%s]", task.Priority) 349 + } 350 + 351 + if task.Project != "" { 352 + fmt.Printf(" +%s", task.Project) 353 + } 354 + 355 + if len(task.Tags) > 0 { 356 + fmt.Printf(" @%s", strings.Join(task.Tags, " @")) 357 + } 358 + 359 + if task.Due != nil { 360 + fmt.Printf(" (due: %s)", task.Due.Format("2006-01-02")) 361 + } 362 + 363 + fmt.Println() 364 + } 365 + 366 + func (h *TaskHandler) printTaskDetail(task *models.Task) { 367 + fmt.Printf("Task ID: %d\n", task.ID) 368 + fmt.Printf("UUID: %s\n", task.UUID) 369 + fmt.Printf("Description: %s\n", task.Description) 370 + fmt.Printf("Status: %s\n", task.Status) 371 + 372 + if task.Priority != "" { 373 + fmt.Printf("Priority: %s\n", task.Priority) 374 + } 375 + 376 + if task.Project != "" { 377 + fmt.Printf("Project: %s\n", task.Project) 378 + } 379 + 380 + if len(task.Tags) > 0 { 381 + fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) 382 + } 383 + 384 + if task.Due != nil { 385 + fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02 15:04")) 386 + } 387 + 388 + fmt.Printf("Created: %s\n", task.Entry.Format("2006-01-02 15:04")) 389 + fmt.Printf("Modified: %s\n", task.Modified.Format("2006-01-02 15:04")) 390 + 391 + if task.Start != nil { 392 + fmt.Printf("Started: %s\n", task.Start.Format("2006-01-02 15:04")) 393 + } 394 + 395 + if task.End != nil { 396 + fmt.Printf("Completed: %s\n", task.End.Format("2006-01-02 15:04")) 397 + } 398 + 399 + if len(task.Annotations) > 0 { 400 + fmt.Printf("Annotations:\n") 401 + for _, annotation := range task.Annotations { 402 + fmt.Printf(" - %s\n", annotation) 403 + } 404 + } 405 + } 406 + 407 + func contains(slice []string, item string) bool { 408 + for _, s := range slice { 409 + if s == item { 410 + return true 411 + } 412 + } 413 + return false 414 + } 415 + 416 + func removeString(slice []string, item string) []string { 417 + var result []string 418 + for _, s := range slice { 419 + if s != item { 420 + result = append(result, s) 421 + } 422 + } 423 + return result 424 + }
+780
cmd/handlers/tasks_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "runtime" 7 + "strconv" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "github.com/google/uuid" 13 + "github.com/stormlightlabs/noteleaf/internal/models" 14 + ) 15 + 16 + func setupTaskTest(t *testing.T) (string, func()) { 17 + tempDir, err := os.MkdirTemp("", "noteleaf-task-test-*") 18 + if err != nil { 19 + t.Fatalf("Failed to create temp dir: %v", err) 20 + } 21 + 22 + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") 23 + os.Setenv("XDG_CONFIG_HOME", tempDir) 24 + 25 + cleanup := func() { 26 + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) 27 + os.RemoveAll(tempDir) 28 + } 29 + 30 + ctx := context.Background() 31 + err = Setup(ctx, []string{}) 32 + if err != nil { 33 + cleanup() 34 + t.Fatalf("Failed to setup database: %v", err) 35 + } 36 + 37 + return tempDir, cleanup 38 + } 39 + 40 + func TestTaskHandler_NewTaskHandler(t *testing.T) { 41 + t.Run("creates handler successfully", func(t *testing.T) { 42 + _, cleanup := setupTaskTest(t) 43 + defer cleanup() 44 + 45 + handler, err := NewTaskHandler() 46 + if err != nil { 47 + t.Errorf("NewTaskHandler failed: %v", err) 48 + } 49 + if handler == nil { 50 + t.Error("Handler should not be nil") 51 + } 52 + defer handler.Close() 53 + 54 + if handler.db == nil { 55 + t.Error("Handler database should not be nil") 56 + } 57 + if handler.config == nil { 58 + t.Error("Handler config should not be nil") 59 + } 60 + if handler.repos == nil { 61 + t.Error("Handler repos should not be nil") 62 + } 63 + }) 64 + 65 + t.Run("handles database initialization error", func(t *testing.T) { 66 + originalXDG := os.Getenv("XDG_CONFIG_HOME") 67 + originalHome := os.Getenv("HOME") 68 + 69 + if runtime.GOOS == "windows" { 70 + originalAppData := os.Getenv("APPDATA") 71 + os.Unsetenv("APPDATA") 72 + defer os.Setenv("APPDATA", originalAppData) 73 + } else { 74 + os.Unsetenv("XDG_CONFIG_HOME") 75 + os.Unsetenv("HOME") 76 + defer os.Setenv("XDG_CONFIG_HOME", originalXDG) 77 + defer os.Setenv("HOME", originalHome) 78 + } 79 + 80 + handler, err := NewTaskHandler() 81 + if err == nil { 82 + if handler != nil { 83 + handler.Close() 84 + } 85 + t.Error("Expected error when database initialization fails") 86 + } 87 + }) 88 + } 89 + 90 + func TestCreateTask(t *testing.T) { 91 + _, cleanup := setupTaskTest(t) 92 + defer cleanup() 93 + 94 + t.Run("creates task successfully", func(t *testing.T) { 95 + ctx := context.Background() 96 + args := []string{"Buy groceries", "and", "cook dinner"} 97 + 98 + err := CreateTask(ctx, args) 99 + if err != nil { 100 + t.Errorf("CreateTask failed: %v", err) 101 + } 102 + 103 + // Verify task was created by listing tasks 104 + handler, err := NewTaskHandler() 105 + if err != nil { 106 + t.Fatalf("Failed to create handler: %v", err) 107 + } 108 + defer handler.Close() 109 + 110 + tasks, err := handler.repos.Tasks.GetPending(ctx) 111 + if err != nil { 112 + t.Fatalf("Failed to get pending tasks: %v", err) 113 + } 114 + 115 + if len(tasks) != 1 { 116 + t.Errorf("Expected 1 task, got %d", len(tasks)) 117 + } 118 + 119 + task := tasks[0] 120 + expectedDesc := "Buy groceries and cook dinner" 121 + if task.Description != expectedDesc { 122 + t.Errorf("Expected description '%s', got '%s'", expectedDesc, task.Description) 123 + } 124 + 125 + if task.Status != "pending" { 126 + t.Errorf("Expected status 'pending', got '%s'", task.Status) 127 + } 128 + 129 + if task.UUID == "" { 130 + t.Error("Task should have a UUID") 131 + } 132 + }) 133 + 134 + t.Run("fails with empty description", func(t *testing.T) { 135 + ctx := context.Background() 136 + args := []string{} 137 + 138 + err := CreateTask(ctx, args) 139 + if err == nil { 140 + t.Error("Expected error for empty description") 141 + } 142 + 143 + if !strings.Contains(err.Error(), "task description required") { 144 + t.Errorf("Expected error about required description, got: %v", err) 145 + } 146 + }) 147 + } 148 + 149 + func TestListTasks(t *testing.T) { 150 + _, cleanup := setupTaskTest(t) 151 + defer cleanup() 152 + 153 + ctx := context.Background() 154 + 155 + // Create test tasks 156 + handler, err := NewTaskHandler() 157 + if err != nil { 158 + t.Fatalf("Failed to create handler: %v", err) 159 + } 160 + defer handler.Close() 161 + 162 + // Create a pending task 163 + task1 := &models.Task{ 164 + UUID: uuid.New().String(), 165 + Description: "Task 1", 166 + Status: "pending", 167 + Priority: "A", 168 + Project: "work", 169 + } 170 + _, err = handler.repos.Tasks.Create(ctx, task1) 171 + if err != nil { 172 + t.Fatalf("Failed to create task1: %v", err) 173 + } 174 + 175 + // Create a completed task 176 + task2 := &models.Task{ 177 + UUID: uuid.New().String(), 178 + Description: "Task 2", 179 + Status: "completed", 180 + } 181 + _, err = handler.repos.Tasks.Create(ctx, task2) 182 + if err != nil { 183 + t.Fatalf("Failed to create task2: %v", err) 184 + } 185 + 186 + t.Run("lists pending tasks by default", func(t *testing.T) { 187 + args := []string{} 188 + 189 + err := ListTasks(ctx, args) 190 + if err != nil { 191 + t.Errorf("ListTasks failed: %v", err) 192 + } 193 + }) 194 + 195 + t.Run("filters by status", func(t *testing.T) { 196 + args := []string{"--status", "completed"} 197 + 198 + err := ListTasks(ctx, args) 199 + if err != nil { 200 + t.Errorf("ListTasks with status filter failed: %v", err) 201 + } 202 + }) 203 + 204 + t.Run("filters by priority", func(t *testing.T) { 205 + args := []string{"--priority", "A"} 206 + 207 + err := ListTasks(ctx, args) 208 + if err != nil { 209 + t.Errorf("ListTasks with priority filter failed: %v", err) 210 + } 211 + }) 212 + 213 + t.Run("filters by project", func(t *testing.T) { 214 + args := []string{"--project", "work"} 215 + 216 + err := ListTasks(ctx, args) 217 + if err != nil { 218 + t.Errorf("ListTasks with project filter failed: %v", err) 219 + } 220 + }) 221 + 222 + t.Run("searches tasks", func(t *testing.T) { 223 + args := []string{"--search", "Task"} 224 + 225 + err := ListTasks(ctx, args) 226 + if err != nil { 227 + t.Errorf("ListTasks with search failed: %v", err) 228 + } 229 + }) 230 + 231 + t.Run("limits results", func(t *testing.T) { 232 + args := []string{"--limit", "1"} 233 + 234 + err := ListTasks(ctx, args) 235 + if err != nil { 236 + t.Errorf("ListTasks with limit failed: %v", err) 237 + } 238 + }) 239 + } 240 + 241 + func TestUpdateTask(t *testing.T) { 242 + _, cleanup := setupTaskTest(t) 243 + defer cleanup() 244 + 245 + ctx := context.Background() 246 + 247 + // Create test task 248 + handler, err := NewTaskHandler() 249 + if err != nil { 250 + t.Fatalf("Failed to create handler: %v", err) 251 + } 252 + defer handler.Close() 253 + 254 + task := &models.Task{ 255 + UUID: uuid.New().String(), 256 + Description: "Original description", 257 + Status: "pending", 258 + } 259 + id, err := handler.repos.Tasks.Create(ctx, task) 260 + if err != nil { 261 + t.Fatalf("Failed to create task: %v", err) 262 + } 263 + 264 + t.Run("updates task by ID", func(t *testing.T) { 265 + args := []string{strconv.FormatInt(id, 10), "--description", "Updated description"} 266 + 267 + err := UpdateTask(ctx, args) 268 + if err != nil { 269 + t.Errorf("UpdateTask failed: %v", err) 270 + } 271 + 272 + // Verify update 273 + updatedTask, err := handler.repos.Tasks.Get(ctx, id) 274 + if err != nil { 275 + t.Fatalf("Failed to get updated task: %v", err) 276 + } 277 + 278 + if updatedTask.Description != "Updated description" { 279 + t.Errorf("Expected description 'Updated description', got '%s'", updatedTask.Description) 280 + } 281 + }) 282 + 283 + t.Run("updates task by UUID", func(t *testing.T) { 284 + args := []string{task.UUID, "--status", "completed"} 285 + 286 + err := UpdateTask(ctx, args) 287 + if err != nil { 288 + t.Errorf("UpdateTask by UUID failed: %v", err) 289 + } 290 + 291 + // Verify update 292 + updatedTask, err := handler.repos.Tasks.GetByUUID(ctx, task.UUID) 293 + if err != nil { 294 + t.Fatalf("Failed to get updated task by UUID: %v", err) 295 + } 296 + 297 + if updatedTask.Status != "completed" { 298 + t.Errorf("Expected status 'completed', got '%s'", updatedTask.Status) 299 + } 300 + }) 301 + 302 + t.Run("updates multiple fields", func(t *testing.T) { 303 + args := []string{ 304 + strconv.FormatInt(id, 10), 305 + "--description", "Multiple updates", 306 + "--priority", "B", 307 + "--project", "test", 308 + "--due", "2024-12-31", 309 + } 310 + 311 + err := UpdateTask(ctx, args) 312 + if err != nil { 313 + t.Errorf("UpdateTask with multiple fields failed: %v", err) 314 + } 315 + 316 + // Verify all updates 317 + updatedTask, err := handler.repos.Tasks.Get(ctx, id) 318 + if err != nil { 319 + t.Fatalf("Failed to get updated task: %v", err) 320 + } 321 + 322 + if updatedTask.Description != "Multiple updates" { 323 + t.Errorf("Expected description 'Multiple updates', got '%s'", updatedTask.Description) 324 + } 325 + if updatedTask.Priority != "B" { 326 + t.Errorf("Expected priority 'B', got '%s'", updatedTask.Priority) 327 + } 328 + if updatedTask.Project != "test" { 329 + t.Errorf("Expected project 'test', got '%s'", updatedTask.Project) 330 + } 331 + if updatedTask.Due == nil { 332 + t.Error("Expected due date to be set") 333 + } 334 + }) 335 + 336 + t.Run("adds and removes tags", func(t *testing.T) { 337 + args := []string{ 338 + strconv.FormatInt(id, 10), 339 + "--add-tag=work", 340 + "--add-tag=urgent", 341 + } 342 + 343 + err := UpdateTask(ctx, args) 344 + if err != nil { 345 + t.Errorf("UpdateTask with add tags failed: %v", err) 346 + } 347 + 348 + // Verify tags added 349 + updatedTask, err := handler.repos.Tasks.Get(ctx, id) 350 + if err != nil { 351 + t.Fatalf("Failed to get updated task: %v", err) 352 + } 353 + 354 + if len(updatedTask.Tags) != 2 { 355 + t.Errorf("Expected 2 tags, got %d", len(updatedTask.Tags)) 356 + } 357 + 358 + // Remove a tag 359 + args = []string{ 360 + strconv.FormatInt(id, 10), 361 + "--remove-tag=urgent", 362 + } 363 + 364 + err = UpdateTask(ctx, args) 365 + if err != nil { 366 + t.Errorf("UpdateTask with remove tag failed: %v", err) 367 + } 368 + 369 + // Verify tag removed 370 + updatedTask, err = handler.repos.Tasks.Get(ctx, id) 371 + if err != nil { 372 + t.Fatalf("Failed to get updated task: %v", err) 373 + } 374 + 375 + if len(updatedTask.Tags) != 1 { 376 + t.Errorf("Expected 1 tag after removal, got %d", len(updatedTask.Tags)) 377 + } 378 + 379 + if updatedTask.Tags[0] != "work" { 380 + t.Errorf("Expected remaining tag 'work', got '%s'", updatedTask.Tags[0]) 381 + } 382 + }) 383 + 384 + t.Run("fails with missing task ID", func(t *testing.T) { 385 + args := []string{} 386 + 387 + err := UpdateTask(ctx, args) 388 + if err == nil { 389 + t.Error("Expected error for missing task ID") 390 + } 391 + 392 + if !strings.Contains(err.Error(), "task ID required") { 393 + t.Errorf("Expected error about required task ID, got: %v", err) 394 + } 395 + }) 396 + 397 + t.Run("fails with invalid task ID", func(t *testing.T) { 398 + args := []string{"99999", "--description", "test"} 399 + 400 + err := UpdateTask(ctx, args) 401 + if err == nil { 402 + t.Error("Expected error for invalid task ID") 403 + } 404 + 405 + if !strings.Contains(err.Error(), "failed to find task") { 406 + t.Errorf("Expected error about task not found, got: %v", err) 407 + } 408 + }) 409 + } 410 + 411 + func TestDeleteTask(t *testing.T) { 412 + _, cleanup := setupTaskTest(t) 413 + defer cleanup() 414 + 415 + ctx := context.Background() 416 + 417 + // Create test task 418 + handler, err := NewTaskHandler() 419 + if err != nil { 420 + t.Fatalf("Failed to create handler: %v", err) 421 + } 422 + defer handler.Close() 423 + 424 + task := &models.Task{ 425 + UUID: uuid.New().String(), 426 + Description: "Task to delete", 427 + Status: "pending", 428 + } 429 + id, err := handler.repos.Tasks.Create(ctx, task) 430 + if err != nil { 431 + t.Fatalf("Failed to create task: %v", err) 432 + } 433 + 434 + t.Run("deletes task by ID", func(t *testing.T) { 435 + args := []string{strconv.FormatInt(id, 10)} 436 + 437 + err := DeleteTask(ctx, args) 438 + if err != nil { 439 + t.Errorf("DeleteTask failed: %v", err) 440 + } 441 + 442 + // Verify task was deleted 443 + _, err = handler.repos.Tasks.Get(ctx, id) 444 + if err == nil { 445 + t.Error("Expected error when getting deleted task") 446 + } 447 + }) 448 + 449 + t.Run("deletes task by UUID", func(t *testing.T) { 450 + // Create another task to delete by UUID 451 + task2 := &models.Task{ 452 + UUID: uuid.New().String(), 453 + Description: "Task to delete by UUID", 454 + Status: "pending", 455 + } 456 + _, err := handler.repos.Tasks.Create(ctx, task2) 457 + if err != nil { 458 + t.Fatalf("Failed to create task2: %v", err) 459 + } 460 + 461 + args := []string{task2.UUID} 462 + 463 + err = DeleteTask(ctx, args) 464 + if err != nil { 465 + t.Errorf("DeleteTask by UUID failed: %v", err) 466 + } 467 + 468 + // Verify task was deleted 469 + _, err = handler.repos.Tasks.GetByUUID(ctx, task2.UUID) 470 + if err == nil { 471 + t.Error("Expected error when getting deleted task by UUID") 472 + } 473 + }) 474 + 475 + t.Run("fails with missing task ID", func(t *testing.T) { 476 + args := []string{} 477 + 478 + err := DeleteTask(ctx, args) 479 + if err == nil { 480 + t.Error("Expected error for missing task ID") 481 + } 482 + 483 + if !strings.Contains(err.Error(), "task ID required") { 484 + t.Errorf("Expected error about required task ID, got: %v", err) 485 + } 486 + }) 487 + 488 + t.Run("fails with invalid task ID", func(t *testing.T) { 489 + args := []string{"99999"} 490 + 491 + err := DeleteTask(ctx, args) 492 + if err == nil { 493 + t.Error("Expected error for invalid task ID") 494 + } 495 + 496 + if !strings.Contains(err.Error(), "failed to find task") { 497 + t.Errorf("Expected error about task not found, got: %v", err) 498 + } 499 + }) 500 + } 501 + 502 + func TestViewTask(t *testing.T) { 503 + _, cleanup := setupTaskTest(t) 504 + defer cleanup() 505 + 506 + ctx := context.Background() 507 + 508 + // Create test task 509 + handler, err := NewTaskHandler() 510 + if err != nil { 511 + t.Fatalf("Failed to create handler: %v", err) 512 + } 513 + defer handler.Close() 514 + 515 + now := time.Now() 516 + task := &models.Task{ 517 + UUID: uuid.New().String(), 518 + Description: "Task to view", 519 + Status: "pending", 520 + Priority: "A", 521 + Project: "test", 522 + Tags: []string{"work", "important"}, 523 + Entry: now, 524 + Modified: now, 525 + } 526 + id, err := handler.repos.Tasks.Create(ctx, task) 527 + if err != nil { 528 + t.Fatalf("Failed to create task: %v", err) 529 + } 530 + 531 + t.Run("views task by ID", func(t *testing.T) { 532 + args := []string{strconv.FormatInt(id, 10)} 533 + 534 + err := ViewTask(ctx, args) 535 + if err != nil { 536 + t.Errorf("ViewTask failed: %v", err) 537 + } 538 + }) 539 + 540 + t.Run("views task by UUID", func(t *testing.T) { 541 + args := []string{task.UUID} 542 + 543 + err := ViewTask(ctx, args) 544 + if err != nil { 545 + t.Errorf("ViewTask by UUID failed: %v", err) 546 + } 547 + }) 548 + 549 + t.Run("fails with missing task ID", func(t *testing.T) { 550 + args := []string{} 551 + 552 + err := ViewTask(ctx, args) 553 + if err == nil { 554 + t.Error("Expected error for missing task ID") 555 + } 556 + 557 + if !strings.Contains(err.Error(), "task ID required") { 558 + t.Errorf("Expected error about required task ID, got: %v", err) 559 + } 560 + }) 561 + 562 + t.Run("fails with invalid task ID", func(t *testing.T) { 563 + args := []string{"99999"} 564 + 565 + err := ViewTask(ctx, args) 566 + if err == nil { 567 + t.Error("Expected error for invalid task ID") 568 + } 569 + 570 + if !strings.Contains(err.Error(), "failed to find task") { 571 + t.Errorf("Expected error about task not found, got: %v", err) 572 + } 573 + }) 574 + } 575 + 576 + func TestDoneTask(t *testing.T) { 577 + _, cleanup := setupTaskTest(t) 578 + defer cleanup() 579 + 580 + ctx := context.Background() 581 + 582 + // Create test task 583 + handler, err := NewTaskHandler() 584 + if err != nil { 585 + t.Fatalf("Failed to create handler: %v", err) 586 + } 587 + defer handler.Close() 588 + 589 + task := &models.Task{ 590 + UUID: uuid.New().String(), 591 + Description: "Task to complete", 592 + Status: "pending", 593 + } 594 + id, err := handler.repos.Tasks.Create(ctx, task) 595 + if err != nil { 596 + t.Fatalf("Failed to create task: %v", err) 597 + } 598 + 599 + t.Run("marks task as done by ID", func(t *testing.T) { 600 + args := []string{strconv.FormatInt(id, 10)} 601 + 602 + err := DoneTask(ctx, args) 603 + if err != nil { 604 + t.Errorf("DoneTask failed: %v", err) 605 + } 606 + 607 + // Verify task was marked as completed 608 + completedTask, err := handler.repos.Tasks.Get(ctx, id) 609 + if err != nil { 610 + t.Fatalf("Failed to get completed task: %v", err) 611 + } 612 + 613 + if completedTask.Status != "completed" { 614 + t.Errorf("Expected status 'completed', got '%s'", completedTask.Status) 615 + } 616 + 617 + if completedTask.End == nil { 618 + t.Error("Expected end time to be set") 619 + } 620 + }) 621 + 622 + t.Run("handles already completed task", func(t *testing.T) { 623 + // Create another task and complete it first 624 + task2 := &models.Task{ 625 + UUID: uuid.New().String(), 626 + Description: "Already completed task", 627 + Status: "completed", 628 + } 629 + id2, err := handler.repos.Tasks.Create(ctx, task2) 630 + if err != nil { 631 + t.Fatalf("Failed to create task2: %v", err) 632 + } 633 + 634 + args := []string{strconv.FormatInt(id2, 10)} 635 + 636 + err = DoneTask(ctx, args) 637 + if err != nil { 638 + t.Errorf("DoneTask on completed task failed: %v", err) 639 + } 640 + }) 641 + 642 + t.Run("marks task as done by UUID", func(t *testing.T) { 643 + // Create another pending task 644 + task3 := &models.Task{ 645 + UUID: uuid.New().String(), 646 + Description: "Task to complete by UUID", 647 + Status: "pending", 648 + } 649 + _, err := handler.repos.Tasks.Create(ctx, task3) 650 + if err != nil { 651 + t.Fatalf("Failed to create task3: %v", err) 652 + } 653 + 654 + args := []string{task3.UUID} 655 + 656 + err = DoneTask(ctx, args) 657 + if err != nil { 658 + t.Errorf("DoneTask by UUID failed: %v", err) 659 + } 660 + 661 + // Verify task was marked as completed 662 + completedTask, err := handler.repos.Tasks.GetByUUID(ctx, task3.UUID) 663 + if err != nil { 664 + t.Fatalf("Failed to get completed task by UUID: %v", err) 665 + } 666 + 667 + if completedTask.Status != "completed" { 668 + t.Errorf("Expected status 'completed', got '%s'", completedTask.Status) 669 + } 670 + 671 + if completedTask.End == nil { 672 + t.Error("Expected end time to be set") 673 + } 674 + }) 675 + 676 + t.Run("fails with missing task ID", func(t *testing.T) { 677 + args := []string{} 678 + 679 + err := DoneTask(ctx, args) 680 + if err == nil { 681 + t.Error("Expected error for missing task ID") 682 + } 683 + 684 + if !strings.Contains(err.Error(), "task ID required") { 685 + t.Errorf("Expected error about required task ID, got: %v", err) 686 + } 687 + }) 688 + 689 + t.Run("fails with invalid task ID", func(t *testing.T) { 690 + args := []string{"99999"} 691 + 692 + err := DoneTask(ctx, args) 693 + if err == nil { 694 + t.Error("Expected error for invalid task ID") 695 + } 696 + 697 + if !strings.Contains(err.Error(), "failed to find task") { 698 + t.Errorf("Expected error about task not found, got: %v", err) 699 + } 700 + }) 701 + } 702 + 703 + func TestHelperFunctions(t *testing.T) { 704 + t.Run("contains function", func(t *testing.T) { 705 + slice := []string{"a", "b", "c"} 706 + 707 + if !contains(slice, "b") { 708 + t.Error("Expected contains to return true for existing item") 709 + } 710 + 711 + if contains(slice, "d") { 712 + t.Error("Expected contains to return false for non-existing item") 713 + } 714 + }) 715 + 716 + t.Run("removeString function", func(t *testing.T) { 717 + slice := []string{"a", "b", "c", "b"} 718 + result := removeString(slice, "b") 719 + 720 + if len(result) != 2 { 721 + t.Errorf("Expected 2 items after removing 'b', got %d", len(result)) 722 + } 723 + 724 + if contains(result, "b") { 725 + t.Error("Expected 'b' to be removed from slice") 726 + } 727 + 728 + if !contains(result, "a") || !contains(result, "c") { 729 + t.Error("Expected 'a' and 'c' to remain in slice") 730 + } 731 + }) 732 + } 733 + 734 + func TestPrintFunctions(t *testing.T) { 735 + _, cleanup := setupTaskTest(t) 736 + defer cleanup() 737 + 738 + handler, err := NewTaskHandler() 739 + if err != nil { 740 + t.Fatalf("Failed to create handler: %v", err) 741 + } 742 + defer handler.Close() 743 + 744 + now := time.Now() 745 + due := now.Add(24 * time.Hour) 746 + 747 + task := &models.Task{ 748 + ID: 1, 749 + UUID: uuid.New().String(), 750 + Description: "Test task", 751 + Status: "pending", 752 + Priority: "A", 753 + Project: "test", 754 + Tags: []string{"work", "urgent"}, 755 + Due: &due, 756 + Entry: now, 757 + Modified: now, 758 + } 759 + 760 + // Test that print functions don't panic 761 + t.Run("printTask doesn't panic", func(t *testing.T) { 762 + defer func() { 763 + if r := recover(); r != nil { 764 + t.Errorf("printTask panicked: %v", r) 765 + } 766 + }() 767 + 768 + handler.printTask(task) 769 + }) 770 + 771 + t.Run("printTaskDetail doesn't panic", func(t *testing.T) { 772 + defer func() { 773 + if r := recover(); r != nil { 774 + t.Errorf("printTaskDetail panicked: %v", r) 775 + } 776 + }() 777 + 778 + handler.printTaskDetail(task) 779 + }) 780 + }
+51 -6
internal/models/models.go
··· 9 9 type Model interface { 10 10 // GetID returns the primary key identifier 11 11 GetID() int64 12 - 13 12 // SetID sets the primary key identifier 14 13 SetID(id int64) 15 - 16 14 // GetTableName returns the database table name for this model 17 15 GetTableName() string 18 - 19 16 // GetCreatedAt returns when the model was created 20 17 GetCreatedAt() time.Time 21 - 22 18 // SetCreatedAt sets when the model was created 23 19 SetCreatedAt(t time.Time) 24 - 25 20 // GetUpdatedAt returns when the model was last updated 26 21 GetUpdatedAt() time.Time 27 - 28 22 // SetUpdatedAt sets when the model was last updated 29 23 SetUpdatedAt(t time.Time) 30 24 } ··· 104 98 Created time.Time `json:"created"` 105 99 Modified time.Time `json:"modified"` 106 100 FilePath string `json:"file_path,omitempty"` 101 + } 102 + 103 + // Album represents a music album 104 + type Album struct { 105 + ID int64 `json:"id"` 106 + Title string `json:"title"` 107 + Artist string `json:"artist"` 108 + Genre string `json:"genre,omitempty"` 109 + ReleaseYear int `json:"release_year,omitempty"` 110 + Tracks []string `json:"tracks,omitempty"` 111 + DurationSeconds int `json:"duration_seconds,omitempty"` 112 + AlbumArtPath string `json:"album_art_path,omitempty"` 113 + Rating int `json:"rating,omitempty"` 114 + Created time.Time `json:"created"` 115 + Modified time.Time `json:"modified"` 107 116 } 108 117 109 118 // MarshalTags converts tags slice to JSON string for database storage ··· 269 278 func (n *Note) SetCreatedAt(time time.Time) { n.Created = time } 270 279 func (n *Note) GetUpdatedAt() time.Time { return n.Modified } 271 280 func (n *Note) SetUpdatedAt(time time.Time) { n.Modified = time } 281 + 282 + // MarshalTracks converts tracks slice to JSON string for database storage 283 + func (a *Album) MarshalTracks() (string, error) { 284 + if len(a.Tracks) == 0 { 285 + return "", nil 286 + } 287 + data, err := json.Marshal(a.Tracks) 288 + return string(data), err 289 + } 290 + 291 + // UnmarshalTracks converts JSON string from database to tracks slice 292 + func (a *Album) UnmarshalTracks(data string) error { 293 + if data == "" { 294 + a.Tracks = nil 295 + return nil 296 + } 297 + return json.Unmarshal([]byte(data), &a.Tracks) 298 + } 299 + 300 + // HasRating returns true if the album has a rating set 301 + func (a *Album) HasRating() bool { 302 + return a.Rating > 0 303 + } 304 + 305 + // IsValidRating returns true if the rating is between 1 and 5 306 + func (a *Album) IsValidRating() bool { 307 + return a.Rating >= 1 && a.Rating <= 5 308 + } 309 + 310 + func (a *Album) GetID() int64 { return a.ID } 311 + func (a *Album) SetID(id int64) { a.ID = id } 312 + func (a *Album) GetTableName() string { return "albums" } 313 + func (a *Album) GetCreatedAt() time.Time { return a.Created } 314 + func (a *Album) SetCreatedAt(time time.Time) { a.Created = time } 315 + func (a *Album) GetUpdatedAt() time.Time { return a.Modified } 316 + func (a *Album) SetUpdatedAt(time time.Time) { a.Modified = time }
+176 -3
internal/models/models_test.go
··· 669 669 }) 670 670 }) 671 671 672 + t.Run("Album Model", func(t *testing.T) { 673 + t.Run("Model Interface Implementation", func(t *testing.T) { 674 + album := &Album{ 675 + ID: 1, 676 + Title: "Test Album", 677 + Artist: "Test Artist", 678 + Created: time.Now(), 679 + } 680 + 681 + if album.GetID() != 1 { 682 + t.Errorf("Expected ID 1, got %d", album.GetID()) 683 + } 684 + 685 + album.SetID(2) 686 + if album.GetID() != 2 { 687 + t.Errorf("Expected ID 2 after SetID, got %d", album.GetID()) 688 + } 689 + 690 + if album.GetTableName() != "albums" { 691 + t.Errorf("Expected table name 'albums', got '%s'", album.GetTableName()) 692 + } 693 + 694 + createdAt := time.Now() 695 + album.SetCreatedAt(createdAt) 696 + if !album.GetCreatedAt().Equal(createdAt) { 697 + t.Errorf("Expected created at %v, got %v", createdAt, album.GetCreatedAt()) 698 + } 699 + 700 + updatedAt := time.Now().Add(time.Hour) 701 + album.SetUpdatedAt(updatedAt) 702 + if !album.GetUpdatedAt().Equal(updatedAt) { 703 + t.Errorf("Expected updated at %v, got %v", updatedAt, album.GetUpdatedAt()) 704 + } 705 + }) 706 + 707 + t.Run("Rating Methods", func(t *testing.T) { 708 + album := &Album{} 709 + 710 + if album.HasRating() { 711 + t.Error("Album with zero rating should return false for HasRating") 712 + } 713 + 714 + if album.IsValidRating() { 715 + t.Error("Album with zero rating should return false for IsValidRating") 716 + } 717 + 718 + album.Rating = 3 719 + if !album.HasRating() { 720 + t.Error("Album with rating should return true for HasRating") 721 + } 722 + 723 + if !album.IsValidRating() { 724 + t.Error("Album with valid rating should return true for IsValidRating") 725 + } 726 + 727 + testCases := []struct { 728 + rating int 729 + isValid bool 730 + }{ 731 + {0, false}, 732 + {1, true}, 733 + {3, true}, 734 + {5, true}, 735 + {6, false}, 736 + {-1, false}, 737 + } 738 + 739 + for _, tc := range testCases { 740 + album.Rating = tc.rating 741 + if album.IsValidRating() != tc.isValid { 742 + t.Errorf("Rating %d: expected IsValidRating %v, got %v", tc.rating, tc.isValid, album.IsValidRating()) 743 + } 744 + } 745 + }) 746 + 747 + t.Run("Tracks Marshaling", func(t *testing.T) { 748 + album := &Album{} 749 + 750 + result, err := album.MarshalTracks() 751 + if err != nil { 752 + t.Fatalf("MarshalTracks failed: %v", err) 753 + } 754 + if result != "" { 755 + t.Errorf("Expected empty string for empty tracks, got '%s'", result) 756 + } 757 + 758 + album.Tracks = []string{"Track 1", "Track 2", "Interlude"} 759 + result, err = album.MarshalTracks() 760 + if err != nil { 761 + t.Fatalf("MarshalTracks failed: %v", err) 762 + } 763 + 764 + expected := `["Track 1","Track 2","Interlude"]` 765 + if result != expected { 766 + t.Errorf("Expected %s, got %s", expected, result) 767 + } 768 + 769 + newAlbum := &Album{} 770 + err = newAlbum.UnmarshalTracks(result) 771 + if err != nil { 772 + t.Fatalf("UnmarshalTracks failed: %v", err) 773 + } 774 + 775 + if len(newAlbum.Tracks) != 3 { 776 + t.Errorf("Expected 3 tracks, got %d", len(newAlbum.Tracks)) 777 + } 778 + if newAlbum.Tracks[0] != "Track 1" || newAlbum.Tracks[1] != "Track 2" || newAlbum.Tracks[2] != "Interlude" { 779 + t.Errorf("Tracks not unmarshaled correctly: %v", newAlbum.Tracks) 780 + } 781 + 782 + emptyAlbum := &Album{} 783 + err = emptyAlbum.UnmarshalTracks("") 784 + if err != nil { 785 + t.Fatalf("UnmarshalTracks with empty string failed: %v", err) 786 + } 787 + if emptyAlbum.Tracks != nil { 788 + t.Error("Expected nil tracks for empty string") 789 + } 790 + }) 791 + 792 + t.Run("JSON Marshaling", func(t *testing.T) { 793 + now := time.Now() 794 + modified := now.Add(time.Hour) 795 + album := &Album{ 796 + ID: 1, 797 + Title: "Test Album", 798 + Artist: "Test Artist", 799 + Genre: "Rock", 800 + ReleaseYear: 2023, 801 + Tracks: []string{"Track 1", "Track 2"}, 802 + DurationSeconds: 3600, 803 + AlbumArtPath: "/path/to/art.jpg", 804 + Rating: 4, 805 + Created: now, 806 + Modified: modified, 807 + } 808 + 809 + data, err := json.Marshal(album) 810 + if err != nil { 811 + t.Fatalf("JSON marshal failed: %v", err) 812 + } 813 + 814 + var unmarshaled Album 815 + err = json.Unmarshal(data, &unmarshaled) 816 + if err != nil { 817 + t.Fatalf("JSON unmarshal failed: %v", err) 818 + } 819 + 820 + if unmarshaled.ID != album.ID { 821 + t.Errorf("Expected ID %d, got %d", album.ID, unmarshaled.ID) 822 + } 823 + if unmarshaled.Title != album.Title { 824 + t.Errorf("Expected title %s, got %s", album.Title, unmarshaled.Title) 825 + } 826 + if unmarshaled.Artist != album.Artist { 827 + t.Errorf("Expected artist %s, got %s", album.Artist, unmarshaled.Artist) 828 + } 829 + if unmarshaled.Genre != album.Genre { 830 + t.Errorf("Expected genre %s, got %s", album.Genre, unmarshaled.Genre) 831 + } 832 + if unmarshaled.ReleaseYear != album.ReleaseYear { 833 + t.Errorf("Expected release year %d, got %d", album.ReleaseYear, unmarshaled.ReleaseYear) 834 + } 835 + if unmarshaled.DurationSeconds != album.DurationSeconds { 836 + t.Errorf("Expected duration %d, got %d", album.DurationSeconds, unmarshaled.DurationSeconds) 837 + } 838 + if unmarshaled.Rating != album.Rating { 839 + t.Errorf("Expected rating %d, got %d", album.Rating, unmarshaled.Rating) 840 + } 841 + }) 842 + }) 843 + 672 844 t.Run("Interface Implementations", func(t *testing.T) { 673 845 t.Run("All models implement Model interface", func(t *testing.T) { 674 846 var models []Model ··· 678 850 tvShow := &TVShow{} 679 851 book := &Book{} 680 852 note := &Note{} 853 + album := &Album{} 681 854 682 - models = append(models, task, movie, tvShow, book, note) 855 + models = append(models, task, movie, tvShow, book, note, album) 683 856 684 - if len(models) != 5 { 685 - t.Errorf("Expected 5 models, got %d", len(models)) 857 + if len(models) != 6 { 858 + t.Errorf("Expected 6 models, got %d", len(models)) 686 859 } 687 860 688 861 // Test that all models have the required methods
+20
internal/services/services.go
··· 1 + // Movies & TV: Rotten Tomatoes with colly 2 + // 3 + // Music: Album of the Year with chromedp 4 + // 5 + // Books: OpenLibrary API 6 + package services 7 + 8 + import ( 9 + "context" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + ) 13 + 14 + // APIService defines the contract for API interactions 15 + type APIService interface { 16 + Get(ctx context.Context, id string) (*models.Model, error) 17 + Search(ctx context.Context, page, limit int) ([]*models.Model, error) 18 + Check(ctx context.Context) error 19 + Close() error 20 + }
+1
internal/services/services_test.go
··· 1 + package services
+2
internal/store/sql/migrations/0003_create_albums_table_down.sql
··· 1 + DROP TRIGGER IF EXISTS update_albums_modified; 2 + DROP TABLE IF EXISTS albums;
+20
internal/store/sql/migrations/0003_create_albums_table_up.sql
··· 1 + CREATE TABLE IF NOT EXISTS albums ( 2 + id INTEGER PRIMARY KEY AUTOINCREMENT, 3 + title TEXT NOT NULL, 4 + artist TEXT NOT NULL, 5 + genre TEXT, 6 + release_year INTEGER, 7 + tracks TEXT, -- JSON array of track names 8 + duration_seconds INTEGER, 9 + album_art_path TEXT, 10 + rating INTEGER CHECK (rating >= 1 AND rating <= 5), 11 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 12 + modified DATETIME DEFAULT CURRENT_TIMESTAMP 13 + ); 14 + 15 + CREATE TRIGGER update_albums_modified 16 + AFTER UPDATE ON albums 17 + FOR EACH ROW 18 + BEGIN 19 + UPDATE albums SET modified = CURRENT_TIMESTAMP WHERE id = NEW.id; 20 + END;