all repos — weimar @ 77821058eddf0c6181cacf983976b6f41e5ee037

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_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}