fix: preserve all tags on upload and return empty array on no match
Tags beyond the first were silently dropped because `r.FormValue("tags")`
returns only the first value when the frontend sends each tag as a
separate FormData field. Changed to `r.Form["tags"]`.
Nil slices in API responses (`var images []Image`) marshalled to JSON
`null`, causing a TypeError in the Svelte template (`null.length`),
making "Loading..." persist forever. Initialized all API-facing slices
with `make()` so they marshal as `[]`.
💘 Generated with Crush
Assisted-by: Crush:deepseek-reasoner
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 15:16:05 +0200
4 files changed,
88 insertions(+),
8 deletions(-)
M
AGENTS.md
→
AGENTS.md
@@ -113,6 +113,8 @@ - **`newTestDB(t)`** in `db_test.go` — opens `:memory:`, migrates
- **`setupTestDB(t)`** in `auth_test.go` — same for auth package - Tests use table-driven style with descriptive test names - No external test dependencies; no test fixtures or golden files +- **Go nil slices marshal to JSON `null`**: Always use `make([]T, 0)` instead of `var s []T` for API-facing responses — the frontend can't call `.length` on `null`. +- **`r.FormValue()` returns first value only**: When a form field appears multiple times (e.g., `tags=foo&tags=bar` from frontend `FormData.append`), `FormValue` returns only the first. Use `r.Form["tags"]` to get all values as `[]string`. ## Naming & Conventions
M
internal/api/api_test.go
→
internal/api/api_test.go
@@ -308,6 +308,40 @@ 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)@@ -413,6 +447,49 @@ }
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) } }
M
internal/api/upload.go
→
internal/api/upload.go
@@ -103,11 +103,12 @@ thumbnailPath = &thumbRel
} } - // Read tags from form field. - tagStr := r.FormValue("tags") + // Read tags from form field(s). The frontend sends each tag as a + // separate FormData entry (e.g., tags=foo&tags=bar) and a single + // comma-separated string is also supported for REST clients. var tagList []string - if tagStr != "" { - for _, t := range strings.Split(tagStr, ",") { + for _, v := range r.Form["tags"] { + for _, t := range strings.Split(v, ",") { t = strings.TrimSpace(t) if t != "" { tagList = append(tagList, t)
M
internal/db/queries.go
→
internal/db/queries.go
@@ -58,7 +58,7 @@ return nil, fmt.Errorf("list users: %w", err)
} defer rows.Close() - var users []User + users := make([]User, 0) for rows.Next() { u, err := scanUser(rows) if err != nil {@@ -273,7 +273,7 @@ return nil, fmt.Errorf("list images: %w", err)
} defer rows.Close() - var images []Image + images := make([]Image, 0) for rows.Next() { img, err := scanImage(rows) if err != nil {@@ -387,7 +387,7 @@ return nil, fmt.Errorf("get image tags: %w", err)
} defer rows.Close() - var tags []string + tags := make([]string, 0) for rows.Next() { var t string if err := rows.Scan(&t); err != nil {@@ -408,7 +408,7 @@ return nil, fmt.Errorf("suggest tags: %w", err)
} defer rows.Close() - var tags []Tag + tags := make([]Tag, 0) for rows.Next() { var t Tag if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil {