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}