all repos — weimar @ 87601e8f1973d0b01106ea99ce63228c88130e1a

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)
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}