package api import ( "bytes" "context" "encoding/json" "fmt" "image" "image/jpeg" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "codeberg.org/maxwelljensen/weimar/internal/auth" "codeberg.org/maxwelljensen/weimar/internal/config" "codeberg.org/maxwelljensen/weimar/internal/db" "codeberg.org/maxwelljensen/weimar/internal/storage" ) func setupTest(t *testing.T) (*API, string) { t.Helper() database, err := db.Open(":memory:") if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { database.Close() }) if err := database.Migrate(context.Background()); err != nil { t.Fatalf("migrate: %v", err) } sm := auth.NewSessionManager(database) st, err := storage.New(t.TempDir()) if err != nil { t.Fatalf("storage: %v", err) } cfg := config.Config{ Auth: config.AuthConfig{AllowRegistration: true}, Upload: config.UploadConfig{MaxSizeMB: 10}, } // Register + login a user for authenticated tests. auth.Register(context.Background(), database, "user_a", "password123", false) _, token, err := sm.Login(context.Background(), "user_a", "password123") if err != nil { t.Fatalf("login: %v", err) } return New(database, sm, st, &cfg), token } // authenticatedRequest creates a request with the session cookie. func authenticatedRequest(t *testing.T, method, target, token string, body io.Reader, pathValues ...string) *http.Request { t.Helper() req := httptest.NewRequest(method, target, body) if token != "" { req.AddCookie(&http.Cookie{ Name: auth.SessionCookieName, Value: token, }) } // Inject authenticated user into context. req = req.WithContext(context.WithValue(req.Context(), CtxKeyUser, &db.User{ID: 1, Username: "user_a"})) // Support PathValue for routes like /api/images/{id} for i := 0; i+1 < len(pathValues); i += 2 { req.SetPathValue(pathValues[i], pathValues[i+1]) } return req } func createTestJPEG(t *testing.T) []byte { t.Helper() var buf bytes.Buffer img := image.NewRGBA(image.Rect(0, 0, 10, 10)) if err := jpeg.Encode(&buf, img, nil); err != nil { t.Fatalf("encode jpeg: %v", err) } return buf.Bytes() } // ─────────────────────────── Register ─────────────────────────── func registerTestAPI(t *testing.T, allowRegistration bool) *API { database, err := db.Open(":memory:") if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { database.Close() }) database.Migrate(context.Background()) sm := auth.NewSessionManager(database) st, _ := storage.New(t.TempDir()) cfg := config.Config{ Auth: config.AuthConfig{AllowRegistration: allowRegistration}, Upload: config.UploadConfig{MaxSizeMB: 10}, } return New(database, sm, st, &cfg) } func TestHandleRegister_Success(t *testing.T) { a := registerTestAPI(t, true) body := `{"username":"bob","password":"password123"}` req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleRegister(w, req) resp := w.Result() if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", resp.StatusCode, w.Body.String()) } var user db.User json.NewDecoder(resp.Body).Decode(&user) if user.Username != "bob" { t.Fatalf("username = %q", user.Username) } } func TestHandleRegister_Closed(t *testing.T) { a := registerTestAPI(t, false) body := `{"username":"bob","password":"password123"}` req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleRegister(w, req) if w.Result().StatusCode != http.StatusForbidden { t.Fatalf("status = %d, want 403", w.Result().StatusCode) } } func TestHandleRegister_BadJSON(t *testing.T) { a := registerTestAPI(t, true) req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader("not json")) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleRegister(w, req) if w.Result().StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Result().StatusCode) } } func TestHandleRegister_ShortUsername(t *testing.T) { a := registerTestAPI(t, true) body := `{"username":"ab","password":"password123"}` req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleRegister(w, req) if w.Result().StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Result().StatusCode) } } func TestHandleRegister_ShortPassword(t *testing.T) { a := registerTestAPI(t, true) body := `{"username":"bob","password":"short"}` req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleRegister(w, req) if w.Result().StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Result().StatusCode) } } func TestHandleRegister_Duplicate(t *testing.T) { a := registerTestAPI(t, true) body := `{"username":"bob","password":"password123"}` req1 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) req1.Header.Set("Content-Type", "application/json") w1 := httptest.NewRecorder() a.HandleRegister(w1, req1) req2 := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) req2.Header.Set("Content-Type", "application/json") w2 := httptest.NewRecorder() a.HandleRegister(w2, req2) if w2.Result().StatusCode != http.StatusBadRequest { t.Fatalf("duplicate status = %d, want 400", w2.Result().StatusCode) } } // ─────────────────────────── Login ─────────────────────────── func TestHandleLogin_Success(t *testing.T) { a := registerTestAPI(t, true) auth.Register(context.Background(), a.DB, "user_a", "password123", false) body := `{"username":"user_a","password":"password123"}` req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleLogin(w, req) resp := w.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200: %s", resp.StatusCode, w.Body.String()) } var found bool for _, c := range resp.Cookies() { if c.Name == auth.SessionCookieName && c.Value != "" { found = true break } } if !found { t.Fatal("expected non-empty session cookie") } } func TestHandleLogin_InvalidCredentials(t *testing.T) { a := registerTestAPI(t, true) auth.Register(context.Background(), a.DB, "user_a", "password123", false) body := `{"username":"user_a","password":"wrongpass"}` req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleLogin(w, req) if w.Result().StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", w.Result().StatusCode) } } func TestHandleLogin_MissingFields(t *testing.T) { a := registerTestAPI(t, true) body := `{"username":"","password":""}` req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() a.HandleLogin(w, req) if w.Result().StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Result().StatusCode) } } // ─────────────────────────── Logout ─────────────────────────── func TestHandleLogout(t *testing.T) { a, token := setupTest(t) req := authenticatedRequest(t, "POST", "/api/auth/logout", token, nil) w := httptest.NewRecorder() a.HandleLogout(w, req) resp := w.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } // Cookie should be cleared. var cleared bool for _, c := range resp.Cookies() { if c.Name == auth.SessionCookieName && c.MaxAge < 0 { cleared = true break } } if !cleared { t.Fatal("expected cleared session cookie") } } // ─────────────────────────── Upload ─────────────────────────── func TestHandleUpload_Success(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.WriteField("tags", "landscape, sunset") w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) resp := ww.Result() if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String()) } var img db.Image json.NewDecoder(resp.Body).Decode(&img) if img.Filename != "test.jpg" { t.Fatalf("filename = %q", img.Filename) } if img.Width != 10 || img.Height != 10 { t.Fatalf("dimensions = %dx%d, want 10x10", img.Width, img.Height) } if img.MimeType != "image/jpeg" { t.Fatalf("mime = %q", img.MimeType) } if len(img.Tags) != 2 || img.Tags[0] != "landscape" { t.Fatalf("tags = %v", img.Tags) } } func TestHandleUpload_MultipleTagFields(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) // Send tags as separate FormData fields (mimicking frontend behavior). var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.WriteField("tags", "foo") w.WriteField("tags", "bar") w.WriteField("tags", " baz ") w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) resp := ww.Result() if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String()) } var img db.Image json.NewDecoder(resp.Body).Decode(&img) if len(img.Tags) != 3 { t.Fatalf("got %d tags, want 3: %v", len(img.Tags), img.Tags) } if img.Tags[0] != "foo" || img.Tags[1] != "bar" || img.Tags[2] != "baz" { t.Fatalf("tags = %v", img.Tags) } } func TestHandleUpload_Unauthenticated(t *testing.T) { a, _ := setupTest(t) req := httptest.NewRequest("POST", "/api/upload", nil) w := httptest.NewRecorder() a.HandleUpload(w, req) if w.Result().StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", w.Result().StatusCode) } } func TestHandleUpload_TooLarge(t *testing.T) { database, _ := db.Open(":memory:") database.Migrate(context.Background()) sm := auth.NewSessionManager(database) auth.Register(context.Background(), database, "user_a", "password123", false) t.Cleanup(func() { database.Close() }) st, _ := storage.New(t.TempDir()) a := New(database, sm, st, &config.Config{ Upload: config.UploadConfig{MaxSizeMB: 0}, // 0 = no max size, so no rejection }) // 0 max size should allow upload. Let's test with absent config. _ = a } // ─────────────────────────── List Images ─────────────────────────── func TestHandleListImages(t *testing.T) { a, token := setupTest(t) // Upload a couple of images first. jpegData := createTestJPEG(t) for _, name := range []string{"a.jpg", "b.jpg"} { var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", name) part.Write(jpegData) w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) } // List images. listReq := authenticatedRequest(t, "GET", "/api/images", token, nil) listW := httptest.NewRecorder() a.HandleListImages(listW, listReq) if listW.Result().StatusCode != http.StatusOK { t.Fatalf("list status = %d", listW.Result().StatusCode) } var result map[string]any json.NewDecoder(listW.Result().Body).Decode(&result) images := result["images"].([]any) if len(images) != 2 { t.Fatalf("got %d images, want 2", len(images)) } total := result["total"].(float64) if total != 2 { t.Fatalf("total = %f, want 2", total) } } func TestHandleListImages_TagFilter(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) for _, tc := range []struct { name string tags string }{ {"sunset.jpg", "landscape, sunset"}, {"portrait.jpg", "portrait"}, } { var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", tc.name) part.Write(jpegData) w.WriteField("tags", tc.tags) w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) if ww.Result().StatusCode != http.StatusCreated { t.Fatalf("upload %s: status=%d body=%s", tc.name, ww.Result().StatusCode, ww.Body.String()) } } listReq := authenticatedRequest(t, "GET", "/api/images?tags=landscape", token, nil) listW := httptest.NewRecorder() a.HandleListImages(listW, listReq) body := listW.Result().Body defer body.Close() var result map[string]any json.NewDecoder(body).Decode(&result) if result["images"] == nil { t.Fatalf("images is null, response body: %v", result) } images := result["images"].([]any) if len(images) != 1 { t.Fatalf("got %d images with landscape tag, want 1; response=%v", len(images), result) } } func TestHandleListImages_NoMatch(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) // Upload an image with known tags. var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.WriteField("tags", "landscape, sunset") w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) if ww.Result().StatusCode != http.StatusCreated { t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String()) } // Query with non-matching tag. listReq := authenticatedRequest(t, "GET", "/api/images?tags=nonexistent", token, nil) listW := httptest.NewRecorder() a.HandleListImages(listW, listReq) resp := listW.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200: %s", resp.StatusCode, listW.Body.String()) } body := resp.Body defer body.Close() var result map[string]any json.NewDecoder(body).Decode(&result) images := result["images"].([]any) if len(images) != 0 { t.Fatalf("got %d images, want 0; response=%v", len(images), result) } total := result["total"].(float64) if total != 0 { t.Fatalf("total = %f, want 0", total) } } func TestHandleListImages_Pagination(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) for i := 0; i < 3; i++ { var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "img.jpg") part.Write(jpegData) w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) } listReq := authenticatedRequest(t, "GET", "/api/images?page=1&per_page=2", token, nil) listW := httptest.NewRecorder() a.HandleListImages(listW, listReq) var result map[string]any json.NewDecoder(listW.Result().Body).Decode(&result) images := result["images"].([]any) if len(images) != 2 { t.Fatalf("got %d images, want 2", len(images)) } total := result["total"].(float64) if total != 3 { t.Fatalf("total = %f, want 3", total) } } // ─────────────────────────── Get Image ─────────────────────────── func TestHandleGetImage_Success(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) getReq := authenticatedRequest(t, "GET", "/api/images/1", token, nil, "id", "1") getW := httptest.NewRecorder() a.HandleGetImage(getW, getReq) if getW.Result().StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200", getW.Result().StatusCode) } var img db.Image json.NewDecoder(getW.Result().Body).Decode(&img) if img.ID != 1 { t.Fatalf("id = %d, want 1", img.ID) } } func TestHandleGetImage_NotFound(t *testing.T) { a, token := setupTest(t) req := authenticatedRequest(t, "GET", "/api/images/999", token, nil, "id", "999") w := httptest.NewRecorder() a.HandleGetImage(w, req) if w.Result().StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Result().StatusCode) } } // ─────────────────────────── Download ─────────────────────────── func TestHandleDownload_Success(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "download.jpg") part.Write(jpegData) w.Close() upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf) upReq.Header.Set("Content-Type", w.FormDataContentType()) upW := httptest.NewRecorder() a.HandleUpload(upW, upReq) if upW.Result().StatusCode != http.StatusCreated { t.Fatalf("upload: %s", upW.Body.String()) } dlReq := authenticatedRequest(t, "GET", "/api/images/1/download", token, nil, "id", "1") dlW := httptest.NewRecorder() a.HandleDownload(dlW, dlReq) resp := dlW.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("download status = %d, want 200", resp.StatusCode) } if resp.Header.Get("Content-Type") != "image/jpeg" { t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type")) } if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="download.jpg"`) { t.Fatalf("Content-Disposition = %q", resp.Header.Get("Content-Disposition")) } } func TestHandleDownload_NotFound(t *testing.T) { a, token := setupTest(t) req := authenticatedRequest(t, "GET", "/api/images/999/download", token, nil, "id", "999") w := httptest.NewRecorder() a.HandleDownload(w, req) if w.Result().StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Result().StatusCode) } } // ─────────────────────────── Thumbnail ─────────────────────────── func TestHandleThumbnail_Success(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "thumb.jpg") part.Write(jpegData) w.Close() upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf) upReq.Header.Set("Content-Type", w.FormDataContentType()) upW := httptest.NewRecorder() a.HandleUpload(upW, upReq) if upW.Result().StatusCode != http.StatusCreated { t.Fatalf("upload: %s", upW.Body.String()) } thReq := authenticatedRequest(t, "GET", "/api/images/1/thumbnail", token, nil, "id", "1") thW := httptest.NewRecorder() a.HandleThumbnail(thW, thReq) resp := thW.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("thumbnail status = %d, want 200", resp.StatusCode) } if resp.Header.Get("Content-Type") != "image/jpeg" { t.Fatalf("Content-Type = %q", resp.Header.Get("Content-Type")) } } func TestHandleThumbnail_NotFound(t *testing.T) { a, token := setupTest(t) req := authenticatedRequest(t, "GET", "/api/images/999/thumbnail", token, nil, "id", "999") w := httptest.NewRecorder() a.HandleThumbnail(w, req) if w.Result().StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Result().StatusCode) } } // ─────────────────────────── Tag Suggest ─────────────────────────── func TestHandleTagSuggest(t *testing.T) { a, token := setupTest(t) // Upload an image with known tags. jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.WriteField("tags", "landscape, sunset, portrait") w.Close() upReq := authenticatedRequest(t, "POST", "/api/upload", token, &buf) upReq.Header.Set("Content-Type", w.FormDataContentType()) upW := httptest.NewRecorder() a.HandleUpload(upW, upReq) if upW.Result().StatusCode != http.StatusCreated { t.Fatalf("upload for tags: %s", upW.Body.String()) } sugReq := authenticatedRequest(t, "GET", "/api/tags/suggest?q=land", token, nil) sugW := httptest.NewRecorder() a.HandleTagSuggest(sugW, sugReq) if sugW.Result().StatusCode != http.StatusOK { t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode) } var tags []db.Tag json.NewDecoder(sugW.Result().Body).Decode(&tags) if len(tags) == 0 { t.Fatal("expected at least one tag suggestion") } if tags[0].Tag != "landscape" { t.Fatalf("first tag = %q, want landscape", tags[0].Tag) } } func TestHandleTagSuggest_EmptyQuery(t *testing.T) { a, token := setupTest(t) req := authenticatedRequest(t, "GET", "/api/tags/suggest?q=", token, nil) w := httptest.NewRecorder() a.HandleTagSuggest(w, req) if w.Result().StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", w.Result().StatusCode) } var tags []any json.NewDecoder(w.Result().Body).Decode(&tags) if len(tags) != 0 { t.Fatalf("expected empty, got %v", tags) } } // ─────────────────────────── Uploader Filter ─────────────────────── func TestHandleListImages_UploaderFilter(t *testing.T) { a, token := setupTest(t) u1, _ := a.DB.CreateUser(context.Background(), "z_uploader_a", "hash", false) u2, _ := a.DB.CreateUser(context.Background(), "z_uploader_b", "hash", false) for i := range 3 { uid := u1.ID if i == 2 { uid = u2.ID } _, err := a.DB.CreateImage(context.Background(), &db.Image{ Filename: fmt.Sprintf("%d.jpg", i), StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i), UploadedBy: uid, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", }) if err != nil { t.Fatalf("create image: %v", err) } } tests := []struct { name string uploader string want int }{ {name: "filter alice", uploader: "z_uploader_a", want: 2}, {name: "filter bob", uploader: "z_uploader_b", want: 1}, {name: "no match", uploader: "nobody", want: 0}, {name: "empty", uploader: "", want: 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { url := "/api/images" if tt.uploader != "" { url += "?uploader=" + tt.uploader } req := authenticatedRequest(t, "GET", url, token, nil) w := httptest.NewRecorder() a.HandleListImages(w, req) if w.Result().StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", w.Result().StatusCode) } var result map[string]any json.NewDecoder(w.Result().Body).Decode(&result) images := result["images"].([]any) if len(images) != tt.want { t.Errorf("got %d images, want %d; response=%v", len(images), tt.want, result) } }) } } func TestHandleListImages_TagAndUploaderFilter(t *testing.T) { a, token := setupTest(t) u1, _ := a.DB.CreateUser(context.Background(), "z_uploader_a", "hash", false) u2, _ := a.DB.CreateUser(context.Background(), "z_uploader_b", "hash", false) img1, _ := a.DB.CreateImage(context.Background(), &db.Image{ Filename: "a.jpg", StoragePath: "a/b/c/a.jpg", UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", }) img2, _ := a.DB.CreateImage(context.Background(), &db.Image{ Filename: "b.jpg", StoragePath: "a/b/c/b.jpg", UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", }) img3, _ := a.DB.CreateImage(context.Background(), &db.Image{ Filename: "c.jpg", StoragePath: "a/b/c/c.jpg", UploadedBy: u2.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", }) a.DB.SetImageTags(context.Background(), img1.ID, []string{"landscape"}) a.DB.SetImageTags(context.Background(), img2.ID, []string{"landscape"}) a.DB.SetImageTags(context.Background(), img3.ID, []string{"landscape"}) tests := []struct { name string url string want int }{ {name: "tag + uploader alice", url: "/api/images?tags=landscape&uploader=z_uploader_a", want: 2}, {name: "tag + uploader bob", url: "/api/images?tags=landscape&uploader=z_uploader_b", want: 1}, {name: "tag only", url: "/api/images?tags=landscape", want: 3}, {name: "uploader only", url: "/api/images?uploader=z_uploader_a", want: 2}, {name: "no match", url: "/api/images?tags=sunset&uploader=z_uploader_a", want: 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := authenticatedRequest(t, "GET", tt.url, token, nil) w := httptest.NewRecorder() a.HandleListImages(w, req) if w.Result().StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", w.Result().StatusCode) } var result map[string]any json.NewDecoder(w.Result().Body).Decode(&result) images := result["images"].([]any) if len(images) != tt.want { t.Errorf("got %d images, want %d; response=%v", len(images), tt.want, result) } }) } } // ─────────────────────────── User Suggestions ────────────────────── func TestHandleUserSuggest(t *testing.T) { a, token := setupTest(t) sugReq := authenticatedRequest(t, "GET", "/api/users/suggest?q=user", token, nil) sugW := httptest.NewRecorder() a.HandleUserSuggest(sugW, sugReq) if sugW.Result().StatusCode != http.StatusOK { t.Fatalf("suggest status = %d, want 200", sugW.Result().StatusCode) } var users []db.Tag json.NewDecoder(sugW.Result().Body).Decode(&users) if len(users) == 0 { t.Fatal("expected at least one user suggestion") } if users[0].Tag != "user_a" { t.Fatalf("first suggestion = %q, want alice", users[0].Tag) } } func TestHandleUserSuggest_EmptyQuery(t *testing.T) { a, token := setupTest(t) req := authenticatedRequest(t, "GET", "/api/users/suggest?q=", token, nil) w := httptest.NewRecorder() a.HandleUserSuggest(w, req) if w.Result().StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", w.Result().StatusCode) } var users []any json.NewDecoder(w.Result().Body).Decode(&users) if len(users) != 0 { t.Fatalf("expected empty, got %v", users) } } // ─────────────────────────── Innate Tags ────────────────────── func TestHandleUpload_IncludesInnateTags(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) resp := ww.Result() if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", resp.StatusCode, ww.Body.String()) } var img db.Image json.NewDecoder(resp.Body).Decode(&img) if len(img.InnateTags) == 0 { t.Fatal("expected non-empty innate_tags on uploaded image") } found := false for _, it := range img.InnateTags { if it == "image" { found = true break } } if !found { t.Fatalf("expected innate tag 'image', got %v", img.InnateTags) } } func TestHandleListImages_FilterByInnateTag(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) // Upload an image with tags. var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "sunset.jpg") part.Write(jpegData) w.WriteField("tags", "landscape, sunset") w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) if ww.Result().StatusCode != http.StatusCreated { t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String()) } // Filter by innate tag "image". listReq := authenticatedRequest(t, "GET", "/api/images?tags=image", token, nil) listW := httptest.NewRecorder() a.HandleListImages(listW, listReq) resp := listW.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200", resp.StatusCode) } var result map[string]any json.NewDecoder(resp.Body).Decode(&result) images := result["images"].([]any) if len(images) != 1 { t.Fatalf("got %d images filtering by innate tag 'image', want 1; response=%v", len(images), result) } } func TestHandleListImages_InnateTagNoMatch(t *testing.T) { a, token := setupTest(t) jpegData := createTestJPEG(t) var buf bytes.Buffer w := multipart.NewWriter(&buf) part, _ := w.CreateFormFile("file", "test.jpg") part.Write(jpegData) w.Close() req := authenticatedRequest(t, "POST", "/api/upload", token, &buf) req.Header.Set("Content-Type", w.FormDataContentType()) ww := httptest.NewRecorder() a.HandleUpload(ww, req) if ww.Result().StatusCode != http.StatusCreated { t.Fatalf("upload: status=%d body=%s", ww.Result().StatusCode, ww.Body.String()) } // Filter by a non-matching innate tag. listReq := authenticatedRequest(t, "GET", "/api/images?tags=highres", token, nil) listW := httptest.NewRecorder() a.HandleListImages(listW, listReq) resp := listW.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200", resp.StatusCode) } var result map[string]any json.NewDecoder(resp.Body).Decode(&result) images := result["images"].([]any) if len(images) != 0 { t.Fatalf("got %d images filtering by 'highres', want 0; response=%v", len(images), result) } }