internal/db/db_test.go (view raw)
1package db
2
3import (
4 "context"
5 "fmt"
6 "testing"
7 "time"
8
9 "golang.org/x/crypto/bcrypt"
10)
11
12func newTestDB(t *testing.T) *DB {
13 t.Helper()
14 db, err := Open(":memory:")
15 if err != nil {
16 t.Fatalf("open :memory: %v", err)
17 }
18 t.Cleanup(func() { db.Close() })
19 if err := db.Migrate(context.Background()); err != nil {
20 t.Fatalf("migrate: %v", err)
21 }
22 return db
23}
24
25func futureTime() time.Time {
26 return time.Now().UTC().Add(24 * time.Hour)
27}
28
29func hashPassword(t *testing.T, pw string) string {
30 t.Helper()
31 h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
32 if err != nil {
33 t.Fatal(err)
34 }
35 return string(h)
36}
37
38// ─── Users ──────────────────────────────────────────────────────────────
39
40func TestCreateUser(t *testing.T) {
41 d := newTestDB(t)
42 ctx := context.Background()
43
44 u, err := d.CreateUser(ctx, "alice", hashPassword(t, "password1"), false)
45 if err != nil {
46 t.Fatalf("CreateUser: %v", err)
47 }
48 if u.ID == 0 {
49 t.Fatal("expected non-zero ID")
50 }
51 if u.Username != "alice" {
52 t.Fatalf("username = %q, want %q", u.Username, "alice")
53 }
54 if u.IsAdmin {
55 t.Fatal("expected non-admin")
56 }
57 if u.CreatedAt.IsZero() {
58 t.Fatal("expected created_at")
59 }
60}
61
62func TestCreateUser_DuplicateUsername(t *testing.T) {
63 d := newTestDB(t)
64 ctx := context.Background()
65
66 d.CreateUser(ctx, "bob", hashPassword(t, "pw1"), false)
67 _, err := d.CreateUser(ctx, "bob", hashPassword(t, "pw2"), false)
68 if err == nil {
69 t.Fatal("expected error on duplicate username")
70 }
71}
72
73func TestGetUserByID(t *testing.T) {
74 d := newTestDB(t)
75 ctx := context.Background()
76
77 created, _ := d.CreateUser(ctx, "charlie", hashPassword(t, "pw"), true)
78 got, err := d.GetUserByID(ctx, created.ID)
79 if err != nil {
80 t.Fatalf("GetUserByID: %v", err)
81 }
82 if got.Username != "charlie" {
83 t.Fatalf("username = %q", "charlie")
84 }
85 if !got.IsAdmin {
86 t.Fatal("expected admin")
87 }
88}
89
90func TestGetUserByID_NotFound(t *testing.T) {
91 d := newTestDB(t)
92 _, err := d.GetUserByID(context.Background(), 999)
93 if err == nil {
94 t.Fatal("expected error for non-existent user")
95 }
96}
97
98func TestGetUserByUsername(t *testing.T) {
99 d := newTestDB(t)
100 ctx := context.Background()
101
102 d.CreateUser(ctx, "dave", hashPassword(t, "pw"), false)
103 got, err := d.GetUserByUsername(ctx, "dave")
104 if err != nil {
105 t.Fatalf("GetUserByUsername: %v", err)
106 }
107 if got.Username != "dave" {
108 t.Fatalf("username = %q", "dave")
109 }
110}
111
112func TestListUsers(t *testing.T) {
113 d := newTestDB(t)
114 ctx := context.Background()
115
116 users, err := d.ListUsers(ctx)
117 if err != nil {
118 t.Fatalf("ListUsers (empty): %v", err)
119 }
120 if len(users) != 0 {
121 t.Fatalf("expected 0 users, got %d", len(users))
122 }
123
124 d.CreateUser(ctx, "zara", hashPassword(t, "pw"), false)
125 d.CreateUser(ctx, "adam", hashPassword(t, "pw"), false)
126
127 users, _ = d.ListUsers(ctx)
128 if len(users) != 2 {
129 t.Fatalf("expected 2 users, got %d", len(users))
130 }
131 if users[0].Username != "adam" {
132 t.Fatalf("expected first user adam, got %s", users[0].Username)
133 }
134 if users[1].Username != "zara" {
135 t.Fatalf("expected second user zara, got %s", users[1].Username)
136 }
137}
138
139func TestDeleteUser(t *testing.T) {
140 d := newTestDB(t)
141 ctx := context.Background()
142
143 u, _ := d.CreateUser(ctx, "eve", hashPassword(t, "pw"), false)
144 if err := d.DeleteUser(ctx, u.ID); err != nil {
145 t.Fatalf("DeleteUser: %v", err)
146 }
147 // Verify deleted.
148 users, _ := d.ListUsers(ctx)
149 if len(users) != 0 {
150 t.Fatalf("expected 0 users after delete")
151 }
152}
153
154func TestDeleteUser_NotFound(t *testing.T) {
155 d := newTestDB(t)
156 err := d.DeleteUser(context.Background(), 999)
157 if err == nil {
158 t.Fatal("expected error for non-existent user")
159 }
160}
161
162func TestUpdateUserPassword(t *testing.T) {
163 d := newTestDB(t)
164 ctx := context.Background()
165
166 u, _ := d.CreateUser(ctx, "frank", hashPassword(t, "oldpw"), false)
167 newHash := hashPassword(t, "newpw")
168 if err := d.UpdateUserPassword(ctx, u.ID, newHash); err != nil {
169 t.Fatalf("UpdateUserPassword: %v", err)
170 }
171
172 updated, _ := d.GetUserByID(ctx, u.ID)
173 if updated.PasswordHash == u.PasswordHash {
174 t.Fatal("password hash should have changed")
175 }
176}
177
178// ─── Sessions ───────────────────────────────────────────────────────────
179
180func TestCreateSession(t *testing.T) {
181 d := newTestDB(t)
182 ctx := context.Background()
183
184 u, _ := d.CreateUser(ctx, "grace", hashPassword(t, "pw"), false)
185 sess, err := d.CreateSession(ctx, u.ID, "tok123", futureTime())
186 if err != nil {
187 t.Fatalf("CreateSession: %v", err)
188 }
189 if sess.ID == 0 {
190 t.Fatal("expected non-zero session ID")
191 }
192}
193
194func TestGetSessionByToken(t *testing.T) {
195 d := newTestDB(t)
196 ctx := context.Background()
197
198 u, _ := d.CreateUser(ctx, "heidi", hashPassword(t, "pw"), false)
199 d.CreateSession(ctx, u.ID, "abc", futureTime())
200
201 sess, err := d.GetSessionByToken(ctx, "abc")
202 if err != nil {
203 t.Fatalf("GetSessionByToken: %v", err)
204 }
205 if sess.Token != "abc" {
206 t.Fatalf("token = %q", "abc")
207 }
208 if sess.UserID != u.ID {
209 t.Fatalf("user_id = %d, want %d", sess.UserID, u.ID)
210 }
211}
212
213func TestDeleteSession(t *testing.T) {
214 d := newTestDB(t)
215 ctx := context.Background()
216
217 u, _ := d.CreateUser(ctx, "ivan", hashPassword(t, "pw"), false)
218 d.CreateSession(ctx, u.ID, "tok1", futureTime())
219
220 if err := d.DeleteSession(ctx, "tok1"); err != nil {
221 t.Fatalf("DeleteSession: %v", err)
222 }
223 _, err := d.GetSessionByToken(ctx, "tok1")
224 if err == nil {
225 t.Fatal("expected error after delete")
226 }
227}
228
229func TestDeleteUserSessions(t *testing.T) {
230 d := newTestDB(t)
231 ctx := context.Background()
232
233 u, _ := d.CreateUser(ctx, "judy", hashPassword(t, "pw"), false)
234 d.CreateSession(ctx, u.ID, "s1", futureTime())
235 d.CreateSession(ctx, u.ID, "s2", futureTime())
236
237 if err := d.DeleteUserSessions(ctx, u.ID); err != nil {
238 t.Fatalf("DeleteUserSessions: %v", err)
239 }
240 _, err := d.GetSessionByToken(ctx, "s1")
241 if err == nil {
242 t.Fatal("expected session s1 to be deleted")
243 }
244 _, err = d.GetSessionByToken(ctx, "s2")
245 if err == nil {
246 t.Fatal("expected session s2 to be deleted")
247 }
248}
249
250func TestExtendSession(t *testing.T) {
251 d := newTestDB(t)
252 ctx := context.Background()
253
254 u, _ := d.CreateUser(ctx, "karl", hashPassword(t, "pw"), false)
255 d.CreateSession(ctx, u.ID, "ext", futureTime())
256
257 newExpiry := futureTime()
258 if err := d.ExtendSession(ctx, "ext", newExpiry); err != nil {
259 t.Fatalf("ExtendSession: %v", err)
260 }
261
262 sess, _ := d.GetSessionByToken(ctx, "ext")
263 if sess.ExpiresAt.Unix() != newExpiry.Unix() {
264 t.Fatalf("expiry = %v, want %v", sess.ExpiresAt, newExpiry)
265 }
266}
267
268// ─── Images & Tags ──────────────────────────────────────────────────────
269
270func TestCreateAndGetImage(t *testing.T) {
271 d := newTestDB(t)
272 ctx := context.Background()
273
274 u, _ := d.CreateUser(ctx, "laurie", hashPassword(t, "pw"), false)
275 img, err := d.CreateImage(ctx, &Image{
276 Filename: "photo.jpg",
277 StoragePath: "ab/cd/ef/abc123.jpg",
278 UploadedBy: u.ID,
279 FileSize: 1024,
280 Width: 800,
281 Height: 600,
282 MimeType: "image/jpeg",
283 })
284 if err != nil {
285 t.Fatalf("CreateImage: %v", err)
286 }
287 if img.ID == 0 {
288 t.Fatal("expected non-zero ID")
289 }
290
291 got, err := d.GetImageByID(ctx, img.ID)
292 if err != nil {
293 t.Fatalf("GetImageByID: %v", err)
294 }
295 if got.Filename != "photo.jpg" {
296 t.Fatalf("filename = %q", "photo.jpg")
297 }
298 if got.Width != 800 {
299 t.Fatalf("width = %d", 800)
300 }
301}
302
303func TestListImages_Pagination(t *testing.T) {
304 d := newTestDB(t)
305 ctx := context.Background()
306
307 u, _ := d.CreateUser(ctx, "mia", hashPassword(t, "pw"), false)
308 for i := range 5 {
309 d.CreateImage(ctx, &Image{
310 Filename: fmt.Sprintf("%d.jpg", i),
311 StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i),
312 UploadedBy: u.ID,
313 FileSize: 100,
314 Width: 100,
315 Height: 100,
316 MimeType: "image/jpeg",
317 })
318 }
319
320 // Test full list.
321 list, err := d.ListImages(ctx, nil, 10, 0)
322 if err != nil {
323 t.Fatalf("ListImages: %v", err)
324 }
325 if len(list.Images) != 5 {
326 t.Fatalf("expected 5 images, got %d", len(list.Images))
327 }
328 if list.Total != 5 {
329 t.Fatalf("total = %d, want 5", list.Total)
330 }
331
332 // Test pagination (newest-first).
333 list, _ = d.ListImages(ctx, nil, 2, 0)
334 if len(list.Images) != 2 {
335 t.Fatalf("expected 2 images, got %d", len(list.Images))
336 }
337 if list.Total != 5 {
338 t.Fatalf("total = %d, want 5", list.Total)
339 }
340}
341
342func TestSetImageTags(t *testing.T) {
343 d := newTestDB(t)
344 ctx := context.Background()
345
346 u, _ := d.CreateUser(ctx, "nick", hashPassword(t, "pw"), false)
347 img, _ := d.CreateImage(ctx, &Image{
348 Filename: "tagged.jpg", StoragePath: "a/b/c/t.jpg",
349 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
350 })
351
352 if err := d.SetImageTags(ctx, img.ID, []string{"funny", "meme"}); err != nil {
353 t.Fatalf("SetImageTags: %v", err)
354 }
355
356 tags, err := d.GetImageTags(ctx, img.ID)
357 if err != nil {
358 t.Fatalf("GetImageTags: %v", err)
359 }
360 if len(tags) != 2 || tags[0] != "funny" || tags[1] != "meme" {
361 t.Fatalf("tags = %v, want [funny meme]", tags)
362 }
363}
364
365func TestSetImageTags_Replace(t *testing.T) {
366 d := newTestDB(t)
367 ctx := context.Background()
368
369 u, _ := d.CreateUser(ctx, "oscar", hashPassword(t, "pw"), false)
370 img, _ := d.CreateImage(ctx, &Image{
371 Filename: "replace.jpg", StoragePath: "a/b/c/r.jpg",
372 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
373 })
374
375 d.SetImageTags(ctx, img.ID, []string{"old1", "old2"})
376 d.SetImageTags(ctx, img.ID, []string{"new1"})
377
378 tags, _ := d.GetImageTags(ctx, img.ID)
379 if len(tags) != 1 || tags[0] != "new1" {
380 t.Fatalf("tags = %v, want [new1]", tags)
381 }
382}
383
384func TestTagUsageCounts(t *testing.T) {
385 d := newTestDB(t)
386 ctx := context.Background()
387
388 u, _ := d.CreateUser(ctx, "pat", hashPassword(t, "pw"), false)
389
390 img1, _ := d.CreateImage(ctx, &Image{Filename: "1.jpg", StoragePath: "a/b/c/1.jpg",
391 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"})
392 img2, _ := d.CreateImage(ctx, &Image{Filename: "2.jpg", StoragePath: "a/b/c/2.jpg",
393 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"})
394
395 d.SetImageTags(ctx, img1.ID, []string{"funny"})
396 d.SetImageTags(ctx, img2.ID, []string{"funny", "meme"})
397
398 suggestions, err := d.SuggestTags(ctx, "", 10)
399 if err != nil {
400 t.Fatalf("SuggestTags: %v", err)
401 }
402
403 // funny should have count 2, meme count 1.
404 if len(suggestions) != 2 {
405 t.Fatalf("expected 2 tag suggestions, got %d", len(suggestions))
406 }
407 if suggestions[0].Tag != "funny" || suggestions[0].UsageCount != 2 {
408 t.Fatalf("expected funny (2), got %s (%d)", suggestions[0].Tag, suggestions[0].UsageCount)
409 }
410 if suggestions[1].Tag != "meme" || suggestions[1].UsageCount != 1 {
411 t.Fatalf("expected meme (1), got %s (%d)", suggestions[1].Tag, suggestions[1].UsageCount)
412 }
413}
414
415func TestDeleteImage(t *testing.T) {
416 d := newTestDB(t)
417 ctx := context.Background()
418
419 u, _ := d.CreateUser(ctx, "quinn", hashPassword(t, "pw"), false)
420 img, _ := d.CreateImage(ctx, &Image{Filename: "del.jpg", StoragePath: "a/b/c/d.jpg",
421 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"})
422 d.SetImageTags(ctx, img.ID, []string{"delete_me"})
423
424 if err := d.DeleteImage(ctx, img.ID); err != nil {
425 t.Fatalf("DeleteImage: %v", err)
426 }
427
428 // Verify image gone.
429 _, err := d.GetImageByID(ctx, img.ID)
430 if err == nil {
431 t.Fatal("expected error after image deletion")
432 }
433
434 // Verify tag usage count decremented.
435 suggestions, _ := d.SuggestTags(ctx, "", 10)
436 if len(suggestions) != 0 {
437 t.Fatalf("expected 0 tag after delete, got %d", len(suggestions))
438 }
439}
440
441func TestSuggestTags(t *testing.T) {
442 d := newTestDB(t)
443 ctx := context.Background()
444
445 u, _ := d.CreateUser(ctx, "ray", hashPassword(t, "pw"), false)
446 img, _ := d.CreateImage(ctx, &Image{Filename: "s.jpg", StoragePath: "a/b/c/s.jpg",
447 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg"})
448 d.SetImageTags(ctx, img.ID, []string{"landscape", "landscape_sunset", "portrait"})
449
450 tests := []struct {
451 name string
452 prefix string
453 want int
454 }{
455 {"match landscape prefix", "land", 2},
456 {"match port prefix", "port", 1},
457 {"no match", "xyz", 0},
458 {"empty prefix returns all", "", 3},
459 }
460
461 for _, tt := range tests {
462 t.Run(tt.name, func(t *testing.T) {
463 results, err := d.SuggestTags(ctx, tt.prefix, 10)
464 if err != nil {
465 t.Fatalf("SuggestTags(%q): %v", tt.prefix, err)
466 }
467 if len(results) != tt.want {
468 t.Fatalf("SuggestTags(%q) = %d results, want %d", tt.prefix, len(results), tt.want)
469 }
470 })
471 }
472}
473
474func TestNormalizeTag(t *testing.T) {
475 tests := []struct {
476 input string
477 want string
478 }{
479 {" Funny ", "funny"},
480 {"MEME", "meme"},
481 {" hello world ", "hello world"},
482 {"", ""},
483 {" ", ""},
484 }
485 for _, tt := range tests {
486 got := normalizeTag(tt.input)
487 if got != tt.want {
488 t.Errorf("normalizeTag(%q) = %q, want %q", tt.input, got, tt.want)
489 }
490 }
491}