all repos — weimar @ master

Unnamed repository; edit this file 'description' to name the repository.

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, false, false)
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, false, false)
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, false, false)
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, false, false)
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, false, false)
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, false, false)
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}
798
799func TestListAllImages(t *testing.T) {
800	d := newTestDB(t)
801	ctx := context.Background()
802	u, _ := d.CreateUser(ctx, "user", hashPassword(t, "pw"), false)
803
804	// Create two images.
805	for i := range 2 {
806		_, err := d.CreateImage(ctx, &Image{
807			Filename:    fmt.Sprintf("%d.jpg", i),
808			StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i),
809			UploadedBy:  u.ID,
810			FileSize:    100, Width: 100, Height: 100, MimeType: "image/jpeg",
811		})
812		if err != nil {
813			t.Fatalf("CreateImage: %v", err)
814		}
815	}
816
817	images, err := d.ListAllImages(ctx)
818	if err != nil {
819		t.Fatalf("ListAllImages: %v", err)
820	}
821	if len(images) != 2 {
822		t.Fatalf("got %d images, want 2", len(images))
823	}
824}
825
826func TestUpdateImageInnateTags(t *testing.T) {
827	d := newTestDB(t)
828	ctx := context.Background()
829	u, _ := d.CreateUser(ctx, "user", hashPassword(t, "pw"), false)
830
831	img, _ := d.CreateImage(ctx, &Image{
832		Filename: "test.jpg", StoragePath: "a/b/c/test.jpg",
833		UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
834	})
835
836	if err := d.UpdateImageInnateTags(ctx, img.ID, []string{"image", "lowres"}); err != nil {
837		t.Fatalf("UpdateImageInnateTags: %v", err)
838	}
839
840	got, _ := d.GetImageByID(ctx, img.ID)
841	if len(got.InnateTags) != 2 {
842		t.Fatalf("expected 2 innate tags, got %d: %v", len(got.InnateTags), got.InnateTags)
843	}
844	if got.InnateTags[0] != "image" || got.InnateTags[1] != "lowres" {
845		t.Fatalf("innate tags = %v, want [image lowres]", got.InnateTags)
846	}
847}