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_Unauthenticated(t *testing.T) {
312 a, _ := setupTest(t)
313 req := httptest.NewRequest("POST", "/api/upload", nil)
314 w := httptest.NewRecorder()
315 a.HandleUpload(w, req)
316 if w.Result().StatusCode != http.StatusUnauthorized {
317 t.Fatalf("status = %d, want 401", w.Result().StatusCode)
318 }
319}
320
321func TestHandleUpload_TooLarge(t *testing.T) {
322 database, _ := db.Open(":memory:")
323 database.Migrate(context.Background())
324 sm := auth.NewSessionManager(database)
325 auth.Register(context.Background(), database, "alice", "password123", false)
326 t.Cleanup(func() { database.Close() })
327 st, _ := storage.New(t.TempDir())
328 a := New(database, sm, st, config.Config{
329 Upload: config.UploadConfig{MaxSizeMB: 0}, // 0 = no max size, so no rejection
330 })
331
332 // 0 max size should allow upload. Let's test with absent config.
333 _ = a
334}
335
336// ─────────────────────────── List Images ───────────────────────────
337
338func TestHandleListImages(t *testing.T) {
339 a, token := setupTest(t)
340
341 // Upload a couple of images first.
342 jpegData := createTestJPEG(t)
343 for _, name := range []string{"a.jpg", "b.jpg"} {
344 var buf bytes.Buffer
345 w := multipart.NewWriter(&buf)
346 part, _ := w.CreateFormFile("file", name)
347 part.Write(jpegData)
348 w.Close()
349 req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
350 req.Header.Set("Content-Type", w.FormDataContentType())
351 ww := httptest.NewRecorder()
352 a.HandleUpload(ww, req)
353 }
354
355 // List images.
356 listReq := authenticatedRequest(t, "GET", "/api/images", token, nil)
357 listW := httptest.NewRecorder()
358 a.HandleListImages(listW, listReq)
359
360 if listW.Result().StatusCode != http.StatusOK {
361 t.Fatalf("list status = %d", listW.Result().StatusCode)
362 }
363
364 var result map[string]any
365 json.NewDecoder(listW.Result().Body).Decode(&result)
366 images := result["images"].([]any)
367 if len(images) != 2 {
368 t.Fatalf("got %d images, want 2", len(images))
369 }
370 total := result["total"].(float64)
371 if total != 2 {
372 t.Fatalf("total = %f, want 2", total)
373 }
374}
375
376func TestHandleListImages_TagFilter(t *testing.T) {
377 a, token := setupTest(t)
378 jpegData := createTestJPEG(t)
379
380 for _, tc := range []struct {
381 name string
382 tags string
383 }{
384 {"sunset.jpg", "landscape, sunset"},
385 {"portrait.jpg", "portrait"},
386 } {
387 var buf bytes.Buffer
388 w := multipart.NewWriter(&buf)
389 part, _ := w.CreateFormFile("file", tc.name)
390 part.Write(jpegData)
391 w.WriteField("tags", tc.tags)
392 w.Close()
393 req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
394 req.Header.Set("Content-Type", w.FormDataContentType())
395 ww := httptest.NewRecorder()
396 a.HandleUpload(ww, req)
397 if ww.Result().StatusCode != http.StatusCreated {
398 t.Fatalf("upload %s: status=%d body=%s", tc.name, ww.Result().StatusCode, ww.Body.String())
399 }
400 }
401
402 listReq := authenticatedRequest(t, "GET", "/api/images?tags=landscape", token, nil)
403 listW := httptest.NewRecorder()
404 a.HandleListImages(listW, listReq)
405
406 body := listW.Result().Body
407 defer body.Close()
408 var result map[string]any
409 json.NewDecoder(body).Decode(&result)
410 if result["images"] == nil {
411 t.Fatalf("images is null, response body: %v", result)
412 }
413 images := result["images"].([]any)
414 if len(images) != 1 {
415 t.Fatalf("got %d images with landscape tag, want 1; response=%v", len(images), result)
416 }
417}
418
419func TestHandleListImages_Pagination(t *testing.T) {
420 a, token := setupTest(t)
421 jpegData := createTestJPEG(t)
422
423 for i := 0; i < 3; i++ {
424 var buf bytes.Buffer
425 w := multipart.NewWriter(&buf)
426 part, _ := w.CreateFormFile("file", "img.jpg")
427 part.Write(jpegData)
428 w.Close()
429 req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
430 req.Header.Set("Content-Type", w.FormDataContentType())
431 ww := httptest.NewRecorder()
432 a.HandleUpload(ww, req)
433 }
434
435 listReq := authenticatedRequest(t, "GET", "/api/images?page=1&per_page=2", token, nil)
436 listW := httptest.NewRecorder()
437 a.HandleListImages(listW, listReq)
438
439 var result map[string]any
440 json.NewDecoder(listW.Result().Body).Decode(&result)
441 images := result["images"].([]any)
442 if len(images) != 2 {
443 t.Fatalf("got %d images, want 2", len(images))
444 }
445 total := result["total"].(float64)
446 if total != 3 {
447 t.Fatalf("total = %f, want 3", total)
448 }
449}
450
451// ─────────────────────────── Get Image ───────────────────────────
452
453func TestHandleGetImage_Success(t *testing.T) {
454 a, token := setupTest(t)
455 jpegData := createTestJPEG(t)
456
457 var buf bytes.Buffer
458 w := multipart.NewWriter(&buf)
459 part, _ := w.CreateFormFile("file", "test.jpg")
460 part.Write(jpegData)
461 w.Close()
462
463 req := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
464 req.Header.Set("Content-Type", w.FormDataContentType())
465 ww := httptest.NewRecorder()
466 a.HandleUpload(ww, req)
467
468 getReq := authenticatedRequest(t, "GET", "/api/images/1", token, nil, "id", "1")
469 getW := httptest.NewRecorder()
470 a.HandleGetImage(getW, getReq)
471
472 if getW.Result().StatusCode != http.StatusOK {
473 t.Fatalf("get status = %d, want 200", getW.Result().StatusCode)
474 }
475 var img db.Image
476 json.NewDecoder(getW.Result().Body).Decode(&img)
477 if img.ID != 1 {
478 t.Fatalf("id = %d, want 1", img.ID)
479 }
480}
481
482func TestHandleGetImage_NotFound(t *testing.T) {
483 a, token := setupTest(t)
484 req := authenticatedRequest(t, "GET", "/api/images/999", token, nil, "id", "999")
485 w := httptest.NewRecorder()
486 a.HandleGetImage(w, req)
487 if w.Result().StatusCode != http.StatusNotFound {
488 t.Fatalf("status = %d, want 404", w.Result().StatusCode)
489 }
490}
491
492// ─────────────────────────── Download ───────────────────────────
493
494func TestHandleDownload_Success(t *testing.T) {
495 a, token := setupTest(t)
496 jpegData := createTestJPEG(t)
497
498 var buf bytes.Buffer
499 w := multipart.NewWriter(&buf)
500 part, _ := w.CreateFormFile("file", "download.jpg")
501 part.Write(jpegData)
502 w.Close()
503
504 upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
505 upReq.Header.Set("Content-Type", w.FormDataContentType())
506 upW := httptest.NewRecorder()
507 a.HandleUpload(upW, upReq)
508 if upW.Result().StatusCode != http.StatusCreated {
509 t.Fatalf("upload: %s", upW.Body.String())
510 }
511
512 dlReq := authenticatedRequest(t, "GET", "/api/images/1/download", token, nil, "id", "1")
513 dlW := httptest.NewRecorder()
514 a.HandleDownload(dlW, dlReq)
515
516 resp := dlW.Result()
517 if resp.StatusCode != http.StatusOK {
518 t.Fatalf("download status = %d, want 200", resp.StatusCode)
519 }
520 if resp.Header.Get("Content-Type") != "image/jpeg" {
521 t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type"))
522 }
523 if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="download.jpg"`) {
524 t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
525 }
526}
527
528func TestHandleDownload_NotFound(t *testing.T) {
529 a, token := setupTest(t)
530 req := authenticatedRequest(t, "GET", "/api/images/999/download", token, nil, "id", "999")
531 w := httptest.NewRecorder()
532 a.HandleDownload(w, req)
533 if w.Result().StatusCode != http.StatusNotFound {
534 t.Fatalf("status = %d, want 404", w.Result().StatusCode)
535 }
536}
537
538// ─────────────────────────── Thumbnail ───────────────────────────
539
540func TestHandleThumbnail_Success(t *testing.T) {
541 a, token := setupTest(t)
542 jpegData := createTestJPEG(t)
543
544 var buf bytes.Buffer
545 w := multipart.NewWriter(&buf)
546 part, _ := w.CreateFormFile("file", "thumb.jpg")
547 part.Write(jpegData)
548 w.Close()
549
550 upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
551 upReq.Header.Set("Content-Type", w.FormDataContentType())
552 upW := httptest.NewRecorder()
553 a.HandleUpload(upW, upReq)
554 if upW.Result().StatusCode != http.StatusCreated {
555 t.Fatalf("upload: %s", upW.Body.String())
556 }
557
558 thReq := authenticatedRequest(t, "GET", "/api/images/1/thumbnail", token, nil, "id", "1")
559 thW := httptest.NewRecorder()
560 a.HandleThumbnail(thW, thReq)
561
562 resp := thW.Result()
563 if resp.StatusCode != http.StatusOK {
564 t.Fatalf("thumbnail status = %d, want 200", resp.StatusCode)
565 }
566 if resp.Header.Get("Content-Type") != "image/jpeg" {
567 t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type"))
568 }
569}
570
571func TestHandleThumbnail_NotFound(t *testing.T) {
572 a, token := setupTest(t)
573 req := authenticatedRequest(t, "GET", "/api/images/999/thumbnail", token, nil, "id", "999")
574 w := httptest.NewRecorder()
575 a.HandleThumbnail(w, req)
576 if w.Result().StatusCode != http.StatusNotFound {
577 t.Fatalf("status = %d, want 404", w.Result().StatusCode)
578 }
579}
580
581// ─────────────────────────── Tag Suggest ───────────────────────────
582
583func TestHandleTagSuggest(t *testing.T) {
584 a, token := setupTest(t)
585
586 // Upload an image with known tags.
587 jpegData := createTestJPEG(t)
588 var buf bytes.Buffer
589 w := multipart.NewWriter(&buf)
590 part, _ := w.CreateFormFile("file", "test.jpg")
591 part.Write(jpegData)
592 w.WriteField("tags", "landscape, sunset, portrait")
593 w.Close()
594
595 upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf)
596 upReq.Header.Set("Content-Type", w.FormDataContentType())
597 upW := httptest.NewRecorder()
598 a.HandleUpload(upW, upReq)
599 if upW.Result().StatusCode != http.StatusCreated {
600 t.Fatalf("upload for tags: %s", upW.Body.String())
601 }
602
603 sugReq := authenticatedRequest(t, "GET", "/api/tags/suggest?q=land", token, nil)
604 sugW := httptest.NewRecorder()
605 a.HandleTagSuggest(sugW, sugReq)
606
607 if sugW.Result().StatusCode != http.StatusOK {
608 t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode)
609 }
610
611 var tags []db.Tag
612 json.NewDecoder(sugW.Result().Body).Decode(&tags)
613 if len(tags) == 0 {
614 t.Fatal("expected at least one tag suggestion")
615 }
616 if tags[0].Tag != "landscape" {
617 t.Fatalf("first tag = %q, want landscape", tags[0].Tag)
618 }
619}
620
621func TestHandleTagSuggest_EmptyQuery(t *testing.T) {
622 a, token := setupTest(t)
623 req := authenticatedRequest(t, "GET", "/api/tags/suggest?q=", token, nil)
624 w := httptest.NewRecorder()
625 a.HandleTagSuggest(w, req)
626 if w.Result().StatusCode != http.StatusOK {
627 t.Fatalf("status = %d, want 200", w.Result().StatusCode)
628 }
629
630 var tags []any
631 json.NewDecoder(w.Result().Body).Decode(&tags)
632 if len(tags) != 0 {
633 t.Fatalf("expected empty, got %v", tags)
634 }
635}