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