tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
feat: book handlers
desertthunder.dev
5 months ago
ec2cb6a6
cc80a0f9
+2480
-1276
7 changed files
expand all
collapse all
unified
split
cmd
cli
commands.go
handlers
books.go
books_test.go
notes_test.go
tasks_test.go
internal
ui
book_list.go
book_list_test.go
+67
-12
cmd/cli/commands.go
···
188
Short: "Manage reading list",
189
}
190
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
191
root.AddCommand(&cobra.Command{
192
-
Use: "add [title]",
193
-
Short: "Add book to reading list",
194
-
Args: cobra.MinimumNArgs(1),
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
195
RunE: func(cmd *cobra.Command, args []string) error {
196
-
title := args[0]
197
-
fmt.Printf("Adding book: %s\n", title)
198
-
// TODO: Implement book addition
199
-
return nil
200
},
201
})
202
0
203
root.AddCommand(&cobra.Command{
204
-
Use: "list",
205
-
Short: "List books in reading list",
0
206
RunE: func(cmd *cobra.Command, args []string) error {
207
-
fmt.Println("Listing books...")
208
-
// TODO: Implement book listing
209
-
return nil
210
},
211
})
212
···
188
Short: "Manage reading list",
189
}
190
191
+
// book add - Search and add book to reading list
192
+
addCmd := &cobra.Command{
193
+
Use: "add [search query...]",
194
+
Short: "Search and add book to reading list",
195
+
Long: `Search for books and add them to your reading list.
196
+
197
+
By default, shows search results in a simple list format where you can select by number.
198
+
Use the -i flag for an interactive interface with navigation keys.`,
199
+
RunE: func(cmd *cobra.Command, args []string) error {
200
+
interactive, _ := cmd.Flags().GetBool("interactive")
201
+
return handlers.SearchAndAddWithOptions(cmd.Context(), args, interactive)
202
+
},
203
+
}
204
+
addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection")
205
+
root.AddCommand(addCmd)
206
+
207
+
// book list - Show reading queue with progress
208
root.AddCommand(&cobra.Command{
209
+
Use: "list [--all|--reading|--finished|--queued]",
210
+
Short: "Show reading queue with progress",
211
+
RunE: func(cmd *cobra.Command, args []string) error {
212
+
return handlers.ListBooks(cmd.Context(), args)
213
+
},
214
+
})
215
+
216
+
// book reading - Mark book as currently reading (alias for update status)
217
+
root.AddCommand(&cobra.Command{
218
+
Use: "reading <id>",
219
+
Short: "Mark book as currently reading",
220
+
Args: cobra.ExactArgs(1),
221
+
RunE: func(cmd *cobra.Command, args []string) error {
222
+
return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "reading"})
223
+
},
224
+
})
225
+
226
+
// book finished - Mark book as completed
227
+
root.AddCommand(&cobra.Command{
228
+
Use: "finished <id>",
229
+
Short: "Mark book as completed",
230
+
Aliases: []string{"read"},
231
+
Args: cobra.ExactArgs(1),
232
+
RunE: func(cmd *cobra.Command, args []string) error {
233
+
return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "finished"})
234
+
},
235
+
})
236
+
237
+
// book remove - Remove from reading list
238
+
root.AddCommand(&cobra.Command{
239
+
Use: "remove <id>",
240
+
Short: "Remove from reading list",
241
+
Aliases: []string{"rm"},
242
+
Args: cobra.ExactArgs(1),
243
+
RunE: func(cmd *cobra.Command, args []string) error {
244
+
return handlers.UpdateBookStatus(cmd.Context(), []string{args[0], "removed"})
245
+
},
246
+
})
247
+
248
+
// book progress - Update reading progress percentage
249
+
root.AddCommand(&cobra.Command{
250
+
Use: "progress <id> <percentage>",
251
+
Short: "Update reading progress percentage (0-100)",
252
+
Args: cobra.ExactArgs(2),
253
RunE: func(cmd *cobra.Command, args []string) error {
254
+
return handlers.UpdateBookProgress(cmd.Context(), args)
0
0
0
255
},
256
})
257
258
+
// book update - Update book status
259
root.AddCommand(&cobra.Command{
260
+
Use: "update <id> <status>",
261
+
Short: "Update book status (queued|reading|finished|removed)",
262
+
Args: cobra.ExactArgs(2),
263
RunE: func(cmd *cobra.Command, args []string) error {
264
+
return handlers.UpdateBookStatus(cmd.Context(), args)
0
0
265
},
266
})
267
+407
cmd/handlers/books.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package handlers
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"os"
7
+
"slices"
8
+
"time"
9
+
10
+
"github.com/stormlightlabs/noteleaf/internal/models"
11
+
"github.com/stormlightlabs/noteleaf/internal/repo"
12
+
"github.com/stormlightlabs/noteleaf/internal/services"
13
+
"github.com/stormlightlabs/noteleaf/internal/store"
14
+
"github.com/stormlightlabs/noteleaf/internal/ui"
15
+
)
16
+
17
+
// BookHandler handles all book-related commands
18
+
type BookHandler struct {
19
+
db *store.Database
20
+
config *store.Config
21
+
repos *repo.Repositories
22
+
service *services.BookService
23
+
}
24
+
25
+
// NewBookHandler creates a new book handler
26
+
func NewBookHandler() (*BookHandler, error) {
27
+
db, err := store.NewDatabase()
28
+
if err != nil {
29
+
return nil, fmt.Errorf("failed to initialize database: %w", err)
30
+
}
31
+
32
+
config, err := store.LoadConfig()
33
+
if err != nil {
34
+
return nil, fmt.Errorf("failed to load configuration: %w", err)
35
+
}
36
+
37
+
repos := repo.NewRepositories(db.DB)
38
+
service := services.NewBookService()
39
+
40
+
return &BookHandler{
41
+
db: db,
42
+
config: config,
43
+
repos: repos,
44
+
service: service,
45
+
}, nil
46
+
}
47
+
48
+
// Close cleans up resources
49
+
func (h *BookHandler) Close() error {
50
+
if err := h.service.Close(); err != nil {
51
+
return fmt.Errorf("failed to close service: %w", err)
52
+
}
53
+
return h.db.Close()
54
+
}
55
+
56
+
// SearchAndAdd searches for books and allows user to select and add to queue
57
+
func SearchAndAdd(ctx context.Context, args []string) error {
58
+
handler, err := NewBookHandler()
59
+
if err != nil {
60
+
return fmt.Errorf("failed to initialize book handler: %w", err)
61
+
}
62
+
defer handler.Close()
63
+
64
+
return handler.searchAndAdd(ctx, args)
65
+
}
66
+
67
+
// SearchAndAddWithOptions searches for books with interactive option
68
+
func SearchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error {
69
+
handler, err := NewBookHandler()
70
+
if err != nil {
71
+
return fmt.Errorf("failed to initialize book handler: %w", err)
72
+
}
73
+
defer handler.Close()
74
+
75
+
return handler.searchAndAddWithOptions(ctx, args, interactive)
76
+
}
77
+
78
+
func (h *BookHandler) searchAndAdd(ctx context.Context, args []string) error {
79
+
if len(args) == 0 {
80
+
return fmt.Errorf("usage: book add <search query> [-i for interactive mode]")
81
+
}
82
+
83
+
interactive := false
84
+
searchArgs := args
85
+
if len(args) > 0 && args[len(args)-1] == "-i" {
86
+
interactive = true
87
+
searchArgs = args[:len(args)-1]
88
+
}
89
+
90
+
if len(searchArgs) == 0 {
91
+
return fmt.Errorf("search query cannot be empty")
92
+
}
93
+
94
+
query := searchArgs[0]
95
+
if len(searchArgs) > 1 {
96
+
for _, arg := range searchArgs[1:] {
97
+
query += " " + arg
98
+
}
99
+
}
100
+
101
+
return h.searchAndAddWithOptions(ctx, searchArgs, interactive)
102
+
}
103
+
104
+
func (h *BookHandler) searchAndAddWithOptions(ctx context.Context, args []string, interactive bool) error {
105
+
if len(args) == 0 {
106
+
return fmt.Errorf("usage: book add <search query>")
107
+
}
108
+
109
+
query := args[0]
110
+
if len(args) > 1 {
111
+
for _, arg := range args[1:] {
112
+
query += " " + arg
113
+
}
114
+
}
115
+
116
+
if interactive {
117
+
bookList := ui.NewBookList(h.service, h.repos.Books, ui.BookListOptions{
118
+
Output: os.Stdout,
119
+
Input: os.Stdin,
120
+
Static: false,
121
+
})
122
+
return bookList.SearchAndSelect(ctx, query)
123
+
}
124
+
125
+
fmt.Printf("Searching for books: %s\n", query)
126
+
fmt.Print("Loading...")
127
+
128
+
results, err := h.service.Search(ctx, query, 1, 5)
129
+
if err != nil {
130
+
fmt.Println(" failed!")
131
+
return fmt.Errorf("search failed: %w", err)
132
+
}
133
+
134
+
fmt.Println(" done!")
135
+
fmt.Println()
136
+
137
+
if len(results) == 0 {
138
+
fmt.Println("No books found.")
139
+
return nil
140
+
}
141
+
142
+
fmt.Printf("Found %d result(s):\n\n", len(results))
143
+
for i, result := range results {
144
+
if book, ok := (*result).(*models.Book); ok {
145
+
fmt.Printf("[%d] %s", i+1, book.Title)
146
+
if book.Author != "" {
147
+
fmt.Printf(" by %s", book.Author)
148
+
}
149
+
if book.Notes != "" {
150
+
notes := book.Notes
151
+
if len(notes) > 80 {
152
+
notes = notes[:77] + "..."
153
+
}
154
+
fmt.Printf("\n %s", notes)
155
+
}
156
+
fmt.Println()
157
+
}
158
+
}
159
+
160
+
fmt.Print("\nEnter number to add (1-", len(results), "), or 0 to cancel: ")
161
+
162
+
var choice int
163
+
if _, err := fmt.Scanf("%d", &choice); err != nil {
164
+
return fmt.Errorf("invalid input")
165
+
}
166
+
167
+
if choice == 0 {
168
+
fmt.Println("Cancelled.")
169
+
return nil
170
+
}
171
+
172
+
if choice < 1 || choice > len(results) {
173
+
return fmt.Errorf("invalid choice: %d", choice)
174
+
}
175
+
176
+
// Add selected book
177
+
selectedBook, ok := (*results[choice-1]).(*models.Book)
178
+
if !ok {
179
+
return fmt.Errorf("error processing selected book")
180
+
}
181
+
182
+
if _, err := h.repos.Books.Create(ctx, selectedBook); err != nil {
183
+
return fmt.Errorf("failed to add book: %w", err)
184
+
}
185
+
186
+
fmt.Printf("โ Added book: %s", selectedBook.Title)
187
+
if selectedBook.Author != "" {
188
+
fmt.Printf(" by %s", selectedBook.Author)
189
+
}
190
+
fmt.Println()
191
+
192
+
return nil
193
+
}
194
+
195
+
// ListBooks lists all books in the queue
196
+
func ListBooks(ctx context.Context, args []string) error {
197
+
handler, err := NewBookHandler()
198
+
if err != nil {
199
+
return fmt.Errorf("failed to initialize book handler: %w", err)
200
+
}
201
+
defer handler.Close()
202
+
203
+
return handler.listBooks(ctx, args)
204
+
}
205
+
206
+
func (h *BookHandler) listBooks(ctx context.Context, args []string) error {
207
+
status := "queued"
208
+
if len(args) > 0 {
209
+
switch args[0] {
210
+
case "all", "--all", "-a":
211
+
status = ""
212
+
case "reading", "--reading", "-r":
213
+
status = "reading"
214
+
case "finished", "--finished", "-f":
215
+
status = "finished"
216
+
case "queued", "--queued", "-q":
217
+
status = "queued"
218
+
}
219
+
}
220
+
221
+
var books []*models.Book
222
+
var err error
223
+
224
+
if status == "" {
225
+
books, err = h.repos.Books.List(ctx, repo.BookListOptions{})
226
+
if err != nil {
227
+
return fmt.Errorf("failed to list books: %w", err)
228
+
}
229
+
} else {
230
+
switch status {
231
+
case "queued":
232
+
books, err = h.repos.Books.GetQueued(ctx)
233
+
case "reading":
234
+
books, err = h.repos.Books.GetReading(ctx)
235
+
case "finished":
236
+
books, err = h.repos.Books.GetFinished(ctx)
237
+
}
238
+
if err != nil {
239
+
return fmt.Errorf("failed to get %s books: %w", status, err)
240
+
}
241
+
}
242
+
243
+
if len(books) == 0 {
244
+
if status == "" {
245
+
fmt.Println("No books found")
246
+
} else {
247
+
fmt.Printf("No %s books found\n", status)
248
+
}
249
+
return nil
250
+
}
251
+
252
+
fmt.Printf("Found %d book(s):\n\n", len(books))
253
+
for _, book := range books {
254
+
h.printBook(book)
255
+
}
256
+
257
+
return nil
258
+
}
259
+
260
+
func UpdateBookStatus(ctx context.Context, args []string) error {
261
+
handler, err := NewBookHandler()
262
+
if err != nil {
263
+
return fmt.Errorf("failed to initialize book handler: %w", err)
264
+
}
265
+
defer handler.Close()
266
+
267
+
return handler.updateBookStatus(ctx, args)
268
+
}
269
+
270
+
func (h *BookHandler) updateBookStatus(ctx context.Context, args []string) error {
271
+
if len(args) < 2 {
272
+
return fmt.Errorf("usage: book update <id> <status>")
273
+
}
274
+
275
+
var bookID int64
276
+
if _, err := fmt.Sscanf(args[0], "%d", &bookID); err != nil {
277
+
return fmt.Errorf("invalid book ID: %s", args[0])
278
+
}
279
+
280
+
status := args[1]
281
+
validStatuses := []string{"queued", "reading", "finished", "removed"}
282
+
valid := slices.Contains(validStatuses, status)
283
+
if !valid {
284
+
return fmt.Errorf("invalid status: %s (valid: %v)", status, validStatuses)
285
+
}
286
+
287
+
book, err := h.repos.Books.Get(ctx, bookID)
288
+
if err != nil {
289
+
return fmt.Errorf("failed to get book: %w", err)
290
+
}
291
+
292
+
book.Status = status
293
+
if status == "reading" && book.Started == nil {
294
+
now := time.Now()
295
+
book.Started = &now
296
+
}
297
+
if status == "finished" && book.Finished == nil {
298
+
now := time.Now()
299
+
book.Finished = &now
300
+
book.Progress = 100
301
+
}
302
+
303
+
if err := h.repos.Books.Update(ctx, book); err != nil {
304
+
return fmt.Errorf("failed to update book: %w", err)
305
+
}
306
+
307
+
fmt.Printf("Book status updated: %s -> %s\n", book.Title, status)
308
+
return nil
309
+
}
310
+
311
+
// UpdateBookProgress updates a book's reading progress percentage
312
+
func UpdateBookProgress(ctx context.Context, args []string) error {
313
+
handler, err := NewBookHandler()
314
+
if err != nil {
315
+
return fmt.Errorf("failed to initialize book handler: %w", err)
316
+
}
317
+
defer handler.Close()
318
+
319
+
return handler.updateBookProgress(ctx, args)
320
+
}
321
+
322
+
func (h *BookHandler) updateBookProgress(ctx context.Context, args []string) error {
323
+
if len(args) < 2 {
324
+
return fmt.Errorf("usage: book progress <id> <percentage>")
325
+
}
326
+
327
+
var bookID int64
328
+
if _, err := fmt.Sscanf(args[0], "%d", &bookID); err != nil {
329
+
return fmt.Errorf("invalid book ID: %s", args[0])
330
+
}
331
+
332
+
var progress int
333
+
if _, err := fmt.Sscanf(args[1], "%d", &progress); err != nil {
334
+
return fmt.Errorf("invalid progress percentage: %s", args[1])
335
+
}
336
+
337
+
if progress < 0 || progress > 100 {
338
+
return fmt.Errorf("progress must be between 0 and 100, got %d", progress)
339
+
}
340
+
341
+
book, err := h.repos.Books.Get(ctx, bookID)
342
+
if err != nil {
343
+
return fmt.Errorf("failed to get book: %w", err)
344
+
}
345
+
346
+
book.Progress = progress
347
+
348
+
if progress == 0 && book.Status == "reading" {
349
+
book.Status = "queued"
350
+
book.Started = nil
351
+
} else if progress > 0 && book.Status == "queued" {
352
+
book.Status = "reading"
353
+
if book.Started == nil {
354
+
now := time.Now()
355
+
book.Started = &now
356
+
}
357
+
} else if progress == 100 {
358
+
book.Status = "finished"
359
+
if book.Finished == nil {
360
+
now := time.Now()
361
+
book.Finished = &now
362
+
}
363
+
}
364
+
365
+
if err := h.repos.Books.Update(ctx, book); err != nil {
366
+
return fmt.Errorf("failed to update book progress: %w", err)
367
+
}
368
+
369
+
fmt.Printf("Book progress updated: %s -> %d%%", book.Title, progress)
370
+
if book.Status != "queued" {
371
+
fmt.Printf(" (%s)", book.Status)
372
+
}
373
+
fmt.Println()
374
+
return nil
375
+
}
376
+
377
+
func (h *BookHandler) printBook(book *models.Book) {
378
+
fmt.Printf("[%d] %s", book.ID, book.Title)
379
+
380
+
if book.Author != "" {
381
+
fmt.Printf(" by %s", book.Author)
382
+
}
383
+
384
+
if book.Status != "queued" {
385
+
fmt.Printf(" (%s)", book.Status)
386
+
}
387
+
388
+
if book.Progress > 0 {
389
+
fmt.Printf(" [%d%%]", book.Progress)
390
+
}
391
+
392
+
if book.Rating > 0 {
393
+
fmt.Printf(" โ %.1f", book.Rating)
394
+
}
395
+
396
+
fmt.Println()
397
+
398
+
if book.Notes != "" {
399
+
notes := book.Notes
400
+
if len(notes) > 80 {
401
+
notes = notes[:77] + "..."
402
+
}
403
+
fmt.Printf(" %s\n", notes)
404
+
}
405
+
406
+
fmt.Println()
407
+
}
+754
cmd/handlers/books_test.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package handlers
2
+
3
+
import (
4
+
"context"
5
+
"os"
6
+
"strconv"
7
+
"strings"
8
+
"testing"
9
+
"time"
10
+
11
+
"github.com/stormlightlabs/noteleaf/internal/models"
12
+
)
13
+
14
+
func setupBookTest(t *testing.T) (string, func()) {
15
+
tempDir, err := os.MkdirTemp("", "noteleaf-book-test-*")
16
+
if err != nil {
17
+
t.Fatalf("Failed to create temp dir: %v", err)
18
+
}
19
+
20
+
oldConfigHome := os.Getenv("XDG_CONFIG_HOME")
21
+
os.Setenv("XDG_CONFIG_HOME", tempDir)
22
+
23
+
cleanup := func() {
24
+
os.Setenv("XDG_CONFIG_HOME", oldConfigHome)
25
+
os.RemoveAll(tempDir)
26
+
}
27
+
28
+
ctx := context.Background()
29
+
err = Setup(ctx, []string{})
30
+
if err != nil {
31
+
cleanup()
32
+
t.Fatalf("Failed to setup database: %v", err)
33
+
}
34
+
35
+
return tempDir, cleanup
36
+
}
37
+
38
+
func createTestBook(t *testing.T, handler *BookHandler, ctx context.Context) *models.Book {
39
+
t.Helper()
40
+
if handler == nil {
41
+
t.Fatal("handler provided to createTestBook is nil")
42
+
}
43
+
book := &models.Book{
44
+
Title: "Test Book",
45
+
Author: "Test Author",
46
+
Status: "queued",
47
+
Added: time.Now(),
48
+
}
49
+
id, err := handler.repos.Books.Create(ctx, book)
50
+
if err != nil {
51
+
t.Fatalf("Failed to create test book: %v", err)
52
+
}
53
+
book.ID = id
54
+
return book
55
+
}
56
+
57
+
func TestBookHandler(t *testing.T) {
58
+
t.Run("New", func(t *testing.T) {
59
+
t.Run("creates handler successfully", func(t *testing.T) {
60
+
_, cleanup := setupBookTest(t)
61
+
defer cleanup()
62
+
63
+
handler, err := NewBookHandler()
64
+
if err != nil {
65
+
t.Fatalf("NewBookHandler failed: %v", err)
66
+
}
67
+
if handler == nil {
68
+
t.Fatal("Handler should not be nil")
69
+
}
70
+
defer handler.Close()
71
+
72
+
if handler.db == nil {
73
+
t.Error("Handler database should not be nil")
74
+
}
75
+
if handler.config == nil {
76
+
t.Error("Handler config should not be nil")
77
+
}
78
+
if handler.repos == nil {
79
+
t.Error("Handler repos should not be nil")
80
+
}
81
+
if handler.service == nil {
82
+
t.Error("Handler service should not be nil")
83
+
}
84
+
})
85
+
86
+
t.Run("handles database initialization error", func(t *testing.T) {
87
+
originalXDG := os.Getenv("XDG_CONFIG_HOME")
88
+
originalHome := os.Getenv("HOME")
89
+
90
+
os.Unsetenv("XDG_CONFIG_HOME")
91
+
os.Unsetenv("HOME")
92
+
defer func() {
93
+
os.Setenv("XDG_CONFIG_HOME", originalXDG)
94
+
os.Setenv("HOME", originalHome)
95
+
}()
96
+
97
+
handler, err := NewBookHandler()
98
+
if err == nil {
99
+
if handler != nil {
100
+
handler.Close()
101
+
}
102
+
t.Error("Expected error when database initialization fails")
103
+
}
104
+
})
105
+
})
106
+
107
+
t.Run("Search & Add", func(t *testing.T) {
108
+
_, cleanup := setupBookTest(t)
109
+
defer cleanup()
110
+
111
+
t.Run("fails with empty args", func(t *testing.T) {
112
+
ctx := context.Background()
113
+
args := []string{}
114
+
115
+
err := SearchAndAdd(ctx, args)
116
+
if err == nil {
117
+
t.Error("Expected error for empty args")
118
+
}
119
+
120
+
if !strings.Contains(err.Error(), "usage: book add") {
121
+
t.Errorf("Expected usage error, got: %v", err)
122
+
}
123
+
})
124
+
125
+
t.Run("fails with empty search", func(t *testing.T) {
126
+
ctx := context.Background()
127
+
args := []string{"-i"}
128
+
129
+
err := SearchAndAdd(ctx, args)
130
+
if err == nil {
131
+
t.Error("Expected error for empty search query")
132
+
}
133
+
134
+
if !strings.Contains(err.Error(), "search query cannot be empty") {
135
+
t.Errorf("Expected empty search query error, got: %v", err)
136
+
}
137
+
})
138
+
139
+
t.Run("with options", func(t *testing.T) {
140
+
_, cleanup := setupBookTest(t)
141
+
defer cleanup()
142
+
143
+
t.Run("fails with empty args", func(t *testing.T) {
144
+
ctx := context.Background()
145
+
args := []string{}
146
+
147
+
err := SearchAndAddWithOptions(ctx, args, false)
148
+
if err == nil {
149
+
t.Error("Expected error for empty args")
150
+
}
151
+
152
+
if !strings.Contains(err.Error(), "usage: book add") {
153
+
t.Errorf("Expected usage error, got: %v", err)
154
+
}
155
+
})
156
+
157
+
t.Run("handles search service errors", func(t *testing.T) {
158
+
ctx := context.Background()
159
+
args := []string{"test", "book"}
160
+
161
+
err := SearchAndAddWithOptions(ctx, args, false)
162
+
if err == nil {
163
+
t.Error("Expected error due to mocked service")
164
+
}
165
+
if strings.Contains(err.Error(), "usage:") {
166
+
t.Error("Should not show usage error for valid args")
167
+
}
168
+
})
169
+
170
+
})
171
+
})
172
+
173
+
t.Run("List", func(t *testing.T) {
174
+
_, cleanup := setupBookTest(t)
175
+
defer cleanup()
176
+
177
+
ctx := context.Background()
178
+
179
+
handler, err := NewBookHandler()
180
+
if err != nil {
181
+
t.Fatalf("Failed to create handler: %v", err)
182
+
}
183
+
defer handler.Close()
184
+
185
+
_ = createTestBook(t, handler, ctx)
186
+
187
+
book2 := &models.Book{
188
+
Title: "Reading Book",
189
+
Author: "Reading Author",
190
+
Status: "reading",
191
+
Added: time.Now(),
192
+
}
193
+
id2, err := handler.repos.Books.Create(ctx, book2)
194
+
if err != nil {
195
+
t.Fatalf("Failed to create book2: %v", err)
196
+
}
197
+
book2.ID = id2
198
+
199
+
book3 := &models.Book{
200
+
Title: "Finished Book",
201
+
Author: "Finished Author",
202
+
Status: "finished",
203
+
Added: time.Now(),
204
+
}
205
+
id3, err := handler.repos.Books.Create(ctx, book3)
206
+
if err != nil {
207
+
t.Fatalf("Failed to create book3: %v", err)
208
+
}
209
+
book3.ID = id3
210
+
211
+
t.Run("lists queued books by default", func(t *testing.T) {
212
+
args := []string{}
213
+
214
+
err := ListBooks(ctx, args)
215
+
if err != nil {
216
+
t.Errorf("ListBooks failed: %v", err)
217
+
}
218
+
})
219
+
220
+
t.Run("filters by status - all", func(t *testing.T) {
221
+
args := []string{"all"}
222
+
223
+
err := ListBooks(ctx, args)
224
+
if err != nil {
225
+
t.Errorf("ListBooks with status all failed: %v", err)
226
+
}
227
+
})
228
+
229
+
t.Run("filters by status - reading", func(t *testing.T) {
230
+
args := []string{"reading"}
231
+
232
+
err := ListBooks(ctx, args)
233
+
if err != nil {
234
+
t.Errorf("ListBooks with status reading failed: %v", err)
235
+
}
236
+
})
237
+
238
+
t.Run("filters by status - finished", func(t *testing.T) {
239
+
args := []string{"finished"}
240
+
241
+
err := ListBooks(ctx, args)
242
+
if err != nil {
243
+
t.Errorf("ListBooks with status finished failed: %v", err)
244
+
}
245
+
})
246
+
247
+
t.Run("filters by status - queued", func(t *testing.T) {
248
+
args := []string{"queued"}
249
+
250
+
err := ListBooks(ctx, args)
251
+
if err != nil {
252
+
t.Errorf("ListBooks with status queued failed: %v", err)
253
+
}
254
+
})
255
+
256
+
t.Run("handles various flag formats", func(t *testing.T) {
257
+
statusVariants := [][]string{
258
+
{"--all"}, {"-a"},
259
+
{"--reading"}, {"-r"},
260
+
{"--finished"}, {"-f"},
261
+
{"--queued"}, {"-q"},
262
+
}
263
+
264
+
for _, args := range statusVariants {
265
+
err := ListBooks(ctx, args)
266
+
if err != nil {
267
+
t.Errorf("ListBooks with args %v failed: %v", args, err)
268
+
}
269
+
}
270
+
})
271
+
})
272
+
273
+
t.Run("Update", func(t *testing.T) {
274
+
t.Run("Update status", func(t *testing.T) {
275
+
_, cleanup := setupBookTest(t)
276
+
defer cleanup()
277
+
278
+
ctx := context.Background()
279
+
280
+
handler, err := NewBookHandler()
281
+
if err != nil {
282
+
t.Fatalf("Failed to create handler: %v", err)
283
+
}
284
+
defer handler.Close()
285
+
286
+
book := createTestBook(t, handler, ctx)
287
+
288
+
t.Run("updates book status successfully", func(t *testing.T) {
289
+
args := []string{strconv.FormatInt(book.ID, 10), "reading"}
290
+
291
+
err := UpdateBookStatus(ctx, args)
292
+
if err != nil {
293
+
t.Errorf("UpdateBookStatus failed: %v", err)
294
+
}
295
+
296
+
updatedBook, err := handler.repos.Books.Get(ctx, book.ID)
297
+
if err != nil {
298
+
t.Fatalf("Failed to get updated book: %v", err)
299
+
}
300
+
301
+
if updatedBook.Status != "reading" {
302
+
t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status)
303
+
}
304
+
305
+
if updatedBook.Started == nil {
306
+
t.Error("Expected started time to be set")
307
+
}
308
+
})
309
+
310
+
t.Run("updates to finished status", func(t *testing.T) {
311
+
args := []string{strconv.FormatInt(book.ID, 10), "finished"}
312
+
313
+
err := UpdateBookStatus(ctx, args)
314
+
if err != nil {
315
+
t.Errorf("UpdateBookStatus failed: %v", err)
316
+
}
317
+
318
+
updatedBook, err := handler.repos.Books.Get(ctx, book.ID)
319
+
if err != nil {
320
+
t.Fatalf("Failed to get updated book: %v", err)
321
+
}
322
+
323
+
if updatedBook.Status != "finished" {
324
+
t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status)
325
+
}
326
+
327
+
if updatedBook.Finished == nil {
328
+
t.Error("Expected finished time to be set")
329
+
}
330
+
331
+
if updatedBook.Progress != 100 {
332
+
t.Errorf("Expected progress 100, got %d", updatedBook.Progress)
333
+
}
334
+
})
335
+
336
+
t.Run("fails with insufficient arguments", func(t *testing.T) {
337
+
args := []string{strconv.FormatInt(book.ID, 10)}
338
+
339
+
err := UpdateBookStatus(ctx, args)
340
+
if err == nil {
341
+
t.Error("Expected error for insufficient arguments")
342
+
}
343
+
344
+
if !strings.Contains(err.Error(), "usage: book update") {
345
+
t.Errorf("Expected usage error, got: %v", err)
346
+
}
347
+
})
348
+
349
+
t.Run("fails with invalid book ID", func(t *testing.T) {
350
+
args := []string{"invalid-id", "reading"}
351
+
352
+
err := UpdateBookStatus(ctx, args)
353
+
if err == nil {
354
+
t.Error("Expected error for invalid book ID")
355
+
}
356
+
357
+
if !strings.Contains(err.Error(), "invalid book ID") {
358
+
t.Errorf("Expected invalid book ID error, got: %v", err)
359
+
}
360
+
})
361
+
362
+
t.Run("fails with invalid status", func(t *testing.T) {
363
+
args := []string{strconv.FormatInt(book.ID, 10), "invalid-status"}
364
+
365
+
err := UpdateBookStatus(ctx, args)
366
+
if err == nil {
367
+
t.Error("Expected error for invalid status")
368
+
}
369
+
370
+
if !strings.Contains(err.Error(), "invalid status") {
371
+
t.Errorf("Expected invalid status error, got: %v", err)
372
+
}
373
+
})
374
+
375
+
t.Run("fails with non-existent book ID", func(t *testing.T) {
376
+
args := []string{"99999", "reading"}
377
+
378
+
err := UpdateBookStatus(ctx, args)
379
+
if err == nil {
380
+
t.Error("Expected error for non-existent book ID")
381
+
}
382
+
383
+
if !strings.Contains(err.Error(), "failed to get book") {
384
+
t.Errorf("Expected book not found error, got: %v", err)
385
+
}
386
+
})
387
+
388
+
t.Run("validates all status options", func(t *testing.T) {
389
+
validStatuses := []string{"queued", "reading", "finished", "removed"}
390
+
391
+
for _, status := range validStatuses {
392
+
args := []string{strconv.FormatInt(book.ID, 10), status}
393
+
394
+
err := UpdateBookStatus(ctx, args)
395
+
if err != nil {
396
+
t.Errorf("UpdateBookStatus with status %s failed: %v", status, err)
397
+
}
398
+
}
399
+
})
400
+
})
401
+
402
+
t.Run("progress", func(t *testing.T) {
403
+
_, cleanup := setupBookTest(t)
404
+
defer cleanup()
405
+
406
+
ctx := context.Background()
407
+
408
+
handler, err := NewBookHandler()
409
+
if err != nil {
410
+
t.Fatalf("Failed to create handler: %v", err)
411
+
}
412
+
defer handler.Close()
413
+
414
+
book := createTestBook(t, handler, ctx)
415
+
416
+
t.Run("updates progress successfully", func(t *testing.T) {
417
+
args := []string{strconv.FormatInt(book.ID, 10), "50"}
418
+
419
+
err := UpdateBookProgress(ctx, args)
420
+
if err != nil {
421
+
t.Errorf("UpdateBookProgress failed: %v", err)
422
+
}
423
+
424
+
updatedBook, err := handler.repos.Books.Get(ctx, book.ID)
425
+
if err != nil {
426
+
t.Fatalf("Failed to get updated book: %v", err)
427
+
}
428
+
429
+
if updatedBook.Progress != 50 {
430
+
t.Errorf("Expected progress 50, got %d", updatedBook.Progress)
431
+
}
432
+
433
+
if updatedBook.Status != "reading" {
434
+
t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status)
435
+
}
436
+
437
+
if updatedBook.Started == nil {
438
+
t.Error("Expected started time to be set")
439
+
}
440
+
})
441
+
442
+
t.Run("auto-completes book at 100%", func(t *testing.T) {
443
+
args := []string{strconv.FormatInt(book.ID, 10), "100"}
444
+
445
+
err := UpdateBookProgress(ctx, args)
446
+
if err != nil {
447
+
t.Errorf("UpdateBookProgress failed: %v", err)
448
+
}
449
+
450
+
updatedBook, err := handler.repos.Books.Get(ctx, book.ID)
451
+
if err != nil {
452
+
t.Fatalf("Failed to get updated book: %v", err)
453
+
}
454
+
455
+
if updatedBook.Progress != 100 {
456
+
t.Errorf("Expected progress 100, got %d", updatedBook.Progress)
457
+
}
458
+
459
+
if updatedBook.Status != "finished" {
460
+
t.Errorf("Expected status 'finished', got '%s'", updatedBook.Status)
461
+
}
462
+
463
+
if updatedBook.Finished == nil {
464
+
t.Error("Expected finished time to be set")
465
+
}
466
+
})
467
+
468
+
t.Run("resets to queued at 0%", func(t *testing.T) {
469
+
book.Status = "reading"
470
+
now := time.Now()
471
+
book.Started = &now
472
+
handler.repos.Books.Update(ctx, book)
473
+
474
+
args := []string{strconv.FormatInt(book.ID, 10), "0"}
475
+
476
+
err := UpdateBookProgress(ctx, args)
477
+
if err != nil {
478
+
t.Errorf("UpdateBookProgress failed: %v", err)
479
+
}
480
+
481
+
updatedBook, err := handler.repos.Books.Get(ctx, book.ID)
482
+
if err != nil {
483
+
t.Fatalf("Failed to get updated book: %v", err)
484
+
}
485
+
486
+
if updatedBook.Progress != 0 {
487
+
t.Errorf("Expected progress 0, got %d", updatedBook.Progress)
488
+
}
489
+
490
+
if updatedBook.Status != "queued" {
491
+
t.Errorf("Expected status 'queued', got '%s'", updatedBook.Status)
492
+
}
493
+
494
+
if updatedBook.Started != nil {
495
+
t.Error("Expected started time to be nil")
496
+
}
497
+
})
498
+
499
+
t.Run("fails with insufficient arguments", func(t *testing.T) {
500
+
args := []string{strconv.FormatInt(book.ID, 10)}
501
+
502
+
err := UpdateBookProgress(ctx, args)
503
+
if err == nil {
504
+
t.Error("Expected error for insufficient arguments")
505
+
}
506
+
507
+
if !strings.Contains(err.Error(), "usage: book progress") {
508
+
t.Errorf("Expected usage error, got: %v", err)
509
+
}
510
+
})
511
+
512
+
t.Run("fails with invalid book ID", func(t *testing.T) {
513
+
args := []string{"invalid-id", "50"}
514
+
515
+
err := UpdateBookProgress(ctx, args)
516
+
if err == nil {
517
+
t.Error("Expected error for invalid book ID")
518
+
}
519
+
520
+
if !strings.Contains(err.Error(), "invalid book ID") {
521
+
t.Errorf("Expected invalid book ID error, got: %v", err)
522
+
}
523
+
})
524
+
525
+
t.Run("fails with invalid progress percentage", func(t *testing.T) {
526
+
args := []string{strconv.FormatInt(book.ID, 10), "invalid-progress"}
527
+
528
+
err := UpdateBookProgress(ctx, args)
529
+
if err == nil {
530
+
t.Error("Expected error for invalid progress percentage")
531
+
}
532
+
533
+
if !strings.Contains(err.Error(), "invalid progress percentage") {
534
+
t.Errorf("Expected invalid progress percentage error, got: %v", err)
535
+
}
536
+
})
537
+
538
+
t.Run("fails with progress out of range", func(t *testing.T) {
539
+
testCases := []string{"-1", "101", "150"}
540
+
541
+
for _, progress := range testCases {
542
+
args := []string{strconv.FormatInt(book.ID, 10), progress}
543
+
544
+
err := UpdateBookProgress(ctx, args)
545
+
if err == nil {
546
+
t.Errorf("Expected error for progress %s", progress)
547
+
}
548
+
549
+
if !strings.Contains(err.Error(), "progress must be between 0 and 100") {
550
+
t.Errorf("Expected range error for progress %s, got: %v", progress, err)
551
+
}
552
+
}
553
+
})
554
+
555
+
t.Run("fails with non-existent book ID", func(t *testing.T) {
556
+
args := []string{"99999", "50"}
557
+
558
+
err := UpdateBookProgress(ctx, args)
559
+
if err == nil {
560
+
t.Error("Expected error for non-existent book ID")
561
+
}
562
+
563
+
if !strings.Contains(err.Error(), "failed to get book") {
564
+
t.Errorf("Expected book not found error, got: %v", err)
565
+
}
566
+
})
567
+
})
568
+
})
569
+
570
+
t.Run("Close", func(t *testing.T) {
571
+
t.Run("closes handler resources", func(t *testing.T) {
572
+
_, cleanup := setupBookTest(t)
573
+
defer cleanup()
574
+
575
+
handler, err := NewBookHandler()
576
+
if err != nil {
577
+
t.Fatalf("NewBookHandler failed: %v", err)
578
+
}
579
+
580
+
err = handler.Close()
581
+
if err != nil {
582
+
t.Errorf("Close failed: %v", err)
583
+
}
584
+
})
585
+
})
586
+
587
+
t.Run("Print", func(t *testing.T) {
588
+
_, cleanup := setupBookTest(t)
589
+
defer cleanup()
590
+
591
+
handler, err := NewBookHandler()
592
+
if err != nil {
593
+
t.Fatalf("Failed to create handler: %v", err)
594
+
}
595
+
defer handler.Close()
596
+
597
+
now := time.Now()
598
+
book := &models.Book{
599
+
ID: 1,
600
+
Title: "Test Book",
601
+
Author: "Test Author",
602
+
Status: "reading",
603
+
Progress: 75,
604
+
Rating: 4.5,
605
+
Notes: "This is a test note that is longer than 80 characters to test the truncation functionality in the print method",
606
+
Added: now,
607
+
}
608
+
609
+
t.Run("printBook doesn't panic", func(t *testing.T) {
610
+
defer func() {
611
+
if r := recover(); r != nil {
612
+
t.Errorf("printBook panicked: %v", r)
613
+
}
614
+
}()
615
+
616
+
handler.printBook(book)
617
+
})
618
+
619
+
t.Run("handles book with minimal fields", func(t *testing.T) {
620
+
minimalBook := &models.Book{
621
+
ID: 2,
622
+
Title: "Minimal Book",
623
+
Status: "queued",
624
+
Added: now,
625
+
}
626
+
627
+
defer func() {
628
+
if r := recover(); r != nil {
629
+
t.Errorf("printBook panicked with minimal book: %v", r)
630
+
}
631
+
}()
632
+
633
+
handler.printBook(minimalBook)
634
+
})
635
+
})
636
+
637
+
t.Run("Error handling", func(t *testing.T) {
638
+
t.Run("handler creation fails with invalid environment", func(t *testing.T) {
639
+
originalXDG := os.Getenv("XDG_CONFIG_HOME")
640
+
originalHome := os.Getenv("HOME")
641
+
642
+
os.Unsetenv("XDG_CONFIG_HOME")
643
+
os.Unsetenv("HOME")
644
+
defer func() {
645
+
os.Setenv("XDG_CONFIG_HOME", originalXDG)
646
+
os.Setenv("HOME", originalHome)
647
+
}()
648
+
649
+
handler, err := NewBookHandler()
650
+
if err == nil {
651
+
if handler != nil {
652
+
handler.Close()
653
+
}
654
+
t.Error("Expected error when environment is invalid")
655
+
}
656
+
})
657
+
658
+
})
659
+
660
+
t.Run("Integration", func(t *testing.T) {
661
+
t.Run("full book lifecycle", func(t *testing.T) {
662
+
_, cleanup := setupBookTest(t)
663
+
defer cleanup()
664
+
665
+
ctx := context.Background()
666
+
667
+
handler, err := NewBookHandler()
668
+
if err != nil {
669
+
t.Fatalf("Failed to create handler: %v", err)
670
+
}
671
+
defer handler.Close()
672
+
673
+
book := createTestBook(t, handler, ctx)
674
+
675
+
if book.Status != "queued" {
676
+
t.Errorf("Expected initial status 'queued', got '%s'", book.Status)
677
+
}
678
+
679
+
err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "25"})
680
+
if err != nil {
681
+
t.Errorf("Failed to update progress: %v", err)
682
+
}
683
+
684
+
updatedBook, err := handler.repos.Books.Get(ctx, book.ID)
685
+
if err != nil {
686
+
t.Fatalf("Failed to get updated book: %v", err)
687
+
}
688
+
689
+
if updatedBook.Status != "reading" {
690
+
t.Errorf("Expected status 'reading', got '%s'", updatedBook.Status)
691
+
}
692
+
693
+
err = UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "100"})
694
+
if err != nil {
695
+
t.Errorf("Failed to complete book: %v", err)
696
+
}
697
+
698
+
completedBook, err := handler.repos.Books.Get(ctx, book.ID)
699
+
if err != nil {
700
+
t.Fatalf("Failed to get completed book: %v", err)
701
+
}
702
+
703
+
if completedBook.Status != "finished" {
704
+
t.Errorf("Expected status 'finished', got '%s'", completedBook.Status)
705
+
}
706
+
707
+
if completedBook.Progress != 100 {
708
+
t.Errorf("Expected progress 100, got %d", completedBook.Progress)
709
+
}
710
+
711
+
if completedBook.Finished == nil {
712
+
t.Error("Expected finished time to be set")
713
+
}
714
+
})
715
+
716
+
t.Run("concurrent book operations", func(t *testing.T) {
717
+
_, cleanup := setupBookTest(t)
718
+
defer cleanup()
719
+
720
+
ctx := context.Background()
721
+
722
+
handler, err := NewBookHandler()
723
+
if err != nil {
724
+
t.Fatalf("Failed to create handler: %v", err)
725
+
}
726
+
defer handler.Close()
727
+
728
+
book := createTestBook(t, handler, ctx)
729
+
730
+
done := make(chan error, 3)
731
+
732
+
go func() {
733
+
time.Sleep(time.Millisecond * 10)
734
+
done <- ListBooks(ctx, []string{})
735
+
}()
736
+
737
+
go func() {
738
+
time.Sleep(time.Millisecond * 15)
739
+
done <- UpdateBookProgress(ctx, []string{strconv.FormatInt(book.ID, 10), "50"})
740
+
}()
741
+
742
+
go func() {
743
+
time.Sleep(time.Millisecond * 20)
744
+
done <- UpdateBookStatus(ctx, []string{strconv.FormatInt(book.ID, 10), "finished"})
745
+
}()
746
+
747
+
for i := range 3 {
748
+
if err := <-done; err != nil {
749
+
t.Errorf("Concurrent operation %d failed: %v", i, err)
750
+
}
751
+
}
752
+
})
753
+
})
754
+
}
+655
-653
cmd/handlers/notes_test.go
···
42
return filePath
43
}
44
45
-
func TestNoteHandler_NewNoteHandler(t *testing.T) {
46
-
t.Run("creates handler successfully", func(t *testing.T) {
47
-
_, cleanup := setupNoteTest(t)
48
-
defer cleanup()
0
49
50
-
handler, err := NewNoteHandler()
51
-
if err != nil {
52
-
t.Errorf("NewNoteHandler failed: %v", err)
53
-
}
54
-
if handler == nil {
55
-
t.Error("Handler should not be nil")
56
-
}
57
-
defer handler.Close()
58
59
-
if handler.db == nil {
60
-
t.Error("Handler database should not be nil")
61
-
}
62
-
if handler.config == nil {
63
-
t.Error("Handler config should not be nil")
64
-
}
65
-
if handler.repos == nil {
66
-
t.Error("Handler repos should not be nil")
67
-
}
68
-
})
69
70
-
t.Run("handles database initialization error", func(t *testing.T) {
71
-
originalXDG := os.Getenv("XDG_CONFIG_HOME")
72
-
originalHome := os.Getenv("HOME")
73
74
-
if runtime.GOOS == "windows" {
75
-
originalAppData := os.Getenv("APPDATA")
76
-
os.Unsetenv("APPDATA")
77
-
defer os.Setenv("APPDATA", originalAppData)
78
-
} else {
79
-
os.Unsetenv("XDG_CONFIG_HOME")
80
-
os.Unsetenv("HOME")
81
-
}
82
83
-
defer func() {
84
-
os.Setenv("XDG_CONFIG_HOME", originalXDG)
85
-
os.Setenv("HOME", originalHome)
86
-
}()
87
88
-
_, err := NewNoteHandler()
89
-
if err == nil {
90
-
t.Error("NewNoteHandler should fail when database initialization fails")
91
-
}
92
-
if !strings.Contains(err.Error(), "failed to initialize database") {
93
-
t.Errorf("Expected database error, got: %v", err)
94
-
}
0
95
})
96
-
}
97
98
-
func TestNoteHandler_parseNoteContent(t *testing.T) {
99
-
handler := &NoteHandler{}
100
101
-
testCases := []struct {
102
-
name string
103
-
input string
104
-
expectedTitle string
105
-
expectedContent string
106
-
expectedTags []string
107
-
}{
108
-
{
109
-
name: "note with title and tags",
110
-
input: `# My Test Note
111
112
This is the content.
113
114
<!-- Tags: personal, work, important -->`,
115
-
expectedTitle: "My Test Note",
116
-
expectedContent: `# My Test Note
117
118
This is the content.
119
120
<!-- Tags: personal, work, important -->`,
121
-
expectedTags: []string{"personal", "work", "important"},
122
-
},
123
-
{
124
-
name: "note without title",
125
-
input: `Just some content here.
126
127
No title heading.
128
129
<!-- Tags: test -->`,
130
-
expectedTitle: "",
131
-
expectedContent: `Just some content here.
132
133
No title heading.
134
135
<!-- Tags: test -->`,
136
-
expectedTags: []string{"test"},
137
-
},
138
-
{
139
-
name: "note without tags",
140
-
input: `# Title Only
141
142
Content without tags.`,
143
-
expectedTitle: "Title Only",
144
-
expectedContent: `# Title Only
145
146
Content without tags.`,
147
-
expectedTags: nil,
148
-
},
149
-
{
150
-
name: "empty tags comment",
151
-
input: `# Test Note
152
153
Content here.
154
155
<!-- Tags: -->`,
156
-
expectedTitle: "Test Note",
157
-
expectedContent: `# Test Note
158
159
Content here.
160
161
<!-- Tags: -->`,
162
-
expectedTags: nil,
163
-
},
164
-
{
165
-
name: "malformed tags comment",
166
-
input: `# Test Note
167
168
Content here.
169
170
<!-- Tags: tag1, , tag2,, tag3 -->`,
171
-
expectedTitle: "Test Note",
172
-
expectedContent: `# Test Note
173
174
Content here.
175
176
<!-- Tags: tag1, , tag2,, tag3 -->`,
177
-
expectedTags: []string{"tag1", "tag2", "tag3"},
178
-
},
179
-
{
180
-
name: "multiple headings",
181
-
input: `## Secondary Heading
182
183
# Main Title
184
185
Content here.`,
186
-
expectedTitle: "Main Title",
187
-
expectedContent: `## Secondary Heading
188
189
# Main Title
190
191
Content here.`,
192
-
expectedTags: nil,
193
-
},
194
-
}
195
196
-
for _, tc := range testCases {
197
-
t.Run(tc.name, func(t *testing.T) {
198
-
title, content, tags := handler.parseNoteContent(tc.input)
199
200
-
if title != tc.expectedTitle {
201
-
t.Errorf("Expected title %q, got %q", tc.expectedTitle, title)
202
-
}
203
204
-
if content != tc.expectedContent {
205
-
t.Errorf("Expected content %q, got %q", tc.expectedContent, content)
206
-
}
207
208
-
if len(tags) != len(tc.expectedTags) {
209
-
t.Errorf("Expected %d tags, got %d", len(tc.expectedTags), len(tags))
210
-
}
211
212
-
for i, expectedTag := range tc.expectedTags {
213
-
if i >= len(tags) || tags[i] != expectedTag {
214
-
t.Errorf("Expected tag %q at position %d, got %q", expectedTag, i, tags[i])
0
215
}
216
-
}
217
-
})
218
-
}
219
-
}
220
221
-
func TestIsFile(t *testing.T) {
222
-
testCases := []struct {
223
-
name string
224
-
input string
225
-
expected bool
226
-
}{
227
-
{"file with extension", "test.md", true},
228
-
{"file with multiple extensions", "test.tar.gz", true},
229
-
{"path with slash", "/path/to/file", true},
230
-
{"path with backslash", "path\\to\\file", true},
231
-
{"relative path", "./file", true},
232
-
{"just text", "hello", false},
233
-
{"empty string", "", false},
234
-
}
235
236
-
tempDir, err := os.MkdirTemp("", "isfile-test-*")
237
-
if err != nil {
238
-
t.Fatalf("Failed to create temp dir: %v", err)
239
-
}
240
-
defer os.RemoveAll(tempDir)
241
242
-
existingFile := filepath.Join(tempDir, "existing")
243
-
err = os.WriteFile(existingFile, []byte("test"), 0644)
244
-
if err != nil {
245
-
t.Fatalf("Failed to create test file: %v", err)
246
-
}
247
248
-
testCases = append(testCases, struct {
249
-
name string
250
-
input string
251
-
expected bool
252
-
}{"existing file without extension", existingFile, true})
253
254
-
for _, tc := range testCases {
255
-
t.Run(tc.name, func(t *testing.T) {
256
-
result := isFile(tc.input)
257
-
if result != tc.expected {
258
-
t.Errorf("isFile(%q) = %v, expected %v", tc.input, result, tc.expected)
259
-
}
260
-
})
261
-
}
262
-
}
263
264
-
func TestNoteHandler_getEditor(t *testing.T) {
265
-
handler := &NoteHandler{}
266
267
-
t.Run("uses EDITOR environment variable", func(t *testing.T) {
268
-
originalEditor := os.Getenv("EDITOR")
269
-
os.Setenv("EDITOR", "test-editor")
270
-
defer os.Setenv("EDITOR", originalEditor)
271
272
-
editor := handler.getEditor()
273
-
if editor != "test-editor" {
274
-
t.Errorf("Expected 'test-editor', got %q", editor)
275
-
}
276
-
})
277
278
-
t.Run("finds available editor", func(t *testing.T) {
279
-
originalEditor := os.Getenv("EDITOR")
280
-
os.Unsetenv("EDITOR")
281
-
defer os.Setenv("EDITOR", originalEditor)
282
283
-
editor := handler.getEditor()
284
-
if editor == "" {
285
-
t.Skip("No common editors found on system, skipping test")
286
-
}
287
-
})
288
289
-
t.Run("returns empty when no editor available", func(t *testing.T) {
290
-
originalEditor := os.Getenv("EDITOR")
291
-
originalPath := os.Getenv("PATH")
292
293
-
os.Unsetenv("EDITOR")
294
-
os.Setenv("PATH", "")
295
296
-
defer func() {
297
-
os.Setenv("EDITOR", originalEditor)
298
-
os.Setenv("PATH", originalPath)
299
-
}()
300
301
-
editor := handler.getEditor()
302
-
if editor != "" {
303
-
t.Errorf("Expected empty string when no editor available, got %q", editor)
304
-
}
0
305
})
306
-
}
307
308
-
func TestNoteCreateErrorScenarios(t *testing.T) {
309
-
errorTests := []struct {
310
-
name string
311
-
setupFunc func(t *testing.T) (cleanup func())
312
-
args []string
313
-
expectError bool
314
-
errorSubstr string
315
-
}{
316
-
{
317
-
name: "database initialization error",
318
-
setupFunc: func(t *testing.T) func() {
319
-
if runtime.GOOS == "windows" {
320
-
original := os.Getenv("APPDATA")
321
-
os.Unsetenv("APPDATA")
322
-
return func() { os.Setenv("APPDATA", original) }
323
-
} else {
324
-
originalXDG := os.Getenv("XDG_CONFIG_HOME")
325
-
originalHome := os.Getenv("HOME")
326
-
os.Unsetenv("XDG_CONFIG_HOME")
327
-
os.Unsetenv("HOME")
328
-
return func() {
329
-
os.Setenv("XDG_CONFIG_HOME", originalXDG)
330
-
os.Setenv("HOME", originalHome)
0
331
}
332
-
}
0
0
0
333
},
334
-
args: []string{"Test Note"},
335
-
expectError: true,
336
-
errorSubstr: "failed to initialize database",
337
-
},
338
-
{
339
-
name: "note creation in database fails",
340
-
setupFunc: func(t *testing.T) func() {
341
-
tempDir, cleanup := setupNoteTest(t)
342
343
-
configDir := filepath.Join(tempDir, "noteleaf")
344
-
dbPath := filepath.Join(configDir, "noteleaf.db")
345
346
-
err := os.WriteFile(dbPath, []byte("invalid sqlite content"), 0644)
347
-
if err != nil {
348
-
t.Fatalf("Failed to corrupt database: %v", err)
349
-
}
350
351
-
return cleanup
0
0
0
0
352
},
353
-
args: []string{"Test Note"},
354
-
expectError: true,
355
-
errorSubstr: "failed to initialize database",
356
-
},
357
-
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
358
359
-
for _, tt := range errorTests {
360
-
t.Run(tt.name, func(t *testing.T) {
361
-
cleanup := tt.setupFunc(t)
362
defer cleanup()
363
364
oldStdin := os.Stdin
···
372
}()
373
374
ctx := context.Background()
375
-
err := Create(ctx, tt.args)
376
-
377
-
if tt.expectError && err == nil {
378
-
t.Errorf("Expected error containing %q, got nil", tt.errorSubstr)
379
-
} else if !tt.expectError && err != nil {
380
-
t.Errorf("Expected no error, got: %v", err)
381
-
} else if tt.expectError && err != nil && !strings.Contains(err.Error(), tt.errorSubstr) {
382
-
t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
383
}
384
})
385
-
}
386
-
}
387
388
-
func TestCreate_WithArgs(t *testing.T) {
389
-
t.Run("creates note from title only", func(t *testing.T) {
390
-
_, cleanup := setupNoteTest(t)
391
-
defer cleanup()
392
393
-
oldStdin := os.Stdin
394
-
r, w, _ := os.Pipe()
395
-
os.Stdin = r
396
-
defer func() { os.Stdin = oldStdin }()
397
398
-
go func() {
399
-
w.WriteString("n\n")
400
-
w.Close()
401
-
}()
402
403
-
ctx := context.Background()
404
-
err := Create(ctx, []string{"Test Note"})
405
-
if err != nil {
406
-
t.Errorf("Create failed: %v", err)
407
-
}
408
-
})
409
410
-
t.Run("creates note from title and content", func(t *testing.T) {
411
-
_, cleanup := setupNoteTest(t)
412
-
defer cleanup()
413
414
-
oldStdin := os.Stdin
415
-
r, w, _ := os.Pipe()
416
-
os.Stdin = r
417
-
defer func() { os.Stdin = oldStdin }()
418
419
-
go func() {
420
-
w.WriteString("n\n")
421
-
w.Close()
422
-
}()
423
424
-
ctx := context.Background()
425
-
err := Create(ctx, []string{"Test Note", "This", "is", "test", "content"})
426
-
if err != nil {
427
-
t.Errorf("Create failed: %v", err)
428
-
}
429
-
})
430
431
-
t.Run("handles database connection error", func(t *testing.T) {
432
-
tempDir, cleanup := setupNoteTest(t)
433
-
defer cleanup()
434
435
-
configDir := filepath.Join(tempDir, "noteleaf")
436
-
dbPath := filepath.Join(configDir, "noteleaf.db")
437
-
os.Remove(dbPath)
0
438
439
-
os.MkdirAll(dbPath, 0755)
440
-
defer os.RemoveAll(dbPath)
0
0
441
442
-
ctx := context.Background()
443
-
err := Create(ctx, []string{"Test Note"})
444
-
if err == nil {
445
-
t.Error("Create should fail when database is inaccessible")
446
-
}
0
447
})
448
449
-
t.Run("New is alias for Create", func(t *testing.T) {
450
-
_, cleanup := setupNoteTest(t)
451
-
defer cleanup()
452
-
453
-
oldStdin := os.Stdin
454
-
r, w, _ := os.Pipe()
455
-
os.Stdin = r
456
-
defer func() { os.Stdin = oldStdin }()
457
-
458
-
go func() {
459
-
w.WriteString("n\n")
460
-
w.Close()
461
-
}()
462
-
463
-
ctx := context.Background()
464
-
err := New(ctx, []string{"Test Note via New"})
465
-
if err != nil {
466
-
t.Errorf("New failed: %v", err)
467
-
}
468
-
})
469
-
}
470
471
-
func TestCreate_FromFile(t *testing.T) {
472
-
t.Run("creates note from markdown file", func(t *testing.T) {
473
-
tempDir, cleanup := setupNoteTest(t)
474
-
defer cleanup()
475
-
476
-
content := `# My Test Note
477
478
This is the content of my test note.
479
···
483
484
<!-- Tags: personal, work -->`
485
486
-
filePath := createTestMarkdownFile(t, tempDir, "test.md", content)
487
488
-
ctx := context.Background()
489
-
err := Create(ctx, []string{filePath})
490
-
if err != nil {
491
-
t.Errorf("Create from file failed: %v", err)
492
-
}
493
-
})
494
495
-
t.Run("handles non-existent file", func(t *testing.T) {
496
-
_, cleanup := setupNoteTest(t)
497
-
defer cleanup()
498
499
-
ctx := context.Background()
500
-
err := Create(ctx, []string{"/non/existent/file.md"})
501
-
if err == nil {
502
-
t.Error("Create should fail for non-existent file")
503
-
}
504
-
if !strings.Contains(err.Error(), "file does not exist") {
505
-
t.Errorf("Expected file not found error, got: %v", err)
506
-
}
507
-
})
508
509
-
t.Run("handles empty file", func(t *testing.T) {
510
-
tempDir, cleanup := setupNoteTest(t)
511
-
defer cleanup()
512
513
-
filePath := createTestMarkdownFile(t, tempDir, "empty.md", "")
514
515
-
ctx := context.Background()
516
-
err := Create(ctx, []string{filePath})
517
-
if err == nil {
518
-
t.Error("Create should fail for empty file")
519
-
}
520
-
if !strings.Contains(err.Error(), "file is empty") {
521
-
t.Errorf("Expected empty file error, got: %v", err)
522
-
}
523
-
})
524
525
-
t.Run("handles whitespace-only file", func(t *testing.T) {
526
-
tempDir, cleanup := setupNoteTest(t)
527
-
defer cleanup()
528
529
-
filePath := createTestMarkdownFile(t, tempDir, "whitespace.md", " \n\t \n ")
530
531
-
ctx := context.Background()
532
-
err := Create(ctx, []string{filePath})
533
-
if err == nil {
534
-
t.Error("Create should fail for whitespace-only file")
535
-
}
536
-
if !strings.Contains(err.Error(), "file is empty") {
537
-
t.Errorf("Expected empty file error, got: %v", err)
538
-
}
539
-
})
540
541
-
t.Run("creates note without title in file", func(t *testing.T) {
542
-
tempDir, cleanup := setupNoteTest(t)
543
-
defer cleanup()
544
545
-
content := `This note has no title heading.
546
547
Just some content here.`
548
549
-
filePath := createTestMarkdownFile(t, tempDir, "notitle.md", content)
550
551
-
ctx := context.Background()
552
-
err := Create(ctx, []string{filePath})
553
-
if err != nil {
554
-
t.Errorf("Create from file without title failed: %v", err)
555
-
}
556
-
})
557
558
-
t.Run("handles file read error", func(t *testing.T) {
559
-
tempDir, cleanup := setupNoteTest(t)
560
-
defer cleanup()
561
562
-
filePath := createTestMarkdownFile(t, tempDir, "unreadable.md", "test content")
563
-
err := os.Chmod(filePath, 0000)
564
-
if err != nil {
565
-
t.Fatalf("Failed to make file unreadable: %v", err)
566
-
}
567
-
defer os.Chmod(filePath, 0644)
568
569
-
ctx := context.Background()
570
-
err = Create(ctx, []string{filePath})
571
-
if err == nil {
572
-
t.Error("Create should fail for unreadable file")
573
-
}
574
-
if !strings.Contains(err.Error(), "failed to read file") {
575
-
t.Errorf("Expected file read error, got: %v", err)
576
-
}
0
577
})
578
-
}
579
580
-
func TestCreate_Interactive(t *testing.T) {
581
-
t.Run("handles no editor configured", func(t *testing.T) {
582
-
_, cleanup := setupNoteTest(t)
583
-
defer cleanup()
584
585
-
originalEditor := os.Getenv("EDITOR")
586
-
originalPath := os.Getenv("PATH")
587
-
os.Unsetenv("EDITOR")
588
-
os.Setenv("PATH", "")
589
-
defer func() {
590
-
os.Setenv("EDITOR", originalEditor)
591
-
os.Setenv("PATH", originalPath)
592
-
}()
593
594
-
ctx := context.Background()
595
-
err := Create(ctx, []string{})
596
-
if err == nil {
597
-
t.Error("Create should fail when no editor is configured")
598
-
}
599
-
if !strings.Contains(err.Error(), "no editor configured") {
600
-
t.Errorf("Expected no editor error, got: %v", err)
601
-
}
602
-
})
603
604
-
t.Run("handles editor command failure", func(t *testing.T) {
605
-
_, cleanup := setupNoteTest(t)
606
-
defer cleanup()
607
608
-
originalEditor := os.Getenv("EDITOR")
609
-
os.Setenv("EDITOR", "nonexistent-editor-12345")
610
-
defer os.Setenv("EDITOR", originalEditor)
611
612
-
ctx := context.Background()
613
-
err := Create(ctx, []string{})
614
-
if err == nil {
615
-
t.Error("Create should fail when editor command fails")
616
-
}
617
-
if !strings.Contains(err.Error(), "failed to open editor") {
618
-
t.Errorf("Expected editor failure error, got: %v", err)
619
-
}
620
-
})
621
622
-
t.Run("creates note successfully with mocked editor", func(t *testing.T) {
623
-
_, cleanup := setupNoteTest(t)
624
-
defer cleanup()
625
626
-
originalEditor := os.Getenv("EDITOR")
627
-
os.Setenv("EDITOR", "test-editor")
628
-
defer os.Setenv("EDITOR", originalEditor)
629
630
-
handler, err := NewNoteHandler()
631
-
if err != nil {
632
-
t.Fatalf("NewNoteHandler failed: %v", err)
633
-
}
634
-
defer handler.Close()
635
636
-
handler.openInEditorFunc = func(editor, filePath string) error {
637
-
content := `# Test Note
638
639
This is edited content.
640
641
<!-- Tags: test, created -->`
642
-
return os.WriteFile(filePath, []byte(content), 0644)
643
-
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
644
645
-
ctx := context.Background()
646
-
err = handler.createInteractive(ctx)
647
-
if err != nil {
648
-
t.Errorf("Interactive create failed: %v", err)
649
-
}
0
650
})
651
652
-
t.Run("handles editor cancellation", func(t *testing.T) {
653
_, cleanup := setupNoteTest(t)
654
defer cleanup()
655
656
-
originalEditor := os.Getenv("EDITOR")
657
-
os.Setenv("EDITOR", "test-editor")
658
-
defer os.Setenv("EDITOR", originalEditor)
659
-
660
handler, err := NewNoteHandler()
661
if err != nil {
662
t.Fatalf("NewNoteHandler failed: %v", err)
663
}
664
-
defer handler.Close()
665
666
-
handler.openInEditorFunc = func(editor, filePath string) error {
667
-
return nil
0
668
}
669
670
-
ctx := context.Background()
671
-
err = handler.createInteractive(ctx)
672
if err != nil {
673
-
t.Errorf("Interactive create should handle cancellation gracefully: %v", err)
674
}
675
})
676
-
}
677
678
-
func TestNoteHandlerClosesResources(t *testing.T) {
679
-
_, cleanup := setupNoteTest(t)
680
-
defer cleanup()
681
-
682
-
handler, err := NewNoteHandler()
683
-
if err != nil {
684
-
t.Fatalf("NewNoteHandler failed: %v", err)
685
-
}
686
-
687
-
err = handler.Close()
688
-
if err != nil {
689
-
t.Errorf("Close should not return error: %v", err)
690
-
}
691
-
692
-
handler.db = nil
693
-
err = handler.Close()
694
-
if err != nil {
695
-
t.Errorf("Close should handle nil database gracefully: %v", err)
696
-
}
697
-
}
698
-
699
-
func TestEdit(t *testing.T) {
700
-
t.Run("validates argument count", func(t *testing.T) {
701
-
_, cleanup := setupNoteTest(t)
702
-
defer cleanup()
703
704
-
ctx := context.Background()
705
706
-
err := Edit(ctx, []string{})
707
-
if err == nil {
708
-
t.Error("Edit should fail with no arguments")
709
-
}
710
-
if !strings.Contains(err.Error(), "edit requires exactly one argument") {
711
-
t.Errorf("Expected argument count error, got: %v", err)
712
-
}
713
714
-
err = Edit(ctx, []string{"1", "2"})
715
-
if err == nil {
716
-
t.Error("Edit should fail with too many arguments")
717
-
}
718
-
if !strings.Contains(err.Error(), "edit requires exactly one argument") {
719
-
t.Errorf("Expected argument count error, got: %v", err)
720
-
}
721
-
})
722
723
-
t.Run("validates note ID format", func(t *testing.T) {
724
-
_, cleanup := setupNoteTest(t)
725
-
defer cleanup()
726
727
-
ctx := context.Background()
728
729
-
err := Edit(ctx, []string{"invalid"})
730
-
if err == nil {
731
-
t.Error("Edit should fail with invalid note ID")
732
-
}
733
-
if !strings.Contains(err.Error(), "invalid note ID") {
734
-
t.Errorf("Expected invalid ID error, got: %v", err)
735
-
}
736
737
-
err = Edit(ctx, []string{"-1"})
738
-
if err == nil {
739
-
t.Error("Edit should fail with negative note ID")
740
-
}
741
742
-
if !strings.Contains(err.Error(), "failed to get note") {
743
-
t.Errorf("Expected note not found error for negative ID, got: %v", err)
744
-
}
745
-
})
746
747
-
t.Run("handles non-existent note", func(t *testing.T) {
748
-
_, cleanup := setupNoteTest(t)
749
-
defer cleanup()
750
751
-
ctx := context.Background()
752
753
-
err := Edit(ctx, []string{"999"})
754
-
if err == nil {
755
-
t.Error("Edit should fail with non-existent note ID")
756
-
}
757
-
if !strings.Contains(err.Error(), "failed to get note") {
758
-
t.Errorf("Expected note not found error, got: %v", err)
759
-
}
760
-
})
761
762
-
t.Run("handles no editor configured", func(t *testing.T) {
763
-
_, cleanup := setupNoteTest(t)
764
-
defer cleanup()
765
766
-
originalEditor := os.Getenv("EDITOR")
767
-
originalPath := os.Getenv("PATH")
768
-
os.Setenv("EDITOR", "")
769
-
os.Setenv("PATH", "")
770
-
defer func() {
771
-
os.Setenv("EDITOR", originalEditor)
772
-
os.Setenv("PATH", originalPath)
773
-
}()
774
775
-
ctx := context.Background()
776
777
-
err := Create(ctx, []string{"Test Note", "Test content"})
778
-
if err != nil {
779
-
t.Fatalf("Failed to create test note: %v", err)
780
-
}
781
782
-
err = Edit(ctx, []string{"1"})
783
-
if err == nil {
784
-
t.Error("Edit should fail when no editor is configured")
785
-
}
786
787
-
if !strings.Contains(err.Error(), "no editor configured") && !strings.Contains(err.Error(), "failed to open editor") {
788
-
t.Errorf("Expected no editor or editor failure error, got: %v", err)
789
-
}
790
-
})
791
792
-
t.Run("handles editor command failure", func(t *testing.T) {
793
-
_, cleanup := setupNoteTest(t)
794
-
defer cleanup()
795
796
-
originalEditor := os.Getenv("EDITOR")
797
-
os.Setenv("EDITOR", "nonexistent-editor-12345")
798
-
defer os.Setenv("EDITOR", originalEditor)
799
800
-
ctx := context.Background()
801
802
-
err := Create(ctx, []string{"Test Note", "Test content"})
803
-
if err != nil {
804
-
t.Fatalf("Failed to create test note: %v", err)
805
-
}
806
807
-
err = Edit(ctx, []string{"1"})
808
-
if err == nil {
809
-
t.Error("Edit should fail when editor command fails")
810
-
}
811
-
if !strings.Contains(err.Error(), "failed to open editor") {
812
-
t.Errorf("Expected editor failure error, got: %v", err)
813
-
}
814
-
})
815
816
-
t.Run("edits note successfully with mocked editor", func(t *testing.T) {
817
-
_, cleanup := setupNoteTest(t)
818
-
defer cleanup()
819
820
-
originalEditor := os.Getenv("EDITOR")
821
-
os.Setenv("EDITOR", "test-editor")
822
-
defer os.Setenv("EDITOR", originalEditor)
823
824
-
ctx := context.Background()
825
826
-
err := Create(ctx, []string{"Original Title", "Original content"})
827
-
if err != nil {
828
-
t.Fatalf("Failed to create test note: %v", err)
829
-
}
830
831
-
handler, err := NewNoteHandler()
832
-
if err != nil {
833
-
t.Fatalf("NewNoteHandler failed: %v", err)
834
-
}
835
-
defer handler.Close()
836
837
-
handler.openInEditorFunc = func(editor, filePath string) error {
838
-
newContent := `# Updated Title
839
840
This is updated content.
841
842
<!-- Tags: updated, test -->`
843
-
return os.WriteFile(filePath, []byte(newContent), 0644)
844
-
}
845
846
-
err = handler.editNote(ctx, 1)
847
-
if err != nil {
848
-
t.Errorf("Edit should succeed with mocked editor: %v", err)
849
-
}
850
851
-
note, err := handler.repos.Notes.Get(ctx, 1)
852
-
if err != nil {
853
-
t.Fatalf("Failed to get updated note: %v", err)
854
-
}
855
856
-
if note.Title != "Updated Title" {
857
-
t.Errorf("Expected title 'Updated Title', got %q", note.Title)
858
-
}
859
860
-
if !strings.Contains(note.Content, "This is updated content") {
861
-
t.Errorf("Expected content to contain 'This is updated content', got %q", note.Content)
862
-
}
863
864
-
expectedTags := []string{"updated", "test"}
865
-
if len(note.Tags) != len(expectedTags) {
866
-
t.Errorf("Expected %d tags, got %d", len(expectedTags), len(note.Tags))
867
-
}
868
-
for i, tag := range expectedTags {
869
-
if i >= len(note.Tags) || note.Tags[i] != tag {
870
-
t.Errorf("Expected tag %q at index %d, got %q", tag, i, note.Tags[i])
0
871
}
872
-
}
873
-
})
874
875
-
t.Run("handles editor cancellation (no changes)", func(t *testing.T) {
876
-
_, cleanup := setupNoteTest(t)
877
-
defer cleanup()
878
879
-
originalEditor := os.Getenv("EDITOR")
880
-
os.Setenv("EDITOR", "test-editor")
881
-
defer os.Setenv("EDITOR", originalEditor)
882
883
-
ctx := context.Background()
884
885
-
err := Create(ctx, []string{"Test Note", "Test content"})
886
-
if err != nil {
887
-
t.Fatalf("Failed to create test note: %v", err)
888
-
}
889
890
-
handler, err := NewNoteHandler()
891
-
if err != nil {
892
-
t.Fatalf("NewNoteHandler failed: %v", err)
893
-
}
894
-
defer handler.Close()
895
896
-
handler.openInEditorFunc = func(editor, filePath string) error {
897
-
return nil
898
-
}
899
900
-
err = handler.editNote(ctx, 1)
901
-
if err != nil {
902
-
t.Errorf("Edit should handle cancellation gracefully: %v", err)
903
-
}
904
905
-
note, err := handler.repos.Notes.Get(ctx, 1)
906
-
if err != nil {
907
-
t.Fatalf("Failed to get note: %v", err)
908
-
}
909
910
-
if note.Title != "Test Note" {
911
-
t.Errorf("Expected title 'Test Note', got %q", note.Title)
912
-
}
913
914
-
if note.Content != "Test content" {
915
-
t.Errorf("Expected content 'Test content', got %q", note.Content)
916
-
}
0
917
})
918
}
···
42
return filePath
43
}
44
45
+
func TestNoteHandler(t *testing.T) {
46
+
t.Run("New", func(t *testing.T) {
47
+
t.Run("creates handler successfully", func(t *testing.T) {
48
+
_, cleanup := setupNoteTest(t)
49
+
defer cleanup()
50
51
+
handler, err := NewNoteHandler()
52
+
if err != nil {
53
+
t.Fatalf("NewNoteHandler failed: %v", err)
54
+
}
55
+
if handler == nil {
56
+
t.Fatal("Handler should not be nil")
57
+
}
58
+
defer handler.Close()
59
60
+
if handler.db == nil {
61
+
t.Error("Handler database should not be nil")
62
+
}
63
+
if handler.config == nil {
64
+
t.Error("Handler config should not be nil")
65
+
}
66
+
if handler.repos == nil {
67
+
t.Error("Handler repos should not be nil")
68
+
}
69
+
})
70
71
+
t.Run("handles database initialization error", func(t *testing.T) {
72
+
originalXDG := os.Getenv("XDG_CONFIG_HOME")
73
+
originalHome := os.Getenv("HOME")
74
75
+
if runtime.GOOS == "windows" {
76
+
originalAppData := os.Getenv("APPDATA")
77
+
os.Unsetenv("APPDATA")
78
+
defer os.Setenv("APPDATA", originalAppData)
79
+
} else {
80
+
os.Unsetenv("XDG_CONFIG_HOME")
81
+
os.Unsetenv("HOME")
82
+
}
83
84
+
defer func() {
85
+
os.Setenv("XDG_CONFIG_HOME", originalXDG)
86
+
os.Setenv("HOME", originalHome)
87
+
}()
88
89
+
_, err := NewNoteHandler()
90
+
if err == nil {
91
+
t.Error("NewNoteHandler should fail when database initialization fails")
92
+
}
93
+
if !strings.Contains(err.Error(), "failed to initialize database") {
94
+
t.Errorf("Expected database error, got: %v", err)
95
+
}
96
+
})
97
})
0
98
99
+
t.Run("parse content", func(t *testing.T) {
100
+
handler := &NoteHandler{}
101
102
+
testCases := []struct {
103
+
name string
104
+
input string
105
+
expectedTitle string
106
+
expectedContent string
107
+
expectedTags []string
108
+
}{
109
+
{
110
+
name: "note with title and tags",
111
+
input: `# My Test Note
112
113
This is the content.
114
115
<!-- Tags: personal, work, important -->`,
116
+
expectedTitle: "My Test Note",
117
+
expectedContent: `# My Test Note
118
119
This is the content.
120
121
<!-- Tags: personal, work, important -->`,
122
+
expectedTags: []string{"personal", "work", "important"},
123
+
},
124
+
{
125
+
name: "note without title",
126
+
input: `Just some content here.
127
128
No title heading.
129
130
<!-- Tags: test -->`,
131
+
expectedTitle: "",
132
+
expectedContent: `Just some content here.
133
134
No title heading.
135
136
<!-- Tags: test -->`,
137
+
expectedTags: []string{"test"},
138
+
},
139
+
{
140
+
name: "note without tags",
141
+
input: `# Title Only
142
143
Content without tags.`,
144
+
expectedTitle: "Title Only",
145
+
expectedContent: `# Title Only
146
147
Content without tags.`,
148
+
expectedTags: nil,
149
+
},
150
+
{
151
+
name: "empty tags comment",
152
+
input: `# Test Note
153
154
Content here.
155
156
<!-- Tags: -->`,
157
+
expectedTitle: "Test Note",
158
+
expectedContent: `# Test Note
159
160
Content here.
161
162
<!-- Tags: -->`,
163
+
expectedTags: nil,
164
+
},
165
+
{
166
+
name: "malformed tags comment",
167
+
input: `# Test Note
168
169
Content here.
170
171
<!-- Tags: tag1, , tag2,, tag3 -->`,
172
+
expectedTitle: "Test Note",
173
+
expectedContent: `# Test Note
174
175
Content here.
176
177
<!-- Tags: tag1, , tag2,, tag3 -->`,
178
+
expectedTags: []string{"tag1", "tag2", "tag3"},
179
+
},
180
+
{
181
+
name: "multiple headings",
182
+
input: `## Secondary Heading
183
184
# Main Title
185
186
Content here.`,
187
+
expectedTitle: "Main Title",
188
+
expectedContent: `## Secondary Heading
189
190
# Main Title
191
192
Content here.`,
193
+
expectedTags: nil,
194
+
},
195
+
}
196
197
+
for _, tc := range testCases {
198
+
t.Run(tc.name, func(t *testing.T) {
199
+
title, content, tags := handler.parseNoteContent(tc.input)
200
201
+
if title != tc.expectedTitle {
202
+
t.Errorf("Expected title %q, got %q", tc.expectedTitle, title)
203
+
}
204
205
+
if content != tc.expectedContent {
206
+
t.Errorf("Expected content %q, got %q", tc.expectedContent, content)
207
+
}
208
209
+
if len(tags) != len(tc.expectedTags) {
210
+
t.Errorf("Expected %d tags, got %d", len(tc.expectedTags), len(tags))
211
+
}
212
213
+
for i, expectedTag := range tc.expectedTags {
214
+
if i >= len(tags) || tags[i] != expectedTag {
215
+
t.Errorf("Expected tag %q at position %d, got %q", expectedTag, i, tags[i])
216
+
}
217
}
218
+
})
219
+
}
220
+
})
0
221
222
+
t.Run("IsFile helper", func(t *testing.T) {
223
+
testCases := []struct {
224
+
name string
225
+
input string
226
+
expected bool
227
+
}{
228
+
{"file with extension", "test.md", true},
229
+
{"file with multiple extensions", "test.tar.gz", true},
230
+
{"path with slash", "/path/to/file", true},
231
+
{"path with backslash", "path\\to\\file", true},
232
+
{"relative path", "./file", true},
233
+
{"just text", "hello", false},
234
+
{"empty string", "", false},
235
+
}
236
237
+
tempDir, err := os.MkdirTemp("", "isfile-test-*")
238
+
if err != nil {
239
+
t.Fatalf("Failed to create temp dir: %v", err)
240
+
}
241
+
defer os.RemoveAll(tempDir)
242
243
+
existingFile := filepath.Join(tempDir, "existing")
244
+
err = os.WriteFile(existingFile, []byte("test"), 0644)
245
+
if err != nil {
246
+
t.Fatalf("Failed to create test file: %v", err)
247
+
}
248
249
+
testCases = append(testCases, struct {
250
+
name string
251
+
input string
252
+
expected bool
253
+
}{"existing file without extension", existingFile, true})
254
255
+
for _, tc := range testCases {
256
+
t.Run(tc.name, func(t *testing.T) {
257
+
result := isFile(tc.input)
258
+
if result != tc.expected {
259
+
t.Errorf("isFile(%q) = %v, expected %v", tc.input, result, tc.expected)
260
+
}
261
+
})
262
+
}
263
+
})
264
265
+
t.Run("getEditor", func(t *testing.T) {
266
+
handler := &NoteHandler{}
267
268
+
t.Run("uses EDITOR environment variable", func(t *testing.T) {
269
+
originalEditor := os.Getenv("EDITOR")
270
+
os.Setenv("EDITOR", "test-editor")
271
+
defer os.Setenv("EDITOR", originalEditor)
272
273
+
editor := handler.getEditor()
274
+
if editor != "test-editor" {
275
+
t.Errorf("Expected 'test-editor', got %q", editor)
276
+
}
277
+
})
278
279
+
t.Run("finds available editor", func(t *testing.T) {
280
+
originalEditor := os.Getenv("EDITOR")
281
+
os.Unsetenv("EDITOR")
282
+
defer os.Setenv("EDITOR", originalEditor)
283
284
+
editor := handler.getEditor()
285
+
if editor == "" {
286
+
t.Skip("No common editors found on system, skipping test")
287
+
}
288
+
})
289
290
+
t.Run("returns empty when no editor available", func(t *testing.T) {
291
+
originalEditor := os.Getenv("EDITOR")
292
+
originalPath := os.Getenv("PATH")
293
294
+
os.Unsetenv("EDITOR")
295
+
os.Setenv("PATH", "")
296
297
+
defer func() {
298
+
os.Setenv("EDITOR", originalEditor)
299
+
os.Setenv("PATH", originalPath)
300
+
}()
301
302
+
editor := handler.getEditor()
303
+
if editor != "" {
304
+
t.Errorf("Expected empty string when no editor available, got %q", editor)
305
+
}
306
+
})
307
})
0
308
309
+
t.Run("Create Errors", func(t *testing.T) {
310
+
errorTests := []struct {
311
+
name string
312
+
setupFunc func(t *testing.T) (cleanup func())
313
+
args []string
314
+
expectError bool
315
+
errorSubstr string
316
+
}{
317
+
{
318
+
name: "database initialization error",
319
+
setupFunc: func(t *testing.T) func() {
320
+
if runtime.GOOS == "windows" {
321
+
original := os.Getenv("APPDATA")
322
+
os.Unsetenv("APPDATA")
323
+
return func() { os.Setenv("APPDATA", original) }
324
+
} else {
325
+
originalXDG := os.Getenv("XDG_CONFIG_HOME")
326
+
originalHome := os.Getenv("HOME")
327
+
os.Unsetenv("XDG_CONFIG_HOME")
328
+
os.Unsetenv("HOME")
329
+
return func() {
330
+
os.Setenv("XDG_CONFIG_HOME", originalXDG)
331
+
os.Setenv("HOME", originalHome)
332
+
}
333
}
334
+
},
335
+
args: []string{"Test Note"},
336
+
expectError: true,
337
+
errorSubstr: "failed to initialize database",
338
},
339
+
{
340
+
name: "note creation in database fails",
341
+
setupFunc: func(t *testing.T) func() {
342
+
tempDir, cleanup := setupNoteTest(t)
0
0
0
0
343
344
+
configDir := filepath.Join(tempDir, "noteleaf")
345
+
dbPath := filepath.Join(configDir, "noteleaf.db")
346
347
+
err := os.WriteFile(dbPath, []byte("invalid sqlite content"), 0644)
348
+
if err != nil {
349
+
t.Fatalf("Failed to corrupt database: %v", err)
350
+
}
351
352
+
return cleanup
353
+
},
354
+
args: []string{"Test Note"},
355
+
expectError: true,
356
+
errorSubstr: "failed to initialize database",
357
},
358
+
}
359
+
360
+
for _, tt := range errorTests {
361
+
t.Run(tt.name, func(t *testing.T) {
362
+
cleanup := tt.setupFunc(t)
363
+
defer cleanup()
364
+
365
+
oldStdin := os.Stdin
366
+
r, w, _ := os.Pipe()
367
+
os.Stdin = r
368
+
defer func() { os.Stdin = oldStdin }()
369
+
370
+
go func() {
371
+
w.WriteString("n\n")
372
+
w.Close()
373
+
}()
374
+
375
+
ctx := context.Background()
376
+
err := Create(ctx, tt.args)
377
+
378
+
if tt.expectError && err == nil {
379
+
t.Errorf("Expected error containing %q, got nil", tt.errorSubstr)
380
+
} else if !tt.expectError && err != nil {
381
+
t.Errorf("Expected no error, got: %v", err)
382
+
} else if tt.expectError && err != nil && !strings.Contains(err.Error(), tt.errorSubstr) {
383
+
t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
384
+
}
385
+
})
386
+
}
387
+
})
388
389
+
t.Run("Create (args)", func(t *testing.T) {
390
+
t.Run("creates note from title only", func(t *testing.T) {
391
+
_, cleanup := setupNoteTest(t)
392
defer cleanup()
393
394
oldStdin := os.Stdin
···
402
}()
403
404
ctx := context.Background()
405
+
err := Create(ctx, []string{"Test Note"})
406
+
if err != nil {
407
+
t.Errorf("Create failed: %v", err)
0
0
0
0
0
408
}
409
})
0
0
410
411
+
t.Run("creates note from title and content", func(t *testing.T) {
412
+
_, cleanup := setupNoteTest(t)
413
+
defer cleanup()
0
414
415
+
oldStdin := os.Stdin
416
+
r, w, _ := os.Pipe()
417
+
os.Stdin = r
418
+
defer func() { os.Stdin = oldStdin }()
419
420
+
go func() {
421
+
w.WriteString("n\n")
422
+
w.Close()
423
+
}()
424
425
+
ctx := context.Background()
426
+
err := Create(ctx, []string{"Test Note", "This", "is", "test", "content"})
427
+
if err != nil {
428
+
t.Errorf("Create failed: %v", err)
429
+
}
430
+
})
431
432
+
t.Run("handles database connection error", func(t *testing.T) {
433
+
tempDir, cleanup := setupNoteTest(t)
434
+
defer cleanup()
435
436
+
configDir := filepath.Join(tempDir, "noteleaf")
437
+
dbPath := filepath.Join(configDir, "noteleaf.db")
438
+
os.Remove(dbPath)
0
439
440
+
os.MkdirAll(dbPath, 0755)
441
+
defer os.RemoveAll(dbPath)
0
0
442
443
+
ctx := context.Background()
444
+
err := Create(ctx, []string{"Test Note"})
445
+
if err == nil {
446
+
t.Error("Create should fail when database is inaccessible")
447
+
}
448
+
})
449
450
+
t.Run("New is alias for Create", func(t *testing.T) {
451
+
_, cleanup := setupNoteTest(t)
452
+
defer cleanup()
453
454
+
oldStdin := os.Stdin
455
+
r, w, _ := os.Pipe()
456
+
os.Stdin = r
457
+
defer func() { os.Stdin = oldStdin }()
458
459
+
go func() {
460
+
w.WriteString("n\n")
461
+
w.Close()
462
+
}()
463
464
+
ctx := context.Background()
465
+
err := New(ctx, []string{"Test Note via New"})
466
+
if err != nil {
467
+
t.Errorf("New failed: %v", err)
468
+
}
469
+
})
470
})
471
472
+
t.Run("Create from file", func(t *testing.T) {
473
+
t.Run("creates note from markdown file", func(t *testing.T) {
474
+
tempDir, cleanup := setupNoteTest(t)
475
+
defer cleanup()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
476
477
+
content := `# My Test Note
0
0
0
0
0
478
479
This is the content of my test note.
480
···
484
485
<!-- Tags: personal, work -->`
486
487
+
filePath := createTestMarkdownFile(t, tempDir, "test.md", content)
488
489
+
ctx := context.Background()
490
+
err := Create(ctx, []string{filePath})
491
+
if err != nil {
492
+
t.Errorf("Create from file failed: %v", err)
493
+
}
494
+
})
495
496
+
t.Run("handles non-existent file", func(t *testing.T) {
497
+
_, cleanup := setupNoteTest(t)
498
+
defer cleanup()
499
500
+
ctx := context.Background()
501
+
err := Create(ctx, []string{"/non/existent/file.md"})
502
+
if err == nil {
503
+
t.Error("Create should fail for non-existent file")
504
+
}
505
+
if !strings.Contains(err.Error(), "file does not exist") {
506
+
t.Errorf("Expected file not found error, got: %v", err)
507
+
}
508
+
})
509
510
+
t.Run("handles empty file", func(t *testing.T) {
511
+
tempDir, cleanup := setupNoteTest(t)
512
+
defer cleanup()
513
514
+
filePath := createTestMarkdownFile(t, tempDir, "empty.md", "")
515
516
+
ctx := context.Background()
517
+
err := Create(ctx, []string{filePath})
518
+
if err == nil {
519
+
t.Error("Create should fail for empty file")
520
+
}
521
+
if !strings.Contains(err.Error(), "file is empty") {
522
+
t.Errorf("Expected empty file error, got: %v", err)
523
+
}
524
+
})
525
526
+
t.Run("handles whitespace-only file", func(t *testing.T) {
527
+
tempDir, cleanup := setupNoteTest(t)
528
+
defer cleanup()
529
530
+
filePath := createTestMarkdownFile(t, tempDir, "whitespace.md", " \n\t \n ")
531
532
+
ctx := context.Background()
533
+
err := Create(ctx, []string{filePath})
534
+
if err == nil {
535
+
t.Error("Create should fail for whitespace-only file")
536
+
}
537
+
if !strings.Contains(err.Error(), "file is empty") {
538
+
t.Errorf("Expected empty file error, got: %v", err)
539
+
}
540
+
})
541
542
+
t.Run("creates note without title in file", func(t *testing.T) {
543
+
tempDir, cleanup := setupNoteTest(t)
544
+
defer cleanup()
545
546
+
content := `This note has no title heading.
547
548
Just some content here.`
549
550
+
filePath := createTestMarkdownFile(t, tempDir, "notitle.md", content)
551
552
+
ctx := context.Background()
553
+
err := Create(ctx, []string{filePath})
554
+
if err != nil {
555
+
t.Errorf("Create from file without title failed: %v", err)
556
+
}
557
+
})
558
559
+
t.Run("handles file read error", func(t *testing.T) {
560
+
tempDir, cleanup := setupNoteTest(t)
561
+
defer cleanup()
562
563
+
filePath := createTestMarkdownFile(t, tempDir, "unreadable.md", "test content")
564
+
err := os.Chmod(filePath, 0000)
565
+
if err != nil {
566
+
t.Fatalf("Failed to make file unreadable: %v", err)
567
+
}
568
+
defer os.Chmod(filePath, 0644)
569
570
+
ctx := context.Background()
571
+
err = Create(ctx, []string{filePath})
572
+
if err == nil {
573
+
t.Error("Create should fail for unreadable file")
574
+
}
575
+
if !strings.Contains(err.Error(), "failed to read file") {
576
+
t.Errorf("Expected file read error, got: %v", err)
577
+
}
578
+
})
579
})
0
580
581
+
t.Run("Interactive Create", func(t *testing.T) {
582
+
t.Run("handles no editor configured", func(t *testing.T) {
583
+
_, cleanup := setupNoteTest(t)
584
+
defer cleanup()
585
586
+
originalEditor := os.Getenv("EDITOR")
587
+
originalPath := os.Getenv("PATH")
588
+
os.Unsetenv("EDITOR")
589
+
os.Setenv("PATH", "")
590
+
defer func() {
591
+
os.Setenv("EDITOR", originalEditor)
592
+
os.Setenv("PATH", originalPath)
593
+
}()
594
595
+
ctx := context.Background()
596
+
err := Create(ctx, []string{})
597
+
if err == nil {
598
+
t.Error("Create should fail when no editor is configured")
599
+
}
600
+
if !strings.Contains(err.Error(), "no editor configured") {
601
+
t.Errorf("Expected no editor error, got: %v", err)
602
+
}
603
+
})
604
605
+
t.Run("handles editor command failure", func(t *testing.T) {
606
+
_, cleanup := setupNoteTest(t)
607
+
defer cleanup()
608
609
+
originalEditor := os.Getenv("EDITOR")
610
+
os.Setenv("EDITOR", "nonexistent-editor-12345")
611
+
defer os.Setenv("EDITOR", originalEditor)
612
613
+
ctx := context.Background()
614
+
err := Create(ctx, []string{})
615
+
if err == nil {
616
+
t.Error("Create should fail when editor command fails")
617
+
}
618
+
if !strings.Contains(err.Error(), "failed to open editor") {
619
+
t.Errorf("Expected editor failure error, got: %v", err)
620
+
}
621
+
})
622
623
+
t.Run("creates note successfully with mocked editor", func(t *testing.T) {
624
+
_, cleanup := setupNoteTest(t)
625
+
defer cleanup()
626
627
+
originalEditor := os.Getenv("EDITOR")
628
+
os.Setenv("EDITOR", "test-editor")
629
+
defer os.Setenv("EDITOR", originalEditor)
630
631
+
handler, err := NewNoteHandler()
632
+
if err != nil {
633
+
t.Fatalf("NewNoteHandler failed: %v", err)
634
+
}
635
+
defer handler.Close()
636
637
+
handler.openInEditorFunc = func(editor, filePath string) error {
638
+
content := `# Test Note
639
640
This is edited content.
641
642
<!-- Tags: test, created -->`
643
+
return os.WriteFile(filePath, []byte(content), 0644)
644
+
}
645
+
646
+
ctx := context.Background()
647
+
err = handler.createInteractive(ctx)
648
+
if err != nil {
649
+
t.Errorf("Interactive create failed: %v", err)
650
+
}
651
+
})
652
+
653
+
t.Run("handles editor cancellation", func(t *testing.T) {
654
+
_, cleanup := setupNoteTest(t)
655
+
defer cleanup()
656
+
657
+
originalEditor := os.Getenv("EDITOR")
658
+
os.Setenv("EDITOR", "test-editor")
659
+
defer os.Setenv("EDITOR", originalEditor)
660
+
661
+
handler, err := NewNoteHandler()
662
+
if err != nil {
663
+
t.Fatalf("NewNoteHandler failed: %v", err)
664
+
}
665
+
defer handler.Close()
666
+
667
+
handler.openInEditorFunc = func(editor, filePath string) error {
668
+
return nil
669
+
}
670
671
+
ctx := context.Background()
672
+
err = handler.createInteractive(ctx)
673
+
if err != nil {
674
+
t.Errorf("Interactive create should handle cancellation gracefully: %v", err)
675
+
}
676
+
})
677
})
678
679
+
t.Run("Close", func(t *testing.T) {
680
_, cleanup := setupNoteTest(t)
681
defer cleanup()
682
0
0
0
0
683
handler, err := NewNoteHandler()
684
if err != nil {
685
t.Fatalf("NewNoteHandler failed: %v", err)
686
}
0
687
688
+
err = handler.Close()
689
+
if err != nil {
690
+
t.Errorf("Close should not return error: %v", err)
691
}
692
693
+
handler.db = nil
694
+
err = handler.Close()
695
if err != nil {
696
+
t.Errorf("Close should handle nil database gracefully: %v", err)
697
}
698
})
0
699
700
+
t.Run("Edit", func(t *testing.T) {
701
+
t.Run("validates argument count", func(t *testing.T) {
702
+
_, cleanup := setupNoteTest(t)
703
+
defer cleanup()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
704
705
+
ctx := context.Background()
706
707
+
err := Edit(ctx, []string{})
708
+
if err == nil {
709
+
t.Error("Edit should fail with no arguments")
710
+
}
711
+
if !strings.Contains(err.Error(), "edit requires exactly one argument") {
712
+
t.Errorf("Expected argument count error, got: %v", err)
713
+
}
714
715
+
err = Edit(ctx, []string{"1", "2"})
716
+
if err == nil {
717
+
t.Error("Edit should fail with too many arguments")
718
+
}
719
+
if !strings.Contains(err.Error(), "edit requires exactly one argument") {
720
+
t.Errorf("Expected argument count error, got: %v", err)
721
+
}
722
+
})
723
724
+
t.Run("validates note ID format", func(t *testing.T) {
725
+
_, cleanup := setupNoteTest(t)
726
+
defer cleanup()
727
728
+
ctx := context.Background()
729
730
+
err := Edit(ctx, []string{"invalid"})
731
+
if err == nil {
732
+
t.Error("Edit should fail with invalid note ID")
733
+
}
734
+
if !strings.Contains(err.Error(), "invalid note ID") {
735
+
t.Errorf("Expected invalid ID error, got: %v", err)
736
+
}
737
738
+
err = Edit(ctx, []string{"-1"})
739
+
if err == nil {
740
+
t.Error("Edit should fail with negative note ID")
741
+
}
742
743
+
if !strings.Contains(err.Error(), "failed to get note") {
744
+
t.Errorf("Expected note not found error for negative ID, got: %v", err)
745
+
}
746
+
})
747
748
+
t.Run("handles non-existent note", func(t *testing.T) {
749
+
_, cleanup := setupNoteTest(t)
750
+
defer cleanup()
751
752
+
ctx := context.Background()
753
754
+
err := Edit(ctx, []string{"999"})
755
+
if err == nil {
756
+
t.Error("Edit should fail with non-existent note ID")
757
+
}
758
+
if !strings.Contains(err.Error(), "failed to get note") {
759
+
t.Errorf("Expected note not found error, got: %v", err)
760
+
}
761
+
})
762
763
+
t.Run("handles no editor configured", func(t *testing.T) {
764
+
_, cleanup := setupNoteTest(t)
765
+
defer cleanup()
766
767
+
originalEditor := os.Getenv("EDITOR")
768
+
originalPath := os.Getenv("PATH")
769
+
os.Setenv("EDITOR", "")
770
+
os.Setenv("PATH", "")
771
+
defer func() {
772
+
os.Setenv("EDITOR", originalEditor)
773
+
os.Setenv("PATH", originalPath)
774
+
}()
775
776
+
ctx := context.Background()
777
778
+
err := Create(ctx, []string{"Test Note", "Test content"})
779
+
if err != nil {
780
+
t.Fatalf("Failed to create test note: %v", err)
781
+
}
782
783
+
err = Edit(ctx, []string{"1"})
784
+
if err == nil {
785
+
t.Error("Edit should fail when no editor is configured")
786
+
}
787
788
+
if !strings.Contains(err.Error(), "no editor configured") && !strings.Contains(err.Error(), "failed to open editor") {
789
+
t.Errorf("Expected no editor or editor failure error, got: %v", err)
790
+
}
791
+
})
792
793
+
t.Run("handles editor command failure", func(t *testing.T) {
794
+
_, cleanup := setupNoteTest(t)
795
+
defer cleanup()
796
797
+
originalEditor := os.Getenv("EDITOR")
798
+
os.Setenv("EDITOR", "nonexistent-editor-12345")
799
+
defer os.Setenv("EDITOR", originalEditor)
800
801
+
ctx := context.Background()
802
803
+
err := Create(ctx, []string{"Test Note", "Test content"})
804
+
if err != nil {
805
+
t.Fatalf("Failed to create test note: %v", err)
806
+
}
807
808
+
err = Edit(ctx, []string{"1"})
809
+
if err == nil {
810
+
t.Error("Edit should fail when editor command fails")
811
+
}
812
+
if !strings.Contains(err.Error(), "failed to open editor") {
813
+
t.Errorf("Expected editor failure error, got: %v", err)
814
+
}
815
+
})
816
817
+
t.Run("edits note successfully with mocked editor", func(t *testing.T) {
818
+
_, cleanup := setupNoteTest(t)
819
+
defer cleanup()
820
821
+
originalEditor := os.Getenv("EDITOR")
822
+
os.Setenv("EDITOR", "test-editor")
823
+
defer os.Setenv("EDITOR", originalEditor)
824
825
+
ctx := context.Background()
826
827
+
err := Create(ctx, []string{"Original Title", "Original content"})
828
+
if err != nil {
829
+
t.Fatalf("Failed to create test note: %v", err)
830
+
}
831
832
+
handler, err := NewNoteHandler()
833
+
if err != nil {
834
+
t.Fatalf("NewNoteHandler failed: %v", err)
835
+
}
836
+
defer handler.Close()
837
838
+
handler.openInEditorFunc = func(editor, filePath string) error {
839
+
newContent := `# Updated Title
840
841
This is updated content.
842
843
<!-- Tags: updated, test -->`
844
+
return os.WriteFile(filePath, []byte(newContent), 0644)
845
+
}
846
847
+
err = handler.editNote(ctx, 1)
848
+
if err != nil {
849
+
t.Errorf("Edit should succeed with mocked editor: %v", err)
850
+
}
851
852
+
note, err := handler.repos.Notes.Get(ctx, 1)
853
+
if err != nil {
854
+
t.Fatalf("Failed to get updated note: %v", err)
855
+
}
856
857
+
if note.Title != "Updated Title" {
858
+
t.Errorf("Expected title 'Updated Title', got %q", note.Title)
859
+
}
860
861
+
if !strings.Contains(note.Content, "This is updated content") {
862
+
t.Errorf("Expected content to contain 'This is updated content', got %q", note.Content)
863
+
}
864
865
+
expectedTags := []string{"updated", "test"}
866
+
if len(note.Tags) != len(expectedTags) {
867
+
t.Errorf("Expected %d tags, got %d", len(expectedTags), len(note.Tags))
868
+
}
869
+
for i, tag := range expectedTags {
870
+
if i >= len(note.Tags) || note.Tags[i] != tag {
871
+
t.Errorf("Expected tag %q at index %d, got %q", tag, i, note.Tags[i])
872
+
}
873
}
874
+
})
0
875
876
+
t.Run("handles editor cancellation (no changes)", func(t *testing.T) {
877
+
_, cleanup := setupNoteTest(t)
878
+
defer cleanup()
879
880
+
originalEditor := os.Getenv("EDITOR")
881
+
os.Setenv("EDITOR", "test-editor")
882
+
defer os.Setenv("EDITOR", originalEditor)
883
884
+
ctx := context.Background()
885
886
+
err := Create(ctx, []string{"Test Note", "Test content"})
887
+
if err != nil {
888
+
t.Fatalf("Failed to create test note: %v", err)
889
+
}
890
891
+
handler, err := NewNoteHandler()
892
+
if err != nil {
893
+
t.Fatalf("NewNoteHandler failed: %v", err)
894
+
}
895
+
defer handler.Close()
896
897
+
handler.openInEditorFunc = func(editor, filePath string) error {
898
+
return nil
899
+
}
900
901
+
err = handler.editNote(ctx, 1)
902
+
if err != nil {
903
+
t.Errorf("Edit should handle cancellation gracefully: %v", err)
904
+
}
905
906
+
note, err := handler.repos.Notes.Get(ctx, 1)
907
+
if err != nil {
908
+
t.Fatalf("Failed to get note: %v", err)
909
+
}
910
911
+
if note.Title != "Test Note" {
912
+
t.Errorf("Expected title 'Test Note', got %q", note.Title)
913
+
}
914
915
+
if note.Content != "Test content" {
916
+
t.Errorf("Expected content 'Test content', got %q", note.Content)
917
+
}
918
+
})
919
})
920
}
+564
-582
cmd/handlers/tasks_test.go
···
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()
0
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()
0
0
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
-
}
0
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)
0
0
0
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)
0
0
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()
0
0
0
0
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
-
}
0
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
-
}
0
0
281
})
282
283
-
t.Run("updates task by UUID", func(t *testing.T) {
284
-
args := []string{task.UUID, "--status", "completed"}
0
0
0
285
286
-
err := UpdateTask(ctx, args)
0
287
if err != nil {
288
-
t.Errorf("UpdateTask by UUID failed: %v", err)
289
}
0
290
291
-
// Verify update
292
-
updatedTask, err := handler.repos.Tasks.GetByUUID(ctx, task.UUID)
0
0
0
0
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
-
}
0
0
0
0
0
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
-
}
0
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
-
}
0
0
0
0
0
0
0
0
374
375
-
if len(updatedTask.Tags) != 1 {
376
-
t.Errorf("Expected 1 tag after removal, got %d", len(updatedTask.Tags))
377
-
}
0
0
0
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{}
0
0
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"}
0
0
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()
0
414
415
-
ctx := context.Background()
0
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{
0
0
0
0
0
0
0
0
0
0
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}
0
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{}
0
0
0
0
0
0
0
0
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"}
0
0
0
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()
0
505
506
-
ctx := context.Background()
0
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}
0
542
543
-
err := ViewTask(ctx, args)
0
0
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")
0
0
0
0
0
0
0
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
-
}
0
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()
0
0
579
580
-
ctx := context.Background()
0
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}
0
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{}
0
0
0
0
0
0
0
0
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
-
})
0
688
689
-
t.Run("fails with invalid task ID", func(t *testing.T) {
690
-
args := []string{"99999"}
0
0
0
0
0
0
0
0
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"}
0
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
-
}
0
723
724
-
if contains(result, "b") {
725
-
t.Error("Expected 'b' to be removed from slice")
726
-
}
0
727
728
-
if !contains(result, "a") || !contains(result, "c") {
729
-
t.Error("Expected 'a' and 'c' to remain in slice")
730
-
}
0
0
0
0
0
0
0
0
0
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)
0
0
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)
0
0
0
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
-
}()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
777
778
-
handler.printTaskDetail(task)
0
779
})
780
-
}
···
37
return tempDir, cleanup
38
}
39
40
+
func TestTaskHandler(t *testing.T) {
41
+
t.Run("New", func(t *testing.T) {
42
+
t.Run("creates handler successfully", func(t *testing.T) {
43
+
_, cleanup := setupTaskTest(t)
44
+
defer cleanup()
45
46
+
handler, err := NewTaskHandler()
47
+
if err != nil {
48
+
t.Fatalf("NewTaskHandler failed: %v", err)
49
+
}
50
+
if handler == nil {
51
+
t.Fatal("Handler should not be nil")
52
+
}
53
+
defer handler.Close()
54
55
+
if handler.db == nil {
56
+
t.Error("Handler database should not be nil")
57
+
}
58
+
if handler.config == nil {
59
+
t.Error("Handler config should not be nil")
60
+
}
61
+
if handler.repos == nil {
62
+
t.Error("Handler repos should not be nil")
63
+
}
64
+
})
65
66
+
t.Run("handles database initialization error", func(t *testing.T) {
67
+
originalXDG := os.Getenv("XDG_CONFIG_HOME")
68
+
originalHome := os.Getenv("HOME")
69
70
+
if runtime.GOOS == "windows" {
71
+
originalAppData := os.Getenv("APPDATA")
72
+
os.Unsetenv("APPDATA")
73
+
defer os.Setenv("APPDATA", originalAppData)
74
+
} else {
75
+
os.Unsetenv("XDG_CONFIG_HOME")
76
+
os.Unsetenv("HOME")
77
+
defer os.Setenv("XDG_CONFIG_HOME", originalXDG)
78
+
defer os.Setenv("HOME", originalHome)
79
+
}
80
81
+
handler, err := NewTaskHandler()
82
+
if err == nil {
83
+
if handler != nil {
84
+
handler.Close()
85
+
}
86
+
t.Error("Expected error when database initialization fails")
87
}
88
+
})
0
89
})
0
90
91
+
t.Run("Create", func(t *testing.T) {
92
+
_, cleanup := setupTaskTest(t)
93
+
defer cleanup()
94
95
+
t.Run("creates task successfully", func(t *testing.T) {
96
+
ctx := context.Background()
97
+
args := []string{"Buy groceries", "and", "cook dinner"}
98
99
+
err := CreateTask(ctx, args)
100
+
if err != nil {
101
+
t.Errorf("CreateTask failed: %v", err)
102
+
}
103
104
+
handler, err := NewTaskHandler()
105
+
if err != nil {
106
+
t.Fatalf("Failed to create handler: %v", err)
107
+
}
108
+
defer handler.Close()
0
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
})
0
0
0
0
0
148
149
+
t.Run("List", func(t *testing.T) {
150
+
_, cleanup := setupTaskTest(t)
151
+
defer cleanup()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
152
153
+
ctx := context.Background()
0
0
0
0
0
0
0
0
0
154
155
+
handler, err := NewTaskHandler()
0
0
0
156
if err != nil {
157
+
t.Fatalf("Failed to create handler: %v", err)
158
}
159
+
defer handler.Close()
0
0
0
160
161
+
task1 := &models.Task{
162
+
UUID: uuid.New().String(),
163
+
Description: "Task 1",
164
+
Status: "pending",
165
+
Priority: "A",
166
+
Project: "work",
167
}
168
+
_, err = handler.repos.Tasks.Create(ctx, task1)
0
0
0
0
0
169
if err != nil {
170
+
t.Fatalf("Failed to create task1: %v", err)
171
}
0
172
173
+
task2 := &models.Task{
174
+
UUID: uuid.New().String(),
175
+
Description: "Task 2",
176
+
Status: "completed",
177
+
}
178
+
_, err = handler.repos.Tasks.Create(ctx, task2)
179
if err != nil {
180
+
t.Fatalf("Failed to create task2: %v", err)
181
}
0
182
183
+
t.Run("lists pending tasks by default", func(t *testing.T) {
184
+
args := []string{}
185
186
+
err := ListTasks(ctx, args)
187
+
if err != nil {
188
+
t.Errorf("ListTasks failed: %v", err)
189
+
}
190
+
})
191
192
+
t.Run("filters by status", func(t *testing.T) {
193
+
args := []string{"--status", "completed"}
194
195
+
err := ListTasks(ctx, args)
196
+
if err != nil {
197
+
t.Errorf("ListTasks with status filter failed: %v", err)
198
+
}
199
+
})
0
200
201
+
t.Run("filters by priority", func(t *testing.T) {
202
+
args := []string{"--priority", "A"}
0
203
204
+
err := ListTasks(ctx, args)
205
+
if err != nil {
206
+
t.Errorf("ListTasks with priority filter failed: %v", err)
207
+
}
208
+
})
209
210
+
t.Run("filters by project", func(t *testing.T) {
211
+
args := []string{"--project", "work"}
0
0
0
0
212
213
+
err := ListTasks(ctx, args)
214
+
if err != nil {
215
+
t.Errorf("ListTasks with project filter failed: %v", err)
216
+
}
217
+
})
0
0
0
0
218
219
+
t.Run("searches tasks", func(t *testing.T) {
220
+
args := []string{"--search", "Task"}
221
222
+
err := ListTasks(ctx, args)
223
+
if err != nil {
224
+
t.Errorf("ListTasks with search failed: %v", err)
225
+
}
226
+
})
227
228
+
t.Run("limits results", func(t *testing.T) {
229
+
args := []string{"--limit", "1"}
0
0
0
230
231
+
err := ListTasks(ctx, args)
232
+
if err != nil {
233
+
t.Errorf("ListTasks with limit failed: %v", err)
234
+
}
235
+
})
236
})
237
238
+
t.Run("Update", func(t *testing.T) {
239
+
_, cleanup := setupTaskTest(t)
240
+
defer cleanup()
241
+
242
+
ctx := context.Background()
243
244
+
// Create test task
245
+
handler, err := NewTaskHandler()
246
if err != nil {
247
+
t.Fatalf("Failed to create handler: %v", err)
248
}
249
+
defer handler.Close()
250
251
+
task := &models.Task{
252
+
UUID: uuid.New().String(),
253
+
Description: "Original description",
254
+
Status: "pending",
255
+
}
256
+
id, err := handler.repos.Tasks.Create(ctx, task)
257
if err != nil {
258
+
t.Fatalf("Failed to create task: %v", err)
259
}
260
261
+
t.Run("updates task by ID", func(t *testing.T) {
262
+
args := []string{strconv.FormatInt(id, 10), "--description", "Updated description"}
0
0
263
264
+
err := UpdateTask(ctx, args)
265
+
if err != nil {
266
+
t.Errorf("UpdateTask failed: %v", err)
267
+
}
0
0
0
0
268
269
+
updatedTask, err := handler.repos.Tasks.Get(ctx, id)
270
+
if err != nil {
271
+
t.Fatalf("Failed to get updated task: %v", err)
272
+
}
273
274
+
if updatedTask.Description != "Updated description" {
275
+
t.Errorf("Expected description 'Updated description', got '%s'", updatedTask.Description)
276
+
}
277
+
})
0
278
279
+
t.Run("updates task by UUID", func(t *testing.T) {
280
+
args := []string{task.UUID, "--status", "completed"}
0
0
0
0
0
0
0
0
0
0
0
281
282
+
err := UpdateTask(ctx, args)
283
+
if err != nil {
284
+
t.Errorf("UpdateTask by UUID failed: %v", err)
285
+
}
0
0
286
287
+
updatedTask, err := handler.repos.Tasks.GetByUUID(ctx, task.UUID)
288
+
if err != nil {
289
+
t.Fatalf("Failed to get updated task by UUID: %v", err)
290
+
}
291
292
+
if updatedTask.Status != "completed" {
293
+
t.Errorf("Expected status 'completed', got '%s'", updatedTask.Status)
294
+
}
295
+
})
0
296
297
+
t.Run("updates multiple fields", func(t *testing.T) {
298
+
args := []string{
299
+
strconv.FormatInt(id, 10),
300
+
"--description", "Multiple updates",
301
+
"--priority", "B",
302
+
"--project", "test",
303
+
"--due", "2024-12-31",
304
+
}
305
306
+
err := UpdateTask(ctx, args)
307
+
if err != nil {
308
+
t.Errorf("UpdateTask with multiple fields failed: %v", err)
309
+
}
0
310
311
+
// Verify all updates
312
+
updatedTask, err := handler.repos.Tasks.Get(ctx, id)
313
+
if err != nil {
314
+
t.Fatalf("Failed to get updated task: %v", err)
315
+
}
316
317
+
if updatedTask.Description != "Multiple updates" {
318
+
t.Errorf("Expected description 'Multiple updates', got '%s'", updatedTask.Description)
319
+
}
320
+
if updatedTask.Priority != "B" {
321
+
t.Errorf("Expected priority 'B', got '%s'", updatedTask.Priority)
322
+
}
323
+
if updatedTask.Project != "test" {
324
+
t.Errorf("Expected project 'test', got '%s'", updatedTask.Project)
325
+
}
326
+
if updatedTask.Due == nil {
327
+
t.Error("Expected due date to be set")
328
+
}
329
+
})
330
331
+
t.Run("adds and removes tags", func(t *testing.T) {
332
+
args := []string{
333
+
strconv.FormatInt(id, 10),
334
+
"--add-tag=work",
335
+
"--add-tag=urgent",
336
+
}
337
338
+
err := UpdateTask(ctx, args)
339
+
if err != nil {
340
+
t.Errorf("UpdateTask with add tags failed: %v", err)
341
+
}
342
343
+
updatedTask, err := handler.repos.Tasks.Get(ctx, id)
344
+
if err != nil {
345
+
t.Fatalf("Failed to get updated task: %v", err)
346
+
}
347
348
+
if len(updatedTask.Tags) != 2 {
349
+
t.Errorf("Expected 2 tags, got %d", len(updatedTask.Tags))
350
+
}
0
351
352
+
args = []string{
353
+
strconv.FormatInt(id, 10),
354
+
"--remove-tag=urgent",
355
+
}
356
357
+
err = UpdateTask(ctx, args)
358
+
if err != nil {
359
+
t.Errorf("UpdateTask with remove tag failed: %v", err)
360
+
}
361
362
+
updatedTask, err = handler.repos.Tasks.Get(ctx, id)
363
+
if err != nil {
364
+
t.Fatalf("Failed to get updated task: %v", err)
365
+
}
366
367
+
if len(updatedTask.Tags) != 1 {
368
+
t.Errorf("Expected 1 tag after removal, got %d", len(updatedTask.Tags))
369
+
}
0
0
370
371
+
if updatedTask.Tags[0] != "work" {
372
+
t.Errorf("Expected remaining tag 'work', got '%s'", updatedTask.Tags[0])
373
+
}
374
+
})
375
376
+
t.Run("fails with missing task ID", func(t *testing.T) {
377
+
args := []string{}
378
379
+
err := UpdateTask(ctx, args)
380
+
if err == nil {
381
+
t.Error("Expected error for missing task ID")
382
+
}
0
0
383
384
+
if !strings.Contains(err.Error(), "task ID required") {
385
+
t.Errorf("Expected error about required task ID, got: %v", err)
386
+
}
387
+
})
0
0
0
0
0
388
389
+
t.Run("fails with invalid task ID", func(t *testing.T) {
390
+
args := []string{"99999", "--description", "test"}
391
392
+
err := UpdateTask(ctx, args)
393
+
if err == nil {
394
+
t.Error("Expected error for invalid task ID")
395
+
}
396
397
+
if !strings.Contains(err.Error(), "failed to find task") {
398
+
t.Errorf("Expected error about task not found, got: %v", err)
399
+
}
400
+
})
0
401
})
402
403
+
t.Run("Delete", func(t *testing.T) {
404
+
_, cleanup := setupTaskTest(t)
405
+
defer cleanup()
406
+
407
+
ctx := context.Background()
408
+
409
+
handler, err := NewTaskHandler()
410
+
if err != nil {
411
+
t.Fatalf("Failed to create handler: %v", err)
412
+
}
413
+
defer handler.Close()
414
+
415
+
task := &models.Task{
416
UUID: uuid.New().String(),
417
+
Description: "Task to delete",
418
Status: "pending",
419
}
420
+
id, err := handler.repos.Tasks.Create(ctx, task)
421
if err != nil {
422
+
t.Fatalf("Failed to create task: %v", err)
423
}
424
425
+
t.Run("deletes task by ID", func(t *testing.T) {
426
+
args := []string{strconv.FormatInt(id, 10)}
427
428
+
err := DeleteTask(ctx, args)
429
+
if err != nil {
430
+
t.Errorf("DeleteTask failed: %v", err)
431
+
}
432
433
+
_, err = handler.repos.Tasks.Get(ctx, id)
434
+
if err == nil {
435
+
t.Error("Expected error when getting deleted task")
436
+
}
437
+
})
0
438
439
+
t.Run("deletes task by UUID", func(t *testing.T) {
440
+
task2 := &models.Task{
441
+
UUID: uuid.New().String(),
442
+
Description: "Task to delete by UUID",
443
+
Status: "pending",
444
+
}
445
+
_, err := handler.repos.Tasks.Create(ctx, task2)
446
+
if err != nil {
447
+
t.Fatalf("Failed to create task2: %v", err)
448
+
}
449
450
+
args := []string{task2.UUID}
0
0
0
451
452
+
err = DeleteTask(ctx, args)
453
+
if err != nil {
454
+
t.Errorf("DeleteTask by UUID failed: %v", err)
455
+
}
456
457
+
_, err = handler.repos.Tasks.GetByUUID(ctx, task2.UUID)
458
+
if err == nil {
459
+
t.Error("Expected error when getting deleted task by UUID")
460
+
}
461
+
})
462
463
+
t.Run("fails with missing task ID", func(t *testing.T) {
464
+
args := []string{}
0
0
465
466
+
err := DeleteTask(ctx, args)
467
+
if err == nil {
468
+
t.Error("Expected error for missing task ID")
469
+
}
0
470
471
+
if !strings.Contains(err.Error(), "task ID required") {
472
+
t.Errorf("Expected error about required task ID, got: %v", err)
473
+
}
474
+
})
475
476
+
t.Run("fails with invalid task ID", func(t *testing.T) {
477
+
args := []string{"99999"}
478
479
+
err := DeleteTask(ctx, args)
480
+
if err == nil {
481
+
t.Error("Expected error for invalid task ID")
482
+
}
0
0
483
484
+
if !strings.Contains(err.Error(), "failed to find task") {
485
+
t.Errorf("Expected error about task not found, got: %v", err)
486
+
}
487
+
})
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
488
})
489
490
+
t.Run("View", func(t *testing.T) {
491
+
_, cleanup := setupTaskTest(t)
492
+
defer cleanup()
493
494
+
ctx := context.Background()
495
+
496
+
handler, err := NewTaskHandler()
497
if err != nil {
498
+
t.Fatalf("Failed to create handler: %v", err)
499
}
500
+
defer handler.Close()
0
0
0
501
502
+
now := time.Now()
503
+
task := &models.Task{
504
+
UUID: uuid.New().String(),
505
+
Description: "Task to view",
506
+
Status: "pending",
507
+
Priority: "A",
508
+
Project: "test",
509
+
Tags: []string{"work", "important"},
510
+
Entry: now,
511
+
Modified: now,
512
}
513
+
id, err := handler.repos.Tasks.Create(ctx, task)
514
+
if err != nil {
515
+
t.Fatalf("Failed to create task: %v", err)
516
}
0
517
518
+
t.Run("views task by ID", func(t *testing.T) {
519
+
args := []string{strconv.FormatInt(id, 10)}
520
521
+
err := ViewTask(ctx, args)
522
+
if err != nil {
523
+
t.Errorf("ViewTask failed: %v", err)
524
+
}
525
+
})
526
527
+
t.Run("views task by UUID", func(t *testing.T) {
528
+
args := []string{task.UUID}
0
0
0
529
530
+
err := ViewTask(ctx, args)
531
+
if err != nil {
532
+
t.Errorf("ViewTask by UUID failed: %v", err)
533
+
}
534
+
})
535
536
+
t.Run("fails with missing task ID", func(t *testing.T) {
537
+
args := []string{}
538
539
+
err := ViewTask(ctx, args)
540
+
if err == nil {
541
+
t.Error("Expected error for missing task ID")
542
+
}
0
0
543
544
+
if !strings.Contains(err.Error(), "task ID required") {
545
+
t.Errorf("Expected error about required task ID, got: %v", err)
546
+
}
547
+
})
0
0
0
0
0
548
549
+
t.Run("fails with invalid task ID", func(t *testing.T) {
550
+
args := []string{"99999"}
551
552
+
err := ViewTask(ctx, args)
553
+
if err == nil {
554
+
t.Error("Expected error for invalid task ID")
555
+
}
556
557
+
if !strings.Contains(err.Error(), "failed to find task") {
558
+
t.Errorf("Expected error about task not found, got: %v", err)
559
+
}
560
+
})
0
0
0
0
0
0
0
0
0
561
})
562
563
+
t.Run("Done", func(t *testing.T) {
564
+
_, cleanup := setupTaskTest(t)
565
+
defer cleanup()
0
0
0
0
0
0
0
0
566
567
+
ctx := context.Background()
568
569
+
handler, err := NewTaskHandler()
570
if err != nil {
571
+
t.Fatalf("Failed to create handler: %v", err)
572
}
573
+
defer handler.Close()
574
575
+
task := &models.Task{
0
0
576
UUID: uuid.New().String(),
577
+
Description: "Task to complete",
578
Status: "pending",
579
}
580
+
id, err := handler.repos.Tasks.Create(ctx, task)
581
if err != nil {
582
+
t.Fatalf("Failed to create task: %v", err)
583
}
584
585
+
t.Run("marks task as done by ID", func(t *testing.T) {
586
+
args := []string{strconv.FormatInt(id, 10)}
587
588
+
err := DoneTask(ctx, args)
589
+
if err != nil {
590
+
t.Errorf("DoneTask failed: %v", err)
591
+
}
592
593
+
completedTask, err := handler.repos.Tasks.Get(ctx, id)
594
+
if err != nil {
595
+
t.Fatalf("Failed to get completed task: %v", err)
596
+
}
0
597
598
+
if completedTask.Status != "completed" {
599
+
t.Errorf("Expected status 'completed', got '%s'", completedTask.Status)
600
+
}
601
602
+
if completedTask.End == nil {
603
+
t.Error("Expected end time to be set")
604
+
}
605
+
})
606
607
+
t.Run("handles already completed task", func(t *testing.T) {
608
+
task2 := &models.Task{
609
+
UUID: uuid.New().String(),
610
+
Description: "Already completed task",
611
+
Status: "completed",
612
+
}
613
+
id2, err := handler.repos.Tasks.Create(ctx, task2)
614
+
if err != nil {
615
+
t.Fatalf("Failed to create task2: %v", err)
616
+
}
617
618
+
args := []string{strconv.FormatInt(id2, 10)}
0
0
0
619
620
+
err = DoneTask(ctx, args)
621
+
if err != nil {
622
+
t.Errorf("DoneTask on completed task failed: %v", err)
623
+
}
624
+
})
625
626
+
t.Run("marks task as done by UUID", func(t *testing.T) {
627
+
task3 := &models.Task{
628
+
UUID: uuid.New().String(),
629
+
Description: "Task to complete by UUID",
630
+
Status: "pending",
631
+
}
632
+
_, err := handler.repos.Tasks.Create(ctx, task3)
633
+
if err != nil {
634
+
t.Fatalf("Failed to create task3: %v", err)
635
+
}
636
637
+
args := []string{task3.UUID}
0
0
0
638
639
+
err = DoneTask(ctx, args)
640
+
if err != nil {
641
+
t.Errorf("DoneTask by UUID failed: %v", err)
642
+
}
0
643
644
+
completedTask, err := handler.repos.Tasks.GetByUUID(ctx, task3.UUID)
645
+
if err != nil {
646
+
t.Fatalf("Failed to get completed task by UUID: %v", err)
647
+
}
648
649
+
if completedTask.Status != "completed" {
650
+
t.Errorf("Expected status 'completed', got '%s'", completedTask.Status)
651
+
}
652
653
+
if completedTask.End == nil {
654
+
t.Error("Expected end time to be set")
655
+
}
656
+
})
657
658
+
t.Run("fails with missing task ID", func(t *testing.T) {
659
+
args := []string{}
0
660
661
+
err := DoneTask(ctx, args)
662
+
if err == nil {
663
+
t.Error("Expected error for missing task ID")
664
+
}
665
666
+
if !strings.Contains(err.Error(), "task ID required") {
667
+
t.Errorf("Expected error about required task ID, got: %v", err)
668
+
}
669
+
})
670
671
+
t.Run("fails with invalid task ID", func(t *testing.T) {
672
+
args := []string{"99999"}
673
+
674
+
err := DoneTask(ctx, args)
675
+
if err == nil {
676
+
t.Error("Expected error for invalid task ID")
677
+
}
678
+
679
+
if !strings.Contains(err.Error(), "failed to find task") {
680
+
t.Errorf("Expected error about task not found, got: %v", err)
681
+
}
682
+
})
683
})
0
684
685
+
t.Run("Helper", func(t *testing.T) {
686
+
t.Run("contains function", func(t *testing.T) {
687
+
slice := []string{"a", "b", "c"}
688
689
+
if !contains(slice, "b") {
690
+
t.Error("Expected contains to return true for existing item")
691
+
}
0
0
692
693
+
if contains(slice, "d") {
694
+
t.Error("Expected contains to return false for non-existing item")
695
+
}
696
+
})
697
698
+
t.Run("removeString function", func(t *testing.T) {
699
+
slice := []string{"a", "b", "c", "b"}
700
+
result := removeString(slice, "b")
701
+
702
+
if len(result) != 2 {
703
+
t.Errorf("Expected 2 items after removing 'b', got %d", len(result))
704
+
}
0
0
0
0
0
705
706
+
if contains(result, "b") {
707
+
t.Error("Expected 'b' to be removed from slice")
0
0
0
708
}
0
709
710
+
if !contains(result, "a") || !contains(result, "c") {
711
+
t.Error("Expected 'a' and 'c' to remain in slice")
712
+
}
713
+
})
714
})
715
716
+
t.Run("Print", func(t *testing.T) {
717
+
_, cleanup := setupTaskTest(t)
718
+
defer cleanup()
719
+
720
+
handler, err := NewTaskHandler()
721
+
if err != nil {
722
+
t.Fatalf("Failed to create handler: %v", err)
723
+
}
724
+
defer handler.Close()
725
+
726
+
now := time.Now()
727
+
due := now.Add(24 * time.Hour)
728
+
729
+
task := &models.Task{
730
+
ID: 1,
731
+
UUID: uuid.New().String(),
732
+
Description: "Test task",
733
+
Status: "pending",
734
+
Priority: "A",
735
+
Project: "test",
736
+
Tags: []string{"work", "urgent"},
737
+
Due: &due,
738
+
Entry: now,
739
+
Modified: now,
740
+
}
741
+
742
+
t.Run("printTask doesn't panic", func(t *testing.T) {
743
+
defer func() {
744
+
if r := recover(); r != nil {
745
+
t.Errorf("printTask panicked: %v", r)
746
+
}
747
+
}()
748
+
749
+
handler.printTask(task)
750
+
})
751
+
752
+
t.Run("printTaskDetail doesn't panic", func(t *testing.T) {
753
+
defer func() {
754
+
if r := recover(); r != nil {
755
+
t.Errorf("printTaskDetail panicked: %v", r)
756
+
}
757
+
}()
758
759
+
handler.printTaskDetail(task)
760
+
})
761
})
762
+
}
+18
-14
internal/ui/book_list.go
···
6
"io"
7
"os"
8
"strings"
0
9
10
tea "github.com/charmbracelet/bubbletea"
11
"github.com/charmbracelet/huh"
···
17
18
// BookListOptions configures the book list UI behavior
19
type BookListOptions struct {
20
-
Output io.Writer // Output destination (stdout for interactive, buffer for testing)
21
-
Input io.Reader // Input source (stdin for interactive, strings reader for testing)
22
-
StaticMode bool // Enable static mode for testing (no interactive components)
0
0
0
23
}
24
25
// BookList handles book search and selection UI
···
64
type bookAddedMsg *models.Book
65
66
func (m searchModel) Init() tea.Cmd {
67
-
return nil
68
}
69
70
func (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
···
106
case bookAddedMsg:
107
m.addedBook = (*models.Book)(msg)
108
m.confirmed = true
109
-
return m, tea.Quit
0
0
110
}
111
return m, nil
112
}
···
204
205
// SearchAndSelect searches for books with the given query and allows selection
206
func (bl *BookList) SearchAndSelect(ctx context.Context, query string) error {
207
-
if bl.opts.StaticMode {
208
-
return bl.searchAndSelectStatic(ctx, query)
209
}
210
211
model := searchModel{
···
219
220
program := tea.NewProgram(model, tea.WithInput(bl.opts.Input), tea.WithOutput(bl.opts.Output))
221
222
-
program.Send(tea.Cmd(model.searchBooks(query)))
223
-
224
_, err := program.Run()
225
return err
226
}
227
228
-
func (bl *BookList) searchAndSelectStatic(ctx context.Context, query string) error {
229
results, err := bl.service.Search(ctx, query, 1, 10)
230
if err != nil {
231
fmt.Fprintf(bl.opts.Output, "Error: %s\n", err)
···
270
271
// InteractiveSearch provides an interactive search interface
272
func (bl *BookList) InteractiveSearch(ctx context.Context) error {
273
-
if bl.opts.StaticMode {
274
-
return bl.interactiveSearchStatic(ctx)
275
}
276
277
var query string
···
295
return bl.SearchAndSelect(ctx, query)
296
}
297
298
-
func (bl *BookList) interactiveSearchStatic(ctx context.Context) error {
299
fmt.Fprintf(bl.opts.Output, "Search for books: test query\n")
300
-
return bl.searchAndSelectStatic(ctx, "test query")
301
}
···
6
"io"
7
"os"
8
"strings"
9
+
"time"
10
11
tea "github.com/charmbracelet/bubbletea"
12
"github.com/charmbracelet/huh"
···
18
19
// BookListOptions configures the book list UI behavior
20
type BookListOptions struct {
21
+
// Output destination (stdout for interactive, buffer for testing)
22
+
Output io.Writer
23
+
// Input source (stdin for interactive, strings reader for testing)
24
+
Input io.Reader
25
+
// Enable static mode (no interactive components)
26
+
Static bool
27
}
28
29
// BookList handles book search and selection UI
···
68
type bookAddedMsg *models.Book
69
70
func (m searchModel) Init() tea.Cmd {
71
+
return m.searchBooks(m.query)
72
}
73
74
func (m searchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
···
110
case bookAddedMsg:
111
m.addedBook = (*models.Book)(msg)
112
m.confirmed = true
113
+
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
114
+
return tea.Quit()
115
+
})
116
}
117
return m, nil
118
}
···
210
211
// SearchAndSelect searches for books with the given query and allows selection
212
func (bl *BookList) SearchAndSelect(ctx context.Context, query string) error {
213
+
if bl.opts.Static {
214
+
return bl.staticSelect(ctx, query)
215
}
216
217
model := searchModel{
···
225
226
program := tea.NewProgram(model, tea.WithInput(bl.opts.Input), tea.WithOutput(bl.opts.Output))
227
0
0
228
_, err := program.Run()
229
return err
230
}
231
232
+
func (bl *BookList) staticSelect(ctx context.Context, query string) error {
233
results, err := bl.service.Search(ctx, query, 1, 10)
234
if err != nil {
235
fmt.Fprintf(bl.opts.Output, "Error: %s\n", err)
···
274
275
// InteractiveSearch provides an interactive search interface
276
func (bl *BookList) InteractiveSearch(ctx context.Context) error {
277
+
if bl.opts.Static {
278
+
return bl.staticSearch(ctx)
279
}
280
281
var query string
···
299
return bl.SearchAndSelect(ctx, query)
300
}
301
302
+
func (bl *BookList) staticSearch(ctx context.Context) error {
303
fmt.Fprintf(bl.opts.Output, "Search for books: test query\n")
304
+
return bl.staticSelect(ctx, "test query")
305
}
+15
-15
internal/ui/book_list_test.go
···
40
t.Run("Options", func(t *testing.T) {
41
t.Run("default options", func(t *testing.T) {
42
opts := BookListOptions{}
43
-
if opts.StaticMode {
44
t.Error("StaticMode should default to false")
45
}
46
})
···
48
t.Run("static mode enabled", func(t *testing.T) {
49
var buf bytes.Buffer
50
opts := BookListOptions{
51
-
Output: &buf,
52
-
StaticMode: true,
53
}
54
55
-
if !opts.StaticMode {
56
t.Error("StaticMode should be enabled")
57
}
58
if opts.Output != &buf {
···
73
service: service,
74
repo: nil,
75
opts: BookListOptions{
76
-
Output: &buf,
77
-
StaticMode: true,
78
},
79
}
80
81
-
err := bl.searchAndSelectStatic(context.Background(), "test query")
82
if err == nil {
83
t.Fatal("Expected error, got nil")
84
}
···
100
service: service,
101
repo: nil,
102
opts: BookListOptions{
103
-
Output: &buf,
104
-
StaticMode: true,
105
},
106
}
107
108
-
err := bl.searchAndSelectStatic(context.Background(), "nonexistent")
109
if err != nil {
110
t.Fatalf("searchAndSelectStatic failed: %v", err)
111
}
···
134
// Skip repo operations for this test
135
// repo: nil,
136
opts: BookListOptions{
137
-
Output: &buf,
138
-
StaticMode: true,
139
},
140
}
141
···
193
service: service,
194
repo: nil,
195
opts: BookListOptions{
196
-
Output: &buf,
197
-
StaticMode: true,
198
},
199
}
200
201
-
err := bl.interactiveSearchStatic(context.Background())
202
if err != nil {
203
t.Fatalf("InteractiveSearch failed: %v", err)
204
}
···
40
t.Run("Options", func(t *testing.T) {
41
t.Run("default options", func(t *testing.T) {
42
opts := BookListOptions{}
43
+
if opts.Static {
44
t.Error("StaticMode should default to false")
45
}
46
})
···
48
t.Run("static mode enabled", func(t *testing.T) {
49
var buf bytes.Buffer
50
opts := BookListOptions{
51
+
Output: &buf,
52
+
Static: true,
53
}
54
55
+
if !opts.Static {
56
t.Error("StaticMode should be enabled")
57
}
58
if opts.Output != &buf {
···
73
service: service,
74
repo: nil,
75
opts: BookListOptions{
76
+
Output: &buf,
77
+
Static: true,
78
},
79
}
80
81
+
err := bl.staticSelect(context.Background(), "test query")
82
if err == nil {
83
t.Fatal("Expected error, got nil")
84
}
···
100
service: service,
101
repo: nil,
102
opts: BookListOptions{
103
+
Output: &buf,
104
+
Static: true,
105
},
106
}
107
108
+
err := bl.staticSelect(context.Background(), "nonexistent")
109
if err != nil {
110
t.Fatalf("searchAndSelectStatic failed: %v", err)
111
}
···
134
// Skip repo operations for this test
135
// repo: nil,
136
opts: BookListOptions{
137
+
Output: &buf,
138
+
Static: true,
139
},
140
}
141
···
193
service: service,
194
repo: nil,
195
opts: BookListOptions{
196
+
Output: &buf,
197
+
Static: true,
198
},
199
}
200
201
+
err := bl.staticSearch(context.Background())
202
if err != nil {
203
t.Fatalf("InteractiveSearch failed: %v", err)
204
}