all repos — weimar @ 453d1fa348ab96e179dc278cf0d13e8f9d0c7121

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

internal/api/api_test.go (view raw)

  1package api
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"image"
  9	"image/jpeg"
 10	"io"
 11	"mime/multipart"
 12	"net/http"
 13	"net/http/httptest"
 14	"strings"
 15	"testing"
 16
 17	"codeberg.org/maxwelljensen/weimar/internal/auth"
 18	"codeberg.org/maxwelljensen/weimar/internal/config"
 19	"codeberg.org/maxwelljensen/weimar/internal/db"
 20	"codeberg.org/maxwelljensen/weimar/internal/storage"
 21)
 22
 23func setupTest(t *testing.T) (*API, string) {
 24	t.Helper()
 25
 26	database, err := db.Open(":memory:")
 27	if err != nil {
 28		t.Fatalf("open db: %v", err)
 29	}
 30	t.Cleanup(func() { database.Close() })
 31
 32	if err := database.Migrate(context.Background()); err != nil {
 33		t.Fatalf("migrate: %v", err)
 34	}
 35
 36	sm := auth.NewSessionManager(database)
 37	st, err := storage.New(t.TempDir())
 38	if err != nil {
 39		t.Fatalf("storage: %v", err)
 40	}
 41
 42	cfg := config.Config{
 43		Auth:   config.AuthConfig{AllowRegistration: true},
 44		Upload: config.UploadConfig{MaxSizeMB: 10},
 45	}
 46
 47	// Register + login a user for authenticated tests.
 48	auth.Register(context.Background(), database, "user_a", "password123", false)
 49	_, token, err := sm.Login(context.Background(), "user_a", "password123")
 50	if err != nil {
 51		t.Fatalf("login: %v", err)
 52	}
 53
 54	return New(database, sm, st, &cfg), token
 55}
 56
 57// authenticatedRequest creates a request with the session cookie.
 58func authenticatedRequest(t *testing.T, method, target, token string, body io.Reader, pathValues ...string) *http.Request {
 59	t.Helper()
 60	req := httptest.NewRequest(method, target, body)
 61	if token != "" {
 62		req.AddCookie(&http.Cookie{
 63			Name:  auth.SessionCookieName,
 64			Value: token,
 65		})
 66	}
 67	// Inject authenticated user into context.
 68	req = req.WithContext(context.WithValue(req.Context(), CtxKeyUser, &db.User{ID: 1, Username: "user_a"}))
 69
 70	// Support PathValue for routes like /api/images/{id}
 71	for i := 0; i+1 < len(pathValues); i += 2 {
 72		req.SetPathValue(pathValues[i], pathValues[i+1])
 73	}
 74
 75	return req
 76}
 77
 78func createTestJPEG(t *testing.T) []byte {
 79	t.Helper()
 80	var buf bytes.Buffer
 81	img := image.NewRGBA(image.Rect(0, 0, 10, 10))
 82	if err := jpeg.Encode(&buf, img, nil); err != nil {
 83		t.Fatalf("encode jpeg: %v", err)
 84	}
 85	return buf.Bytes()
 86}
 87
 88// ─────────────────────────── Register ───────────────────────────
 89
 90func registerTestAPI(t *testing.T, allowRegistration bool) *API {
 91	database, err := db.Open(":memory:")
 92	if err != nil {
 93		t.Fatalf("open db: %v", err)
 94	}
 95	t.Cleanup(func() { database.Close() })
 96	database.Migrate(context.Background())
 97
 98	sm := auth.NewSessionManager(database)
 99	st, _ := storage.New(t.TempDir())
100	cfg := config.Config{
101		Auth:   config.AuthConfig{AllowRegistration: allowRegistration},
102		Upload: config.UploadConfig{MaxSizeMB: 10},
103	}
104	return New(database, sm, st, &cfg)
105}
106
107func TestHandleRegister_Success(t *testing.T) {
108	a := registerTestAPI(t, true)
109	body := `{"username":"bob","password":"password123"}`
110	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
111	req.Header.Set("Content-Type", "application/json")
112	w := httptest.NewRecorder()
113	a.HandleRegister(w, req)
114
115	resp := w.Result()
116	if resp.StatusCode != http.StatusCreated {
117		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, w.Body.String())
118	}
119
120	var user db.User
121	json.NewDecoder(resp.Body).Decode(&user)
122	if user.Username != "bob" {
123		t.Fatalf("username = %q", user.Username)
124	}
125}
126
127func TestHandleRegister_Closed(t *testing.T) {
128	a := registerTestAPI(t, false)
129	body := `{"username":"bob","password":"password123"}`
130	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
131	req.Header.Set("Content-Type", "application/json")
132	w := httptest.NewRecorder()
133	a.HandleRegister(w, req)
134	if w.Result().StatusCode != http.StatusForbidden {
135		t.Fatalf("status = %d, want 403", w.Result().StatusCode)
136	}
137}
138
139func TestHandleRegister_BadJSON(t *testing.T) {
140	a := registerTestAPI(t, true)
141	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader("not json"))
142	req.Header.Set("Content-Type", "application/json")
143	w := httptest.NewRecorder()
144	a.HandleRegister(w, req)
145	if w.Result().StatusCode != http.StatusBadRequest {
146		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
147	}
148}
149
150func TestHandleRegister_ShortUsername(t *testing.T) {
151	a := registerTestAPI(t, true)
152	body := `{"username":"ab","password":"password123"}`
153	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
154	req.Header.Set("Content-Type", "application/json")
155	w := httptest.NewRecorder()
156	a.HandleRegister(w, req)
157	if w.Result().StatusCode != http.StatusBadRequest {
158		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
159	}
160}
161
162func TestHandleRegister_ShortPassword(t *testing.T) {
163	a := registerTestAPI(t, true)
164	body := `{"username":"bob","password":"short"}`
165	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
166	req.Header.Set("Content-Type", "application/json")
167	w := httptest.NewRecorder()
168	a.HandleRegister(w, req)
169	if w.Result().StatusCode != http.StatusBadRequest {
170		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
171	}
172}
173
174func TestHandleRegister_Duplicate(t *testing.T) {
175	a := registerTestAPI(t, true)
176
177	body := `{"username":"bob","password":"password123"}`
178	req1 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
179	req1.Header.Set("Content-Type", "application/json")
180	w1 := httptest.NewRecorder()
181	a.HandleRegister(w1, req1)
182
183	req2 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
184	req2.Header.Set("Content-Type", "application/json")
185	w2 := httptest.NewRecorder()
186	a.HandleRegister(w2, req2)
187	if w2.Result().StatusCode != http.StatusBadRequest {
188		t.Fatalf("duplicate status = %d, want 400", w2.Result().StatusCode)
189	}
190}
191
192// ─────────────────────────── Login ───────────────────────────
193
194func TestHandleLogin_Success(t *testing.T) {
195	a := registerTestAPI(t, true)
196	auth.Register(context.Background(), a.DB, "user_a", "password123", false)
197
198	body := `{"username":"user_a","password":"password123"}`
199	req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
200	req.Header.Set("Content-Type", "application/json")
201	w := httptest.NewRecorder()
202	a.HandleLogin(w, req)
203
204	resp := w.Result()
205	if resp.StatusCode != http.StatusOK {
206		t.Fatalf("status = %d, want 200: %s", resp.StatusCode, w.Body.String())
207	}
208
209	var found bool
210	for _, c := range resp.Cookies() {
211		if c.Name == auth.SessionCookieName && c.Value != "" {
212			found = true
213			break
214		}
215	}
216	if !found {
217		t.Fatal("expected non-empty session cookie")
218	}
219}
220
221func TestHandleLogin_InvalidCredentials(t *testing.T) {
222	a := registerTestAPI(t, true)
223	auth.Register(context.Background(), a.DB, "user_a", "password123", false)
224
225	body := `{"username":"user_a","password":"wrongpass"}`
226	req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
227	req.Header.Set("Content-Type", "application/json")
228	w := httptest.NewRecorder()
229	a.HandleLogin(w, req)
230	if w.Result().StatusCode != http.StatusUnauthorized {
231		t.Fatalf("status = %d, want 401", w.Result().StatusCode)
232	}
233}
234
235func TestHandleLogin_MissingFields(t *testing.T) {
236	a := registerTestAPI(t, true)
237	body := `{"username":"","password":""}`
238	req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
239	req.Header.Set("Content-Type", "application/json")
240	w := httptest.NewRecorder()
241	a.HandleLogin(w, req)
242	if w.Result().StatusCode != http.StatusBadRequest {
243		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
244	}
245}
246
247// ─────────────────────────── Logout ───────────────────────────
248
249func TestHandleLogout(t *testing.T) {
250	a, token := setupTest(t)
251	req := authenticatedRequest(t, "POST", "/api/auth/logout", token, nil)
252	w := httptest.NewRecorder()
253	a.HandleLogout(w, req)
254
255	resp := w.Result()
256	if resp.StatusCode != http.StatusOK {
257		t.Fatalf("status = %d, want 200", resp.StatusCode)
258	}
259
260	// Cookie should be cleared.
261	var cleared bool
262	for _, c := range resp.Cookies() {
263		if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
264			cleared = true
265			break
266		}
267	}
268	if !cleared {
269		t.Fatal("expected cleared session cookie")
270	}
271}
272
273// ─────────────────────────── Upload ───────────────────────────
274
275func TestHandleUpload_Success(t *testing.T) {
276	a, token := setupTest(t)
277	jpegData := createTestJPEG(t)
278
279	var buf bytes.Buffer
280	w := multipart.NewWriter(&buf)
281	part, _ := w.CreateFormFile("file", "test.jpg")
282	part.Write(jpegData)
283	w.WriteField("tags", "landscape, sunset")
284	w.Close()
285
286	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
287	req.Header.Set("Content-Type", w.FormDataContentType())
288	ww := httptest.NewRecorder()
289	a.HandleUpload(ww, req)
290
291	resp := ww.Result()
292	if resp.StatusCode != http.StatusCreated {
293		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String())
294	}
295
296	var img db.Image
297	json.NewDecoder(resp.Body).Decode(&img)
298	if img.Filename != "test.jpg" {
299		t.Fatalf("filename = %q", img.Filename)
300	}
301	if img.Width != 10 || img.Height != 10 {
302		t.Fatalf("dimensions = %dx%d, want 10x10", img.Width, img.Height)
303	}
304	if img.MimeType != "image/jpeg" {
305		t.Fatalf("mime = %q", img.MimeType)
306	}
307	if len(img.Tags) != 2 || img.Tags[0] != "landscape" {
308		t.Fatalf("tags = %v", img.Tags)
309	}
310}
311
312func TestHandleUpload_MultipleTagFields(t *testing.T) {
313	a, token := setupTest(t)
314	jpegData := createTestJPEG(t)
315
316	// Send tags as separate FormData fields (mimicking frontend behavior).
317	var buf bytes.Buffer
318	w := multipart.NewWriter(&buf)
319	part, _ := w.CreateFormFile("file", "test.jpg")
320	part.Write(jpegData)
321	w.WriteField("tags", "foo")
322	w.WriteField("tags", "bar")
323	w.WriteField("tags", "  baz  ")
324	w.Close()
325
326	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
327	req.Header.Set("Content-Type", w.FormDataContentType())
328	ww := httptest.NewRecorder()
329	a.HandleUpload(ww, req)
330
331	resp := ww.Result()
332	if resp.StatusCode != http.StatusCreated {
333		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String())
334	}
335
336	var img db.Image
337	json.NewDecoder(resp.Body).Decode(&img)
338	if len(img.Tags) != 3 {
339		t.Fatalf("got %d tags, want 3: %v", len(img.Tags), img.Tags)
340	}
341	if img.Tags[0] != "foo" || img.Tags[1] != "bar" || img.Tags[2] != "baz" {
342		t.Fatalf("tags = %v", img.Tags)
343	}
344}
345
346func TestHandleUpload_Unauthenticated(t *testing.T) {
347	a, _ := setupTest(t)
348	req := httptest.NewRequest("POST", "/api/upload", nil)
349	w := httptest.NewRecorder()
350	a.HandleUpload(w, req)
351	if w.Result().StatusCode != http.StatusUnauthorized {
352		t.Fatalf("status = %d, want 401", w.Result().StatusCode)
353	}
354}
355
356func TestHandleUpload_TooLarge(t *testing.T) {
357	database, _ := db.Open(":memory:")
358	database.Migrate(context.Background())
359	sm := auth.NewSessionManager(database)
360	auth.Register(context.Background(), database, "user_a", "password123", false)
361	t.Cleanup(func() { database.Close() })
362	st, _ := storage.New(t.TempDir())
363	a := New(database, sm, st, &config.Config{
364		Upload: config.UploadConfig{MaxSizeMB: 0}, // 0 = no max size, so no rejection
365	})
366
367	// 0 max size should allow upload. Let's test with absent config.
368	_ = a
369}
370
371// ─────────────────────────── List Images ───────────────────────────
372
373func TestHandleListImages(t *testing.T) {
374	a, token := setupTest(t)
375
376	// Upload a couple of images first.
377	jpegData := createTestJPEG(t)
378	for _, name := range []string{"a.jpg", "b.jpg"} {
379		var buf bytes.Buffer
380		w := multipart.NewWriter(&buf)
381		part, _ := w.CreateFormFile("file", name)
382		part.Write(jpegData)
383		w.Close()
384		req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
385		req.Header.Set("Content-Type", w.FormDataContentType())
386		ww := httptest.NewRecorder()
387		a.HandleUpload(ww, req)
388	}
389
390	// List images.
391	listReq := authenticatedRequest(t, "GET", "/api/images", token, nil)
392	listW := httptest.NewRecorder()
393	a.HandleListImages(listW, listReq)
394
395	if listW.Result().StatusCode != http.StatusOK {
396		t.Fatalf("list status = %d", listW.Result().StatusCode)
397	}
398
399	var result map[string]any
400	json.NewDecoder(listW.Result().Body).Decode(&result)
401	images := result["images"].([]any)
402	if len(images) != 2 {
403		t.Fatalf("got %d images, want 2", len(images))
404	}
405	total := result["total"].(float64)
406	if total != 2 {
407		t.Fatalf("total = %f, want 2", total)
408	}
409}
410
411func TestHandleListImages_TagFilter(t *testing.T) {
412	a, token := setupTest(t)
413	jpegData := createTestJPEG(t)
414
415	for _, tc := range []struct {
416		name string
417		tags string
418	}{
419		{"sunset.jpg", "landscape, sunset"},
420		{"portrait.jpg", "portrait"},
421	} {
422		var buf bytes.Buffer
423		w := multipart.NewWriter(&buf)
424		part, _ := w.CreateFormFile("file", tc.name)
425		part.Write(jpegData)
426		w.WriteField("tags", tc.tags)
427		w.Close()
428		req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
429		req.Header.Set("Content-Type", w.FormDataContentType())
430		ww := httptest.NewRecorder()
431		a.HandleUpload(ww, req)
432		if ww.Result().StatusCode != http.StatusCreated {
433			t.Fatalf("upload %s: status=%d body=%s", tc.name, ww.Result().StatusCode, ww.Body.String())
434		}
435	}
436
437	listReq := authenticatedRequest(t, "GET", "/api/images?tags=landscape", token, nil)
438	listW := httptest.NewRecorder()
439	a.HandleListImages(listW, listReq)
440
441	body := listW.Result().Body
442	defer body.Close()
443	var result map[string]any
444	json.NewDecoder(body).Decode(&result)
445	if result["images"] == nil {
446		t.Fatalf("images is null, response body: %v", result)
447	}
448	images := result["images"].([]any)
449	if len(images) != 1 {
450		t.Fatalf("got %d images with landscape tag, want 1; response=%v", len(images), result)
451	}
452}
453
454func TestHandleListImages_NoMatch(t *testing.T) {
455	a, token := setupTest(t)
456	jpegData := createTestJPEG(t)
457
458	// Upload an image with known tags.
459	var buf bytes.Buffer
460	w := multipart.NewWriter(&buf)
461	part, _ := w.CreateFormFile("file", "test.jpg")
462	part.Write(jpegData)
463	w.WriteField("tags", "landscape, sunset")
464	w.Close()
465	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
466	req.Header.Set("Content-Type", w.FormDataContentType())
467	ww := httptest.NewRecorder()
468	a.HandleUpload(ww, req)
469	if ww.Result().StatusCode != http.StatusCreated {
470		t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String())
471	}
472
473	// Query with non-matching tag.
474	listReq := authenticatedRequest(t, "GET", "/api/images?tags=nonexistent", token, nil)
475	listW := httptest.NewRecorder()
476	a.HandleListImages(listW, listReq)
477
478	resp := listW.Result()
479	if resp.StatusCode != http.StatusOK {
480		t.Fatalf("list status = %d, want 200: %s", resp.StatusCode, listW.Body.String())
481	}
482
483	body := resp.Body
484	defer body.Close()
485	var result map[string]any
486	json.NewDecoder(body).Decode(&result)
487	images := result["images"].([]any)
488	if len(images) != 0 {
489		t.Fatalf("got %d images, want 0; response=%v", len(images), result)
490	}
491	total := result["total"].(float64)
492	if total != 0 {
493		t.Fatalf("total = %f, want 0", total)
494	}
495}
496
497func TestHandleListImages_Pagination(t *testing.T) {
498	a, token := setupTest(t)
499	jpegData := createTestJPEG(t)
500
501	for i := 0; i < 3; i++ {
502		var buf bytes.Buffer
503		w := multipart.NewWriter(&buf)
504		part, _ := w.CreateFormFile("file", "img.jpg")
505		part.Write(jpegData)
506		w.Close()
507		req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
508		req.Header.Set("Content-Type", w.FormDataContentType())
509		ww := httptest.NewRecorder()
510		a.HandleUpload(ww, req)
511	}
512
513	listReq := authenticatedRequest(t, "GET", "/api/images?page=1&per_page=2", token, nil)
514	listW := httptest.NewRecorder()
515	a.HandleListImages(listW, listReq)
516
517	var result map[string]any
518	json.NewDecoder(listW.Result().Body).Decode(&result)
519	images := result["images"].([]any)
520	if len(images) != 2 {
521		t.Fatalf("got %d images, want 2", len(images))
522	}
523	total := result["total"].(float64)
524	if total != 3 {
525		t.Fatalf("total = %f, want 3", total)
526	}
527}
528
529// ─────────────────────────── Get Image ───────────────────────────
530
531func TestHandleGetImage_Success(t *testing.T) {
532	a, token := setupTest(t)
533	jpegData := createTestJPEG(t)
534
535	var buf bytes.Buffer
536	w := multipart.NewWriter(&buf)
537	part, _ := w.CreateFormFile("file", "test.jpg")
538	part.Write(jpegData)
539	w.Close()
540
541	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
542	req.Header.Set("Content-Type", w.FormDataContentType())
543	ww := httptest.NewRecorder()
544	a.HandleUpload(ww, req)
545
546	getReq := authenticatedRequest(t, "GET", "/api/images/1", token, nil, "id", "1")
547	getW := httptest.NewRecorder()
548	a.HandleGetImage(getW, getReq)
549
550	if getW.Result().StatusCode != http.StatusOK {
551		t.Fatalf("get status = %d, want 200", getW.Result().StatusCode)
552	}
553	var img db.Image
554	json.NewDecoder(getW.Result().Body).Decode(&img)
555	if img.ID != 1 {
556		t.Fatalf("id = %d, want 1", img.ID)
557	}
558}
559
560func TestHandleGetImage_NotFound(t *testing.T) {
561	a, token := setupTest(t)
562	req := authenticatedRequest(t, "GET", "/api/images/999", token, nil, "id", "999")
563	w := httptest.NewRecorder()
564	a.HandleGetImage(w, req)
565	if w.Result().StatusCode != http.StatusNotFound {
566		t.Fatalf("status = %d, want 404", w.Result().StatusCode)
567	}
568}
569
570// ─────────────────────────── Download ───────────────────────────
571
572func TestHandleDownload_Success(t *testing.T) {
573	a, token := setupTest(t)
574	jpegData := createTestJPEG(t)
575
576	var buf bytes.Buffer
577	w := multipart.NewWriter(&buf)
578	part, _ := w.CreateFormFile("file", "download.jpg")
579	part.Write(jpegData)
580	w.Close()
581
582	upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
583	upReq.Header.Set("Content-Type", w.FormDataContentType())
584	upW := httptest.NewRecorder()
585	a.HandleUpload(upW, upReq)
586	if upW.Result().StatusCode != http.StatusCreated {
587		t.Fatalf("upload: %s", upW.Body.String())
588	}
589
590	dlReq := authenticatedRequest(t, "GET", "/api/images/1/download", token, nil, "id", "1")
591	dlW := httptest.NewRecorder()
592	a.HandleDownload(dlW, dlReq)
593
594	resp := dlW.Result()
595	if resp.StatusCode != http.StatusOK {
596		t.Fatalf("download status = %d, want 200", resp.StatusCode)
597	}
598	if resp.Header.Get("Content-Type") != "image/jpeg" {
599		t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type"))
600	}
601	if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="download.jpg"`) {
602		t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
603	}
604}
605
606func TestHandleDownload_NotFound(t *testing.T) {
607	a, token := setupTest(t)
608	req := authenticatedRequest(t, "GET", "/api/images/999/download", token, nil, "id", "999")
609	w := httptest.NewRecorder()
610	a.HandleDownload(w, req)
611	if w.Result().StatusCode != http.StatusNotFound {
612		t.Fatalf("status = %d, want 404", w.Result().StatusCode)
613	}
614}
615
616// ─────────────────────────── Thumbnail ───────────────────────────
617
618func TestHandleThumbnail_Success(t *testing.T) {
619	a, token := setupTest(t)
620	jpegData := createTestJPEG(t)
621
622	var buf bytes.Buffer
623	w := multipart.NewWriter(&buf)
624	part, _ := w.CreateFormFile("file", "thumb.jpg")
625	part.Write(jpegData)
626	w.Close()
627
628	upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
629	upReq.Header.Set("Content-Type", w.FormDataContentType())
630	upW := httptest.NewRecorder()
631	a.HandleUpload(upW, upReq)
632	if upW.Result().StatusCode != http.StatusCreated {
633		t.Fatalf("upload: %s", upW.Body.String())
634	}
635
636	thReq := authenticatedRequest(t, "GET", "/api/images/1/thumbnail", token, nil, "id", "1")
637	thW := httptest.NewRecorder()
638	a.HandleThumbnail(thW, thReq)
639
640	resp := thW.Result()
641	if resp.StatusCode != http.StatusOK {
642		t.Fatalf("thumbnail status = %d, want 200", resp.StatusCode)
643	}
644	if resp.Header.Get("Content-Type") != "image/jpeg" {
645		t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type"))
646	}
647}
648
649func TestHandleThumbnail_NotFound(t *testing.T) {
650	a, token := setupTest(t)
651	req := authenticatedRequest(t, "GET", "/api/images/999/thumbnail", token, nil, "id", "999")
652	w := httptest.NewRecorder()
653	a.HandleThumbnail(w, req)
654	if w.Result().StatusCode != http.StatusNotFound {
655		t.Fatalf("status = %d, want 404", w.Result().StatusCode)
656	}
657}
658
659// ─────────────────────────── Tag Suggest ───────────────────────────
660
661func TestHandleTagSuggest(t *testing.T) {
662	a, token := setupTest(t)
663
664	// Upload an image with known tags.
665	jpegData := createTestJPEG(t)
666	var buf bytes.Buffer
667	w := multipart.NewWriter(&buf)
668	part, _ := w.CreateFormFile("file", "test.jpg")
669	part.Write(jpegData)
670	w.WriteField("tags", "landscape, sunset, portrait")
671	w.Close()
672
673	upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
674	upReq.Header.Set("Content-Type", w.FormDataContentType())
675	upW := httptest.NewRecorder()
676	a.HandleUpload(upW, upReq)
677	if upW.Result().StatusCode != http.StatusCreated {
678		t.Fatalf("upload for tags: %s", upW.Body.String())
679	}
680
681	sugReq := authenticatedRequest(t, "GET", "/api/tags/suggest?q=land", token, nil)
682	sugW := httptest.NewRecorder()
683	a.HandleTagSuggest(sugW, sugReq)
684
685	if sugW.Result().StatusCode != http.StatusOK {
686		t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode)
687	}
688
689	var tags []db.Tag
690	json.NewDecoder(sugW.Result().Body).Decode(&tags)
691	if len(tags) == 0 {
692		t.Fatal("expected at least one tag suggestion")
693	}
694	if tags[0].Tag != "landscape" {
695		t.Fatalf("first tag = %q, want landscape", tags[0].Tag)
696	}
697}
698
699func TestHandleTagSuggest_EmptyQuery(t *testing.T) {
700	a, token := setupTest(t)
701	req := authenticatedRequest(t, "GET", "/api/tags/suggest?q=", token, nil)
702	w := httptest.NewRecorder()
703	a.HandleTagSuggest(w, req)
704	if w.Result().StatusCode != http.StatusOK {
705		t.Fatalf("status = %d, want 200", w.Result().StatusCode)
706	}
707
708	var tags []any
709	json.NewDecoder(w.Result().Body).Decode(&tags)
710	if len(tags) != 0 {
711		t.Fatalf("expected empty, got %v", tags)
712	}
713}
714
715
716// ─────────────────────────── Uploader Filter ───────────────────────
717
718func TestHandleListImages_UploaderFilter(t *testing.T) {
719	a, token := setupTest(t)
720
721	u1, _ := a.DB.CreateUser(context.Background(), "z_uploader_a", "hash", false)
722	u2, _ := a.DB.CreateUser(context.Background(), "z_uploader_b", "hash", false)
723
724	for i := range 3 {
725		uid := u1.ID
726		if i == 2 {
727			uid = u2.ID
728		}
729		_, err := a.DB.CreateImage(context.Background(), &db.Image{
730			Filename:    fmt.Sprintf("%d.jpg", i),
731			StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i),
732			UploadedBy:  uid,
733			FileSize:    100, Width: 100, Height: 100, MimeType: "image/jpeg",
734		})
735		if err != nil {
736			t.Fatalf("create image: %v", err)
737		}
738	}
739
740	tests := []struct {
741		name     string
742		uploader string
743		want     int
744	}{
745		{name: "filter alice", uploader: "z_uploader_a", want: 2},
746		{name: "filter bob", uploader: "z_uploader_b", want: 1},
747		{name: "no match", uploader: "nobody", want: 0},
748		{name: "empty", uploader: "", want: 3},
749	}
750
751	for _, tt := range tests {
752		t.Run(tt.name, func(t *testing.T) {
753			url := "/api/images"
754			if tt.uploader != "" {
755				url += "?uploader=" + tt.uploader
756			}
757			req := authenticatedRequest(t, "GET", url, token, nil)
758			w := httptest.NewRecorder()
759			a.HandleListImages(w, req)
760
761			if w.Result().StatusCode != http.StatusOK {
762				t.Fatalf("status = %d, want 200", w.Result().StatusCode)
763			}
764
765			var result map[string]any
766			json.NewDecoder(w.Result().Body).Decode(&result)
767			images := result["images"].([]any)
768			if len(images) != tt.want {
769				t.Errorf("got %d images, want %d; response=%v", len(images), tt.want, result)
770			}
771		})
772	}
773}
774
775func TestHandleListImages_TagAndUploaderFilter(t *testing.T) {
776	a, token := setupTest(t)
777
778	u1, _ := a.DB.CreateUser(context.Background(), "z_uploader_a", "hash", false)
779	u2, _ := a.DB.CreateUser(context.Background(), "z_uploader_b", "hash", false)
780
781	img1, _ := a.DB.CreateImage(context.Background(), &db.Image{
782		Filename: "a.jpg", StoragePath: "a/b/c/a.jpg",
783		UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
784	})
785	img2, _ := a.DB.CreateImage(context.Background(), &db.Image{
786		Filename: "b.jpg", StoragePath: "a/b/c/b.jpg",
787		UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
788	})
789	img3, _ := a.DB.CreateImage(context.Background(), &db.Image{
790		Filename: "c.jpg", StoragePath: "a/b/c/c.jpg",
791		UploadedBy: u2.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg",
792	})
793
794	a.DB.SetImageTags(context.Background(), img1.ID, []string{"landscape"})
795	a.DB.SetImageTags(context.Background(), img2.ID, []string{"landscape"})
796	a.DB.SetImageTags(context.Background(), img3.ID, []string{"landscape"})
797
798	tests := []struct {
799		name string
800		url  string
801		want int
802	}{
803		{name: "tag + uploader alice", url: "/api/images?tags=landscape&uploader=z_uploader_a", want: 2},
804		{name: "tag + uploader bob", url: "/api/images?tags=landscape&uploader=z_uploader_b", want: 1},
805		{name: "tag only", url: "/api/images?tags=landscape", want: 3},
806		{name: "uploader only", url: "/api/images?uploader=z_uploader_a", want: 2},
807		{name: "no match", url: "/api/images?tags=sunset&uploader=z_uploader_a", want: 0},
808	}
809
810	for _, tt := range tests {
811		t.Run(tt.name, func(t *testing.T) {
812			req := authenticatedRequest(t, "GET", tt.url, token, nil)
813			w := httptest.NewRecorder()
814			a.HandleListImages(w, req)
815
816			if w.Result().StatusCode != http.StatusOK {
817				t.Fatalf("status = %d, want 200", w.Result().StatusCode)
818			}
819
820			var result map[string]any
821			json.NewDecoder(w.Result().Body).Decode(&result)
822			images := result["images"].([]any)
823			if len(images) != tt.want {
824				t.Errorf("got %d images, want %d; response=%v", len(images), tt.want, result)
825			}
826		})
827	}
828}
829
830// ─────────────────────────── User Suggestions ──────────────────────
831
832func TestHandleUserSuggest(t *testing.T) {
833	a, token := setupTest(t)
834
835	sugReq := authenticatedRequest(t, "GET", "/api/users/suggest?q=user", token, nil)
836	sugW := httptest.NewRecorder()
837	a.HandleUserSuggest(sugW, sugReq)
838
839	if sugW.Result().StatusCode != http.StatusOK {
840		t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode)
841	}
842
843	var users []db.Tag
844	json.NewDecoder(sugW.Result().Body).Decode(&users)
845	if len(users) == 0 {
846		t.Fatal("expected at least one user suggestion")
847	}
848	if users[0].Tag != "user_a" {
849		t.Fatalf("first suggestion = %q, want alice", users[0].Tag)
850	}
851}
852
853func TestHandleUserSuggest_EmptyQuery(t *testing.T) {
854	a, token := setupTest(t)
855	req := authenticatedRequest(t, "GET", "/api/users/suggest?q=", token, nil)
856	w := httptest.NewRecorder()
857	a.HandleUserSuggest(w, req)
858	if w.Result().StatusCode != http.StatusOK {
859		t.Fatalf("status = %d, want 200", w.Result().StatusCode)
860	}
861
862	var users []any
863	json.NewDecoder(w.Result().Body).Decode(&users)
864	if len(users) != 0 {
865		t.Fatalf("expected empty, got %v", users)
866	}
867}
868
869// ─────────────────────────── Innate Tags ──────────────────────
870
871func TestHandleUpload_IncludesInnateTags(t *testing.T) {
872	a, token := setupTest(t)
873	jpegData := createTestJPEG(t)
874
875	var buf bytes.Buffer
876	w := multipart.NewWriter(&buf)
877	part, _ := w.CreateFormFile("file", "test.jpg")
878	part.Write(jpegData)
879	w.Close()
880
881	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
882	req.Header.Set("Content-Type", w.FormDataContentType())
883	ww := httptest.NewRecorder()
884	a.HandleUpload(ww, req)
885
886	resp := ww.Result()
887	if resp.StatusCode != http.StatusCreated {
888		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String())
889	}
890
891	var img db.Image
892	json.NewDecoder(resp.Body).Decode(&img)
893	if len(img.InnateTags) == 0 {
894		t.Fatal("expected non-empty innate_tags on uploaded image")
895	}
896	found := false
897	for _, it := range img.InnateTags {
898		if it == "image" {
899			found = true
900			break
901		}
902	}
903	if !found {
904		t.Fatalf("expected innate tag 'image', got %v", img.InnateTags)
905	}
906}
907
908func TestHandleListImages_FilterByInnateTag(t *testing.T) {
909	a, token := setupTest(t)
910	jpegData := createTestJPEG(t)
911
912	// Upload an image with tags.
913	var buf bytes.Buffer
914	w := multipart.NewWriter(&buf)
915	part, _ := w.CreateFormFile("file", "sunset.jpg")
916	part.Write(jpegData)
917	w.WriteField("tags", "landscape, sunset")
918	w.Close()
919	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
920	req.Header.Set("Content-Type", w.FormDataContentType())
921	ww := httptest.NewRecorder()
922	a.HandleUpload(ww, req)
923	if ww.Result().StatusCode != http.StatusCreated {
924		t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String())
925	}
926
927	// Filter by innate tag "image".
928	listReq := authenticatedRequest(t, "GET", "/api/images?tags=image", token, nil)
929	listW := httptest.NewRecorder()
930	a.HandleListImages(listW, listReq)
931
932	resp := listW.Result()
933	if resp.StatusCode != http.StatusOK {
934		t.Fatalf("list status = %d, want 200", resp.StatusCode)
935	}
936
937	var result map[string]any
938	json.NewDecoder(resp.Body).Decode(&result)
939	images := result["images"].([]any)
940	if len(images) != 1 {
941		t.Fatalf("got %d images filtering by innate tag 'image', want 1; response=%v", len(images), result)
942	}
943}
944
945func TestHandleListImages_InnateTagNoMatch(t *testing.T) {
946	a, token := setupTest(t)
947	jpegData := createTestJPEG(t)
948
949	var buf bytes.Buffer
950	w := multipart.NewWriter(&buf)
951	part, _ := w.CreateFormFile("file", "test.jpg")
952	part.Write(jpegData)
953	w.Close()
954	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
955	req.Header.Set("Content-Type", w.FormDataContentType())
956	ww := httptest.NewRecorder()
957	a.HandleUpload(ww, req)
958	if ww.Result().StatusCode != http.StatusCreated {
959		t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String())
960	}
961
962	// Filter by a non-matching innate tag.
963	listReq := authenticatedRequest(t, "GET", "/api/images?tags=highres", token, nil)
964	listW := httptest.NewRecorder()
965	a.HandleListImages(listW, listReq)
966
967	resp := listW.Result()
968	if resp.StatusCode != http.StatusOK {
969		t.Fatalf("list status = %d, want 200", resp.StatusCode)
970	}
971
972	var result map[string]any
973	json.NewDecoder(resp.Body).Decode(&result)
974	images := result["images"].([]any)
975	if len(images) != 0 {
976		t.Fatalf("got %d images filtering by 'highres', want 0; response=%v", len(images), result)
977	}
978}