all repos — weimar @ 87601e8f1973d0b01106ea99ce63228c88130e1a

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	"image"
  8	"image/jpeg"
  9	"io"
 10	"mime/multipart"
 11	"net/http"
 12	"net/http/httptest"
 13	"strings"
 14	"testing"
 15
 16	"github.com/mjensen/weimar/internal/auth"
 17	"github.com/mjensen/weimar/internal/config"
 18	"github.com/mjensen/weimar/internal/db"
 19	"github.com/mjensen/weimar/internal/storage"
 20)
 21
 22func setupTest(t *testing.T) (*API, string) {
 23	t.Helper()
 24
 25	database, err := db.Open(":memory:")
 26	if err != nil {
 27		t.Fatalf("open db: %v", err)
 28	}
 29	t.Cleanup(func() { database.Close() })
 30
 31	if err := database.Migrate(context.Background()); err != nil {
 32		t.Fatalf("migrate: %v", err)
 33	}
 34
 35	sm := auth.NewSessionManager(database)
 36	st, err := storage.New(t.TempDir())
 37	if err != nil {
 38		t.Fatalf("storage: %v", err)
 39	}
 40
 41	cfg := config.Config{
 42		Auth:   config.AuthConfig{AllowRegistration: true},
 43		Upload: config.UploadConfig{MaxSizeMB: 10},
 44	}
 45
 46	// Register + login a user for authenticated tests.
 47	auth.Register(context.Background(), database, "alice", "password123", false)
 48	_, token, err := sm.Login(context.Background(), "alice", "password123")
 49	if err != nil {
 50		t.Fatalf("login: %v", err)
 51	}
 52
 53	return New(database, sm, st, cfg), token
 54}
 55
 56// authenticatedRequest creates a request with the session cookie.
 57func authenticatedRequest(t *testing.T, method, target, token string, body io.Reader, pathValues ...string) *http.Request {
 58	t.Helper()
 59	req := httptest.NewRequest(method, target, body)
 60	if token != "" {
 61		req.AddCookie(&http.Cookie{
 62			Name:  auth.SessionCookieName,
 63			Value: token,
 64		})
 65	}
 66	// Inject authenticated user into context.
 67	req = req.WithContext(context.WithValue(req.Context(), CtxKeyUser, &db.User{ID: 1, Username: "alice"}))
 68
 69	// Support PathValue for routes like /api/images/{id}
 70	for i := 0; i+1 < len(pathValues); i += 2 {
 71		req.SetPathValue(pathValues[i], pathValues[i+1])
 72	}
 73
 74	return req
 75}
 76
 77func createTestJPEG(t *testing.T) []byte {
 78	t.Helper()
 79	var buf bytes.Buffer
 80	img := image.NewRGBA(image.Rect(0, 0, 10, 10))
 81	if err := jpeg.Encode(&buf, img, nil); err != nil {
 82		t.Fatalf("encode jpeg: %v", err)
 83	}
 84	return buf.Bytes()
 85}
 86
 87// ─────────────────────────── Register ───────────────────────────
 88
 89func registerTestAPI(t *testing.T, allowRegistration bool) *API {
 90	database, err := db.Open(":memory:")
 91	if err != nil {
 92		t.Fatalf("open db: %v", err)
 93	}
 94	t.Cleanup(func() { database.Close() })
 95	database.Migrate(context.Background())
 96
 97	sm := auth.NewSessionManager(database)
 98	st, _ := storage.New(t.TempDir())
 99	cfg := config.Config{
100		Auth:   config.AuthConfig{AllowRegistration: allowRegistration},
101		Upload: config.UploadConfig{MaxSizeMB: 10},
102	}
103	return New(database, sm, st, cfg)
104}
105
106func TestHandleRegister_Success(t *testing.T) {
107	a := registerTestAPI(t, true)
108	body := `{"username":"bob","password":"password123"}`
109	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
110	req.Header.Set("Content-Type", "application/json")
111	w := httptest.NewRecorder()
112	a.HandleRegister(w, req)
113
114	resp := w.Result()
115	if resp.StatusCode != http.StatusCreated {
116		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, w.Body.String())
117	}
118
119	var user db.User
120	json.NewDecoder(resp.Body).Decode(&user)
121	if user.Username != "bob" {
122		t.Fatalf("username = %q", user.Username)
123	}
124}
125
126func TestHandleRegister_Closed(t *testing.T) {
127	a := registerTestAPI(t, false)
128	body := `{"username":"bob","password":"password123"}`
129	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
130	req.Header.Set("Content-Type", "application/json")
131	w := httptest.NewRecorder()
132	a.HandleRegister(w, req)
133	if w.Result().StatusCode != http.StatusForbidden {
134		t.Fatalf("status = %d, want 403", w.Result().StatusCode)
135	}
136}
137
138func TestHandleRegister_BadJSON(t *testing.T) {
139	a := registerTestAPI(t, true)
140	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader("not json"))
141	req.Header.Set("Content-Type", "application/json")
142	w := httptest.NewRecorder()
143	a.HandleRegister(w, req)
144	if w.Result().StatusCode != http.StatusBadRequest {
145		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
146	}
147}
148
149func TestHandleRegister_ShortUsername(t *testing.T) {
150	a := registerTestAPI(t, true)
151	body := `{"username":"ab","password":"password123"}`
152	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
153	req.Header.Set("Content-Type", "application/json")
154	w := httptest.NewRecorder()
155	a.HandleRegister(w, req)
156	if w.Result().StatusCode != http.StatusBadRequest {
157		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
158	}
159}
160
161func TestHandleRegister_ShortPassword(t *testing.T) {
162	a := registerTestAPI(t, true)
163	body := `{"username":"bob","password":"short"}`
164	req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
165	req.Header.Set("Content-Type", "application/json")
166	w := httptest.NewRecorder()
167	a.HandleRegister(w, req)
168	if w.Result().StatusCode != http.StatusBadRequest {
169		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
170	}
171}
172
173func TestHandleRegister_Duplicate(t *testing.T) {
174	a := registerTestAPI(t, true)
175
176	body := `{"username":"bob","password":"password123"}`
177	req1 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
178	req1.Header.Set("Content-Type", "application/json")
179	w1 := httptest.NewRecorder()
180	a.HandleRegister(w1, req1)
181
182	req2 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
183	req2.Header.Set("Content-Type", "application/json")
184	w2 := httptest.NewRecorder()
185	a.HandleRegister(w2, req2)
186	if w2.Result().StatusCode != http.StatusBadRequest {
187		t.Fatalf("duplicate status = %d, want 400", w2.Result().StatusCode)
188	}
189}
190
191// ─────────────────────────── Login ───────────────────────────
192
193func TestHandleLogin_Success(t *testing.T) {
194	a := registerTestAPI(t, true)
195	auth.Register(context.Background(), a.DB, "alice", "password123", false)
196
197	body := `{"username":"alice","password":"password123"}`
198	req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
199	req.Header.Set("Content-Type", "application/json")
200	w := httptest.NewRecorder()
201	a.HandleLogin(w, req)
202
203	resp := w.Result()
204	if resp.StatusCode != http.StatusOK {
205		t.Fatalf("status = %d, want 200: %s", resp.StatusCode, w.Body.String())
206	}
207
208	var found bool
209	for _, c := range resp.Cookies() {
210		if c.Name == auth.SessionCookieName && c.Value != "" {
211			found = true
212			break
213		}
214	}
215	if !found {
216		t.Fatal("expected non-empty session cookie")
217	}
218}
219
220func TestHandleLogin_InvalidCredentials(t *testing.T) {
221	a := registerTestAPI(t, true)
222	auth.Register(context.Background(), a.DB, "alice", "password123", false)
223
224	body := `{"username":"alice","password":"wrongpass"}`
225	req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
226	req.Header.Set("Content-Type", "application/json")
227	w := httptest.NewRecorder()
228	a.HandleLogin(w, req)
229	if w.Result().StatusCode != http.StatusUnauthorized {
230		t.Fatalf("status = %d, want 401", w.Result().StatusCode)
231	}
232}
233
234func TestHandleLogin_MissingFields(t *testing.T) {
235	a := registerTestAPI(t, true)
236	body := `{"username":"","password":""}`
237	req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
238	req.Header.Set("Content-Type", "application/json")
239	w := httptest.NewRecorder()
240	a.HandleLogin(w, req)
241	if w.Result().StatusCode != http.StatusBadRequest {
242		t.Fatalf("status = %d, want 400", w.Result().StatusCode)
243	}
244}
245
246// ─────────────────────────── Logout ───────────────────────────
247
248func TestHandleLogout(t *testing.T) {
249	a, token := setupTest(t)
250	req := authenticatedRequest(t, "POST", "/api/auth/logout", token, nil)
251	w := httptest.NewRecorder()
252	a.HandleLogout(w, req)
253
254	resp := w.Result()
255	if resp.StatusCode != http.StatusOK {
256		t.Fatalf("status = %d, want 200", resp.StatusCode)
257	}
258
259	// Cookie should be cleared.
260	var cleared bool
261	for _, c := range resp.Cookies() {
262		if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
263			cleared = true
264			break
265		}
266	}
267	if !cleared {
268		t.Fatal("expected cleared session cookie")
269	}
270}
271
272// ─────────────────────────── Upload ───────────────────────────
273
274func TestHandleUpload_Success(t *testing.T) {
275	a, token := setupTest(t)
276	jpegData := createTestJPEG(t)
277
278	var buf bytes.Buffer
279	w := multipart.NewWriter(&buf)
280	part, _ := w.CreateFormFile("file", "test.jpg")
281	part.Write(jpegData)
282	w.WriteField("tags", "landscape, sunset")
283	w.Close()
284
285	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
286	req.Header.Set("Content-Type", w.FormDataContentType())
287	ww := httptest.NewRecorder()
288	a.HandleUpload(ww, req)
289
290	resp := ww.Result()
291	if resp.StatusCode != http.StatusCreated {
292		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String())
293	}
294
295	var img db.Image
296	json.NewDecoder(resp.Body).Decode(&img)
297	if img.Filename != "test.jpg" {
298		t.Fatalf("filename = %q", img.Filename)
299	}
300	if img.Width != 10 || img.Height != 10 {
301		t.Fatalf("dimensions = %dx%d, want 10x10", img.Width, img.Height)
302	}
303	if img.MimeType != "image/jpeg" {
304		t.Fatalf("mime = %q", img.MimeType)
305	}
306	if len(img.Tags) != 2 || img.Tags[0] != "landscape" {
307		t.Fatalf("tags = %v", img.Tags)
308	}
309}
310
311func TestHandleUpload_MultipleTagFields(t *testing.T) {
312	a, token := setupTest(t)
313	jpegData := createTestJPEG(t)
314
315	// Send tags as separate FormData fields (mimicking frontend behavior).
316	var buf bytes.Buffer
317	w := multipart.NewWriter(&buf)
318	part, _ := w.CreateFormFile("file", "test.jpg")
319	part.Write(jpegData)
320	w.WriteField("tags", "foo")
321	w.WriteField("tags", "bar")
322	w.WriteField("tags", "  baz  ")
323	w.Close()
324
325	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
326	req.Header.Set("Content-Type", w.FormDataContentType())
327	ww := httptest.NewRecorder()
328	a.HandleUpload(ww, req)
329
330	resp := ww.Result()
331	if resp.StatusCode != http.StatusCreated {
332		t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String())
333	}
334
335	var img db.Image
336	json.NewDecoder(resp.Body).Decode(&img)
337	if len(img.Tags) != 3 {
338		t.Fatalf("got %d tags, want 3: %v", len(img.Tags), img.Tags)
339	}
340	if img.Tags[0] != "foo" || img.Tags[1] != "bar" || img.Tags[2] != "baz" {
341		t.Fatalf("tags = %v", img.Tags)
342	}
343}
344
345func TestHandleUpload_Unauthenticated(t *testing.T) {
346	a, _ := setupTest(t)
347	req := httptest.NewRequest("POST", "/api/upload", nil)
348	w := httptest.NewRecorder()
349	a.HandleUpload(w, req)
350	if w.Result().StatusCode != http.StatusUnauthorized {
351		t.Fatalf("status = %d, want 401", w.Result().StatusCode)
352	}
353}
354
355func TestHandleUpload_TooLarge(t *testing.T) {
356	database, _ := db.Open(":memory:")
357	database.Migrate(context.Background())
358	sm := auth.NewSessionManager(database)
359	auth.Register(context.Background(), database, "alice", "password123", false)
360	t.Cleanup(func() { database.Close() })
361	st, _ := storage.New(t.TempDir())
362	a := New(database, sm, st, config.Config{
363		Upload: config.UploadConfig{MaxSizeMB: 0}, // 0 = no max size, so no rejection
364	})
365
366	// 0 max size should allow upload. Let's test with absent config.
367	_ = a
368}
369
370// ─────────────────────────── List Images ───────────────────────────
371
372func TestHandleListImages(t *testing.T) {
373	a, token := setupTest(t)
374
375	// Upload a couple of images first.
376	jpegData := createTestJPEG(t)
377	for _, name := range []string{"a.jpg", "b.jpg"} {
378		var buf bytes.Buffer
379		w := multipart.NewWriter(&buf)
380		part, _ := w.CreateFormFile("file", name)
381		part.Write(jpegData)
382		w.Close()
383		req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
384		req.Header.Set("Content-Type", w.FormDataContentType())
385		ww := httptest.NewRecorder()
386		a.HandleUpload(ww, req)
387	}
388
389	// List images.
390	listReq := authenticatedRequest(t, "GET", "/api/images", token, nil)
391	listW := httptest.NewRecorder()
392	a.HandleListImages(listW, listReq)
393
394	if listW.Result().StatusCode != http.StatusOK {
395		t.Fatalf("list status = %d", listW.Result().StatusCode)
396	}
397
398	var result map[string]any
399	json.NewDecoder(listW.Result().Body).Decode(&result)
400	images := result["images"].([]any)
401	if len(images) != 2 {
402		t.Fatalf("got %d images, want 2", len(images))
403	}
404	total := result["total"].(float64)
405	if total != 2 {
406		t.Fatalf("total = %f, want 2", total)
407	}
408}
409
410func TestHandleListImages_TagFilter(t *testing.T) {
411	a, token := setupTest(t)
412	jpegData := createTestJPEG(t)
413
414	for _, tc := range []struct {
415		name string
416		tags string
417	}{
418		{"sunset.jpg", "landscape, sunset"},
419		{"portrait.jpg", "portrait"},
420	} {
421		var buf bytes.Buffer
422		w := multipart.NewWriter(&buf)
423		part, _ := w.CreateFormFile("file", tc.name)
424		part.Write(jpegData)
425		w.WriteField("tags", tc.tags)
426		w.Close()
427		req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
428		req.Header.Set("Content-Type", w.FormDataContentType())
429		ww := httptest.NewRecorder()
430		a.HandleUpload(ww, req)
431		if ww.Result().StatusCode != http.StatusCreated {
432			t.Fatalf("upload %s: status=%d body=%s", tc.name, ww.Result().StatusCode, ww.Body.String())
433		}
434	}
435
436	listReq := authenticatedRequest(t, "GET", "/api/images?tags=landscape", token, nil)
437	listW := httptest.NewRecorder()
438	a.HandleListImages(listW, listReq)
439
440	body := listW.Result().Body
441	defer body.Close()
442	var result map[string]any
443	json.NewDecoder(body).Decode(&result)
444	if result["images"] == nil {
445		t.Fatalf("images is null, response body: %v", result)
446	}
447	images := result["images"].([]any)
448	if len(images) != 1 {
449		t.Fatalf("got %d images with landscape tag, want 1; response=%v", len(images), result)
450	}
451}
452
453func TestHandleListImages_NoMatch(t *testing.T) {
454	a, token := setupTest(t)
455	jpegData := createTestJPEG(t)
456
457	// Upload an image with known tags.
458	var buf bytes.Buffer
459	w := multipart.NewWriter(&buf)
460	part, _ := w.CreateFormFile("file", "test.jpg")
461	part.Write(jpegData)
462	w.WriteField("tags", "landscape, sunset")
463	w.Close()
464	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
465	req.Header.Set("Content-Type", w.FormDataContentType())
466	ww := httptest.NewRecorder()
467	a.HandleUpload(ww, req)
468	if ww.Result().StatusCode != http.StatusCreated {
469		t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String())
470	}
471
472	// Query with non-matching tag.
473	listReq := authenticatedRequest(t, "GET", "/api/images?tags=nonexistent", token, nil)
474	listW := httptest.NewRecorder()
475	a.HandleListImages(listW, listReq)
476
477	resp := listW.Result()
478	if resp.StatusCode != http.StatusOK {
479		t.Fatalf("list status = %d, want 200: %s", resp.StatusCode, listW.Body.String())
480	}
481
482	body := resp.Body
483	defer body.Close()
484	var result map[string]any
485	json.NewDecoder(body).Decode(&result)
486	images := result["images"].([]any)
487	if len(images) != 0 {
488		t.Fatalf("got %d images, want 0; response=%v", len(images), result)
489	}
490	total := result["total"].(float64)
491	if total != 0 {
492		t.Fatalf("total = %f, want 0", total)
493	}
494}
495
496func TestHandleListImages_Pagination(t *testing.T) {
497	a, token := setupTest(t)
498	jpegData := createTestJPEG(t)
499
500	for i := 0; i < 3; i++ {
501		var buf bytes.Buffer
502		w := multipart.NewWriter(&buf)
503		part, _ := w.CreateFormFile("file", "img.jpg")
504		part.Write(jpegData)
505		w.Close()
506		req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
507		req.Header.Set("Content-Type", w.FormDataContentType())
508		ww := httptest.NewRecorder()
509		a.HandleUpload(ww, req)
510	}
511
512	listReq := authenticatedRequest(t, "GET", "/api/images?page=1&per_page=2", token, nil)
513	listW := httptest.NewRecorder()
514	a.HandleListImages(listW, listReq)
515
516	var result map[string]any
517	json.NewDecoder(listW.Result().Body).Decode(&result)
518	images := result["images"].([]any)
519	if len(images) != 2 {
520		t.Fatalf("got %d images, want 2", len(images))
521	}
522	total := result["total"].(float64)
523	if total != 3 {
524		t.Fatalf("total = %f, want 3", total)
525	}
526}
527
528// ─────────────────────────── Get Image ───────────────────────────
529
530func TestHandleGetImage_Success(t *testing.T) {
531	a, token := setupTest(t)
532	jpegData := createTestJPEG(t)
533
534	var buf bytes.Buffer
535	w := multipart.NewWriter(&buf)
536	part, _ := w.CreateFormFile("file", "test.jpg")
537	part.Write(jpegData)
538	w.Close()
539
540	req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
541	req.Header.Set("Content-Type", w.FormDataContentType())
542	ww := httptest.NewRecorder()
543	a.HandleUpload(ww, req)
544
545	getReq := authenticatedRequest(t, "GET", "/api/images/1", token, nil, "id", "1")
546	getW := httptest.NewRecorder()
547	a.HandleGetImage(getW, getReq)
548
549	if getW.Result().StatusCode != http.StatusOK {
550		t.Fatalf("get status = %d, want 200", getW.Result().StatusCode)
551	}
552	var img db.Image
553	json.NewDecoder(getW.Result().Body).Decode(&img)
554	if img.ID != 1 {
555		t.Fatalf("id = %d, want 1", img.ID)
556	}
557}
558
559func TestHandleGetImage_NotFound(t *testing.T) {
560	a, token := setupTest(t)
561	req := authenticatedRequest(t, "GET", "/api/images/999", token, nil, "id", "999")
562	w := httptest.NewRecorder()
563	a.HandleGetImage(w, req)
564	if w.Result().StatusCode != http.StatusNotFound {
565		t.Fatalf("status = %d, want 404", w.Result().StatusCode)
566	}
567}
568
569// ─────────────────────────── Download ───────────────────────────
570
571func TestHandleDownload_Success(t *testing.T) {
572	a, token := setupTest(t)
573	jpegData := createTestJPEG(t)
574
575	var buf bytes.Buffer
576	w := multipart.NewWriter(&buf)
577	part, _ := w.CreateFormFile("file", "download.jpg")
578	part.Write(jpegData)
579	w.Close()
580
581	upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
582	upReq.Header.Set("Content-Type", w.FormDataContentType())
583	upW := httptest.NewRecorder()
584	a.HandleUpload(upW, upReq)
585	if upW.Result().StatusCode != http.StatusCreated {
586		t.Fatalf("upload: %s", upW.Body.String())
587	}
588
589	dlReq := authenticatedRequest(t, "GET", "/api/images/1/download", token, nil, "id", "1")
590	dlW := httptest.NewRecorder()
591	a.HandleDownload(dlW, dlReq)
592
593	resp := dlW.Result()
594	if resp.StatusCode != http.StatusOK {
595		t.Fatalf("download status = %d, want 200", resp.StatusCode)
596	}
597	if resp.Header.Get("Content-Type") != "image/jpeg" {
598		t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type"))
599	}
600	if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="download.jpg"`) {
601		t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
602	}
603}
604
605func TestHandleDownload_NotFound(t *testing.T) {
606	a, token := setupTest(t)
607	req := authenticatedRequest(t, "GET", "/api/images/999/download", token, nil, "id", "999")
608	w := httptest.NewRecorder()
609	a.HandleDownload(w, req)
610	if w.Result().StatusCode != http.StatusNotFound {
611		t.Fatalf("status = %d, want 404", w.Result().StatusCode)
612	}
613}
614
615// ─────────────────────────── Thumbnail ───────────────────────────
616
617func TestHandleThumbnail_Success(t *testing.T) {
618	a, token := setupTest(t)
619	jpegData := createTestJPEG(t)
620
621	var buf bytes.Buffer
622	w := multipart.NewWriter(&buf)
623	part, _ := w.CreateFormFile("file", "thumb.jpg")
624	part.Write(jpegData)
625	w.Close()
626
627	upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
628	upReq.Header.Set("Content-Type", w.FormDataContentType())
629	upW := httptest.NewRecorder()
630	a.HandleUpload(upW, upReq)
631	if upW.Result().StatusCode != http.StatusCreated {
632		t.Fatalf("upload: %s", upW.Body.String())
633	}
634
635	thReq := authenticatedRequest(t, "GET", "/api/images/1/thumbnail", token, nil, "id", "1")
636	thW := httptest.NewRecorder()
637	a.HandleThumbnail(thW, thReq)
638
639	resp := thW.Result()
640	if resp.StatusCode != http.StatusOK {
641		t.Fatalf("thumbnail status = %d, want 200", resp.StatusCode)
642	}
643	if resp.Header.Get("Content-Type") != "image/jpeg" {
644		t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type"))
645	}
646}
647
648func TestHandleThumbnail_NotFound(t *testing.T) {
649	a, token := setupTest(t)
650	req := authenticatedRequest(t, "GET", "/api/images/999/thumbnail", token, nil, "id", "999")
651	w := httptest.NewRecorder()
652	a.HandleThumbnail(w, req)
653	if w.Result().StatusCode != http.StatusNotFound {
654		t.Fatalf("status = %d, want 404", w.Result().StatusCode)
655	}
656}
657
658// ─────────────────────────── Tag Suggest ───────────────────────────
659
660func TestHandleTagSuggest(t *testing.T) {
661	a, token := setupTest(t)
662
663	// Upload an image with known tags.
664	jpegData := createTestJPEG(t)
665	var buf bytes.Buffer
666	w := multipart.NewWriter(&buf)
667	part, _ := w.CreateFormFile("file", "test.jpg")
668	part.Write(jpegData)
669	w.WriteField("tags", "landscape, sunset, portrait")
670	w.Close()
671
672	upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
673	upReq.Header.Set("Content-Type", w.FormDataContentType())
674	upW := httptest.NewRecorder()
675	a.HandleUpload(upW, upReq)
676	if upW.Result().StatusCode != http.StatusCreated {
677		t.Fatalf("upload for tags: %s", upW.Body.String())
678	}
679
680	sugReq := authenticatedRequest(t, "GET", "/api/tags/suggest?q=land", token, nil)
681	sugW := httptest.NewRecorder()
682	a.HandleTagSuggest(sugW, sugReq)
683
684	if sugW.Result().StatusCode != http.StatusOK {
685		t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode)
686	}
687
688	var tags []db.Tag
689	json.NewDecoder(sugW.Result().Body).Decode(&tags)
690	if len(tags) == 0 {
691		t.Fatal("expected at least one tag suggestion")
692	}
693	if tags[0].Tag != "landscape" {
694		t.Fatalf("first tag = %q, want landscape", tags[0].Tag)
695	}
696}
697
698func TestHandleTagSuggest_EmptyQuery(t *testing.T) {
699	a, token := setupTest(t)
700	req := authenticatedRequest(t, "GET", "/api/tags/suggest?q=", token, nil)
701	w := httptest.NewRecorder()
702	a.HandleTagSuggest(w, req)
703	if w.Result().StatusCode != http.StatusOK {
704		t.Fatalf("status = %d, want 200", w.Result().StatusCode)
705	}
706
707	var tags []any
708	json.NewDecoder(w.Result().Body).Decode(&tags)
709	if len(tags) != 0 {
710		t.Fatalf("expected empty, got %v", tags)
711	}
712}