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

feat: notes table database access

+1065 -1
+342
internal/repo/note_repository.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "slices" 8 + "strings" 9 + "time" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + ) 13 + 14 + // NoteRepository provides database operations for notes 15 + type NoteRepository struct { 16 + db *sql.DB 17 + } 18 + 19 + // NewNoteRepository creates a new note repository 20 + func NewNoteRepository(db *sql.DB) *NoteRepository { 21 + return &NoteRepository{db: db} 22 + } 23 + 24 + // NoteListOptions defines filtering options for listing notes 25 + type NoteListOptions struct { 26 + Tags []string 27 + Archived *bool 28 + Title string 29 + Content string 30 + Limit int 31 + Offset int 32 + } 33 + 34 + // Create stores a new note and returns its assigned ID 35 + func (r *NoteRepository) Create(ctx context.Context, note *models.Note) (int64, error) { 36 + now := time.Now() 37 + note.Created = now 38 + note.Modified = now 39 + 40 + tags, err := note.MarshalTags() 41 + if err != nil { 42 + return 0, fmt.Errorf("failed to marshal tags: %w", err) 43 + } 44 + 45 + query := ` 46 + INSERT INTO notes (title, content, tags, archived, created, modified, file_path) 47 + VALUES (?, ?, ?, ?, ?, ?, ?)` 48 + 49 + result, err := r.db.ExecContext(ctx, query, 50 + note.Title, note.Content, tags, note.Archived, note.Created, note.Modified, note.FilePath) 51 + if err != nil { 52 + return 0, fmt.Errorf("failed to insert note: %w", err) 53 + } 54 + 55 + id, err := result.LastInsertId() 56 + if err != nil { 57 + return 0, fmt.Errorf("failed to get last insert id: %w", err) 58 + } 59 + 60 + note.ID = id 61 + return id, nil 62 + } 63 + 64 + // Get retrieves a note by its ID 65 + func (r *NoteRepository) Get(ctx context.Context, id int64) (*models.Note, error) { 66 + query := ` 67 + SELECT id, title, content, tags, archived, created, modified, file_path 68 + FROM notes WHERE id = ?` 69 + 70 + row := r.db.QueryRowContext(ctx, query, id) 71 + 72 + var note models.Note 73 + var tags string 74 + err := row.Scan(&note.ID, &note.Title, &note.Content, &tags, &note.Archived, 75 + &note.Created, &note.Modified, &note.FilePath) 76 + if err != nil { 77 + if err == sql.ErrNoRows { 78 + return nil, fmt.Errorf("note with id %d not found", id) 79 + } 80 + return nil, fmt.Errorf("failed to scan note: %w", err) 81 + } 82 + 83 + if err := note.UnmarshalTags(tags); err != nil { 84 + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 85 + } 86 + 87 + return &note, nil 88 + } 89 + 90 + // Update modifies an existing note 91 + func (r *NoteRepository) Update(ctx context.Context, note *models.Note) error { 92 + note.Modified = time.Now() 93 + 94 + tags, err := note.MarshalTags() 95 + if err != nil { 96 + return fmt.Errorf("failed to marshal tags: %w", err) 97 + } 98 + 99 + query := ` 100 + UPDATE notes 101 + SET title = ?, content = ?, tags = ?, archived = ?, modified = ?, file_path = ? 102 + WHERE id = ?` 103 + 104 + result, err := r.db.ExecContext(ctx, query, 105 + note.Title, note.Content, tags, note.Archived, note.Modified, note.FilePath, note.ID) 106 + if err != nil { 107 + return fmt.Errorf("failed to update note: %w", err) 108 + } 109 + 110 + rowsAffected, err := result.RowsAffected() 111 + if err != nil { 112 + return fmt.Errorf("failed to get rows affected: %w", err) 113 + } 114 + 115 + if rowsAffected == 0 { 116 + return fmt.Errorf("note with id %d not found", note.ID) 117 + } 118 + 119 + return nil 120 + } 121 + 122 + // Delete removes a note by its ID 123 + func (r *NoteRepository) Delete(ctx context.Context, id int64) error { 124 + query := `DELETE FROM notes WHERE id = ?` 125 + 126 + result, err := r.db.ExecContext(ctx, query, id) 127 + if err != nil { 128 + return fmt.Errorf("failed to delete note: %w", err) 129 + } 130 + 131 + rowsAffected, err := result.RowsAffected() 132 + if err != nil { 133 + return fmt.Errorf("failed to get rows affected: %w", err) 134 + } 135 + 136 + if rowsAffected == 0 { 137 + return fmt.Errorf("note with id %d not found", id) 138 + } 139 + 140 + return nil 141 + } 142 + 143 + // List retrieves notes with optional filtering 144 + func (r *NoteRepository) List(ctx context.Context, options NoteListOptions) ([]*models.Note, error) { 145 + query := "SELECT id, title, content, tags, archived, created, modified, file_path FROM notes" 146 + args := []any{} 147 + conditions := []string{} 148 + 149 + if options.Archived != nil { 150 + conditions = append(conditions, "archived = ?") 151 + args = append(args, *options.Archived) 152 + } 153 + 154 + if options.Title != "" { 155 + conditions = append(conditions, "title LIKE ?") 156 + args = append(args, "%"+options.Title+"%") 157 + } 158 + 159 + if options.Content != "" { 160 + conditions = append(conditions, "content LIKE ?") 161 + args = append(args, "%"+options.Content+"%") 162 + } 163 + 164 + if len(conditions) > 0 { 165 + query += " WHERE " + strings.Join(conditions, " AND ") 166 + } 167 + 168 + query += " ORDER BY modified DESC" 169 + 170 + if options.Limit > 0 { 171 + query += fmt.Sprintf(" LIMIT %d", options.Limit) 172 + if options.Offset > 0 { 173 + query += fmt.Sprintf(" OFFSET %d", options.Offset) 174 + } 175 + } 176 + 177 + rows, err := r.db.QueryContext(ctx, query, args...) 178 + if err != nil { 179 + return nil, fmt.Errorf("failed to query notes: %w", err) 180 + } 181 + defer rows.Close() 182 + 183 + var notes []*models.Note 184 + for rows.Next() { 185 + var note models.Note 186 + var tags string 187 + err := rows.Scan(&note.ID, &note.Title, &note.Content, &tags, &note.Archived, 188 + &note.Created, &note.Modified, &note.FilePath) 189 + if err != nil { 190 + return nil, fmt.Errorf("failed to scan note: %w", err) 191 + } 192 + 193 + if err := note.UnmarshalTags(tags); err != nil { 194 + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 195 + } 196 + 197 + notes = append(notes, &note) 198 + } 199 + 200 + if err := rows.Err(); err != nil { 201 + return nil, fmt.Errorf("error iterating over notes: %w", err) 202 + } 203 + 204 + return notes, nil 205 + } 206 + 207 + // GetByTitle searches for notes by title pattern 208 + func (r *NoteRepository) GetByTitle(ctx context.Context, title string) ([]*models.Note, error) { 209 + return r.List(ctx, NoteListOptions{Title: title}) 210 + } 211 + 212 + // GetArchived retrieves all archived notes 213 + func (r *NoteRepository) GetArchived(ctx context.Context) ([]*models.Note, error) { 214 + archived := true 215 + return r.List(ctx, NoteListOptions{Archived: &archived}) 216 + } 217 + 218 + // GetActive retrieves all non-archived notes 219 + func (r *NoteRepository) GetActive(ctx context.Context) ([]*models.Note, error) { 220 + archived := false 221 + return r.List(ctx, NoteListOptions{Archived: &archived}) 222 + } 223 + 224 + // Archive marks a note as archived 225 + func (r *NoteRepository) Archive(ctx context.Context, id int64) error { 226 + note, err := r.Get(ctx, id) 227 + if err != nil { 228 + return err 229 + } 230 + 231 + note.Archived = true 232 + return r.Update(ctx, note) 233 + } 234 + 235 + // Unarchive marks a note as not archived 236 + func (r *NoteRepository) Unarchive(ctx context.Context, id int64) error { 237 + note, err := r.Get(ctx, id) 238 + if err != nil { 239 + return err 240 + } 241 + 242 + note.Archived = false 243 + return r.Update(ctx, note) 244 + } 245 + 246 + // SearchContent searches for notes containing the specified text in content 247 + func (r *NoteRepository) SearchContent(ctx context.Context, searchText string) ([]*models.Note, error) { 248 + return r.List(ctx, NoteListOptions{Content: searchText}) 249 + } 250 + 251 + // GetRecent retrieves the most recently modified notes 252 + func (r *NoteRepository) GetRecent(ctx context.Context, limit int) ([]*models.Note, error) { 253 + return r.List(ctx, NoteListOptions{Limit: limit}) 254 + } 255 + 256 + // AddTag adds a tag to a note 257 + func (r *NoteRepository) AddTag(ctx context.Context, id int64, tag string) error { 258 + note, err := r.Get(ctx, id) 259 + if err != nil { 260 + return err 261 + } 262 + 263 + if slices.Contains(note.Tags, tag) { 264 + return nil 265 + } 266 + 267 + note.Tags = append(note.Tags, tag) 268 + return r.Update(ctx, note) 269 + } 270 + 271 + // RemoveTag removes a tag from a note 272 + func (r *NoteRepository) RemoveTag(ctx context.Context, id int64, tag string) error { 273 + note, err := r.Get(ctx, id) 274 + if err != nil { 275 + return err 276 + } 277 + 278 + for i, existingTag := range note.Tags { 279 + if existingTag == tag { 280 + note.Tags = append(note.Tags[:i], note.Tags[i+1:]...) 281 + break 282 + } 283 + } 284 + 285 + return r.Update(ctx, note) 286 + } 287 + 288 + // GetByTags retrieves notes that have any of the specified tags 289 + func (r *NoteRepository) GetByTags(ctx context.Context, tags []string) ([]*models.Note, error) { 290 + if len(tags) == 0 { 291 + return []*models.Note{}, nil 292 + } 293 + 294 + placeholders := make([]string, len(tags)) 295 + args := make([]any, len(tags)) 296 + for i, tag := range tags { 297 + placeholders[i] = "?" 298 + args[i] = "%\"" + tag + "\"%" 299 + } 300 + 301 + query := fmt.Sprintf(` 302 + SELECT id, title, content, tags, archived, created, modified, file_path 303 + FROM notes 304 + WHERE %s 305 + ORDER BY modified DESC`, 306 + strings.Join(func() []string { 307 + conditions := make([]string, len(tags)) 308 + for i := range tags { 309 + conditions[i] = "tags LIKE ?" 310 + } 311 + return conditions 312 + }(), " OR ")) 313 + 314 + rows, err := r.db.QueryContext(ctx, query, args...) 315 + if err != nil { 316 + return nil, fmt.Errorf("failed to query notes by tags: %w", err) 317 + } 318 + defer rows.Close() 319 + 320 + var notes []*models.Note 321 + for rows.Next() { 322 + var note models.Note 323 + var tagsJSON string 324 + err := rows.Scan(&note.ID, &note.Title, &note.Content, &tagsJSON, &note.Archived, 325 + &note.Created, &note.Modified, &note.FilePath) 326 + if err != nil { 327 + return nil, fmt.Errorf("failed to scan note: %w", err) 328 + } 329 + 330 + if err := note.UnmarshalTags(tagsJSON); err != nil { 331 + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) 332 + } 333 + 334 + notes = append(notes, &note) 335 + } 336 + 337 + if err := rows.Err(); err != nil { 338 + return nil, fmt.Errorf("error iterating over notes: %w", err) 339 + } 340 + 341 + return notes, nil 342 + }
+677
internal/repo/note_repository_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "testing" 7 + 8 + _ "github.com/mattn/go-sqlite3" 9 + "github.com/stormlightlabs/noteleaf/internal/models" 10 + ) 11 + 12 + func createNoteTestDB(t *testing.T) *sql.DB { 13 + db, err := sql.Open("sqlite3", ":memory:") 14 + if err != nil { 15 + t.Fatalf("Failed to create in-memory database: %v", err) 16 + } 17 + 18 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 19 + t.Fatalf("Failed to enable foreign keys: %v", err) 20 + } 21 + 22 + schema := ` 23 + CREATE TABLE IF NOT EXISTS notes ( 24 + id INTEGER PRIMARY KEY AUTOINCREMENT, 25 + title TEXT NOT NULL, 26 + content TEXT NOT NULL, 27 + tags TEXT, 28 + archived BOOLEAN DEFAULT FALSE, 29 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 30 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 31 + file_path TEXT 32 + ); 33 + ` 34 + 35 + if _, err := db.Exec(schema); err != nil { 36 + t.Fatalf("Failed to create schema: %v", err) 37 + } 38 + 39 + t.Cleanup(func() { 40 + db.Close() 41 + }) 42 + 43 + return db 44 + } 45 + 46 + func createSampleNote() *models.Note { 47 + return &models.Note{ 48 + Title: "Test Note", 49 + Content: "This is test content with **markdown**", 50 + Tags: []string{"personal", "work"}, 51 + Archived: false, 52 + FilePath: "/path/to/note.md", 53 + } 54 + } 55 + 56 + func TestNoteRepository_CRUD(t *testing.T) { 57 + db := createNoteTestDB(t) 58 + repo := NewNoteRepository(db) 59 + ctx := context.Background() 60 + 61 + t.Run("Create Note", func(t *testing.T) { 62 + note := createSampleNote() 63 + 64 + id, err := repo.Create(ctx, note) 65 + if err != nil { 66 + t.Errorf("Failed to create note: %v", err) 67 + } 68 + 69 + if id == 0 { 70 + t.Error("Expected non-zero ID") 71 + } 72 + 73 + if note.ID != id { 74 + t.Errorf("Expected note ID to be set to %d, got %d", id, note.ID) 75 + } 76 + 77 + if note.Created.IsZero() { 78 + t.Error("Expected Created timestamp to be set") 79 + } 80 + if note.Modified.IsZero() { 81 + t.Error("Expected Modified timestamp to be set") 82 + } 83 + }) 84 + 85 + t.Run("Get Note", func(t *testing.T) { 86 + original := createSampleNote() 87 + id, err := repo.Create(ctx, original) 88 + if err != nil { 89 + t.Fatalf("Failed to create note: %v", err) 90 + } 91 + 92 + retrieved, err := repo.Get(ctx, id) 93 + if err != nil { 94 + t.Fatalf("Failed to get note: %v", err) 95 + } 96 + 97 + if retrieved.ID != original.ID { 98 + t.Errorf("Expected ID %d, got %d", original.ID, retrieved.ID) 99 + } 100 + if retrieved.Title != original.Title { 101 + t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title) 102 + } 103 + if retrieved.Content != original.Content { 104 + t.Errorf("Expected content %s, got %s", original.Content, retrieved.Content) 105 + } 106 + if len(retrieved.Tags) != len(original.Tags) { 107 + t.Errorf("Expected %d tags, got %d", len(original.Tags), len(retrieved.Tags)) 108 + } 109 + if retrieved.Archived != original.Archived { 110 + t.Errorf("Expected archived %v, got %v", original.Archived, retrieved.Archived) 111 + } 112 + if retrieved.FilePath != original.FilePath { 113 + t.Errorf("Expected file path %s, got %s", original.FilePath, retrieved.FilePath) 114 + } 115 + }) 116 + 117 + t.Run("Update Note", func(t *testing.T) { 118 + note := createSampleNote() 119 + id, err := repo.Create(ctx, note) 120 + if err != nil { 121 + t.Fatalf("Failed to create note: %v", err) 122 + } 123 + 124 + originalModified := note.Modified 125 + 126 + note.Title = "Updated Title" 127 + note.Content = "Updated content" 128 + note.Tags = []string{"updated", "test"} 129 + note.Archived = true 130 + note.FilePath = "/new/path/note.md" 131 + 132 + err = repo.Update(ctx, note) 133 + if err != nil { 134 + t.Errorf("Failed to update note: %v", err) 135 + } 136 + 137 + retrieved, err := repo.Get(ctx, id) 138 + if err != nil { 139 + t.Fatalf("Failed to get updated note: %v", err) 140 + } 141 + 142 + if retrieved.Title != "Updated Title" { 143 + t.Errorf("Expected updated title, got %s", retrieved.Title) 144 + } 145 + if retrieved.Content != "Updated content" { 146 + t.Errorf("Expected updated content, got %s", retrieved.Content) 147 + } 148 + if len(retrieved.Tags) != 2 || retrieved.Tags[0] != "updated" || retrieved.Tags[1] != "test" { 149 + t.Errorf("Expected updated tags, got %v", retrieved.Tags) 150 + } 151 + if !retrieved.Archived { 152 + t.Error("Expected note to be archived") 153 + } 154 + if retrieved.FilePath != "/new/path/note.md" { 155 + t.Errorf("Expected updated file path, got %s", retrieved.FilePath) 156 + } 157 + if !retrieved.Modified.After(originalModified) { 158 + t.Error("Expected Modified timestamp to be updated") 159 + } 160 + }) 161 + 162 + t.Run("Delete Note", func(t *testing.T) { 163 + note := createSampleNote() 164 + id, err := repo.Create(ctx, note) 165 + if err != nil { 166 + t.Fatalf("Failed to create note: %v", err) 167 + } 168 + 169 + err = repo.Delete(ctx, id) 170 + if err != nil { 171 + t.Errorf("Failed to delete note: %v", err) 172 + } 173 + 174 + _, err = repo.Get(ctx, id) 175 + if err == nil { 176 + t.Error("Expected error when getting deleted note") 177 + } 178 + }) 179 + } 180 + 181 + func TestNoteRepository_List(t *testing.T) { 182 + db := createNoteTestDB(t) 183 + repo := NewNoteRepository(db) 184 + ctx := context.Background() 185 + 186 + notes := []*models.Note{ 187 + {Title: "First Note", Content: "Content 1", Tags: []string{"work"}, Archived: false}, 188 + {Title: "Second Note", Content: "Content 2", Tags: []string{"personal"}, Archived: true}, 189 + {Title: "Third Note", Content: "Important content", Tags: []string{"work", "important"}, Archived: false}, 190 + } 191 + 192 + for _, note := range notes { 193 + _, err := repo.Create(ctx, note) 194 + if err != nil { 195 + t.Fatalf("Failed to create test note: %v", err) 196 + } 197 + } 198 + 199 + t.Run("List All Notes", func(t *testing.T) { 200 + results, err := repo.List(ctx, NoteListOptions{}) 201 + if err != nil { 202 + t.Fatalf("Failed to list notes: %v", err) 203 + } 204 + 205 + if len(results) != 3 { 206 + t.Errorf("Expected 3 notes, got %d", len(results)) 207 + } 208 + }) 209 + 210 + t.Run("List Archived Notes Only", func(t *testing.T) { 211 + archived := true 212 + results, err := repo.List(ctx, NoteListOptions{Archived: &archived}) 213 + if err != nil { 214 + t.Fatalf("Failed to list archived notes: %v", err) 215 + } 216 + 217 + if len(results) != 1 { 218 + t.Errorf("Expected 1 archived note, got %d", len(results)) 219 + } 220 + if !results[0].Archived { 221 + t.Error("Retrieved note should be archived") 222 + } 223 + }) 224 + 225 + t.Run("List Active Notes Only", func(t *testing.T) { 226 + archived := false 227 + results, err := repo.List(ctx, NoteListOptions{Archived: &archived}) 228 + if err != nil { 229 + t.Fatalf("Failed to list active notes: %v", err) 230 + } 231 + 232 + if len(results) != 2 { 233 + t.Errorf("Expected 2 active notes, got %d", len(results)) 234 + } 235 + for _, note := range results { 236 + if note.Archived { 237 + t.Error("Retrieved note should not be archived") 238 + } 239 + } 240 + }) 241 + 242 + t.Run("Search by Title", func(t *testing.T) { 243 + results, err := repo.List(ctx, NoteListOptions{Title: "First"}) 244 + if err != nil { 245 + t.Fatalf("Failed to search by title: %v", err) 246 + } 247 + 248 + if len(results) != 1 { 249 + t.Errorf("Expected 1 note, got %d", len(results)) 250 + } 251 + if results[0].Title != "First Note" { 252 + t.Errorf("Expected 'First Note', got %s", results[0].Title) 253 + } 254 + }) 255 + 256 + t.Run("Search by Content", func(t *testing.T) { 257 + results, err := repo.List(ctx, NoteListOptions{Content: "Important"}) 258 + if err != nil { 259 + t.Fatalf("Failed to search by content: %v", err) 260 + } 261 + 262 + if len(results) != 1 { 263 + t.Errorf("Expected 1 note, got %d", len(results)) 264 + } 265 + if results[0].Title != "Third Note" { 266 + t.Errorf("Expected 'Third Note', got %s", results[0].Title) 267 + } 268 + }) 269 + 270 + t.Run("Limit and Offset", func(t *testing.T) { 271 + results, err := repo.List(ctx, NoteListOptions{Limit: 2}) 272 + if err != nil { 273 + t.Fatalf("Failed to list with limit: %v", err) 274 + } 275 + 276 + if len(results) != 2 { 277 + t.Errorf("Expected 2 notes, got %d", len(results)) 278 + } 279 + 280 + results, err = repo.List(ctx, NoteListOptions{Limit: 2, Offset: 1}) 281 + if err != nil { 282 + t.Fatalf("Failed to list with limit and offset: %v", err) 283 + } 284 + 285 + if len(results) != 2 { 286 + t.Errorf("Expected 2 notes with offset, got %d", len(results)) 287 + } 288 + }) 289 + } 290 + 291 + func TestNoteRepository_SpecializedMethods(t *testing.T) { 292 + db := createNoteTestDB(t) 293 + repo := NewNoteRepository(db) 294 + ctx := context.Background() 295 + 296 + notes := []*models.Note{ 297 + {Title: "Work Note", Content: "Work content", Tags: []string{"work"}, Archived: false}, 298 + {Title: "Personal Note", Content: "Personal content", Tags: []string{"personal"}, Archived: true}, 299 + {Title: "Important Note", Content: "Important content", Tags: []string{"work", "important"}, Archived: false}, 300 + } 301 + 302 + for _, note := range notes { 303 + _, err := repo.Create(ctx, note) 304 + if err != nil { 305 + t.Fatalf("Failed to create test note: %v", err) 306 + } 307 + } 308 + 309 + t.Run("GetByTitle", func(t *testing.T) { 310 + results, err := repo.GetByTitle(ctx, "Work") 311 + if err != nil { 312 + t.Fatalf("Failed to get by title: %v", err) 313 + } 314 + 315 + if len(results) != 1 { 316 + t.Errorf("Expected 1 note, got %d", len(results)) 317 + } 318 + if results[0].Title != "Work Note" { 319 + t.Errorf("Expected 'Work Note', got %s", results[0].Title) 320 + } 321 + }) 322 + 323 + t.Run("GetArchived", func(t *testing.T) { 324 + results, err := repo.GetArchived(ctx) 325 + if err != nil { 326 + t.Fatalf("Failed to get archived notes: %v", err) 327 + } 328 + 329 + if len(results) != 1 { 330 + t.Errorf("Expected 1 archived note, got %d", len(results)) 331 + } 332 + if !results[0].Archived { 333 + t.Error("Retrieved note should be archived") 334 + } 335 + }) 336 + 337 + t.Run("GetActive", func(t *testing.T) { 338 + results, err := repo.GetActive(ctx) 339 + if err != nil { 340 + t.Fatalf("Failed to get active notes: %v", err) 341 + } 342 + 343 + if len(results) != 2 { 344 + t.Errorf("Expected 2 active notes, got %d", len(results)) 345 + } 346 + for _, note := range results { 347 + if note.Archived { 348 + t.Error("Retrieved note should not be archived") 349 + } 350 + } 351 + }) 352 + 353 + t.Run("Archive and Unarchive", func(t *testing.T) { 354 + note := &models.Note{ 355 + Title: "Test Archive", 356 + Content: "Archive test", 357 + Archived: false, 358 + } 359 + id, err := repo.Create(ctx, note) 360 + if err != nil { 361 + t.Fatalf("Failed to create note: %v", err) 362 + } 363 + 364 + err = repo.Archive(ctx, id) 365 + if err != nil { 366 + t.Fatalf("Failed to archive note: %v", err) 367 + } 368 + 369 + retrieved, err := repo.Get(ctx, id) 370 + if err != nil { 371 + t.Fatalf("Failed to get note: %v", err) 372 + } 373 + if !retrieved.Archived { 374 + t.Error("Note should be archived") 375 + } 376 + 377 + err = repo.Unarchive(ctx, id) 378 + if err != nil { 379 + t.Fatalf("Failed to unarchive note: %v", err) 380 + } 381 + 382 + retrieved, err = repo.Get(ctx, id) 383 + if err != nil { 384 + t.Fatalf("Failed to get note: %v", err) 385 + } 386 + if retrieved.Archived { 387 + t.Error("Note should not be archived") 388 + } 389 + }) 390 + 391 + t.Run("SearchContent", func(t *testing.T) { 392 + results, err := repo.SearchContent(ctx, "Important") 393 + if err != nil { 394 + t.Fatalf("Failed to search content: %v", err) 395 + } 396 + 397 + if len(results) != 1 { 398 + t.Errorf("Expected 1 note, got %d", len(results)) 399 + } 400 + if results[0].Title != "Important Note" { 401 + t.Errorf("Expected 'Important Note', got %s", results[0].Title) 402 + } 403 + }) 404 + 405 + t.Run("GetRecent", func(t *testing.T) { 406 + results, err := repo.GetRecent(ctx, 2) 407 + if err != nil { 408 + t.Fatalf("Failed to get recent notes: %v", err) 409 + } 410 + 411 + if len(results) != 2 { 412 + t.Errorf("Expected 2 notes, got %d", len(results)) 413 + } 414 + }) 415 + } 416 + 417 + func TestNoteRepository_TagMethods(t *testing.T) { 418 + db := createNoteTestDB(t) 419 + repo := NewNoteRepository(db) 420 + ctx := context.Background() 421 + 422 + note := &models.Note{ 423 + Title: "Tag Test Note", 424 + Content: "Testing tags", 425 + Tags: []string{"initial"}, 426 + } 427 + id, err := repo.Create(ctx, note) 428 + if err != nil { 429 + t.Fatalf("Failed to create note: %v", err) 430 + } 431 + 432 + t.Run("AddTag", func(t *testing.T) { 433 + err := repo.AddTag(ctx, id, "new-tag") 434 + if err != nil { 435 + t.Fatalf("Failed to add tag: %v", err) 436 + } 437 + 438 + retrieved, err := repo.Get(ctx, id) 439 + if err != nil { 440 + t.Fatalf("Failed to get note: %v", err) 441 + } 442 + 443 + if len(retrieved.Tags) != 2 { 444 + t.Errorf("Expected 2 tags, got %d", len(retrieved.Tags)) 445 + } 446 + 447 + found := false 448 + for _, tag := range retrieved.Tags { 449 + if tag == "new-tag" { 450 + found = true 451 + break 452 + } 453 + } 454 + if !found { 455 + t.Error("New tag not found in note") 456 + } 457 + }) 458 + 459 + t.Run("AddTag Duplicate", func(t *testing.T) { 460 + err := repo.AddTag(ctx, id, "new-tag") 461 + if err != nil { 462 + t.Fatalf("Failed to add duplicate tag: %v", err) 463 + } 464 + 465 + retrieved, err := repo.Get(ctx, id) 466 + if err != nil { 467 + t.Fatalf("Failed to get note: %v", err) 468 + } 469 + 470 + if len(retrieved.Tags) != 2 { 471 + t.Errorf("Expected 2 tags (no duplicate), got %d", len(retrieved.Tags)) 472 + } 473 + }) 474 + 475 + t.Run("RemoveTag", func(t *testing.T) { 476 + err := repo.RemoveTag(ctx, id, "initial") 477 + if err != nil { 478 + t.Fatalf("Failed to remove tag: %v", err) 479 + } 480 + 481 + retrieved, err := repo.Get(ctx, id) 482 + if err != nil { 483 + t.Fatalf("Failed to get note: %v", err) 484 + } 485 + 486 + if len(retrieved.Tags) != 1 { 487 + t.Errorf("Expected 1 tag after removal, got %d", len(retrieved.Tags)) 488 + } 489 + 490 + for _, tag := range retrieved.Tags { 491 + if tag == "initial" { 492 + t.Error("Removed tag still found in note") 493 + } 494 + } 495 + }) 496 + 497 + t.Run("GetByTags", func(t *testing.T) { 498 + note1 := &models.Note{ 499 + Title: "Note 1", 500 + Content: "Content 1", 501 + Tags: []string{"work", "urgent"}, 502 + } 503 + note2 := &models.Note{ 504 + Title: "Note 2", 505 + Content: "Content 2", 506 + Tags: []string{"personal", "ideas"}, 507 + } 508 + note3 := &models.Note{ 509 + Title: "Note 3", 510 + Content: "Content 3", 511 + Tags: []string{"work", "planning"}, 512 + } 513 + 514 + _, err := repo.Create(ctx, note1) 515 + if err != nil { 516 + t.Fatalf("Failed to create note1: %v", err) 517 + } 518 + _, err = repo.Create(ctx, note2) 519 + if err != nil { 520 + t.Fatalf("Failed to create note2: %v", err) 521 + } 522 + _, err = repo.Create(ctx, note3) 523 + if err != nil { 524 + t.Fatalf("Failed to create note3: %v", err) 525 + } 526 + 527 + results, err := repo.GetByTags(ctx, []string{"work"}) 528 + if err != nil { 529 + t.Fatalf("Failed to get notes by tag: %v", err) 530 + } 531 + 532 + if len(results) < 2 { 533 + t.Errorf("Expected at least 2 notes with 'work' tag, got %d", len(results)) 534 + } 535 + 536 + results, err = repo.GetByTags(ctx, []string{"nonexistent"}) 537 + if err != nil { 538 + t.Fatalf("Failed to get notes by nonexistent tag: %v", err) 539 + } 540 + 541 + if len(results) != 0 { 542 + t.Errorf("Expected 0 notes with nonexistent tag, got %d", len(results)) 543 + } 544 + 545 + results, err = repo.GetByTags(ctx, []string{}) 546 + if err != nil { 547 + t.Fatalf("Failed to get notes with empty tags: %v", err) 548 + } 549 + 550 + if len(results) != 0 { 551 + t.Errorf("Expected 0 notes with empty tag list, got %d", len(results)) 552 + } 553 + }) 554 + } 555 + 556 + func TestNoteRepository_ErrorCases(t *testing.T) { 557 + db := createNoteTestDB(t) 558 + repo := NewNoteRepository(db) 559 + ctx := context.Background() 560 + 561 + t.Run("Get Nonexistent Note", func(t *testing.T) { 562 + _, err := repo.Get(ctx, 999) 563 + if err == nil { 564 + t.Error("Expected error when getting nonexistent note") 565 + } 566 + }) 567 + 568 + t.Run("Update Nonexistent Note", func(t *testing.T) { 569 + note := &models.Note{ 570 + ID: 999, 571 + Title: "Nonexistent", 572 + Content: "Should fail", 573 + } 574 + 575 + err := repo.Update(ctx, note) 576 + if err == nil { 577 + t.Error("Expected error when updating nonexistent note") 578 + } 579 + }) 580 + 581 + t.Run("Delete Nonexistent Note", func(t *testing.T) { 582 + err := repo.Delete(ctx, 999) 583 + if err == nil { 584 + t.Error("Expected error when deleting nonexistent note") 585 + } 586 + }) 587 + 588 + t.Run("Archive Nonexistent Note", func(t *testing.T) { 589 + err := repo.Archive(ctx, 999) 590 + if err == nil { 591 + t.Error("Expected error when archiving nonexistent note") 592 + } 593 + }) 594 + 595 + t.Run("AddTag to Nonexistent Note", func(t *testing.T) { 596 + err := repo.AddTag(ctx, 999, "tag") 597 + if err == nil { 598 + t.Error("Expected error when adding tag to nonexistent note") 599 + } 600 + }) 601 + } 602 + 603 + func TestNoteRepository_EdgeCases(t *testing.T) { 604 + db := createNoteTestDB(t) 605 + repo := NewNoteRepository(db) 606 + ctx := context.Background() 607 + 608 + t.Run("Note with Empty Tags", func(t *testing.T) { 609 + note := &models.Note{ 610 + Title: "No Tags Note", 611 + Content: "This note has no tags", 612 + Tags: []string{}, 613 + } 614 + 615 + id, err := repo.Create(ctx, note) 616 + if err != nil { 617 + t.Fatalf("Failed to create note with empty tags: %v", err) 618 + } 619 + 620 + retrieved, err := repo.Get(ctx, id) 621 + if err != nil { 622 + t.Fatalf("Failed to get note: %v", err) 623 + } 624 + 625 + if len(retrieved.Tags) != 0 { 626 + t.Errorf("Expected empty tags slice, got %d tags", len(retrieved.Tags)) 627 + } 628 + }) 629 + 630 + t.Run("Note with Nil Tags", func(t *testing.T) { 631 + note := &models.Note{ 632 + Title: "Nil Tags Note", 633 + Content: "This note has nil tags", 634 + Tags: nil, 635 + } 636 + 637 + id, err := repo.Create(ctx, note) 638 + if err != nil { 639 + t.Fatalf("Failed to create note with nil tags: %v", err) 640 + } 641 + 642 + retrieved, err := repo.Get(ctx, id) 643 + if err != nil { 644 + t.Fatalf("Failed to get note: %v", err) 645 + } 646 + 647 + if retrieved.Tags != nil { 648 + t.Errorf("Expected nil tags, got %v", retrieved.Tags) 649 + } 650 + }) 651 + 652 + t.Run("Note with Long Content", func(t *testing.T) { 653 + longContent := "" 654 + for i := 0; i < 1000; i++ { 655 + longContent += "This is a very long content string. " 656 + } 657 + 658 + note := &models.Note{ 659 + Title: "Long Content Note", 660 + Content: longContent, 661 + } 662 + 663 + id, err := repo.Create(ctx, note) 664 + if err != nil { 665 + t.Fatalf("Failed to create note with long content: %v", err) 666 + } 667 + 668 + retrieved, err := repo.Get(ctx, id) 669 + if err != nil { 670 + t.Fatalf("Failed to get note: %v", err) 671 + } 672 + 673 + if retrieved.Content != longContent { 674 + t.Error("Long content was not stored/retrieved correctly") 675 + } 676 + }) 677 + }
+2
internal/repo/repo.go
··· 10 10 Movies *MovieRepository 11 11 TV *TVRepository 12 12 Books *BookRepository 13 + Notes *NoteRepository 13 14 } 14 15 15 16 // NewRepositories creates a new set of repositories ··· 19 20 Movies: NewMovieRepository(db), 20 21 TV: NewTVRepository(db), 21 22 Books: NewBookRepository(db), 23 + Notes: NewNoteRepository(db), 22 24 } 23 25 }
+44 -1
internal/repo/repositories_test.go
··· 20 20 t.Fatalf("Failed to enable foreign keys: %v", err) 21 21 } 22 22 23 - // Create all tables 24 23 schema := ` 25 24 -- Tasks table 26 25 CREATE TABLE IF NOT EXISTS tasks ( ··· 78 77 started DATETIME, 79 78 finished DATETIME 80 79 ); 80 + 81 + -- Notes table 82 + CREATE TABLE IF NOT EXISTS notes ( 83 + id INTEGER PRIMARY KEY AUTOINCREMENT, 84 + title TEXT NOT NULL, 85 + content TEXT NOT NULL, 86 + tags TEXT, 87 + archived BOOLEAN DEFAULT FALSE, 88 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 89 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 90 + file_path TEXT 91 + ); 81 92 ` 82 93 83 94 if _, err := db.Exec(schema); err != nil { ··· 155 166 if bookID == 0 { 156 167 t.Error("Expected non-zero book ID") 157 168 } 169 + 170 + note := &models.Note{ 171 + Title: "Integration Note", 172 + Content: "This is test content for integration", 173 + Tags: []string{"integration", "test"}, 174 + } 175 + noteID, err := repos.Notes.Create(ctx, note) 176 + if err != nil { 177 + t.Errorf("Failed to create note: %v", err) 178 + } 179 + if noteID == 0 { 180 + t.Error("Expected non-zero note ID") 181 + } 158 182 }) 159 183 160 184 t.Run("Retrieve all resources", func(t *testing.T) { ··· 188 212 } 189 213 if len(books) != 1 { 190 214 t.Errorf("Expected 1 book, got %d", len(books)) 215 + } 216 + 217 + notes, err := repos.Notes.List(ctx, NoteListOptions{}) 218 + if err != nil { 219 + t.Errorf("Failed to list notes: %v", err) 220 + } 221 + if len(notes) != 1 { 222 + t.Errorf("Expected 1 note, got %d", len(notes)) 191 223 } 192 224 }) 193 225 ··· 257 289 if len(queuedBooks) != 1 { 258 290 t.Errorf("Expected 1 queued book, got %d", len(queuedBooks)) 259 291 } 292 + 293 + activeNotes, err := repos.Notes.GetActive(ctx) 294 + if err != nil { 295 + t.Errorf("Failed to get active notes: %v", err) 296 + } 297 + if len(activeNotes) != 1 { 298 + t.Errorf("Expected 1 active note, got %d", len(activeNotes)) 299 + } 260 300 }) 261 301 }) 262 302 ··· 276 316 } 277 317 if repos.Books == nil { 278 318 t.Error("Books repository should be initialized") 319 + } 320 + if repos.Notes == nil { 321 + t.Error("Notes repository should be initialized") 279 322 } 280 323 }) 281 324