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}
492
493func TestListImages_UploaderFilter(t *testing.T) {
494 d := newTestDB(t)
495 ctx := context.Background()
496
497 u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false)
498 u2, _ := d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false)
499
500 for i := range 3 {
501 uid := u1.ID
502 if i == 2 {
503 uid = u2.ID
504 }
505 d.CreateImage(ctx, &Image{
506 Filename: fmt.Sprintf("%d.jpg", i),
507 StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i),
508 UploadedBy: uid,
509 FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
510 })
511 }
512
513 tests := []struct {
514 name string
515 uploader string
516 want int
517 }{
518 {name: "filter alice (2 images)", uploader: "alice", want: 2},
519 {name: "filter bob (1 image)", uploader: "bob", want: 1},
520 {name: "no match", uploader: "nobody", want: 0},
521 {name: "empty filter returns all", uploader: "", want: 3},
522 }
523
524 for _, tt := range tests {
525 t.Run(tt.name, func(t *testing.T) {
526 result, err := d.ListImages(ctx, nil, tt.uploader, 10, 0)
527 if err != nil {
528 t.Fatalf("ListImages: %v", err)
529 }
530 if len(result.Images) != tt.want {
531 t.Errorf("got %d images, want %d", len(result.Images), tt.want)
532 }
533 if result.Total != tt.want {
534 t.Errorf("total = %d, want %d", result.Total, tt.want)
535 }
536 })
537 }
538}
539
540func TestListImages_TagAndUploaderFilter(t *testing.T) {
541 d := newTestDB(t)
542 ctx := context.Background()
543
544 u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false)
545 u2, _ := d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false)
546
547 img1, _ := d.CreateImage(ctx, &Image{
548 Filename: "a.jpg", StoragePath: "a/b/c/a.jpg",
549 UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
550 })
551 img2, _ := d.CreateImage(ctx, &Image{
552 Filename: "b.jpg", StoragePath: "a/b/c/b.jpg",
553 UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
554 })
555 img3, _ := d.CreateImage(ctx, &Image{
556 Filename: "c.jpg", StoragePath: "a/b/c/c.jpg",
557 UploadedBy: u2.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
558 })
559
560 d.SetImageTags(ctx, img1.ID, []string{"landscape"})
561 d.SetImageTags(ctx, img2.ID, []string{"landscape"})
562 d.SetImageTags(ctx, img3.ID, []string{"landscape"})
563
564 tests := []struct {
565 name string
566 tags []string
567 uploader string
568 want int
569 }{
570 {name: "tag + uploader alice", tags: []string{"landscape"}, uploader: "alice", want: 2},
571 {name: "tag + uploader bob", tags: []string{"landscape"}, uploader: "bob", want: 1},
572 {name: "tag only", tags: []string{"landscape"}, uploader: "", want: 3},
573 {name: "uploader only", tags: nil, uploader: "alice", want: 2},
574 {name: "no match tag", tags: []string{"sunset"}, uploader: "alice", want: 0},
575 }
576
577 for _, tt := range tests {
578 t.Run(tt.name, func(t *testing.T) {
579 result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0)
580 if err != nil {
581 t.Fatalf("ListImages: %v", err)
582 }
583 if len(result.Images) != tt.want {
584 t.Errorf("got %d images, want %d", len(result.Images), tt.want)
585 }
586 if result.Total != tt.want {
587 t.Errorf("total = %d, want %d", result.Total, tt.want)
588 }
589 })
590 }
591}
592
593func TestSuggestUploaders(t *testing.T) {
594 d := newTestDB(t)
595 ctx := context.Background()
596
597 u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false)
598 d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false)
599
600 // alice uploads 2 images, bob uploads 0.
601 for i := range 2 {
602 d.CreateImage(ctx, &Image{
603 Filename: fmt.Sprintf("%d.jpg", i),
604 StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i),
605 UploadedBy: u1.ID,
606 FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
607 })
608 }
609
610 tests := []struct {
611 name string
612 prefix string
613 want int
614 first string
615 count int
616 }{
617 {name: "match ali prefix", prefix: "ali", want: 1, first: "alice", count: 2},
618 {name: "match bo prefix", prefix: "bo", want: 1, first: "bob", count: 0},
619 {name: "no match", prefix: "xyz", want: 0},
620 {name: "all with empty prefix", prefix: "", want: 2, first: "alice", count: 2},
621 }
622
623 for _, tt := range tests {
624 t.Run(tt.name, func(t *testing.T) {
625 results, err := d.SuggestUploaders(ctx, tt.prefix, 10)
626 if err != nil {
627 t.Fatalf("SuggestUploaders: %v", err)
628 }
629 if len(results) != tt.want {
630 t.Fatalf("got %d results, want %d", len(results), tt.want)
631 }
632 if tt.want > 0 {
633 if results[0].Tag != tt.first {
634 t.Errorf("first result = %q, want %q", results[0].Tag, tt.first)
635 }
636 if results[0].UsageCount != tt.count {
637 t.Errorf("first result count = %d, want %d", results[0].UsageCount, tt.count)
638 }
639 }
640 })
641 }
642}
643
644// ─── Innate Tags ────────────────────────────────────────────────────────
645
646func TestCreateImageWithInnateTags(t *testing.T) {
647 d := newTestDB(t)
648 ctx := context.Background()
649 u, _ := d.CreateUser(ctx, "innate_user", hashPassword(t, "pw"), false)
650
651 img, err := d.CreateImage(ctx, &Image{
652 Filename: "innate.jpg",
653 StoragePath: "a/b/c/innate.jpg",
654 UploadedBy: u.ID,
655 FileSize: 1024,
656 Width: 1920,
657 Height: 1080,
658 MimeType: "image/jpeg",
659 InnateTags: []string{"image", "highres"},
660 })
661 if err != nil {
662 t.Fatalf("CreateImage: %v", err)
663 }
664
665 if len(img.InnateTags) != 2 {
666 t.Fatalf("expected 2 innate tags, got %d: %v", len(img.InnateTags), img.InnateTags)
667 }
668 if img.InnateTags[0] != "image" || img.InnateTags[1] != "highres" {
669 t.Fatalf("innate tags = %v, want [image highres]", img.InnateTags)
670 }
671}
672
673func TestCreateImageWithEmptyInnateTags(t *testing.T) {
674 d := newTestDB(t)
675 ctx := context.Background()
676 u, _ := d.CreateUser(ctx, "no_innate", hashPassword(t, "pw"), false)
677
678 img, err := d.CreateImage(ctx, &Image{
679 Filename: "plain.jpg",
680 StoragePath: "a/b/c/plain.jpg",
681 UploadedBy: u.ID,
682 FileSize: 1024,
683 Width: 800,
684 Height: 600,
685 MimeType: "image/jpeg",
686 })
687 if err != nil {
688 t.Fatalf("CreateImage: %v", err)
689 }
690 if len(img.InnateTags) != 0 {
691 t.Fatalf("expected 0 innate tags, got %d: %v", len(img.InnateTags), img.InnateTags)
692 }
693}
694
695func TestListImages_TagFilterIncludesInnateTags(t *testing.T) {
696 d := newTestDB(t)
697 ctx := context.Background()
698 u, _ := d.CreateUser(ctx, "tagger", hashPassword(t, "pw"), false)
699
700 // Image with regular tags only.
701 img1, _ := d.CreateImage(ctx, &Image{
702 Filename: "1.jpg", StoragePath: "a/b/c/1.jpg",
703 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
704 })
705 d.SetImageTags(ctx, img1.ID, []string{"landscape"})
706
707 // Image with innate tags and no user tags.
708 d.CreateImage(ctx, &Image{
709 Filename: "2.jpg", StoragePath: "a/b/c/2.jpg",
710 UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
711 InnateTags: []string{"lowres"},
712 })
713
714 // Image with both user tags and innate tags.
715 img3, _ := d.CreateImage(ctx, &Image{
716 Filename: "3.jpg", StoragePath: "a/b/c/3.jpg",
717 UploadedBy: u.ID, FileSize: 1024, Width: 2560, Height: 1440, MimeType: "image/jpeg",
718 InnateTags: []string{"highres"},
719 })
720 d.SetImageTags(ctx, img3.ID, []string{"landscape"})
721
722 tests := []struct {
723 name string
724 tags []string
725 want int
726 }{
727 {name: "filter by regular tag", tags: []string{"landscape"}, want: 2},
728 {name: "filter by innate tag", tags: []string{"lowres"}, want: 1},
729 {name: "filter by innate tag highres", tags: []string{"highres"}, want: 1},
730 {name: "combined regular+innate tag", tags: []string{"landscape", "highres"}, want: 1},
731 {name: "no match", tags: []string{"nonexistent"}, want: 0},
732 }
733
734 for _, tt := range tests {
735 t.Run(tt.name, func(t *testing.T) {
736 result, err := d.ListImages(ctx, tt.tags, "", 10, 0)
737 if err != nil {
738 t.Fatalf("ListImages: %v", err)
739 }
740 if len(result.Images) != tt.want {
741 t.Errorf("got %d images, want %d; tags=%v", len(result.Images), tt.want, tt.tags)
742 }
743 })
744 }
745}
746
747func TestListImages_TagFilterInnateAndUploader(t *testing.T) {
748 d := newTestDB(t)
749 ctx := context.Background()
750 u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false)
751 u2, _ := d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false)
752
753 img1, _ := d.CreateImage(ctx, &Image{
754 Filename: "a.jpg", StoragePath: "a/b/c/a.jpg",
755 UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
756 InnateTags: []string{"lowres"},
757 })
758 img2, _ := d.CreateImage(ctx, &Image{
759 Filename: "b.jpg", StoragePath: "a/b/c/b.jpg",
760 UploadedBy: u1.ID, FileSize: 1000, Width: 2560, Height: 1440, MimeType: "image/jpeg",
761 InnateTags: []string{"highres"},
762 })
763 img3, _ := d.CreateImage(ctx, &Image{
764 Filename: "c.jpg", StoragePath: "a/b/c/c.jpg",
765 UploadedBy: u2.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
766 InnateTags: []string{"lowres"},
767 })
768
769 d.SetImageTags(ctx, img1.ID, []string{"landscape"})
770 d.SetImageTags(ctx, img2.ID, []string{"landscape"})
771 d.SetImageTags(ctx, img3.ID, []string{"landscape"})
772
773 tests := []struct {
774 name string
775 tags []string
776 uploader string
777 want int
778 }{
779 {name: "lowres+alice", tags: []string{"lowres"}, uploader: "alice", want: 1},
780 {name: "highres+alice", tags: []string{"highres"}, uploader: "alice", want: 1},
781 {name: "lowres+bob", tags: []string{"lowres"}, uploader: "bob", want: 1},
782 {name: "landscape+highres+alice", tags: []string{"landscape", "highres"}, uploader: "alice", want: 1},
783 {name: "no match", tags: []string{"highres"}, uploader: "bob", want: 0},
784 }
785
786 for _, tt := range tests {
787 t.Run(tt.name, func(t *testing.T) {
788 result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0)
789 if err != nil {
790 t.Fatalf("ListImages: %v", err)
791 }
792 if len(result.Images) != tt.want {
793 t.Errorf("got %d images, want %d; tags=%v uploader=%s", len(result.Images), tt.want, tt.tags, tt.uploader)
794 }
795 })
796 }
797}