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

feat: concrete repository implementations

+3530 -61
+27 -16
ROADMAP.md
··· 2 2 3 3 ## Core Task Management (TaskWarrior-inspired) 4 4 5 - - `add` - Add new task with description and optional metadata 6 5 - `list` - Display tasks with filtering and sorting options 6 + - `projects` - List all project names 7 + - `tags` - List all tag names 8 + 9 + - `create` - Add new task with description and optional metadata 10 + 11 + - `view` - View task by ID 7 12 - `done` - Mark task as completed 8 - - `delete` - Remove task permanently 9 - - `modify` - Edit task properties (description, priority, project, tags) 13 + - `update` - Edit task properties (description, priority, project, tags) 10 14 - `start/stop` - Track active time on tasks 11 15 - `annotate` - Add notes/comments to existing tasks 12 - - `projects` - List all project names 13 - - `tags` - List all tag names 16 + 17 + - `delete` - Remove task permanently 18 + 14 19 - `calendar` - Display tasks in calendar view 15 20 - `timesheet` - Show time tracking summaries 16 21 17 22 ## Todo.txt Compatibility 18 23 19 24 - `archive` - Move completed tasks to done.txt 20 - - `listcon` - List all contexts (@context) 21 - - `listproj` - List all projects (+project) 22 - - `pri` - Set task priority (A-Z) 23 - - `depri` - Remove priority from task 24 - - `replace` - Replace task text entirely 25 + - `[con]texts` - List all contexts (@context) 26 + - `[proj]ects` - List all projects (+project) 27 + - `[pri]ority` - Set task priority (A-Z) 28 + - `[depri]oritize` - Remove priority from task 29 + - `[re]place` - Replace task text entirely 25 30 - `prepend/append` - Add text to beginning/end of task 26 31 27 32 ## Media Queue Management 28 33 29 34 - `movie add` - Add movie to watch queue 30 35 - `movie list` - Show movie queue with ratings/metadata 31 - - `movie watched` - Mark movie as watched 32 - - `movie remove` - Remove from queue 36 + - `movie watched|seen` - Mark movie as watched 37 + - `movie remove|rm` - Remove from queue 38 + 33 39 - `tv add` - Add TV show/season to queue 34 40 - `tv list` - Show TV queue with episode tracking 35 - - `tv watched` - Mark episodes/seasons as watched 36 - - `tv remove` - Remove from TV queue 41 + - `tv watched|seen` - Mark episodes/seasons as watched 42 + - `tv remove|rm` - Remove from TV queue 37 43 38 44 ## Reading List Management 39 45 40 46 - `book add` - Add book to reading list 41 47 - `book list` - Show reading queue with progress 42 48 - `book reading` - Mark book as currently reading 43 - - `book finished` - Mark book as completed 44 - - `book remove` - Remove from reading list 49 + - `book finished|read` - Mark book as completed 50 + - `book remove|rm` - Remove from reading list 45 51 - `book progress` - Update reading progress percentage 46 52 47 53 ## Data Management 48 54 49 55 - `sync` - Synchronize with remote storage 56 + - `sync setup` - Setup remote storage 57 + 50 58 - `backup` - Create local backup 59 + 51 60 - `import` - Import from various formats (CSV, JSON, todo.txt) 52 61 - `export` - Export to various formats 62 + 53 63 - `config` - Manage configuration settings 64 + 54 65 - `undo` - Reverse last operation
+2
go.mod
··· 9 9 github.com/spf13/cobra v1.9.1 10 10 ) 11 11 12 + require github.com/google/uuid v1.6.0 13 + 12 14 require ( 13 15 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 16 github.com/charmbracelet/colorprofile v0.3.1 // indirect
+2
go.sum
··· 29 29 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 30 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 31 31 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 32 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 33 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 32 34 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 33 35 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 34 36 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+332
internal/repo/book_repository.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + // BookRepository provides database operations for books 14 + type BookRepository struct { 15 + db *sql.DB 16 + } 17 + 18 + // NewBookRepository creates a new book repository 19 + func NewBookRepository(db *sql.DB) *BookRepository { 20 + return &BookRepository{db: db} 21 + } 22 + 23 + // Create stores a new book and returns its assigned ID 24 + func (r *BookRepository) Create(ctx context.Context, book *models.Book) (int64, error) { 25 + now := time.Now() 26 + book.Added = now 27 + 28 + query := ` 29 + INSERT INTO books (title, author, status, progress, pages, rating, notes, added, started, finished) 30 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 31 + 32 + result, err := r.db.ExecContext(ctx, query, 33 + book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, 34 + book.Notes, book.Added, book.Started, book.Finished) 35 + if err != nil { 36 + return 0, fmt.Errorf("failed to insert book: %w", err) 37 + } 38 + 39 + id, err := result.LastInsertId() 40 + if err != nil { 41 + return 0, fmt.Errorf("failed to get last insert id: %w", err) 42 + } 43 + 44 + book.ID = id 45 + return id, nil 46 + } 47 + 48 + // Get retrieves a book by ID 49 + func (r *BookRepository) Get(ctx context.Context, id int64) (*models.Book, error) { 50 + query := ` 51 + SELECT id, title, author, status, progress, pages, rating, notes, added, started, finished 52 + FROM books WHERE id = ?` 53 + 54 + book := &models.Book{} 55 + err := r.db.QueryRowContext(ctx, query, id).Scan( 56 + &book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &book.Pages, 57 + &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished) 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get book: %w", err) 60 + } 61 + 62 + return book, nil 63 + } 64 + 65 + // Update modifies an existing book 66 + func (r *BookRepository) Update(ctx context.Context, book *models.Book) error { 67 + query := ` 68 + UPDATE books SET title = ?, author = ?, status = ?, progress = ?, pages = ?, 69 + rating = ?, notes = ?, started = ?, finished = ? 70 + WHERE id = ?` 71 + 72 + _, err := r.db.ExecContext(ctx, query, 73 + book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, 74 + book.Notes, book.Started, book.Finished, book.ID) 75 + if err != nil { 76 + return fmt.Errorf("failed to update book: %w", err) 77 + } 78 + 79 + return nil 80 + } 81 + 82 + // Delete removes a book by ID 83 + func (r *BookRepository) Delete(ctx context.Context, id int64) error { 84 + query := "DELETE FROM books WHERE id = ?" 85 + _, err := r.db.ExecContext(ctx, query, id) 86 + if err != nil { 87 + return fmt.Errorf("failed to delete book: %w", err) 88 + } 89 + return nil 90 + } 91 + 92 + // List retrieves books with optional filtering and sorting 93 + func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 94 + query := r.buildListQuery(opts) 95 + args := r.buildListArgs(opts) 96 + 97 + rows, err := r.db.QueryContext(ctx, query, args...) 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to list books: %w", err) 100 + } 101 + defer rows.Close() 102 + 103 + var books []*models.Book 104 + for rows.Next() { 105 + book := &models.Book{} 106 + if err := r.scanBookRow(rows, book); err != nil { 107 + return nil, err 108 + } 109 + books = append(books, book) 110 + } 111 + 112 + return books, rows.Err() 113 + } 114 + 115 + func (r *BookRepository) buildListQuery(opts BookListOptions) string { 116 + query := "SELECT id, title, author, status, progress, pages, rating, notes, added, started, finished FROM books" 117 + 118 + var conditions []string 119 + 120 + if opts.Status != "" { 121 + conditions = append(conditions, "status = ?") 122 + } 123 + if opts.Author != "" { 124 + conditions = append(conditions, "author = ?") 125 + } 126 + if opts.MinProgress > 0 { 127 + conditions = append(conditions, "progress >= ?") 128 + } 129 + if opts.MinRating > 0 { 130 + conditions = append(conditions, "rating >= ?") 131 + } 132 + 133 + if opts.Search != "" { 134 + searchConditions := []string{ 135 + "title LIKE ?", 136 + "author LIKE ?", 137 + "notes LIKE ?", 138 + } 139 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 140 + } 141 + 142 + if len(conditions) > 0 { 143 + query += " WHERE " + strings.Join(conditions, " AND ") 144 + } 145 + 146 + if opts.SortBy != "" { 147 + order := "ASC" 148 + if strings.ToUpper(opts.SortOrder) == "DESC" { 149 + order = "DESC" 150 + } 151 + query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order) 152 + } else { 153 + query += " ORDER BY added DESC" 154 + } 155 + 156 + if opts.Limit > 0 { 157 + query += fmt.Sprintf(" LIMIT %d", opts.Limit) 158 + if opts.Offset > 0 { 159 + query += fmt.Sprintf(" OFFSET %d", opts.Offset) 160 + } 161 + } 162 + 163 + return query 164 + } 165 + 166 + func (r *BookRepository) buildListArgs(opts BookListOptions) []any { 167 + var args []any 168 + 169 + if opts.Status != "" { 170 + args = append(args, opts.Status) 171 + } 172 + if opts.Author != "" { 173 + args = append(args, opts.Author) 174 + } 175 + if opts.MinProgress > 0 { 176 + args = append(args, opts.MinProgress) 177 + } 178 + if opts.MinRating > 0 { 179 + args = append(args, opts.MinRating) 180 + } 181 + 182 + if opts.Search != "" { 183 + searchPattern := "%" + opts.Search + "%" 184 + args = append(args, searchPattern, searchPattern, searchPattern) 185 + } 186 + 187 + return args 188 + } 189 + 190 + func (r *BookRepository) scanBookRow(rows *sql.Rows, book *models.Book) error { 191 + return rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &book.Pages, 192 + &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished) 193 + } 194 + 195 + // Find retrieves books matching specific conditions 196 + func (r *BookRepository) Find(ctx context.Context, conditions BookListOptions) ([]*models.Book, error) { 197 + return r.List(ctx, conditions) 198 + } 199 + 200 + // Count returns the number of books matching conditions 201 + func (r *BookRepository) Count(ctx context.Context, opts BookListOptions) (int64, error) { 202 + query := "SELECT COUNT(*) FROM books" 203 + args := []any{} 204 + 205 + var conditions []string 206 + 207 + if opts.Status != "" { 208 + conditions = append(conditions, "status = ?") 209 + args = append(args, opts.Status) 210 + } 211 + if opts.Author != "" { 212 + conditions = append(conditions, "author = ?") 213 + args = append(args, opts.Author) 214 + } 215 + if opts.MinProgress > 0 { 216 + conditions = append(conditions, "progress >= ?") 217 + args = append(args, opts.MinProgress) 218 + } 219 + if opts.MinRating > 0 { 220 + conditions = append(conditions, "rating >= ?") 221 + args = append(args, opts.MinRating) 222 + } 223 + 224 + if opts.Search != "" { 225 + searchConditions := []string{ 226 + "title LIKE ?", 227 + "author LIKE ?", 228 + "notes LIKE ?", 229 + } 230 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 231 + searchPattern := "%" + opts.Search + "%" 232 + args = append(args, searchPattern, searchPattern, searchPattern) 233 + } 234 + 235 + if len(conditions) > 0 { 236 + query += " WHERE " + strings.Join(conditions, " AND ") 237 + } 238 + 239 + var count int64 240 + err := r.db.QueryRowContext(ctx, query, args...).Scan(&count) 241 + if err != nil { 242 + return 0, fmt.Errorf("failed to count books: %w", err) 243 + } 244 + 245 + return count, nil 246 + } 247 + 248 + // GetQueued retrieves all books in the queue 249 + func (r *BookRepository) GetQueued(ctx context.Context) ([]*models.Book, error) { 250 + return r.List(ctx, BookListOptions{Status: "queued"}) 251 + } 252 + 253 + // GetReading retrieves all books currently being read 254 + func (r *BookRepository) GetReading(ctx context.Context) ([]*models.Book, error) { 255 + return r.List(ctx, BookListOptions{Status: "reading"}) 256 + } 257 + 258 + // GetFinished retrieves all finished books 259 + func (r *BookRepository) GetFinished(ctx context.Context) ([]*models.Book, error) { 260 + return r.List(ctx, BookListOptions{Status: "finished"}) 261 + } 262 + 263 + // GetByAuthor retrieves all books by a specific author 264 + func (r *BookRepository) GetByAuthor(ctx context.Context, author string) ([]*models.Book, error) { 265 + return r.List(ctx, BookListOptions{Author: author}) 266 + } 267 + 268 + // StartReading marks a book as started 269 + func (r *BookRepository) StartReading(ctx context.Context, id int64) error { 270 + book, err := r.Get(ctx, id) 271 + if err != nil { 272 + return err 273 + } 274 + 275 + now := time.Now() 276 + book.Status = "reading" 277 + book.Started = &now 278 + 279 + return r.Update(ctx, book) 280 + } 281 + 282 + // FinishReading marks a book as finished 283 + func (r *BookRepository) FinishReading(ctx context.Context, id int64) error { 284 + book, err := r.Get(ctx, id) 285 + if err != nil { 286 + return err 287 + } 288 + 289 + now := time.Now() 290 + book.Status = "finished" 291 + book.Progress = 100 292 + book.Finished = &now 293 + 294 + return r.Update(ctx, book) 295 + } 296 + 297 + // UpdateProgress updates the reading progress of a book 298 + func (r *BookRepository) UpdateProgress(ctx context.Context, id int64, progress int) error { 299 + book, err := r.Get(ctx, id) 300 + if err != nil { 301 + return err 302 + } 303 + 304 + book.Progress = progress 305 + 306 + if progress >= 100 { 307 + book.Status = "finished" 308 + now := time.Now() 309 + book.Finished = &now 310 + } else if progress > 0 && book.Status == "queued" { 311 + book.Status = "reading" 312 + if book.Started == nil { 313 + now := time.Now() 314 + book.Started = &now 315 + } 316 + } 317 + 318 + return r.Update(ctx, book) 319 + } 320 + 321 + // BookListOptions defines options for listing books 322 + type BookListOptions struct { 323 + Status string 324 + Author string 325 + MinProgress int 326 + MinRating float64 327 + Search string 328 + SortBy string 329 + SortOrder string 330 + Limit int 331 + Offset int 332 + }
+560
internal/repo/book_repository_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + "time" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + func createBookTestDB(t *testing.T) *sql.DB { 14 + db, err := sql.Open("sqlite3", ":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory database: %v", err) 17 + } 18 + 19 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 20 + t.Fatalf("Failed to enable foreign keys: %v", err) 21 + } 22 + 23 + schema := ` 24 + CREATE TABLE IF NOT EXISTS books ( 25 + id INTEGER PRIMARY KEY AUTOINCREMENT, 26 + title TEXT NOT NULL, 27 + author TEXT, 28 + status TEXT DEFAULT 'queued', 29 + progress INTEGER DEFAULT 0, 30 + pages INTEGER, 31 + rating REAL, 32 + notes TEXT, 33 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 34 + started DATETIME, 35 + finished DATETIME 36 + ); 37 + ` 38 + 39 + if _, err := db.Exec(schema); err != nil { 40 + t.Fatalf("Failed to create schema: %v", err) 41 + } 42 + 43 + t.Cleanup(func() { 44 + db.Close() 45 + }) 46 + 47 + return db 48 + } 49 + 50 + func createSampleBook() *models.Book { 51 + return &models.Book{ 52 + Title: "Test Book", 53 + Author: "Test Author", 54 + Status: "queued", 55 + Progress: 25, 56 + Pages: 300, 57 + Rating: 4.5, 58 + Notes: "Interesting read", 59 + } 60 + } 61 + 62 + func TestBookRepository(t *testing.T) { 63 + t.Run("CRUD Operations", func(t *testing.T) { 64 + db := createBookTestDB(t) 65 + repo := NewBookRepository(db) 66 + ctx := context.Background() 67 + 68 + t.Run("Create Book", func(t *testing.T) { 69 + book := createSampleBook() 70 + 71 + id, err := repo.Create(ctx, book) 72 + if err != nil { 73 + t.Errorf("Failed to create book: %v", err) 74 + } 75 + 76 + if id == 0 { 77 + t.Error("Expected non-zero ID") 78 + } 79 + 80 + if book.ID != id { 81 + t.Errorf("Expected book ID to be set to %d, got %d", id, book.ID) 82 + } 83 + 84 + if book.Added.IsZero() { 85 + t.Error("Expected Added timestamp to be set") 86 + } 87 + }) 88 + 89 + t.Run("Get Book", func(t *testing.T) { 90 + original := createSampleBook() 91 + id, err := repo.Create(ctx, original) 92 + if err != nil { 93 + t.Fatalf("Failed to create book: %v", err) 94 + } 95 + 96 + retrieved, err := repo.Get(ctx, id) 97 + if err != nil { 98 + t.Errorf("Failed to get book: %v", err) 99 + } 100 + 101 + if retrieved.Title != original.Title { 102 + t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title) 103 + } 104 + if retrieved.Author != original.Author { 105 + t.Errorf("Expected author %s, got %s", original.Author, retrieved.Author) 106 + } 107 + if retrieved.Status != original.Status { 108 + t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status) 109 + } 110 + if retrieved.Progress != original.Progress { 111 + t.Errorf("Expected progress %d, got %d", original.Progress, retrieved.Progress) 112 + } 113 + if retrieved.Pages != original.Pages { 114 + t.Errorf("Expected pages %d, got %d", original.Pages, retrieved.Pages) 115 + } 116 + if retrieved.Rating != original.Rating { 117 + t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating) 118 + } 119 + if retrieved.Notes != original.Notes { 120 + t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes) 121 + } 122 + }) 123 + 124 + t.Run("Update Book", func(t *testing.T) { 125 + book := createSampleBook() 126 + id, err := repo.Create(ctx, book) 127 + if err != nil { 128 + t.Fatalf("Failed to create book: %v", err) 129 + } 130 + 131 + book.Title = "Updated Book" 132 + book.Status = "reading" 133 + book.Progress = 50 134 + book.Rating = 5.0 135 + now := time.Now() 136 + book.Started = &now 137 + 138 + err = repo.Update(ctx, book) 139 + if err != nil { 140 + t.Errorf("Failed to update book: %v", err) 141 + } 142 + 143 + updated, err := repo.Get(ctx, id) 144 + if err != nil { 145 + t.Fatalf("Failed to get updated book: %v", err) 146 + } 147 + 148 + if updated.Title != "Updated Book" { 149 + t.Errorf("Expected updated title, got %s", updated.Title) 150 + } 151 + if updated.Status != "reading" { 152 + t.Errorf("Expected status reading, got %s", updated.Status) 153 + } 154 + if updated.Progress != 50 { 155 + t.Errorf("Expected progress 50, got %d", updated.Progress) 156 + } 157 + if updated.Rating != 5.0 { 158 + t.Errorf("Expected rating 5.0, got %f", updated.Rating) 159 + } 160 + if updated.Started == nil { 161 + t.Error("Expected started time to be set") 162 + } 163 + }) 164 + 165 + t.Run("Delete Book", func(t *testing.T) { 166 + book := createSampleBook() 167 + id, err := repo.Create(ctx, book) 168 + if err != nil { 169 + t.Fatalf("Failed to create book: %v", err) 170 + } 171 + 172 + err = repo.Delete(ctx, id) 173 + if err != nil { 174 + t.Errorf("Failed to delete book: %v", err) 175 + } 176 + 177 + _, err = repo.Get(ctx, id) 178 + if err == nil { 179 + t.Error("Expected error when getting deleted book") 180 + } 181 + }) 182 + }) 183 + 184 + t.Run("List", func(t *testing.T) { 185 + db := createBookTestDB(t) 186 + repo := NewBookRepository(db) 187 + ctx := context.Background() 188 + 189 + books := []*models.Book{ 190 + {Title: "Book 1", Author: "Author A", Status: "queued", Progress: 0, Rating: 4.0}, 191 + {Title: "Book 2", Author: "Author A", Status: "reading", Progress: 50, Rating: 4.5}, 192 + {Title: "Book 3", Author: "Author B", Status: "finished", Progress: 100, Rating: 5.0}, 193 + {Title: "Book 4", Author: "Author C", Status: "queued", Progress: 0, Rating: 3.5}, 194 + } 195 + 196 + for _, book := range books { 197 + _, err := repo.Create(ctx, book) 198 + if err != nil { 199 + t.Fatalf("Failed to create book: %v", err) 200 + } 201 + } 202 + 203 + t.Run("List All Books", func(t *testing.T) { 204 + results, err := repo.List(ctx, BookListOptions{}) 205 + if err != nil { 206 + t.Errorf("Failed to list books: %v", err) 207 + } 208 + 209 + if len(results) != 4 { 210 + t.Errorf("Expected 4 books, got %d", len(results)) 211 + } 212 + }) 213 + 214 + t.Run("List Books with Status Filter", func(t *testing.T) { 215 + results, err := repo.List(ctx, BookListOptions{Status: "queued"}) 216 + if err != nil { 217 + t.Errorf("Failed to list books: %v", err) 218 + } 219 + 220 + if len(results) != 2 { 221 + t.Errorf("Expected 2 queued books, got %d", len(results)) 222 + } 223 + 224 + for _, book := range results { 225 + if book.Status != "queued" { 226 + t.Errorf("Expected queued status, got %s", book.Status) 227 + } 228 + } 229 + }) 230 + 231 + t.Run("List Books by Author", func(t *testing.T) { 232 + results, err := repo.List(ctx, BookListOptions{Author: "Author A"}) 233 + if err != nil { 234 + t.Errorf("Failed to list books: %v", err) 235 + } 236 + 237 + if len(results) != 2 { 238 + t.Errorf("Expected 2 books by Author A, got %d", len(results)) 239 + } 240 + 241 + for _, book := range results { 242 + if book.Author != "Author A" { 243 + t.Errorf("Expected author 'Author A', got %s", book.Author) 244 + } 245 + } 246 + }) 247 + 248 + t.Run("List Books with Progress Filter", func(t *testing.T) { 249 + results, err := repo.List(ctx, BookListOptions{MinProgress: 50}) 250 + if err != nil { 251 + t.Errorf("Failed to list books: %v", err) 252 + } 253 + 254 + if len(results) != 2 { 255 + t.Errorf("Expected 2 books with progress >= 50, got %d", len(results)) 256 + } 257 + 258 + for _, book := range results { 259 + if book.Progress < 50 { 260 + t.Errorf("Expected progress >= 50, got %d", book.Progress) 261 + } 262 + } 263 + }) 264 + 265 + t.Run("List Books with Rating Filter", func(t *testing.T) { 266 + results, err := repo.List(ctx, BookListOptions{MinRating: 4.5}) 267 + if err != nil { 268 + t.Errorf("Failed to list books: %v", err) 269 + } 270 + 271 + if len(results) != 2 { 272 + t.Errorf("Expected 2 books with rating >= 4.5, got %d", len(results)) 273 + } 274 + 275 + for _, book := range results { 276 + if book.Rating < 4.5 { 277 + t.Errorf("Expected rating >= 4.5, got %f", book.Rating) 278 + } 279 + } 280 + }) 281 + 282 + t.Run("List Books with Search", func(t *testing.T) { 283 + results, err := repo.List(ctx, BookListOptions{Search: "Book 1"}) 284 + if err != nil { 285 + t.Errorf("Failed to list books: %v", err) 286 + } 287 + 288 + if len(results) != 1 { 289 + t.Errorf("Expected 1 book matching search, got %d", len(results)) 290 + } 291 + 292 + if len(results) > 0 && results[0].Title != "Book 1" { 293 + t.Errorf("Expected 'Book 1', got %s", results[0].Title) 294 + } 295 + }) 296 + 297 + t.Run("List Books with Limit", func(t *testing.T) { 298 + results, err := repo.List(ctx, BookListOptions{Limit: 2}) 299 + if err != nil { 300 + t.Errorf("Failed to list books: %v", err) 301 + } 302 + 303 + if len(results) != 2 { 304 + t.Errorf("Expected 2 books due to limit, got %d", len(results)) 305 + } 306 + }) 307 + }) 308 + 309 + t.Run("Special Methods", func(t *testing.T) { 310 + db := createBookTestDB(t) 311 + repo := NewBookRepository(db) 312 + ctx := context.Background() 313 + 314 + book1 := &models.Book{Title: "Queued Book", Author: "Author A", Status: "queued", Progress: 0} 315 + book2 := &models.Book{Title: "Reading Book", Author: "Author B", Status: "reading", Progress: 45} 316 + book3 := &models.Book{Title: "Finished Book", Author: "Author C", Status: "finished", Progress: 100} 317 + book4 := &models.Book{Title: "Another Book", Author: "Author A", Status: "queued", Progress: 0} 318 + 319 + var book1ID int64 320 + for _, book := range []*models.Book{book1, book2, book3, book4} { 321 + id, err := repo.Create(ctx, book) 322 + if err != nil { 323 + t.Fatalf("Failed to create book: %v", err) 324 + } 325 + if book == book1 { 326 + book1ID = id 327 + } 328 + } 329 + 330 + t.Run("GetQueued", func(t *testing.T) { 331 + results, err := repo.GetQueued(ctx) 332 + if err != nil { 333 + t.Errorf("Failed to get queued books: %v", err) 334 + } 335 + 336 + if len(results) != 2 { 337 + t.Errorf("Expected 2 queued books, got %d", len(results)) 338 + } 339 + 340 + for _, book := range results { 341 + if book.Status != "queued" { 342 + t.Errorf("Expected queued status, got %s", book.Status) 343 + } 344 + } 345 + }) 346 + 347 + t.Run("GetReading", func(t *testing.T) { 348 + results, err := repo.GetReading(ctx) 349 + if err != nil { 350 + t.Errorf("Failed to get reading books: %v", err) 351 + } 352 + 353 + if len(results) != 1 { 354 + t.Errorf("Expected 1 reading book, got %d", len(results)) 355 + } 356 + 357 + if len(results) > 0 && results[0].Status != "reading" { 358 + t.Errorf("Expected reading status, got %s", results[0].Status) 359 + } 360 + }) 361 + 362 + t.Run("GetFinished", func(t *testing.T) { 363 + results, err := repo.GetFinished(ctx) 364 + if err != nil { 365 + t.Errorf("Failed to get finished books: %v", err) 366 + } 367 + 368 + if len(results) != 1 { 369 + t.Errorf("Expected 1 finished book, got %d", len(results)) 370 + } 371 + 372 + if len(results) > 0 && results[0].Status != "finished" { 373 + t.Errorf("Expected finished status, got %s", results[0].Status) 374 + } 375 + }) 376 + 377 + t.Run("GetByAuthor", func(t *testing.T) { 378 + results, err := repo.GetByAuthor(ctx, "Author A") 379 + if err != nil { 380 + t.Errorf("Failed to get books by author: %v", err) 381 + } 382 + 383 + if len(results) != 2 { 384 + t.Errorf("Expected 2 books by Author A, got %d", len(results)) 385 + } 386 + 387 + for _, book := range results { 388 + if book.Author != "Author A" { 389 + t.Errorf("Expected author 'Author A', got %s", book.Author) 390 + } 391 + } 392 + }) 393 + 394 + t.Run("StartReading", func(t *testing.T) { 395 + err := repo.StartReading(ctx, book1ID) 396 + if err != nil { 397 + t.Errorf("Failed to start reading book: %v", err) 398 + } 399 + 400 + updated, err := repo.Get(ctx, book1ID) 401 + if err != nil { 402 + t.Fatalf("Failed to get updated book: %v", err) 403 + } 404 + 405 + if updated.Status != "reading" { 406 + t.Errorf("Expected status to be reading, got %s", updated.Status) 407 + } 408 + 409 + if updated.Started == nil { 410 + t.Error("Expected started timestamp to be set") 411 + } 412 + }) 413 + 414 + t.Run("FinishReading", func(t *testing.T) { 415 + newBook := &models.Book{Title: "New Book", Status: "reading", Progress: 80} 416 + id, err := repo.Create(ctx, newBook) 417 + if err != nil { 418 + t.Fatalf("Failed to create new book: %v", err) 419 + } 420 + 421 + err = repo.FinishReading(ctx, id) 422 + if err != nil { 423 + t.Errorf("Failed to finish reading book: %v", err) 424 + } 425 + 426 + updated, err := repo.Get(ctx, id) 427 + if err != nil { 428 + t.Fatalf("Failed to get updated book: %v", err) 429 + } 430 + 431 + if updated.Status != "finished" { 432 + t.Errorf("Expected status to be finished, got %s", updated.Status) 433 + } 434 + 435 + if updated.Progress != 100 { 436 + t.Errorf("Expected progress to be 100, got %d", updated.Progress) 437 + } 438 + 439 + if updated.Finished == nil { 440 + t.Error("Expected finished timestamp to be set") 441 + } 442 + }) 443 + 444 + t.Run("UpdateProgress", func(t *testing.T) { 445 + newBook := &models.Book{Title: "Progress Book", Status: "queued", Progress: 0} 446 + id, err := repo.Create(ctx, newBook) 447 + if err != nil { 448 + t.Fatalf("Failed to create new book: %v", err) 449 + } 450 + 451 + err = repo.UpdateProgress(ctx, id, 25) 452 + if err != nil { 453 + t.Errorf("Failed to update progress: %v", err) 454 + } 455 + 456 + updated, err := repo.Get(ctx, id) 457 + if err != nil { 458 + t.Fatalf("Failed to get updated book: %v", err) 459 + } 460 + 461 + if updated.Status != "reading" { 462 + t.Errorf("Expected status to be reading when progress > 0, got %s", updated.Status) 463 + } 464 + 465 + if updated.Progress != 25 { 466 + t.Errorf("Expected progress 25, got %d", updated.Progress) 467 + } 468 + 469 + if updated.Started == nil { 470 + t.Error("Expected started timestamp to be set when progress > 0") 471 + } 472 + 473 + err = repo.UpdateProgress(ctx, id, 100) 474 + if err != nil { 475 + t.Errorf("Failed to update progress to 100: %v", err) 476 + } 477 + 478 + updated, err = repo.Get(ctx, id) 479 + if err != nil { 480 + t.Fatalf("Failed to get updated book: %v", err) 481 + } 482 + 483 + if updated.Status != "finished" { 484 + t.Errorf("Expected status to be finished when progress = 100, got %s", updated.Status) 485 + } 486 + 487 + if updated.Progress != 100 { 488 + t.Errorf("Expected progress 100, got %d", updated.Progress) 489 + } 490 + 491 + if updated.Finished == nil { 492 + t.Error("Expected finished timestamp to be set when progress = 100") 493 + } 494 + }) 495 + }) 496 + 497 + t.Run("Count", func(t *testing.T) { 498 + db := createBookTestDB(t) 499 + repo := NewBookRepository(db) 500 + ctx := context.Background() 501 + 502 + books := []*models.Book{ 503 + {Title: "Book 1", Status: "queued", Progress: 0, Rating: 4.0}, 504 + {Title: "Book 2", Status: "reading", Progress: 50, Rating: 3.5}, 505 + {Title: "Book 3", Status: "finished", Progress: 100, Rating: 5.0}, 506 + {Title: "Book 4", Status: "queued", Progress: 0, Rating: 4.5}, 507 + } 508 + 509 + for _, book := range books { 510 + _, err := repo.Create(ctx, book) 511 + if err != nil { 512 + t.Fatalf("Failed to create book: %v", err) 513 + } 514 + } 515 + 516 + t.Run("Count all books", func(t *testing.T) { 517 + count, err := repo.Count(ctx, BookListOptions{}) 518 + if err != nil { 519 + t.Errorf("Failed to count books: %v", err) 520 + } 521 + 522 + if count != 4 { 523 + t.Errorf("Expected 4 books, got %d", count) 524 + } 525 + }) 526 + 527 + t.Run("Count queued books", func(t *testing.T) { 528 + count, err := repo.Count(ctx, BookListOptions{Status: "queued"}) 529 + if err != nil { 530 + t.Errorf("Failed to count queued books: %v", err) 531 + } 532 + 533 + if count != 2 { 534 + t.Errorf("Expected 2 queued books, got %d", count) 535 + } 536 + }) 537 + 538 + t.Run("Count books by progress", func(t *testing.T) { 539 + count, err := repo.Count(ctx, BookListOptions{MinProgress: 50}) 540 + if err != nil { 541 + t.Errorf("Failed to count books with progress >= 50: %v", err) 542 + } 543 + 544 + if count != 2 { 545 + t.Errorf("Expected 2 books with progress >= 50, got %d", count) 546 + } 547 + }) 548 + 549 + t.Run("Count books by rating", func(t *testing.T) { 550 + count, err := repo.Count(ctx, BookListOptions{MinRating: 4.0}) 551 + if err != nil { 552 + t.Errorf("Failed to count high-rated books: %v", err) 553 + } 554 + 555 + if count != 3 { 556 + t.Errorf("Expected 3 books with rating >= 4.0, got %d", count) 557 + } 558 + }) 559 + }) 560 + }
+267
internal/repo/movie_repository.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + // MovieRepository provides database operations for movies 14 + type MovieRepository struct { 15 + db *sql.DB 16 + } 17 + 18 + // NewMovieRepository creates a new movie repository 19 + func NewMovieRepository(db *sql.DB) *MovieRepository { 20 + return &MovieRepository{db: db} 21 + } 22 + 23 + // Create stores a new movie and returns its assigned ID 24 + func (r *MovieRepository) Create(ctx context.Context, movie *models.Movie) (int64, error) { 25 + now := time.Now() 26 + movie.Added = now 27 + 28 + query := ` 29 + INSERT INTO movies (title, year, status, rating, notes, added, watched) 30 + VALUES (?, ?, ?, ?, ?, ?, ?)` 31 + 32 + result, err := r.db.ExecContext(ctx, query, 33 + movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Added, movie.Watched) 34 + if err != nil { 35 + return 0, fmt.Errorf("failed to insert movie: %w", err) 36 + } 37 + 38 + id, err := result.LastInsertId() 39 + if err != nil { 40 + return 0, fmt.Errorf("failed to get last insert id: %w", err) 41 + } 42 + 43 + movie.ID = id 44 + return id, nil 45 + } 46 + 47 + // Get retrieves a movie by ID 48 + func (r *MovieRepository) Get(ctx context.Context, id int64) (*models.Movie, error) { 49 + query := ` 50 + SELECT id, title, year, status, rating, notes, added, watched 51 + FROM movies WHERE id = ?` 52 + 53 + movie := &models.Movie{} 54 + err := r.db.QueryRowContext(ctx, query, id).Scan( 55 + &movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating, 56 + &movie.Notes, &movie.Added, &movie.Watched) 57 + if err != nil { 58 + return nil, fmt.Errorf("failed to get movie: %w", err) 59 + } 60 + 61 + return movie, nil 62 + } 63 + 64 + // Update modifies an existing movie 65 + func (r *MovieRepository) Update(ctx context.Context, movie *models.Movie) error { 66 + query := ` 67 + UPDATE movies SET title = ?, year = ?, status = ?, rating = ?, notes = ?, watched = ? 68 + WHERE id = ?` 69 + 70 + _, err := r.db.ExecContext(ctx, query, 71 + movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Watched, movie.ID) 72 + if err != nil { 73 + return fmt.Errorf("failed to update movie: %w", err) 74 + } 75 + 76 + return nil 77 + } 78 + 79 + // Delete removes a movie by ID 80 + func (r *MovieRepository) Delete(ctx context.Context, id int64) error { 81 + query := "DELETE FROM movies WHERE id = ?" 82 + _, err := r.db.ExecContext(ctx, query, id) 83 + if err != nil { 84 + return fmt.Errorf("failed to delete movie: %w", err) 85 + } 86 + return nil 87 + } 88 + 89 + // List retrieves movies with optional filtering and sorting 90 + func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) { 91 + query := r.buildListQuery(opts) 92 + args := r.buildListArgs(opts) 93 + 94 + rows, err := r.db.QueryContext(ctx, query, args...) 95 + if err != nil { 96 + return nil, fmt.Errorf("failed to list movies: %w", err) 97 + } 98 + defer rows.Close() 99 + 100 + var movies []*models.Movie 101 + for rows.Next() { 102 + movie := &models.Movie{} 103 + if err := r.scanMovieRow(rows, movie); err != nil { 104 + return nil, err 105 + } 106 + movies = append(movies, movie) 107 + } 108 + 109 + return movies, rows.Err() 110 + } 111 + 112 + func (r *MovieRepository) buildListQuery(opts MovieListOptions) string { 113 + query := "SELECT id, title, year, status, rating, notes, added, watched FROM movies" 114 + 115 + var conditions []string 116 + 117 + if opts.Status != "" { 118 + conditions = append(conditions, "status = ?") 119 + } 120 + if opts.Year > 0 { 121 + conditions = append(conditions, "year = ?") 122 + } 123 + if opts.MinRating > 0 { 124 + conditions = append(conditions, "rating >= ?") 125 + } 126 + 127 + if opts.Search != "" { 128 + searchConditions := []string{ 129 + "title LIKE ?", 130 + "notes LIKE ?", 131 + } 132 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 133 + } 134 + 135 + if len(conditions) > 0 { 136 + query += " WHERE " + strings.Join(conditions, " AND ") 137 + } 138 + 139 + if opts.SortBy != "" { 140 + order := "ASC" 141 + if strings.ToUpper(opts.SortOrder) == "DESC" { 142 + order = "DESC" 143 + } 144 + query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order) 145 + } else { 146 + query += " ORDER BY added DESC" 147 + } 148 + 149 + if opts.Limit > 0 { 150 + query += fmt.Sprintf(" LIMIT %d", opts.Limit) 151 + if opts.Offset > 0 { 152 + query += fmt.Sprintf(" OFFSET %d", opts.Offset) 153 + } 154 + } 155 + 156 + return query 157 + } 158 + 159 + func (r *MovieRepository) buildListArgs(opts MovieListOptions) []any { 160 + var args []any 161 + 162 + if opts.Status != "" { 163 + args = append(args, opts.Status) 164 + } 165 + if opts.Year > 0 { 166 + args = append(args, opts.Year) 167 + } 168 + if opts.MinRating > 0 { 169 + args = append(args, opts.MinRating) 170 + } 171 + 172 + if opts.Search != "" { 173 + searchPattern := "%" + opts.Search + "%" 174 + args = append(args, searchPattern, searchPattern) 175 + } 176 + 177 + return args 178 + } 179 + 180 + func (r *MovieRepository) scanMovieRow(rows *sql.Rows, movie *models.Movie) error { 181 + return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating, 182 + &movie.Notes, &movie.Added, &movie.Watched) 183 + } 184 + 185 + // Find retrieves movies matching specific conditions 186 + func (r *MovieRepository) Find(ctx context.Context, conditions MovieListOptions) ([]*models.Movie, error) { 187 + return r.List(ctx, conditions) 188 + } 189 + 190 + // Count returns the number of movies matching conditions 191 + func (r *MovieRepository) Count(ctx context.Context, opts MovieListOptions) (int64, error) { 192 + query := "SELECT COUNT(*) FROM movies" 193 + args := []any{} 194 + 195 + var conditions []string 196 + 197 + if opts.Status != "" { 198 + conditions = append(conditions, "status = ?") 199 + args = append(args, opts.Status) 200 + } 201 + if opts.Year > 0 { 202 + conditions = append(conditions, "year = ?") 203 + args = append(args, opts.Year) 204 + } 205 + if opts.MinRating > 0 { 206 + conditions = append(conditions, "rating >= ?") 207 + args = append(args, opts.MinRating) 208 + } 209 + 210 + if opts.Search != "" { 211 + searchConditions := []string{ 212 + "title LIKE ?", 213 + "notes LIKE ?", 214 + } 215 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 216 + searchPattern := "%" + opts.Search + "%" 217 + args = append(args, searchPattern, searchPattern) 218 + } 219 + 220 + if len(conditions) > 0 { 221 + query += " WHERE " + strings.Join(conditions, " AND ") 222 + } 223 + 224 + var count int64 225 + err := r.db.QueryRowContext(ctx, query, args...).Scan(&count) 226 + if err != nil { 227 + return 0, fmt.Errorf("failed to count movies: %w", err) 228 + } 229 + 230 + return count, nil 231 + } 232 + 233 + // GetQueued retrieves all movies in the queue 234 + func (r *MovieRepository) GetQueued(ctx context.Context) ([]*models.Movie, error) { 235 + return r.List(ctx, MovieListOptions{Status: "queued"}) 236 + } 237 + 238 + // GetWatched retrieves all watched movies 239 + func (r *MovieRepository) GetWatched(ctx context.Context) ([]*models.Movie, error) { 240 + return r.List(ctx, MovieListOptions{Status: "watched"}) 241 + } 242 + 243 + // MarkWatched marks a movie as watched 244 + func (r *MovieRepository) MarkWatched(ctx context.Context, id int64) error { 245 + movie, err := r.Get(ctx, id) 246 + if err != nil { 247 + return err 248 + } 249 + 250 + now := time.Now() 251 + movie.Status = "watched" 252 + movie.Watched = &now 253 + 254 + return r.Update(ctx, movie) 255 + } 256 + 257 + // MovieListOptions defines options for listing movies 258 + type MovieListOptions struct { 259 + Status string 260 + Year int 261 + MinRating float64 262 + Search string 263 + SortBy string 264 + SortOrder string 265 + Limit int 266 + Offset int 267 + }
+398
internal/repo/movie_repository_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + "time" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + func createMovieTestDB(t *testing.T) *sql.DB { 14 + db, err := sql.Open("sqlite3", ":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory database: %v", err) 17 + } 18 + 19 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 20 + t.Fatalf("Failed to enable foreign keys: %v", err) 21 + } 22 + 23 + schema := ` 24 + CREATE TABLE IF NOT EXISTS movies ( 25 + id INTEGER PRIMARY KEY AUTOINCREMENT, 26 + title TEXT NOT NULL, 27 + year INTEGER, 28 + status TEXT DEFAULT 'queued', 29 + rating REAL, 30 + notes TEXT, 31 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 32 + watched DATETIME 33 + ); 34 + ` 35 + 36 + if _, err := db.Exec(schema); err != nil { 37 + t.Fatalf("Failed to create schema: %v", err) 38 + } 39 + 40 + t.Cleanup(func() { 41 + db.Close() 42 + }) 43 + 44 + return db 45 + } 46 + 47 + func createSampleMovie() *models.Movie { 48 + return &models.Movie{ 49 + Title: "Test Movie", 50 + Year: 2023, 51 + Status: "queued", 52 + Rating: 8.5, 53 + Notes: "Great movie to watch", 54 + } 55 + } 56 + 57 + func TestMovieRepository(t *testing.T) { 58 + t.Run("CRUD Operations", func(t *testing.T) { 59 + db := createMovieTestDB(t) 60 + repo := NewMovieRepository(db) 61 + ctx := context.Background() 62 + 63 + t.Run("Create Movie", func(t *testing.T) { 64 + movie := createSampleMovie() 65 + 66 + id, err := repo.Create(ctx, movie) 67 + if err != nil { 68 + t.Errorf("Failed to create movie: %v", err) 69 + } 70 + 71 + if id == 0 { 72 + t.Error("Expected non-zero ID") 73 + } 74 + 75 + if movie.ID != id { 76 + t.Errorf("Expected movie ID to be set to %d, got %d", id, movie.ID) 77 + } 78 + 79 + if movie.Added.IsZero() { 80 + t.Error("Expected Added timestamp to be set") 81 + } 82 + }) 83 + 84 + t.Run("Get Movie", func(t *testing.T) { 85 + original := createSampleMovie() 86 + id, err := repo.Create(ctx, original) 87 + if err != nil { 88 + t.Fatalf("Failed to create movie: %v", err) 89 + } 90 + 91 + retrieved, err := repo.Get(ctx, id) 92 + if err != nil { 93 + t.Errorf("Failed to get movie: %v", err) 94 + } 95 + 96 + if retrieved.Title != original.Title { 97 + t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title) 98 + } 99 + if retrieved.Year != original.Year { 100 + t.Errorf("Expected year %d, got %d", original.Year, retrieved.Year) 101 + } 102 + if retrieved.Status != original.Status { 103 + t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status) 104 + } 105 + if retrieved.Rating != original.Rating { 106 + t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating) 107 + } 108 + if retrieved.Notes != original.Notes { 109 + t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes) 110 + } 111 + }) 112 + 113 + t.Run("Update Movie", func(t *testing.T) { 114 + movie := createSampleMovie() 115 + id, err := repo.Create(ctx, movie) 116 + if err != nil { 117 + t.Fatalf("Failed to create movie: %v", err) 118 + } 119 + 120 + movie.Title = "Updated Movie" 121 + movie.Status = "watched" 122 + movie.Rating = 9.0 123 + now := time.Now() 124 + movie.Watched = &now 125 + 126 + err = repo.Update(ctx, movie) 127 + if err != nil { 128 + t.Errorf("Failed to update movie: %v", err) 129 + } 130 + 131 + updated, err := repo.Get(ctx, id) 132 + if err != nil { 133 + t.Fatalf("Failed to get updated movie: %v", err) 134 + } 135 + 136 + if updated.Title != "Updated Movie" { 137 + t.Errorf("Expected updated title, got %s", updated.Title) 138 + } 139 + if updated.Status != "watched" { 140 + t.Errorf("Expected status watched, got %s", updated.Status) 141 + } 142 + if updated.Rating != 9.0 { 143 + t.Errorf("Expected rating 9.0, got %f", updated.Rating) 144 + } 145 + if updated.Watched == nil { 146 + t.Error("Expected watched time to be set") 147 + } 148 + }) 149 + 150 + t.Run("Delete Movie", func(t *testing.T) { 151 + movie := createSampleMovie() 152 + id, err := repo.Create(ctx, movie) 153 + if err != nil { 154 + t.Fatalf("Failed to create movie: %v", err) 155 + } 156 + 157 + err = repo.Delete(ctx, id) 158 + if err != nil { 159 + t.Errorf("Failed to delete movie: %v", err) 160 + } 161 + 162 + _, err = repo.Get(ctx, id) 163 + if err == nil { 164 + t.Error("Expected error when getting deleted movie") 165 + } 166 + }) 167 + }) 168 + 169 + t.Run("List", func(t *testing.T) { 170 + db := createMovieTestDB(t) 171 + repo := NewMovieRepository(db) 172 + ctx := context.Background() 173 + 174 + movies := []*models.Movie{ 175 + {Title: "Movie 1", Year: 2020, Status: "queued", Rating: 8.0}, 176 + {Title: "Movie 2", Year: 2021, Status: "watched", Rating: 7.5}, 177 + {Title: "Movie 3", Year: 2022, Status: "queued", Rating: 9.0}, 178 + } 179 + 180 + for _, movie := range movies { 181 + _, err := repo.Create(ctx, movie) 182 + if err != nil { 183 + t.Fatalf("Failed to create movie: %v", err) 184 + } 185 + } 186 + 187 + t.Run("List All Movies", func(t *testing.T) { 188 + results, err := repo.List(ctx, MovieListOptions{}) 189 + if err != nil { 190 + t.Errorf("Failed to list movies: %v", err) 191 + } 192 + 193 + if len(results) != 3 { 194 + t.Errorf("Expected 3 movies, got %d", len(results)) 195 + } 196 + }) 197 + 198 + t.Run("List Movies with Status Filter", func(t *testing.T) { 199 + results, err := repo.List(ctx, MovieListOptions{Status: "queued"}) 200 + if err != nil { 201 + t.Errorf("Failed to list movies: %v", err) 202 + } 203 + 204 + if len(results) != 2 { 205 + t.Errorf("Expected 2 queued movies, got %d", len(results)) 206 + } 207 + 208 + for _, movie := range results { 209 + if movie.Status != "queued" { 210 + t.Errorf("Expected queued status, got %s", movie.Status) 211 + } 212 + } 213 + }) 214 + 215 + t.Run("List Movies with Year Filter", func(t *testing.T) { 216 + results, err := repo.List(ctx, MovieListOptions{Year: 2021}) 217 + if err != nil { 218 + t.Errorf("Failed to list movies: %v", err) 219 + } 220 + 221 + if len(results) != 1 { 222 + t.Errorf("Expected 1 movie from 2021, got %d", len(results)) 223 + } 224 + 225 + if len(results) > 0 && results[0].Year != 2021 { 226 + t.Errorf("Expected year 2021, got %d", results[0].Year) 227 + } 228 + }) 229 + 230 + t.Run("List Movies with Rating Filter", func(t *testing.T) { 231 + results, err := repo.List(ctx, MovieListOptions{MinRating: 8.0}) 232 + if err != nil { 233 + t.Errorf("Failed to list movies: %v", err) 234 + } 235 + 236 + if len(results) != 2 { 237 + t.Errorf("Expected 2 movies with rating >= 8.0, got %d", len(results)) 238 + } 239 + 240 + for _, movie := range results { 241 + if movie.Rating < 8.0 { 242 + t.Errorf("Expected rating >= 8.0, got %f", movie.Rating) 243 + } 244 + } 245 + }) 246 + 247 + t.Run("List Movies with Search", func(t *testing.T) { 248 + results, err := repo.List(ctx, MovieListOptions{Search: "Movie 1"}) 249 + if err != nil { 250 + t.Errorf("Failed to list movies: %v", err) 251 + } 252 + 253 + if len(results) != 1 { 254 + t.Errorf("Expected 1 movie matching search, got %d", len(results)) 255 + } 256 + 257 + if len(results) > 0 && results[0].Title != "Movie 1" { 258 + t.Errorf("Expected 'Movie 1', got %s", results[0].Title) 259 + } 260 + }) 261 + 262 + t.Run("List Movies with Limit", func(t *testing.T) { 263 + results, err := repo.List(ctx, MovieListOptions{Limit: 2}) 264 + if err != nil { 265 + t.Errorf("Failed to list movies: %v", err) 266 + } 267 + 268 + if len(results) != 2 { 269 + t.Errorf("Expected 2 movies due to limit, got %d", len(results)) 270 + } 271 + }) 272 + }) 273 + 274 + t.Run("Special Methods", func(t *testing.T) { 275 + db := createMovieTestDB(t) 276 + repo := NewMovieRepository(db) 277 + ctx := context.Background() 278 + 279 + movie1 := &models.Movie{Title: "Queued Movie", Status: "queued", Rating: 8.0} 280 + movie2 := &models.Movie{Title: "Watched Movie", Status: "watched", Rating: 9.0} 281 + movie3 := &models.Movie{Title: "Another Queued", Status: "queued", Rating: 7.0} 282 + 283 + var movie1ID int64 284 + for _, movie := range []*models.Movie{movie1, movie2, movie3} { 285 + id, err := repo.Create(ctx, movie) 286 + if err != nil { 287 + t.Fatalf("Failed to create movie: %v", err) 288 + } 289 + if movie == movie1 { 290 + movie1ID = id 291 + } 292 + } 293 + 294 + t.Run("GetQueued", func(t *testing.T) { 295 + results, err := repo.GetQueued(ctx) 296 + if err != nil { 297 + t.Errorf("Failed to get queued movies: %v", err) 298 + } 299 + 300 + if len(results) != 2 { 301 + t.Errorf("Expected 2 queued movies, got %d", len(results)) 302 + } 303 + 304 + for _, movie := range results { 305 + if movie.Status != "queued" { 306 + t.Errorf("Expected queued status, got %s", movie.Status) 307 + } 308 + } 309 + }) 310 + 311 + t.Run("GetWatched", func(t *testing.T) { 312 + results, err := repo.GetWatched(ctx) 313 + if err != nil { 314 + t.Errorf("Failed to get watched movies: %v", err) 315 + } 316 + 317 + if len(results) != 1 { 318 + t.Errorf("Expected 1 watched movie, got %d", len(results)) 319 + } 320 + 321 + if len(results) > 0 && results[0].Status != "watched" { 322 + t.Errorf("Expected watched status, got %s", results[0].Status) 323 + } 324 + }) 325 + 326 + t.Run("MarkWatched", func(t *testing.T) { 327 + err := repo.MarkWatched(ctx, movie1ID) 328 + if err != nil { 329 + t.Errorf("Failed to mark movie as watched: %v", err) 330 + } 331 + 332 + updated, err := repo.Get(ctx, movie1ID) 333 + if err != nil { 334 + t.Fatalf("Failed to get updated movie: %v", err) 335 + } 336 + 337 + if updated.Status != "watched" { 338 + t.Errorf("Expected status to be watched, got %s", updated.Status) 339 + } 340 + 341 + if updated.Watched == nil { 342 + t.Error("Expected watched timestamp to be set") 343 + } 344 + }) 345 + }) 346 + 347 + t.Run("Count", func(t *testing.T) { 348 + db := createMovieTestDB(t) 349 + repo := NewMovieRepository(db) 350 + ctx := context.Background() 351 + 352 + movies := []*models.Movie{ 353 + {Title: "Movie 1", Status: "queued", Rating: 8.0}, 354 + {Title: "Movie 2", Status: "watched", Rating: 7.0}, 355 + {Title: "Movie 3", Status: "queued", Rating: 9.0}, 356 + } 357 + 358 + for _, movie := range movies { 359 + _, err := repo.Create(ctx, movie) 360 + if err != nil { 361 + t.Fatalf("Failed to create movie: %v", err) 362 + } 363 + } 364 + 365 + t.Run("Count all movies", func(t *testing.T) { 366 + count, err := repo.Count(ctx, MovieListOptions{}) 367 + if err != nil { 368 + t.Errorf("Failed to count movies: %v", err) 369 + } 370 + 371 + if count != 3 { 372 + t.Errorf("Expected 3 movies, got %d", count) 373 + } 374 + }) 375 + 376 + t.Run("Count queued movies", func(t *testing.T) { 377 + count, err := repo.Count(ctx, MovieListOptions{Status: "queued"}) 378 + if err != nil { 379 + t.Errorf("Failed to count queued movies: %v", err) 380 + } 381 + 382 + if count != 2 { 383 + t.Errorf("Expected 2 queued movies, got %d", count) 384 + } 385 + }) 386 + 387 + t.Run("Count movies by rating", func(t *testing.T) { 388 + count, err := repo.Count(ctx, MovieListOptions{MinRating: 8.0}) 389 + if err != nil { 390 + t.Errorf("Failed to count high-rated movies: %v", err) 391 + } 392 + 393 + if count != 2 { 394 + t.Errorf("Expected 2 movies with rating >= 8.0, got %d", count) 395 + } 396 + }) 397 + }) 398 + }
+15 -45
internal/repo/repo.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "context" 5 - 6 - "stormlightlabs.org/noteleaf/internal/models" 4 + "database/sql" 7 5 ) 8 6 9 - // Repository defines a general, behavior-focused interface for data access 10 - type Repository interface { 11 - // Create stores a new model and returns its assigned ID 12 - Create(ctx context.Context, model models.Model) (int64, error) 13 - 14 - // Get retrieves a model by ID 15 - Get(ctx context.Context, table string, id int64, dest models.Model) error 16 - 17 - // Update modifies an existing model 18 - Update(ctx context.Context, model models.Model) error 19 - 20 - // Delete removes a model by ID 21 - Delete(ctx context.Context, table string, id int64) error 22 - 23 - // List retrieves models with optional filtering and sorting 24 - List(ctx context.Context, table string, opts ListOptions, dest any) error 25 - 26 - // Find retrieves models matching specific conditions 27 - Find(ctx context.Context, table string, conditions map[string]any, dest any) error 28 - 29 - // Count returns the number of models matching conditions 30 - Count(ctx context.Context, table string, conditions map[string]any) (int64, error) 31 - 32 - // Execute runs a custom query with parameters 33 - Execute(ctx context.Context, query string, args ...any) error 34 - 35 - // Query runs a custom query and returns results 36 - Query(ctx context.Context, query string, dest any, args ...any) error 7 + // Repositories provides access to all resource repositories 8 + type Repositories struct { 9 + Tasks *TaskRepository 10 + Movies *MovieRepository 11 + TV *TVRepository 12 + Books *BookRepository 37 13 } 38 14 39 - // ListOptions defines generic options for listing items 40 - type ListOptions struct { 41 - // field: value pairs for WHERE conditions 42 - Where map[string]any 43 - Limit int 44 - Offset int 45 - // field name to sort by 46 - SortBy string 47 - // "asc" or "desc" 48 - SortOrder string 49 - // general search term 50 - Search string 51 - // fields to search in 52 - SearchFields []string 15 + // NewRepositories creates a new set of repositories 16 + func NewRepositories(db *sql.DB) *Repositories { 17 + return &Repositories{ 18 + Tasks: NewTaskRepository(db), 19 + Movies: NewMovieRepository(db), 20 + TV: NewTVRepository(db), 21 + Books: NewBookRepository(db), 22 + } 53 23 }
+283
internal/repo/repositories_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + 8 + "github.com/google/uuid" 9 + _ "github.com/mattn/go-sqlite3" 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + func createFullTestDB(t *testing.T) *sql.DB { 14 + db, err := sql.Open("sqlite3", ":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory database: %v", err) 17 + } 18 + 19 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 20 + t.Fatalf("Failed to enable foreign keys: %v", err) 21 + } 22 + 23 + // Create all tables 24 + schema := ` 25 + -- Tasks table 26 + CREATE TABLE IF NOT EXISTS tasks ( 27 + id INTEGER PRIMARY KEY AUTOINCREMENT, 28 + uuid TEXT UNIQUE NOT NULL, 29 + description TEXT NOT NULL, 30 + status TEXT DEFAULT 'pending', 31 + priority TEXT, 32 + project TEXT, 33 + tags TEXT, 34 + due DATETIME, 35 + entry DATETIME DEFAULT CURRENT_TIMESTAMP, 36 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 37 + end DATETIME, 38 + start DATETIME, 39 + annotations TEXT 40 + ); 41 + 42 + -- Movies table 43 + CREATE TABLE IF NOT EXISTS movies ( 44 + id INTEGER PRIMARY KEY AUTOINCREMENT, 45 + title TEXT NOT NULL, 46 + year INTEGER, 47 + status TEXT DEFAULT 'queued', 48 + rating REAL, 49 + notes TEXT, 50 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 51 + watched DATETIME 52 + ); 53 + 54 + -- TV Shows table 55 + CREATE TABLE IF NOT EXISTS tv_shows ( 56 + id INTEGER PRIMARY KEY AUTOINCREMENT, 57 + title TEXT NOT NULL, 58 + season INTEGER, 59 + episode INTEGER, 60 + status TEXT DEFAULT 'queued', 61 + rating REAL, 62 + notes TEXT, 63 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 64 + last_watched DATETIME 65 + ); 66 + 67 + -- Books table 68 + CREATE TABLE IF NOT EXISTS books ( 69 + id INTEGER PRIMARY KEY AUTOINCREMENT, 70 + title TEXT NOT NULL, 71 + author TEXT, 72 + status TEXT DEFAULT 'queued', 73 + progress INTEGER DEFAULT 0, 74 + pages INTEGER, 75 + rating REAL, 76 + notes TEXT, 77 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 78 + started DATETIME, 79 + finished DATETIME 80 + ); 81 + ` 82 + 83 + if _, err := db.Exec(schema); err != nil { 84 + t.Fatalf("Failed to create schema: %v", err) 85 + } 86 + 87 + t.Cleanup(func() { 88 + db.Close() 89 + }) 90 + 91 + return db 92 + } 93 + 94 + func TestRepositories(t *testing.T) { 95 + t.Run("Integration", func(t *testing.T) { 96 + db := createFullTestDB(t) 97 + repos := NewRepositories(db) 98 + ctx := context.Background() 99 + 100 + t.Run("Create all resource types", func(t *testing.T) { 101 + task := &models.Task{ 102 + UUID: uuid.New().String(), 103 + Description: "Integration test task", 104 + Status: "pending", 105 + Project: "integration", 106 + } 107 + taskID, err := repos.Tasks.Create(ctx, task) 108 + if err != nil { 109 + t.Errorf("Failed to create task: %v", err) 110 + } 111 + if taskID == 0 { 112 + t.Error("Expected non-zero task ID") 113 + } 114 + 115 + movie := &models.Movie{ 116 + Title: "Integration Movie", 117 + Year: 2023, 118 + Status: "queued", 119 + Rating: 8.5, 120 + } 121 + movieID, err := repos.Movies.Create(ctx, movie) 122 + if err != nil { 123 + t.Errorf("Failed to create movie: %v", err) 124 + } 125 + if movieID == 0 { 126 + t.Error("Expected non-zero movie ID") 127 + } 128 + 129 + tvShow := &models.TVShow{ 130 + Title: "Integration Series", 131 + Season: 1, 132 + Episode: 1, 133 + Status: "queued", 134 + Rating: 9.0, 135 + } 136 + tvID, err := repos.TV.Create(ctx, tvShow) 137 + if err != nil { 138 + t.Errorf("Failed to create TV show: %v", err) 139 + } 140 + if tvID == 0 { 141 + t.Error("Expected non-zero TV show ID") 142 + } 143 + 144 + book := &models.Book{ 145 + Title: "Integration Book", 146 + Author: "Test Author", 147 + Status: "queued", 148 + Progress: 0, 149 + Pages: 300, 150 + } 151 + bookID, err := repos.Books.Create(ctx, book) 152 + if err != nil { 153 + t.Errorf("Failed to create book: %v", err) 154 + } 155 + if bookID == 0 { 156 + t.Error("Expected non-zero book ID") 157 + } 158 + }) 159 + 160 + t.Run("Retrieve all resources", func(t *testing.T) { 161 + tasks, err := repos.Tasks.List(ctx, TaskListOptions{}) 162 + if err != nil { 163 + t.Errorf("Failed to list tasks: %v", err) 164 + } 165 + if len(tasks) != 1 { 166 + t.Errorf("Expected 1 task, got %d", len(tasks)) 167 + } 168 + 169 + movies, err := repos.Movies.List(ctx, MovieListOptions{}) 170 + if err != nil { 171 + t.Errorf("Failed to list movies: %v", err) 172 + } 173 + if len(movies) != 1 { 174 + t.Errorf("Expected 1 movie, got %d", len(movies)) 175 + } 176 + 177 + tvShows, err := repos.TV.List(ctx, TVListOptions{}) 178 + if err != nil { 179 + t.Errorf("Failed to list TV shows: %v", err) 180 + } 181 + if len(tvShows) != 1 { 182 + t.Errorf("Expected 1 TV show, got %d", len(tvShows)) 183 + } 184 + 185 + books, err := repos.Books.List(ctx, BookListOptions{}) 186 + if err != nil { 187 + t.Errorf("Failed to list books: %v", err) 188 + } 189 + if len(books) != 1 { 190 + t.Errorf("Expected 1 book, got %d", len(books)) 191 + } 192 + }) 193 + 194 + t.Run("Count all resources", func(t *testing.T) { 195 + taskCount, err := repos.Tasks.Count(ctx, TaskListOptions{}) 196 + if err != nil { 197 + t.Errorf("Failed to count tasks: %v", err) 198 + } 199 + if taskCount != 1 { 200 + t.Errorf("Expected 1 task, got %d", taskCount) 201 + } 202 + 203 + movieCount, err := repos.Movies.Count(ctx, MovieListOptions{}) 204 + if err != nil { 205 + t.Errorf("Failed to count movies: %v", err) 206 + } 207 + if movieCount != 1 { 208 + t.Errorf("Expected 1 movie, got %d", movieCount) 209 + } 210 + 211 + tvCount, err := repos.TV.Count(ctx, TVListOptions{}) 212 + if err != nil { 213 + t.Errorf("Failed to count TV shows: %v", err) 214 + } 215 + if tvCount != 1 { 216 + t.Errorf("Expected 1 TV show, got %d", tvCount) 217 + } 218 + 219 + bookCount, err := repos.Books.Count(ctx, BookListOptions{}) 220 + if err != nil { 221 + t.Errorf("Failed to count books: %v", err) 222 + } 223 + if bookCount != 1 { 224 + t.Errorf("Expected 1 book, got %d", bookCount) 225 + } 226 + }) 227 + 228 + t.Run("Use specialized methods", func(t *testing.T) { 229 + pendingTasks, err := repos.Tasks.GetPending(ctx) 230 + if err != nil { 231 + t.Errorf("Failed to get pending tasks: %v", err) 232 + } 233 + if len(pendingTasks) != 1 { 234 + t.Errorf("Expected 1 pending task, got %d", len(pendingTasks)) 235 + } 236 + 237 + queuedMovies, err := repos.Movies.GetQueued(ctx) 238 + if err != nil { 239 + t.Errorf("Failed to get queued movies: %v", err) 240 + } 241 + if len(queuedMovies) != 1 { 242 + t.Errorf("Expected 1 queued movie, got %d", len(queuedMovies)) 243 + } 244 + 245 + queuedTV, err := repos.TV.GetQueued(ctx) 246 + if err != nil { 247 + t.Errorf("Failed to get queued TV shows: %v", err) 248 + } 249 + if len(queuedTV) != 1 { 250 + t.Errorf("Expected 1 queued TV show, got %d", len(queuedTV)) 251 + } 252 + 253 + queuedBooks, err := repos.Books.GetQueued(ctx) 254 + if err != nil { 255 + t.Errorf("Failed to get queued books: %v", err) 256 + } 257 + if len(queuedBooks) != 1 { 258 + t.Errorf("Expected 1 queued book, got %d", len(queuedBooks)) 259 + } 260 + }) 261 + }) 262 + 263 + t.Run("New", func(t *testing.T) { 264 + db := createFullTestDB(t) 265 + repos := NewRepositories(db) 266 + 267 + t.Run("All repositories are initialized", func(t *testing.T) { 268 + if repos.Tasks == nil { 269 + t.Error("Tasks repository should be initialized") 270 + } 271 + if repos.Movies == nil { 272 + t.Error("Movies repository should be initialized") 273 + } 274 + if repos.TV == nil { 275 + t.Error("TV repository should be initialized") 276 + } 277 + if repos.Books == nil { 278 + t.Error("Books repository should be initialized") 279 + } 280 + }) 281 + 282 + }) 283 + }
+374
internal/repo/task_repository.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + // TaskRepository provides database operations for tasks 14 + type TaskRepository struct { 15 + db *sql.DB 16 + } 17 + 18 + // NewTaskRepository creates a new task repository 19 + func NewTaskRepository(db *sql.DB) *TaskRepository { 20 + return &TaskRepository{db: db} 21 + } 22 + 23 + // Create stores a new task and returns its assigned ID 24 + func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, error) { 25 + now := time.Now() 26 + task.Entry = now 27 + task.Modified = now 28 + 29 + tags, err := task.MarshalTags() 30 + if err != nil { 31 + return 0, fmt.Errorf("failed to marshal tags: %w", err) 32 + } 33 + 34 + annotations, err := task.MarshalAnnotations() 35 + if err != nil { 36 + return 0, fmt.Errorf("failed to marshal annotations: %w", err) 37 + } 38 + 39 + query := ` 40 + INSERT INTO tasks (uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations) 41 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 42 + 43 + result, err := r.db.ExecContext(ctx, query, 44 + task.UUID, task.Description, task.Status, task.Priority, task.Project, 45 + tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations) 46 + if err != nil { 47 + return 0, fmt.Errorf("failed to insert task: %w", err) 48 + } 49 + 50 + id, err := result.LastInsertId() 51 + if err != nil { 52 + return 0, fmt.Errorf("failed to get last insert id: %w", err) 53 + } 54 + 55 + task.ID = id 56 + return id, nil 57 + } 58 + 59 + // Get retrieves a task by ID 60 + func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) { 61 + query := ` 62 + SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations 63 + FROM tasks WHERE id = ?` 64 + 65 + task := &models.Task{} 66 + var tags, annotations sql.NullString 67 + 68 + err := r.db.QueryRowContext(ctx, query, id).Scan( 69 + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, 70 + &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations) 71 + if err != nil { 72 + return nil, fmt.Errorf("failed to get task: %w", err) 73 + } 74 + 75 + if tags.Valid { 76 + if err := task.UnmarshalTags(tags.String); err != nil { 77 + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 78 + } 79 + } 80 + 81 + if annotations.Valid { 82 + if err := task.UnmarshalAnnotations(annotations.String); err != nil { 83 + return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 84 + } 85 + } 86 + 87 + return task, nil 88 + } 89 + 90 + // Update modifies an existing task 91 + func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error { 92 + task.Modified = time.Now() 93 + 94 + tags, err := task.MarshalTags() 95 + if err != nil { 96 + return fmt.Errorf("failed to marshal tags: %w", err) 97 + } 98 + 99 + annotations, err := task.MarshalAnnotations() 100 + if err != nil { 101 + return fmt.Errorf("failed to marshal annotations: %w", err) 102 + } 103 + 104 + query := ` 105 + UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?, 106 + tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ? 107 + WHERE id = ?` 108 + 109 + _, err = r.db.ExecContext(ctx, query, 110 + task.UUID, task.Description, task.Status, task.Priority, task.Project, 111 + tags, task.Due, task.Modified, task.End, task.Start, annotations, task.ID) 112 + if err != nil { 113 + return fmt.Errorf("failed to update task: %w", err) 114 + } 115 + 116 + return nil 117 + } 118 + 119 + // Delete removes a task by ID 120 + func (r *TaskRepository) Delete(ctx context.Context, id int64) error { 121 + query := "DELETE FROM tasks WHERE id = ?" 122 + _, err := r.db.ExecContext(ctx, query, id) 123 + if err != nil { 124 + return fmt.Errorf("failed to delete task: %w", err) 125 + } 126 + return nil 127 + } 128 + 129 + // List retrieves tasks with optional filtering and sorting 130 + func (r *TaskRepository) List(ctx context.Context, opts TaskListOptions) ([]*models.Task, error) { 131 + query := r.buildListQuery(opts) 132 + args := r.buildListArgs(opts) 133 + 134 + rows, err := r.db.QueryContext(ctx, query, args...) 135 + if err != nil { 136 + return nil, fmt.Errorf("failed to list tasks: %w", err) 137 + } 138 + defer rows.Close() 139 + 140 + var tasks []*models.Task 141 + for rows.Next() { 142 + task := &models.Task{} 143 + if err := r.scanTaskRow(rows, task); err != nil { 144 + return nil, err 145 + } 146 + tasks = append(tasks, task) 147 + } 148 + 149 + return tasks, rows.Err() 150 + } 151 + 152 + func (r *TaskRepository) buildListQuery(opts TaskListOptions) string { 153 + query := "SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations FROM tasks" 154 + 155 + var conditions []string 156 + 157 + if opts.Status != "" { 158 + conditions = append(conditions, "status = ?") 159 + } 160 + if opts.Priority != "" { 161 + conditions = append(conditions, "priority = ?") 162 + } 163 + if opts.Project != "" { 164 + conditions = append(conditions, "project = ?") 165 + } 166 + if !opts.DueAfter.IsZero() { 167 + conditions = append(conditions, "due >= ?") 168 + } 169 + if !opts.DueBefore.IsZero() { 170 + conditions = append(conditions, "due <= ?") 171 + } 172 + 173 + if opts.Search != "" { 174 + searchConditions := []string{ 175 + "description LIKE ?", 176 + "project LIKE ?", 177 + "tags LIKE ?", 178 + } 179 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 180 + } 181 + 182 + if len(conditions) > 0 { 183 + query += " WHERE " + strings.Join(conditions, " AND ") 184 + } 185 + 186 + if opts.SortBy != "" { 187 + order := "ASC" 188 + if strings.ToUpper(opts.SortOrder) == "DESC" { 189 + order = "DESC" 190 + } 191 + query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order) 192 + } else { 193 + query += " ORDER BY modified DESC" 194 + } 195 + 196 + if opts.Limit > 0 { 197 + query += fmt.Sprintf(" LIMIT %d", opts.Limit) 198 + if opts.Offset > 0 { 199 + query += fmt.Sprintf(" OFFSET %d", opts.Offset) 200 + } 201 + } 202 + 203 + return query 204 + } 205 + 206 + func (r *TaskRepository) buildListArgs(opts TaskListOptions) []any { 207 + var args []any 208 + 209 + if opts.Status != "" { 210 + args = append(args, opts.Status) 211 + } 212 + if opts.Priority != "" { 213 + args = append(args, opts.Priority) 214 + } 215 + if opts.Project != "" { 216 + args = append(args, opts.Project) 217 + } 218 + if !opts.DueAfter.IsZero() { 219 + args = append(args, opts.DueAfter) 220 + } 221 + if !opts.DueBefore.IsZero() { 222 + args = append(args, opts.DueBefore) 223 + } 224 + 225 + // Search args 226 + if opts.Search != "" { 227 + searchPattern := "%" + opts.Search + "%" 228 + // Add search pattern for each search field 229 + args = append(args, searchPattern, searchPattern, searchPattern) 230 + } 231 + 232 + return args 233 + } 234 + 235 + func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error { 236 + var tags, annotations sql.NullString 237 + 238 + err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 239 + &task.Project, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations) 240 + if err != nil { 241 + return fmt.Errorf("failed to scan task row: %w", err) 242 + } 243 + 244 + if tags.Valid { 245 + if err := task.UnmarshalTags(tags.String); err != nil { 246 + return fmt.Errorf("failed to unmarshal tags: %w", err) 247 + } 248 + } 249 + 250 + if annotations.Valid { 251 + if err := task.UnmarshalAnnotations(annotations.String); err != nil { 252 + return fmt.Errorf("failed to unmarshal annotations: %w", err) 253 + } 254 + } 255 + 256 + return nil 257 + } 258 + 259 + // Find retrieves tasks matching specific conditions 260 + func (r *TaskRepository) Find(ctx context.Context, conditions TaskListOptions) ([]*models.Task, error) { 261 + return r.List(ctx, conditions) 262 + } 263 + 264 + // Count returns the number of tasks matching conditions 265 + func (r *TaskRepository) Count(ctx context.Context, opts TaskListOptions) (int64, error) { 266 + query := "SELECT COUNT(*) FROM tasks" 267 + args := []any{} 268 + 269 + var conditions []string 270 + 271 + if opts.Status != "" { 272 + conditions = append(conditions, "status = ?") 273 + args = append(args, opts.Status) 274 + } 275 + if opts.Priority != "" { 276 + conditions = append(conditions, "priority = ?") 277 + args = append(args, opts.Priority) 278 + } 279 + if opts.Project != "" { 280 + conditions = append(conditions, "project = ?") 281 + args = append(args, opts.Project) 282 + } 283 + if !opts.DueAfter.IsZero() { 284 + conditions = append(conditions, "due >= ?") 285 + args = append(args, opts.DueAfter) 286 + } 287 + if !opts.DueBefore.IsZero() { 288 + conditions = append(conditions, "due <= ?") 289 + args = append(args, opts.DueBefore) 290 + } 291 + 292 + if opts.Search != "" { 293 + searchConditions := []string{ 294 + "description LIKE ?", 295 + "project LIKE ?", 296 + "tags LIKE ?", 297 + } 298 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 299 + searchPattern := "%" + opts.Search + "%" 300 + args = append(args, searchPattern, searchPattern, searchPattern) 301 + } 302 + 303 + if len(conditions) > 0 { 304 + query += " WHERE " + strings.Join(conditions, " AND ") 305 + } 306 + 307 + var count int64 308 + err := r.db.QueryRowContext(ctx, query, args...).Scan(&count) 309 + if err != nil { 310 + return 0, fmt.Errorf("failed to count tasks: %w", err) 311 + } 312 + 313 + return count, nil 314 + } 315 + 316 + // GetByUUID retrieves a task by UUID 317 + func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) { 318 + query := ` 319 + SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations 320 + FROM tasks WHERE uuid = ?` 321 + 322 + task := &models.Task{} 323 + var tags, annotations sql.NullString 324 + 325 + err := r.db.QueryRowContext(ctx, query, uuid).Scan( 326 + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, 327 + &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations) 328 + if err != nil { 329 + return nil, fmt.Errorf("failed to get task by UUID: %w", err) 330 + } 331 + 332 + if tags.Valid { 333 + if err := task.UnmarshalTags(tags.String); err != nil { 334 + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 335 + } 336 + } 337 + 338 + if annotations.Valid { 339 + if err := task.UnmarshalAnnotations(annotations.String); err != nil { 340 + return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 341 + } 342 + } 343 + 344 + return task, nil 345 + } 346 + 347 + // GetPending retrieves all pending tasks 348 + func (r *TaskRepository) GetPending(ctx context.Context) ([]*models.Task, error) { 349 + return r.List(ctx, TaskListOptions{Status: "pending"}) 350 + } 351 + 352 + // GetCompleted retrieves all completed tasks 353 + func (r *TaskRepository) GetCompleted(ctx context.Context) ([]*models.Task, error) { 354 + return r.List(ctx, TaskListOptions{Status: "completed"}) 355 + } 356 + 357 + // GetByProject retrieves all tasks for a specific project 358 + func (r *TaskRepository) GetByProject(ctx context.Context, project string) ([]*models.Task, error) { 359 + return r.List(ctx, TaskListOptions{Project: project}) 360 + } 361 + 362 + // TaskListOptions defines options for listing tasks 363 + type TaskListOptions struct { 364 + Status string 365 + Priority string 366 + Project string 367 + DueAfter time.Time 368 + DueBefore time.Time 369 + Search string 370 + SortBy string 371 + SortOrder string 372 + Limit int 373 + Offset int 374 + }
+385
internal/repo/task_repository_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + "time" 8 + 9 + "github.com/google/uuid" 10 + _ "github.com/mattn/go-sqlite3" 11 + "stormlightlabs.org/noteleaf/internal/models" 12 + ) 13 + 14 + func createTaskTestDB(t *testing.T) *sql.DB { 15 + db, err := sql.Open("sqlite3", ":memory:") 16 + if err != nil { 17 + t.Fatalf("Failed to create in-memory database: %v", err) 18 + } 19 + 20 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 21 + t.Fatalf("Failed to enable foreign keys: %v", err) 22 + } 23 + 24 + schema := ` 25 + CREATE TABLE IF NOT EXISTS tasks ( 26 + id INTEGER PRIMARY KEY AUTOINCREMENT, 27 + uuid TEXT UNIQUE NOT NULL, 28 + description TEXT NOT NULL, 29 + status TEXT DEFAULT 'pending', 30 + priority TEXT, 31 + project TEXT, 32 + tags TEXT, 33 + due DATETIME, 34 + entry DATETIME DEFAULT CURRENT_TIMESTAMP, 35 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 36 + end DATETIME, 37 + start DATETIME, 38 + annotations TEXT 39 + ); 40 + ` 41 + 42 + if _, err := db.Exec(schema); err != nil { 43 + t.Fatalf("Failed to create schema: %v", err) 44 + } 45 + 46 + t.Cleanup(func() { 47 + db.Close() 48 + }) 49 + 50 + return db 51 + } 52 + 53 + func createSampleTask() *models.Task { 54 + return &models.Task{ 55 + UUID: uuid.New().String(), 56 + Description: "Test task", 57 + Status: "pending", 58 + Priority: "H", 59 + Project: "test-project", 60 + Tags: []string{"test", "important"}, 61 + Annotations: []string{"This is a test", "Another annotation"}, 62 + } 63 + } 64 + 65 + func TestTaskRepository_CRUD(t *testing.T) { 66 + db := createTaskTestDB(t) 67 + repo := NewTaskRepository(db) 68 + ctx := context.Background() 69 + 70 + t.Run("Create Task", func(t *testing.T) { 71 + task := createSampleTask() 72 + 73 + id, err := repo.Create(ctx, task) 74 + if err != nil { 75 + t.Errorf("Failed to create task: %v", err) 76 + } 77 + 78 + if id == 0 { 79 + t.Error("Expected non-zero ID") 80 + } 81 + 82 + if task.ID != id { 83 + t.Errorf("Expected task ID to be set to %d, got %d", id, task.ID) 84 + } 85 + 86 + if task.Entry.IsZero() { 87 + t.Error("Expected Entry timestamp to be set") 88 + } 89 + if task.Modified.IsZero() { 90 + t.Error("Expected Modified timestamp to be set") 91 + } 92 + }) 93 + 94 + t.Run("Get Task", func(t *testing.T) { 95 + original := createSampleTask() 96 + id, err := repo.Create(ctx, original) 97 + if err != nil { 98 + t.Fatalf("Failed to create task: %v", err) 99 + } 100 + 101 + retrieved, err := repo.Get(ctx, id) 102 + if err != nil { 103 + t.Errorf("Failed to get task: %v", err) 104 + } 105 + 106 + if retrieved.UUID != original.UUID { 107 + t.Errorf("Expected UUID %s, got %s", original.UUID, retrieved.UUID) 108 + } 109 + if retrieved.Description != original.Description { 110 + t.Errorf("Expected description %s, got %s", original.Description, retrieved.Description) 111 + } 112 + if retrieved.Status != original.Status { 113 + t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status) 114 + } 115 + if retrieved.Priority != original.Priority { 116 + t.Errorf("Expected priority %s, got %s", original.Priority, retrieved.Priority) 117 + } 118 + if retrieved.Project != original.Project { 119 + t.Errorf("Expected project %s, got %s", original.Project, retrieved.Project) 120 + } 121 + 122 + if len(retrieved.Tags) != len(original.Tags) { 123 + t.Errorf("Expected %d tags, got %d", len(original.Tags), len(retrieved.Tags)) 124 + } 125 + for i, tag := range original.Tags { 126 + if i < len(retrieved.Tags) && retrieved.Tags[i] != tag { 127 + t.Errorf("Expected tag %s, got %s", tag, retrieved.Tags[i]) 128 + } 129 + } 130 + 131 + if len(retrieved.Annotations) != len(original.Annotations) { 132 + t.Errorf("Expected %d annotations, got %d", len(original.Annotations), len(retrieved.Annotations)) 133 + } 134 + }) 135 + 136 + t.Run("Update Task", func(t *testing.T) { 137 + task := createSampleTask() 138 + id, err := repo.Create(ctx, task) 139 + if err != nil { 140 + t.Fatalf("Failed to create task: %v", err) 141 + } 142 + 143 + task.Description = "Updated description" 144 + task.Status = "completed" 145 + task.Priority = "L" 146 + now := time.Now() 147 + task.End = &now 148 + 149 + err = repo.Update(ctx, task) 150 + if err != nil { 151 + t.Errorf("Failed to update task: %v", err) 152 + } 153 + 154 + updated, err := repo.Get(ctx, id) 155 + if err != nil { 156 + t.Fatalf("Failed to get updated task: %v", err) 157 + } 158 + 159 + if updated.Description != "Updated description" { 160 + t.Errorf("Expected updated description, got %s", updated.Description) 161 + } 162 + if updated.Status != "completed" { 163 + t.Errorf("Expected status completed, got %s", updated.Status) 164 + } 165 + if updated.Priority != "L" { 166 + t.Errorf("Expected priority L, got %s", updated.Priority) 167 + } 168 + if updated.End == nil { 169 + t.Error("Expected end time to be set") 170 + } 171 + }) 172 + 173 + t.Run("Delete Task", func(t *testing.T) { 174 + task := createSampleTask() 175 + id, err := repo.Create(ctx, task) 176 + if err != nil { 177 + t.Fatalf("Failed to create task: %v", err) 178 + } 179 + 180 + err = repo.Delete(ctx, id) 181 + if err != nil { 182 + t.Errorf("Failed to delete task: %v", err) 183 + } 184 + 185 + _, err = repo.Get(ctx, id) 186 + if err == nil { 187 + t.Error("Expected error when getting deleted task") 188 + } 189 + }) 190 + } 191 + 192 + func TestTaskRepository_List(t *testing.T) { 193 + db := createTaskTestDB(t) 194 + repo := NewTaskRepository(db) 195 + ctx := context.Background() 196 + 197 + tasks := []*models.Task{ 198 + {UUID: uuid.New().String(), Description: "Task 1", Status: "pending", Project: "proj1"}, 199 + {UUID: uuid.New().String(), Description: "Task 2", Status: "completed", Project: "proj1"}, 200 + {UUID: uuid.New().String(), Description: "Task 3", Status: "pending", Project: "proj2"}, 201 + } 202 + 203 + for _, task := range tasks { 204 + _, err := repo.Create(ctx, task) 205 + if err != nil { 206 + t.Fatalf("Failed to create task: %v", err) 207 + } 208 + } 209 + 210 + t.Run("List All Tasks", func(t *testing.T) { 211 + results, err := repo.List(ctx, TaskListOptions{}) 212 + if err != nil { 213 + t.Errorf("Failed to list tasks: %v", err) 214 + } 215 + 216 + if len(results) != 3 { 217 + t.Errorf("Expected 3 tasks, got %d", len(results)) 218 + } 219 + }) 220 + 221 + t.Run("List Tasks with Filter", func(t *testing.T) { 222 + results, err := repo.List(ctx, TaskListOptions{Status: "pending"}) 223 + if err != nil { 224 + t.Errorf("Failed to list tasks: %v", err) 225 + } 226 + 227 + if len(results) != 2 { 228 + t.Errorf("Expected 2 pending tasks, got %d", len(results)) 229 + } 230 + 231 + for _, task := range results { 232 + if task.Status != "pending" { 233 + t.Errorf("Expected pending status, got %s", task.Status) 234 + } 235 + } 236 + }) 237 + 238 + t.Run("List Tasks with Limit", func(t *testing.T) { 239 + results, err := repo.List(ctx, TaskListOptions{Limit: 2}) 240 + if err != nil { 241 + t.Errorf("Failed to list tasks: %v", err) 242 + } 243 + 244 + if len(results) != 2 { 245 + t.Errorf("Expected 2 tasks due to limit, got %d", len(results)) 246 + } 247 + }) 248 + 249 + t.Run("List Tasks with Search", func(t *testing.T) { 250 + results, err := repo.List(ctx, TaskListOptions{Search: "Task 1"}) 251 + if err != nil { 252 + t.Errorf("Failed to list tasks: %v", err) 253 + } 254 + 255 + if len(results) != 1 { 256 + t.Errorf("Expected 1 task matching search, got %d", len(results)) 257 + } 258 + 259 + if len(results) > 0 && results[0].Description != "Task 1" { 260 + t.Errorf("Expected 'Task 1', got %s", results[0].Description) 261 + } 262 + }) 263 + } 264 + 265 + func TestTaskRepository_SpecialMethods(t *testing.T) { 266 + db := createTaskTestDB(t) 267 + repo := NewTaskRepository(db) 268 + ctx := context.Background() 269 + 270 + task1 := &models.Task{UUID: uuid.New().String(), Description: "Pending task", Status: "pending", Project: "test"} 271 + task2 := &models.Task{UUID: uuid.New().String(), Description: "Completed task", Status: "completed", Project: "test"} 272 + task3 := &models.Task{UUID: uuid.New().String(), Description: "Other project", Status: "pending", Project: "other"} 273 + 274 + for _, task := range []*models.Task{task1, task2, task3} { 275 + _, err := repo.Create(ctx, task) 276 + if err != nil { 277 + t.Fatalf("Failed to create task: %v", err) 278 + } 279 + } 280 + 281 + t.Run("GetPending", func(t *testing.T) { 282 + results, err := repo.GetPending(ctx) 283 + if err != nil { 284 + t.Errorf("Failed to get pending tasks: %v", err) 285 + } 286 + 287 + if len(results) != 2 { 288 + t.Errorf("Expected 2 pending tasks, got %d", len(results)) 289 + } 290 + }) 291 + 292 + t.Run("GetCompleted", func(t *testing.T) { 293 + results, err := repo.GetCompleted(ctx) 294 + if err != nil { 295 + t.Errorf("Failed to get completed tasks: %v", err) 296 + } 297 + 298 + if len(results) != 1 { 299 + t.Errorf("Expected 1 completed task, got %d", len(results)) 300 + } 301 + }) 302 + 303 + t.Run("GetByProject", func(t *testing.T) { 304 + results, err := repo.GetByProject(ctx, "test") 305 + if err != nil { 306 + t.Errorf("Failed to get tasks by project: %v", err) 307 + } 308 + 309 + if len(results) != 2 { 310 + t.Errorf("Expected 2 tasks in test project, got %d", len(results)) 311 + } 312 + 313 + for _, task := range results { 314 + if task.Project != "test" { 315 + t.Errorf("Expected project 'test', got %s", task.Project) 316 + } 317 + } 318 + }) 319 + 320 + t.Run("GetByUUID", func(t *testing.T) { 321 + result, err := repo.GetByUUID(ctx, task1.UUID) 322 + if err != nil { 323 + t.Errorf("Failed to get task by UUID: %v", err) 324 + } 325 + 326 + if result.UUID != task1.UUID { 327 + t.Errorf("Expected UUID %s, got %s", task1.UUID, result.UUID) 328 + } 329 + if result.Description != task1.Description { 330 + t.Errorf("Expected description %s, got %s", task1.Description, result.Description) 331 + } 332 + }) 333 + } 334 + 335 + func TestTaskRepository_Count(t *testing.T) { 336 + db := createTaskTestDB(t) 337 + repo := NewTaskRepository(db) 338 + ctx := context.Background() 339 + 340 + tasks := []*models.Task{ 341 + {UUID: uuid.New().String(), Description: "Test 1", Status: "pending"}, 342 + {UUID: uuid.New().String(), Description: "Test 2", Status: "pending"}, 343 + {UUID: uuid.New().String(), Description: "Test 3", Status: "completed"}, 344 + } 345 + 346 + for _, task := range tasks { 347 + _, err := repo.Create(ctx, task) 348 + if err != nil { 349 + t.Fatalf("Failed to create task: %v", err) 350 + } 351 + } 352 + 353 + t.Run("Count all tasks", func(t *testing.T) { 354 + count, err := repo.Count(ctx, TaskListOptions{}) 355 + if err != nil { 356 + t.Errorf("Failed to count tasks: %v", err) 357 + } 358 + 359 + if count != 3 { 360 + t.Errorf("Expected 3 tasks, got %d", count) 361 + } 362 + }) 363 + 364 + t.Run("Count pending tasks", func(t *testing.T) { 365 + count, err := repo.Count(ctx, TaskListOptions{Status: "pending"}) 366 + if err != nil { 367 + t.Errorf("Failed to count pending tasks: %v", err) 368 + } 369 + 370 + if count != 2 { 371 + t.Errorf("Expected 2 pending tasks, got %d", count) 372 + } 373 + }) 374 + 375 + t.Run("Count completed tasks", func(t *testing.T) { 376 + count, err := repo.Count(ctx, TaskListOptions{Status: "completed"}) 377 + if err != nil { 378 + t.Errorf("Failed to count completed tasks: %v", err) 379 + } 380 + 381 + if count != 1 { 382 + t.Errorf("Expected 1 completed task, got %d", count) 383 + } 384 + }) 385 + }
+310
internal/repo/tv_repository.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + // TVRepository provides database operations for TV shows 14 + type TVRepository struct { 15 + db *sql.DB 16 + } 17 + 18 + // NewTVRepository creates a new TV show repository 19 + func NewTVRepository(db *sql.DB) *TVRepository { 20 + return &TVRepository{db: db} 21 + } 22 + 23 + // Create stores a new TV show and returns its assigned ID 24 + func (r *TVRepository) Create(ctx context.Context, tvShow *models.TVShow) (int64, error) { 25 + now := time.Now() 26 + tvShow.Added = now 27 + 28 + query := ` 29 + INSERT INTO tv_shows (title, season, episode, status, rating, notes, added, last_watched) 30 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 31 + 32 + result, err := r.db.ExecContext(ctx, query, 33 + tvShow.Title, tvShow.Season, tvShow.Episode, tvShow.Status, tvShow.Rating, 34 + tvShow.Notes, tvShow.Added, tvShow.LastWatched) 35 + if err != nil { 36 + return 0, fmt.Errorf("failed to insert TV show: %w", err) 37 + } 38 + 39 + id, err := result.LastInsertId() 40 + if err != nil { 41 + return 0, fmt.Errorf("failed to get last insert id: %w", err) 42 + } 43 + 44 + tvShow.ID = id 45 + return id, nil 46 + } 47 + 48 + // Get retrieves a TV show by ID 49 + func (r *TVRepository) Get(ctx context.Context, id int64) (*models.TVShow, error) { 50 + query := ` 51 + SELECT id, title, season, episode, status, rating, notes, added, last_watched 52 + FROM tv_shows WHERE id = ?` 53 + 54 + tvShow := &models.TVShow{} 55 + err := r.db.QueryRowContext(ctx, query, id).Scan( 56 + &tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status, 57 + &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched) 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get TV show: %w", err) 60 + } 61 + 62 + return tvShow, nil 63 + } 64 + 65 + // Update modifies an existing TV show 66 + func (r *TVRepository) Update(ctx context.Context, tvShow *models.TVShow) error { 67 + query := ` 68 + UPDATE tv_shows SET title = ?, season = ?, episode = ?, status = ?, rating = ?, 69 + notes = ?, last_watched = ? 70 + WHERE id = ?` 71 + 72 + _, err := r.db.ExecContext(ctx, query, 73 + tvShow.Title, tvShow.Season, tvShow.Episode, tvShow.Status, tvShow.Rating, 74 + tvShow.Notes, tvShow.LastWatched, tvShow.ID) 75 + if err != nil { 76 + return fmt.Errorf("failed to update TV show: %w", err) 77 + } 78 + 79 + return nil 80 + } 81 + 82 + // Delete removes a TV show by ID 83 + func (r *TVRepository) Delete(ctx context.Context, id int64) error { 84 + query := "DELETE FROM tv_shows WHERE id = ?" 85 + _, err := r.db.ExecContext(ctx, query, id) 86 + if err != nil { 87 + return fmt.Errorf("failed to delete TV show: %w", err) 88 + } 89 + return nil 90 + } 91 + 92 + // List retrieves TV shows with optional filtering and sorting 93 + func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) { 94 + query := r.buildListQuery(opts) 95 + args := r.buildListArgs(opts) 96 + 97 + rows, err := r.db.QueryContext(ctx, query, args...) 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to list TV shows: %w", err) 100 + } 101 + defer rows.Close() 102 + 103 + var tvShows []*models.TVShow 104 + for rows.Next() { 105 + tvShow := &models.TVShow{} 106 + if err := r.scanTVShowRow(rows, tvShow); err != nil { 107 + return nil, err 108 + } 109 + tvShows = append(tvShows, tvShow) 110 + } 111 + 112 + return tvShows, rows.Err() 113 + } 114 + 115 + func (r *TVRepository) buildListQuery(opts TVListOptions) string { 116 + query := "SELECT id, title, season, episode, status, rating, notes, added, last_watched FROM tv_shows" 117 + 118 + var conditions []string 119 + 120 + if opts.Status != "" { 121 + conditions = append(conditions, "status = ?") 122 + } 123 + if opts.Title != "" { 124 + conditions = append(conditions, "title = ?") 125 + } 126 + if opts.Season > 0 { 127 + conditions = append(conditions, "season = ?") 128 + } 129 + if opts.MinRating > 0 { 130 + conditions = append(conditions, "rating >= ?") 131 + } 132 + 133 + if opts.Search != "" { 134 + searchConditions := []string{ 135 + "title LIKE ?", 136 + "notes LIKE ?", 137 + } 138 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 139 + } 140 + 141 + if len(conditions) > 0 { 142 + query += " WHERE " + strings.Join(conditions, " AND ") 143 + } 144 + 145 + if opts.SortBy != "" { 146 + order := "ASC" 147 + if strings.ToUpper(opts.SortOrder) == "DESC" { 148 + order = "DESC" 149 + } 150 + query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order) 151 + } else { 152 + query += " ORDER BY title, season, episode" 153 + } 154 + 155 + if opts.Limit > 0 { 156 + query += fmt.Sprintf(" LIMIT %d", opts.Limit) 157 + if opts.Offset > 0 { 158 + query += fmt.Sprintf(" OFFSET %d", opts.Offset) 159 + } 160 + } 161 + 162 + return query 163 + } 164 + 165 + func (r *TVRepository) buildListArgs(opts TVListOptions) []any { 166 + var args []any 167 + 168 + if opts.Status != "" { 169 + args = append(args, opts.Status) 170 + } 171 + if opts.Title != "" { 172 + args = append(args, opts.Title) 173 + } 174 + if opts.Season > 0 { 175 + args = append(args, opts.Season) 176 + } 177 + if opts.MinRating > 0 { 178 + args = append(args, opts.MinRating) 179 + } 180 + 181 + if opts.Search != "" { 182 + searchPattern := "%" + opts.Search + "%" 183 + args = append(args, searchPattern, searchPattern) 184 + } 185 + 186 + return args 187 + } 188 + 189 + func (r *TVRepository) scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error { 190 + return rows.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status, 191 + &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched) 192 + } 193 + 194 + // Find retrieves TV shows matching specific conditions 195 + func (r *TVRepository) Find(ctx context.Context, conditions TVListOptions) ([]*models.TVShow, error) { 196 + return r.List(ctx, conditions) 197 + } 198 + 199 + // Count returns the number of TV shows matching conditions 200 + func (r *TVRepository) Count(ctx context.Context, opts TVListOptions) (int64, error) { 201 + query := "SELECT COUNT(*) FROM tv_shows" 202 + args := []any{} 203 + 204 + var conditions []string 205 + 206 + if opts.Status != "" { 207 + conditions = append(conditions, "status = ?") 208 + args = append(args, opts.Status) 209 + } 210 + if opts.Title != "" { 211 + conditions = append(conditions, "title = ?") 212 + args = append(args, opts.Title) 213 + } 214 + if opts.Season > 0 { 215 + conditions = append(conditions, "season = ?") 216 + args = append(args, opts.Season) 217 + } 218 + if opts.MinRating > 0 { 219 + conditions = append(conditions, "rating >= ?") 220 + args = append(args, opts.MinRating) 221 + } 222 + 223 + if opts.Search != "" { 224 + searchConditions := []string{ 225 + "title LIKE ?", 226 + "notes LIKE ?", 227 + } 228 + conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 229 + searchPattern := "%" + opts.Search + "%" 230 + args = append(args, searchPattern, searchPattern) 231 + } 232 + 233 + if len(conditions) > 0 { 234 + query += " WHERE " + strings.Join(conditions, " AND ") 235 + } 236 + 237 + var count int64 238 + err := r.db.QueryRowContext(ctx, query, args...).Scan(&count) 239 + if err != nil { 240 + return 0, fmt.Errorf("failed to count TV shows: %w", err) 241 + } 242 + 243 + return count, nil 244 + } 245 + 246 + // GetQueued retrieves all TV shows in the queue 247 + func (r *TVRepository) GetQueued(ctx context.Context) ([]*models.TVShow, error) { 248 + return r.List(ctx, TVListOptions{Status: "queued"}) 249 + } 250 + 251 + // GetWatching retrieves all TV shows currently being watched 252 + func (r *TVRepository) GetWatching(ctx context.Context) ([]*models.TVShow, error) { 253 + return r.List(ctx, TVListOptions{Status: "watching"}) 254 + } 255 + 256 + // GetWatched retrieves all watched TV shows 257 + func (r *TVRepository) GetWatched(ctx context.Context) ([]*models.TVShow, error) { 258 + return r.List(ctx, TVListOptions{Status: "watched"}) 259 + } 260 + 261 + // GetByTitle retrieves all episodes for a specific TV show title 262 + func (r *TVRepository) GetByTitle(ctx context.Context, title string) ([]*models.TVShow, error) { 263 + return r.List(ctx, TVListOptions{Title: title}) 264 + } 265 + 266 + // GetBySeason retrieves all episodes for a specific season of a show 267 + func (r *TVRepository) GetBySeason(ctx context.Context, title string, season int) ([]*models.TVShow, error) { 268 + return r.List(ctx, TVListOptions{Title: title, Season: season}) 269 + } 270 + 271 + // MarkWatched marks a TV show episode as watched 272 + func (r *TVRepository) MarkWatched(ctx context.Context, id int64) error { 273 + tvShow, err := r.Get(ctx, id) 274 + if err != nil { 275 + return err 276 + } 277 + 278 + now := time.Now() 279 + tvShow.Status = "watched" 280 + tvShow.LastWatched = &now 281 + 282 + return r.Update(ctx, tvShow) 283 + } 284 + 285 + // StartWatching marks a TV show as currently being watched 286 + func (r *TVRepository) StartWatching(ctx context.Context, id int64) error { 287 + tvShow, err := r.Get(ctx, id) 288 + if err != nil { 289 + return err 290 + } 291 + 292 + now := time.Now() 293 + tvShow.Status = "watching" 294 + tvShow.LastWatched = &now 295 + 296 + return r.Update(ctx, tvShow) 297 + } 298 + 299 + // TVListOptions defines options for listing TV shows 300 + type TVListOptions struct { 301 + Status string 302 + Title string 303 + Season int 304 + MinRating float64 305 + Search string 306 + SortBy string 307 + SortOrder string 308 + Limit int 309 + Offset int 310 + }
+512
internal/repo/tv_repository_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + "time" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "stormlightlabs.org/noteleaf/internal/models" 11 + ) 12 + 13 + func createTVTestDB(t *testing.T) *sql.DB { 14 + db, err := sql.Open("sqlite3", ":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory database: %v", err) 17 + } 18 + 19 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 20 + t.Fatalf("Failed to enable foreign keys: %v", err) 21 + } 22 + 23 + schema := ` 24 + CREATE TABLE IF NOT EXISTS tv_shows ( 25 + id INTEGER PRIMARY KEY AUTOINCREMENT, 26 + title TEXT NOT NULL, 27 + season INTEGER, 28 + episode INTEGER, 29 + status TEXT DEFAULT 'queued', 30 + rating REAL, 31 + notes TEXT, 32 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 33 + last_watched DATETIME 34 + ); 35 + ` 36 + 37 + if _, err := db.Exec(schema); err != nil { 38 + t.Fatalf("Failed to create schema: %v", err) 39 + } 40 + 41 + t.Cleanup(func() { 42 + db.Close() 43 + }) 44 + 45 + return db 46 + } 47 + 48 + func createSampleTVShow() *models.TVShow { 49 + return &models.TVShow{ 50 + Title: "Test Show", 51 + Season: 1, 52 + Episode: 1, 53 + Status: "queued", 54 + Rating: 9.0, 55 + Notes: "Excellent series", 56 + } 57 + } 58 + 59 + func TestTVRepository(t *testing.T) { 60 + t.Run("CRUD Operations", func(t *testing.T) { 61 + db := createTVTestDB(t) 62 + repo := NewTVRepository(db) 63 + ctx := context.Background() 64 + 65 + t.Run("Create TV Show", func(t *testing.T) { 66 + tvShow := createSampleTVShow() 67 + 68 + id, err := repo.Create(ctx, tvShow) 69 + if err != nil { 70 + t.Errorf("Failed to create TV show: %v", err) 71 + } 72 + 73 + if id == 0 { 74 + t.Error("Expected non-zero ID") 75 + } 76 + 77 + if tvShow.ID != id { 78 + t.Errorf("Expected TV show ID to be set to %d, got %d", id, tvShow.ID) 79 + } 80 + 81 + if tvShow.Added.IsZero() { 82 + t.Error("Expected Added timestamp to be set") 83 + } 84 + }) 85 + 86 + t.Run("Get TV Show", func(t *testing.T) { 87 + original := createSampleTVShow() 88 + id, err := repo.Create(ctx, original) 89 + if err != nil { 90 + t.Fatalf("Failed to create TV show: %v", err) 91 + } 92 + 93 + retrieved, err := repo.Get(ctx, id) 94 + if err != nil { 95 + t.Errorf("Failed to get TV show: %v", err) 96 + } 97 + 98 + if retrieved.Title != original.Title { 99 + t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title) 100 + } 101 + if retrieved.Season != original.Season { 102 + t.Errorf("Expected season %d, got %d", original.Season, retrieved.Season) 103 + } 104 + if retrieved.Episode != original.Episode { 105 + t.Errorf("Expected episode %d, got %d", original.Episode, retrieved.Episode) 106 + } 107 + if retrieved.Status != original.Status { 108 + t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status) 109 + } 110 + if retrieved.Rating != original.Rating { 111 + t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating) 112 + } 113 + if retrieved.Notes != original.Notes { 114 + t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes) 115 + } 116 + }) 117 + 118 + t.Run("Update TV Show", func(t *testing.T) { 119 + tvShow := createSampleTVShow() 120 + id, err := repo.Create(ctx, tvShow) 121 + if err != nil { 122 + t.Fatalf("Failed to create TV show: %v", err) 123 + } 124 + 125 + tvShow.Title = "Updated Show" 126 + tvShow.Season = 2 127 + tvShow.Episode = 5 128 + tvShow.Status = "watching" 129 + tvShow.Rating = 9.5 130 + now := time.Now() 131 + tvShow.LastWatched = &now 132 + 133 + err = repo.Update(ctx, tvShow) 134 + if err != nil { 135 + t.Errorf("Failed to update TV show: %v", err) 136 + } 137 + 138 + updated, err := repo.Get(ctx, id) 139 + if err != nil { 140 + t.Fatalf("Failed to get updated TV show: %v", err) 141 + } 142 + 143 + if updated.Title != "Updated Show" { 144 + t.Errorf("Expected updated title, got %s", updated.Title) 145 + } 146 + if updated.Season != 2 { 147 + t.Errorf("Expected season 2, got %d", updated.Season) 148 + } 149 + if updated.Episode != 5 { 150 + t.Errorf("Expected episode 5, got %d", updated.Episode) 151 + } 152 + if updated.Status != "watching" { 153 + t.Errorf("Expected status watching, got %s", updated.Status) 154 + } 155 + if updated.Rating != 9.5 { 156 + t.Errorf("Expected rating 9.5, got %f", updated.Rating) 157 + } 158 + if updated.LastWatched == nil { 159 + t.Error("Expected last watched time to be set") 160 + } 161 + }) 162 + 163 + t.Run("Delete TV Show", func(t *testing.T) { 164 + tvShow := createSampleTVShow() 165 + id, err := repo.Create(ctx, tvShow) 166 + if err != nil { 167 + t.Fatalf("Failed to create TV show: %v", err) 168 + } 169 + 170 + err = repo.Delete(ctx, id) 171 + if err != nil { 172 + t.Errorf("Failed to delete TV show: %v", err) 173 + } 174 + 175 + _, err = repo.Get(ctx, id) 176 + if err == nil { 177 + t.Error("Expected error when getting deleted TV show") 178 + } 179 + }) 180 + }) 181 + 182 + t.Run("List", func(t *testing.T) { 183 + db := createTVTestDB(t) 184 + repo := NewTVRepository(db) 185 + ctx := context.Background() 186 + 187 + tvShows := []*models.TVShow{ 188 + {Title: "Show A", Season: 1, Episode: 1, Status: "queued", Rating: 8.0}, 189 + {Title: "Show A", Season: 1, Episode: 2, Status: "watching", Rating: 8.5}, 190 + {Title: "Show B", Season: 1, Episode: 1, Status: "queued", Rating: 9.0}, 191 + {Title: "Show B", Season: 2, Episode: 1, Status: "watched", Rating: 9.2}, 192 + } 193 + 194 + for _, tvShow := range tvShows { 195 + _, err := repo.Create(ctx, tvShow) 196 + if err != nil { 197 + t.Fatalf("Failed to create TV show: %v", err) 198 + } 199 + } 200 + 201 + t.Run("List All TV Shows", func(t *testing.T) { 202 + results, err := repo.List(ctx, TVListOptions{}) 203 + if err != nil { 204 + t.Errorf("Failed to list TV shows: %v", err) 205 + } 206 + 207 + if len(results) != 4 { 208 + t.Errorf("Expected 4 TV shows, got %d", len(results)) 209 + } 210 + }) 211 + 212 + t.Run("List TV Shows with Status Filter", func(t *testing.T) { 213 + results, err := repo.List(ctx, TVListOptions{Status: "queued"}) 214 + if err != nil { 215 + t.Errorf("Failed to list TV shows: %v", err) 216 + } 217 + 218 + if len(results) != 2 { 219 + t.Errorf("Expected 2 queued TV shows, got %d", len(results)) 220 + } 221 + 222 + for _, tvShow := range results { 223 + if tvShow.Status != "queued" { 224 + t.Errorf("Expected queued status, got %s", tvShow.Status) 225 + } 226 + } 227 + }) 228 + 229 + t.Run("List TV Shows by Title", func(t *testing.T) { 230 + results, err := repo.List(ctx, TVListOptions{Title: "Show A"}) 231 + if err != nil { 232 + t.Errorf("Failed to list TV shows: %v", err) 233 + } 234 + 235 + if len(results) != 2 { 236 + t.Errorf("Expected 2 episodes of Show A, got %d", len(results)) 237 + } 238 + 239 + for _, tvShow := range results { 240 + if tvShow.Title != "Show A" { 241 + t.Errorf("Expected title 'Show A', got %s", tvShow.Title) 242 + } 243 + } 244 + }) 245 + 246 + t.Run("List TV Shows by Season", func(t *testing.T) { 247 + results, err := repo.List(ctx, TVListOptions{Title: "Show B", Season: 1}) 248 + if err != nil { 249 + t.Errorf("Failed to list TV shows: %v", err) 250 + } 251 + 252 + if len(results) != 1 { 253 + t.Errorf("Expected 1 episode of Show B season 1, got %d", len(results)) 254 + } 255 + 256 + if len(results) > 0 { 257 + if results[0].Title != "Show B" || results[0].Season != 1 { 258 + t.Errorf("Expected Show B season 1, got %s season %d", results[0].Title, results[0].Season) 259 + } 260 + } 261 + }) 262 + 263 + t.Run("List TV Shows with Rating Filter", func(t *testing.T) { 264 + results, err := repo.List(ctx, TVListOptions{MinRating: 9.0}) 265 + if err != nil { 266 + t.Errorf("Failed to list TV shows: %v", err) 267 + } 268 + 269 + if len(results) != 2 { 270 + t.Errorf("Expected 2 TV shows with rating >= 9.0, got %d", len(results)) 271 + } 272 + 273 + for _, tvShow := range results { 274 + if tvShow.Rating < 9.0 { 275 + t.Errorf("Expected rating >= 9.0, got %f", tvShow.Rating) 276 + } 277 + } 278 + }) 279 + 280 + t.Run("List TV Shows with Search", func(t *testing.T) { 281 + results, err := repo.List(ctx, TVListOptions{Search: "Show A"}) 282 + if err != nil { 283 + t.Errorf("Failed to list TV shows: %v", err) 284 + } 285 + 286 + if len(results) != 2 { 287 + t.Errorf("Expected 2 TV shows matching search, got %d", len(results)) 288 + } 289 + 290 + for _, tvShow := range results { 291 + if tvShow.Title != "Show A" { 292 + t.Errorf("Expected 'Show A', got %s", tvShow.Title) 293 + } 294 + } 295 + }) 296 + 297 + t.Run("List TV Shows with Limit", func(t *testing.T) { 298 + results, err := repo.List(ctx, TVListOptions{Limit: 2}) 299 + if err != nil { 300 + t.Errorf("Failed to list TV shows: %v", err) 301 + } 302 + 303 + if len(results) != 2 { 304 + t.Errorf("Expected 2 TV shows due to limit, got %d", len(results)) 305 + } 306 + }) 307 + }) 308 + 309 + t.Run("Special Methods", func(t *testing.T) { 310 + db := createTVTestDB(t) 311 + repo := NewTVRepository(db) 312 + ctx := context.Background() 313 + 314 + tvShow1 := &models.TVShow{Title: "Queued Show", Status: "queued", Rating: 8.0} 315 + tvShow2 := &models.TVShow{Title: "Watching Show", Status: "watching", Rating: 9.0} 316 + tvShow3 := &models.TVShow{Title: "Watched Show", Status: "watched", Rating: 8.5} 317 + tvShow4 := &models.TVShow{Title: "Test Series", Season: 1, Episode: 1, Status: "queued"} 318 + tvShow5 := &models.TVShow{Title: "Test Series", Season: 1, Episode: 2, Status: "queued"} 319 + tvShow6 := &models.TVShow{Title: "Test Series", Season: 2, Episode: 1, Status: "queued"} 320 + 321 + var tvShow1ID int64 322 + for _, tvShow := range []*models.TVShow{tvShow1, tvShow2, tvShow3, tvShow4, tvShow5, tvShow6} { 323 + id, err := repo.Create(ctx, tvShow) 324 + if err != nil { 325 + t.Fatalf("Failed to create TV show: %v", err) 326 + } 327 + if tvShow == tvShow1 { 328 + tvShow1ID = id 329 + } 330 + } 331 + 332 + t.Run("GetQueued", func(t *testing.T) { 333 + results, err := repo.GetQueued(ctx) 334 + if err != nil { 335 + t.Errorf("Failed to get queued TV shows: %v", err) 336 + } 337 + 338 + if len(results) != 4 { 339 + t.Errorf("Expected 4 queued TV shows, got %d", len(results)) 340 + } 341 + 342 + for _, tvShow := range results { 343 + if tvShow.Status != "queued" { 344 + t.Errorf("Expected queued status, got %s", tvShow.Status) 345 + } 346 + } 347 + }) 348 + 349 + t.Run("GetWatching", func(t *testing.T) { 350 + results, err := repo.GetWatching(ctx) 351 + if err != nil { 352 + t.Errorf("Failed to get watching TV shows: %v", err) 353 + } 354 + 355 + if len(results) != 1 { 356 + t.Errorf("Expected 1 watching TV show, got %d", len(results)) 357 + } 358 + 359 + if len(results) > 0 && results[0].Status != "watching" { 360 + t.Errorf("Expected watching status, got %s", results[0].Status) 361 + } 362 + }) 363 + 364 + t.Run("GetWatched", func(t *testing.T) { 365 + results, err := repo.GetWatched(ctx) 366 + if err != nil { 367 + t.Errorf("Failed to get watched TV shows: %v", err) 368 + } 369 + 370 + if len(results) != 1 { 371 + t.Errorf("Expected 1 watched TV show, got %d", len(results)) 372 + } 373 + 374 + if len(results) > 0 && results[0].Status != "watched" { 375 + t.Errorf("Expected watched status, got %s", results[0].Status) 376 + } 377 + }) 378 + 379 + t.Run("GetByTitle", func(t *testing.T) { 380 + results, err := repo.GetByTitle(ctx, "Test Series") 381 + if err != nil { 382 + t.Errorf("Failed to get TV shows by title: %v", err) 383 + } 384 + 385 + if len(results) != 3 { 386 + t.Errorf("Expected 3 episodes of Test Series, got %d", len(results)) 387 + } 388 + 389 + for _, tvShow := range results { 390 + if tvShow.Title != "Test Series" { 391 + t.Errorf("Expected title 'Test Series', got %s", tvShow.Title) 392 + } 393 + } 394 + }) 395 + 396 + t.Run("GetBySeason", func(t *testing.T) { 397 + results, err := repo.GetBySeason(ctx, "Test Series", 1) 398 + if err != nil { 399 + t.Errorf("Failed to get TV shows by season: %v", err) 400 + } 401 + 402 + if len(results) != 2 { 403 + t.Errorf("Expected 2 episodes of Test Series season 1, got %d", len(results)) 404 + } 405 + 406 + for _, tvShow := range results { 407 + if tvShow.Title != "Test Series" || tvShow.Season != 1 { 408 + t.Errorf("Expected Test Series season 1, got %s season %d", tvShow.Title, tvShow.Season) 409 + } 410 + } 411 + }) 412 + 413 + t.Run("MarkWatched", func(t *testing.T) { 414 + err := repo.MarkWatched(ctx, tvShow1ID) 415 + if err != nil { 416 + t.Errorf("Failed to mark TV show as watched: %v", err) 417 + } 418 + 419 + updated, err := repo.Get(ctx, tvShow1ID) 420 + if err != nil { 421 + t.Fatalf("Failed to get updated TV show: %v", err) 422 + } 423 + 424 + if updated.Status != "watched" { 425 + t.Errorf("Expected status to be watched, got %s", updated.Status) 426 + } 427 + 428 + if updated.LastWatched == nil { 429 + t.Error("Expected last watched timestamp to be set") 430 + } 431 + }) 432 + 433 + t.Run("StartWatching", func(t *testing.T) { 434 + newShow := &models.TVShow{Title: "New Show", Status: "queued"} 435 + id, err := repo.Create(ctx, newShow) 436 + if err != nil { 437 + t.Fatalf("Failed to create new TV show: %v", err) 438 + } 439 + 440 + err = repo.StartWatching(ctx, id) 441 + if err != nil { 442 + t.Errorf("Failed to start watching TV show: %v", err) 443 + } 444 + 445 + updated, err := repo.Get(ctx, id) 446 + if err != nil { 447 + t.Fatalf("Failed to get updated TV show: %v", err) 448 + } 449 + 450 + if updated.Status != "watching" { 451 + t.Errorf("Expected status to be watching, got %s", updated.Status) 452 + } 453 + 454 + if updated.LastWatched == nil { 455 + t.Error("Expected last watched timestamp to be set") 456 + } 457 + }) 458 + }) 459 + 460 + t.Run("Count", func(t *testing.T) { 461 + db := createTVTestDB(t) 462 + repo := NewTVRepository(db) 463 + ctx := context.Background() 464 + 465 + tvShows := []*models.TVShow{ 466 + {Title: "Show 1", Status: "queued", Rating: 8.0}, 467 + {Title: "Show 2", Status: "watching", Rating: 7.0}, 468 + {Title: "Show 3", Status: "watched", Rating: 9.0}, 469 + {Title: "Show 4", Status: "queued", Rating: 8.5}, 470 + } 471 + 472 + for _, tvShow := range tvShows { 473 + _, err := repo.Create(ctx, tvShow) 474 + if err != nil { 475 + t.Fatalf("Failed to create TV show: %v", err) 476 + } 477 + } 478 + 479 + t.Run("Count all TV shows", func(t *testing.T) { 480 + count, err := repo.Count(ctx, TVListOptions{}) 481 + if err != nil { 482 + t.Errorf("Failed to count TV shows: %v", err) 483 + } 484 + 485 + if count != 4 { 486 + t.Errorf("Expected 4 TV shows, got %d", count) 487 + } 488 + }) 489 + 490 + t.Run("Count queued TV shows", func(t *testing.T) { 491 + count, err := repo.Count(ctx, TVListOptions{Status: "queued"}) 492 + if err != nil { 493 + t.Errorf("Failed to count queued TV shows: %v", err) 494 + } 495 + 496 + if count != 2 { 497 + t.Errorf("Expected 2 queued TV shows, got %d", count) 498 + } 499 + }) 500 + 501 + t.Run("Count TV shows by rating", func(t *testing.T) { 502 + count, err := repo.Count(ctx, TVListOptions{MinRating: 8.0}) 503 + if err != nil { 504 + t.Errorf("Failed to count high-rated TV shows: %v", err) 505 + } 506 + 507 + if count != 3 { 508 + t.Errorf("Expected 3 TV shows with rating >= 8.0, got %d", count) 509 + } 510 + }) 511 + }) 512 + }
+63
justfile
··· 1 + # Noteleaf project commands 2 + 3 + # Default recipe - show available commands 4 + default: 5 + @just --list 6 + 7 + # Run all tests 8 + test: 9 + go test ./... -v 10 + 11 + # Run tests with coverage 12 + coverage: 13 + go test ./... -coverprofile=coverage.out 14 + go tool cover -html=coverage.out -o coverage.html 15 + @echo "Coverage report generated: coverage.html" 16 + 17 + # Run tests and show coverage in terminal 18 + test-coverage: 19 + go test ./... -coverprofile=coverage.out 20 + go tool cover -func=coverage.out 21 + 22 + # Build the binary to /tmp/ 23 + build: 24 + mkdir -p /tmp/ 25 + go build -o /tmp/noteleaf ./cmd/cli/ 26 + @echo "Binary built: /tmp/noteleaf/noteleaf" 27 + 28 + # Clean build artifacts 29 + clean: 30 + rm -f coverage.out coverage.html 31 + rm -rf /tmp/noteleaf 32 + 33 + # Run linting 34 + lint: 35 + go vet ./... 36 + go fmt ./... 37 + 38 + # Run all quality checks 39 + check: lint test-coverage 40 + 41 + # Install dependencies 42 + deps: 43 + go mod download 44 + go mod tidy 45 + 46 + # Run the application (after building) 47 + run: build 48 + /tmp/noteleaf/noteleaf 49 + 50 + # Show project status 51 + status: 52 + @echo "Go version:" 53 + @go version 54 + @echo "" 55 + @echo "Module info:" 56 + @go list -m 57 + @echo "" 58 + @echo "Dependencies:" 59 + @go list -m all | head -10 60 + 61 + # Quick development workflow 62 + dev: clean lint test build 63 + @echo "Development workflow complete!"