test: add tests for uploader filter, tag+uploader combo, and user suggestions Also fixes a bug in ListImages where placeholder args were in the wrong order when both tag and uploader filters were combined. Assisted-by: Crush:deepseek-v4-flash
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 21:29:58 +0200
3 files changed,
319 insertions(+),
13 deletions(-)
M
internal/api/api_test.go
→
internal/api/api_test.go
@@ -4,6 +4,7 @@ import (
"bytes" "context" "encoding/json" + "fmt" "image" "image/jpeg" "io"@@ -44,8 +45,8 @@ Upload: config.UploadConfig{MaxSizeMB: 10},
} // Register + login a user for authenticated tests. - auth.Register(context.Background(), database, "alice", "password123", false) - _, token, err := sm.Login(context.Background(), "alice", "password123") + 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) }@@ -64,7 +65,7 @@ Value: token,
}) } // Inject authenticated user into context. - req = req.WithContext(context.WithValue(req.Context(), CtxKeyUser, &db.User{ID: 1, Username: "alice"})) + 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 {@@ -192,9 +193,9 @@ // ─────────────────────────── Login ───────────────────────────
func TestHandleLogin_Success(t *testing.T) { a := registerTestAPI(t, true) - auth.Register(context.Background(), a.DB, "alice", "password123", false) + auth.Register(context.Background(), a.DB, "user_a", "password123", false) - body := `{"username":"alice","password":"password123"}` + 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()@@ -219,9 +220,9 @@ }
func TestHandleLogin_InvalidCredentials(t *testing.T) { a := registerTestAPI(t, true) - auth.Register(context.Background(), a.DB, "alice", "password123", false) + auth.Register(context.Background(), a.DB, "user_a", "password123", false) - body := `{"username":"alice","password":"wrongpass"}` + 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()@@ -356,7 +357,7 @@ func TestHandleUpload_TooLarge(t *testing.T) {
database, _ := db.Open(":memory:") database.Migrate(context.Background()) sm := auth.NewSessionManager(database) - auth.Register(context.Background(), database, "alice", "password123", false) + 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{@@ -710,3 +711,157 @@ 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) + } +}
M
internal/db/db_test.go
→
internal/db/db_test.go
@@ -489,3 +489,154 @@ t.Errorf("normalizeTag(%q) = %q, want %q", tt.input, got, tt.want)
} } } + +func TestListImages_UploaderFilter(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false) + u2, _ := d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false) + + for i := range 3 { + uid := u1.ID + if i == 2 { + uid = u2.ID + } + d.CreateImage(ctx, &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", + }) + } + + tests := []struct { + name string + uploader string + want int + }{ + {name: "filter alice (2 images)", uploader: "alice", want: 2}, + {name: "filter bob (1 image)", uploader: "bob", want: 1}, + {name: "no match", uploader: "nobody", want: 0}, + {name: "empty filter returns all", uploader: "", want: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := d.ListImages(ctx, nil, tt.uploader, 10, 0) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(result.Images) != tt.want { + t.Errorf("got %d images, want %d", len(result.Images), tt.want) + } + if result.Total != tt.want { + t.Errorf("total = %d, want %d", result.Total, tt.want) + } + }) + } +} + +func TestListImages_TagAndUploaderFilter(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false) + u2, _ := d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false) + + img1, _ := d.CreateImage(ctx, &Image{ + Filename: "a.jpg", StoragePath: "a/b/c/a.jpg", + UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + img2, _ := d.CreateImage(ctx, &Image{ + Filename: "b.jpg", StoragePath: "a/b/c/b.jpg", + UploadedBy: u1.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + img3, _ := d.CreateImage(ctx, &Image{ + Filename: "c.jpg", StoragePath: "a/b/c/c.jpg", + UploadedBy: u2.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + + d.SetImageTags(ctx, img1.ID, []string{"landscape"}) + d.SetImageTags(ctx, img2.ID, []string{"landscape"}) + d.SetImageTags(ctx, img3.ID, []string{"landscape"}) + + tests := []struct { + name string + tags []string + uploader string + want int + }{ + {name: "tag + uploader alice", tags: []string{"landscape"}, uploader: "alice", want: 2}, + {name: "tag + uploader bob", tags: []string{"landscape"}, uploader: "bob", want: 1}, + {name: "tag only", tags: []string{"landscape"}, uploader: "", want: 3}, + {name: "uploader only", tags: nil, uploader: "alice", want: 2}, + {name: "no match tag", tags: []string{"sunset"}, uploader: "alice", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := d.ListImages(ctx, tt.tags, tt.uploader, 10, 0) + if err != nil { + t.Fatalf("ListImages: %v", err) + } + if len(result.Images) != tt.want { + t.Errorf("got %d images, want %d", len(result.Images), tt.want) + } + if result.Total != tt.want { + t.Errorf("total = %d, want %d", result.Total, tt.want) + } + }) + } +} + +func TestSuggestUploaders(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + u1, _ := d.CreateUser(ctx, "alice", hashPassword(t, "pw"), false) + d.CreateUser(ctx, "bob", hashPassword(t, "pw"), false) + + // alice uploads 2 images, bob uploads 0. + for i := range 2 { + d.CreateImage(ctx, &Image{ + Filename: fmt.Sprintf("%d.jpg", i), + StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i), + UploadedBy: u1.ID, + FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + } + + tests := []struct { + name string + prefix string + want int + first string + count int + }{ + {name: "match ali prefix", prefix: "ali", want: 1, first: "alice", count: 2}, + {name: "match bo prefix", prefix: "bo", want: 1, first: "bob", count: 0}, + {name: "no match", prefix: "xyz", want: 0}, + {name: "all with empty prefix", prefix: "", want: 2, first: "alice", count: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := d.SuggestUploaders(ctx, tt.prefix, 10) + if err != nil { + t.Fatalf("SuggestUploaders: %v", err) + } + if len(results) != tt.want { + t.Fatalf("got %d results, want %d", len(results), tt.want) + } + if tt.want > 0 { + if results[0].Tag != tt.first { + t.Errorf("first result = %q, want %q", results[0].Tag, tt.first) + } + if results[0].UsageCount != tt.count { + t.Errorf("first result count = %d, want %d", results[0].UsageCount, tt.count) + } + } + }) + } +}
M
internal/db/queries.go
→
internal/db/queries.go
@@ -253,11 +253,6 @@ var tagJoin string
var uploaderWhere string var tagWhere string - if filterUploader != "" { - uploaderWhere = `i.uploaded_by = (SELECT id FROM users WHERE username = ?)` - args = append(args, filterUploader) - } - if len(filterTags) > 0 { placeholders := make([]string, len(filterTags)) for j, t := range filterTags {@@ -266,6 +261,11 @@ args = append(args, t)
} tagJoin = ` JOIN image_tags it ON i.id = it.image_id` tagWhere = `it.tag IN (` + strings.Join(placeholders, ",") + `)` + } + + if filterUploader != "" { + uploaderWhere = `i.uploaded_by = (SELECT id FROM users WHERE username = ?)` + args = append(args, filterUploader) } // Build WHERE clause.