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}