feat(backend): update portfolio images handler
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -119,6 +119,13 @@ type Tag struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseCursor splits a "createdAt|id" cursor string into its components.
|
||||||
|
|
||||||
|
type ImageListResponse struct {
|
||||||
|
Images []Image `json:"images"`
|
||||||
|
NextCursor *string `json:"next_cursor,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func ListImages(w http.ResponseWriter, r *http.Request) {
|
func ListImages(w http.ResponseWriter, r *http.Request) {
|
||||||
tagFilter := r.URL.Query().Get("tag")
|
tagFilter := r.URL.Query().Get("tag")
|
||||||
tagsFilter := r.URL.Query().Get("tags")
|
tagsFilter := r.URL.Query().Get("tags")
|
||||||
@@ -138,18 +145,13 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
limit := 20
|
limit := 20
|
||||||
offset := 0
|
cursorStr := r.URL.Query().Get("cursor")
|
||||||
|
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||||
limit = parsed
|
limit = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o := r.URL.Query().Get("offset"); o != "" {
|
|
||||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 && parsed <= 10000 {
|
|
||||||
offset = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate filter categories against allowed list from DB
|
// Validate filter categories against allowed list from DB
|
||||||
allowedCategories, err := getAllowedCategories(r.Context())
|
allowedCategories, err := getAllowedCategories(r.Context())
|
||||||
@@ -184,7 +186,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1)
|
filterClauses += fmt.Sprintf(" AND $%d::text = ANY(tag_names)", len(filterArgs)+1)
|
||||||
filterArgs = append(filterArgs, category+":"+value)
|
filterArgs = append(filterArgs, category+":"+value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +201,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
tagList := strings.Split(tagsFilter, ",")
|
tagList := strings.Split(tagsFilter, ",")
|
||||||
cleanTags := make([]string, len(tagList))
|
cleanTags := make([]string, len(tagList))
|
||||||
for i, t := range tagList {
|
for i, t := range tagList {
|
||||||
cleanTags[i] = strings.TrimSpace(t)
|
cleanTags[i] = "%" + strings.TrimSpace(t) + "%"
|
||||||
}
|
}
|
||||||
conditions := make([]string, len(cleanTags))
|
conditions := make([]string, len(cleanTags))
|
||||||
for i := range cleanTags {
|
for i := range cleanTags {
|
||||||
@@ -219,37 +221,68 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
%s as relevance
|
%s as relevance
|
||||||
FROM images, unnest(tag_names) as t
|
FROM images, unnest(tag_names) as t
|
||||||
WHERE %s%s
|
WHERE %s%s
|
||||||
GROUP BY id, url, thumbnail_url, tag_names, created_at, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url
|
GROUP BY id
|
||||||
ORDER BY match_count DESC, relevance DESC, created_at DESC
|
`, formatCols, similaritySum, whereClause, filterClauses)
|
||||||
LIMIT $%d OFFSET $%d
|
|
||||||
`, formatCols, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
|
|
||||||
|
|
||||||
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2)
|
var cursorArgs []interface{}
|
||||||
|
if cursorStr != "" {
|
||||||
|
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cursorArgs = []interface{}{cursorCreatedAt, cursorID}
|
||||||
|
havingIdx := len(cleanTags) + len(filterArgs) + 1
|
||||||
|
query += fmt.Sprintf(" HAVING (created_at, id) < ($%d, $%d)", havingIdx, havingIdx+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY match_count DESC, relevance DESC, created_at DESC, id DESC"
|
||||||
|
argOffset = len(filterArgs) + len(cleanTags) + len(cursorArgs)
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
|
||||||
|
|
||||||
|
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+len(cursorArgs)+1)
|
||||||
copy(queryArgs, filterArgs)
|
copy(queryArgs, filterArgs)
|
||||||
for i, t := range cleanTags {
|
for i, t := range cleanTags {
|
||||||
queryArgs[len(filterArgs)+i] = t
|
queryArgs[len(filterArgs)+i] = t
|
||||||
}
|
}
|
||||||
queryArgs[len(filterArgs)+len(cleanTags)] = limit
|
for i, ca := range cursorArgs {
|
||||||
queryArgs[len(filterArgs)+len(cleanTags)+1] = offset
|
queryArgs[len(filterArgs)+len(cleanTags)+i] = ca
|
||||||
|
}
|
||||||
|
queryArgs[len(filterArgs)+len(cleanTags)+len(cursorArgs)] = limit
|
||||||
args = queryArgs
|
args = queryArgs
|
||||||
} else if tagFilter != "" {
|
} else if tagFilter != "" {
|
||||||
argOffset := len(filterArgs)
|
argOffset := len(filterArgs)
|
||||||
searchPattern := "%" + tagFilter + "%"
|
searchPattern := "%" + tagFilter + "%"
|
||||||
|
searchIdx := argOffset + 1
|
||||||
query = fmt.Sprintf(`
|
query = fmt.Sprintf(`
|
||||||
SELECT id, url, thumbnail_url, tag_names, created_at%s,
|
SELECT id, url, thumbnail_url, tag_names, created_at%s,
|
||||||
CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority,
|
CASE WHEN t = $%d THEN 2 ELSE 1 END as match_priority,
|
||||||
similarity(t, $1) as relevance
|
similarity(t, $%d) as relevance
|
||||||
FROM images, unnest(tag_names) as t
|
FROM images, unnest(tag_names) as t
|
||||||
WHERE t ILIKE '%%' || $1 || '%%%s'
|
WHERE 1=1%s AND t ILIKE '%%' || $%d || '%%'
|
||||||
ORDER BY match_priority DESC, relevance DESC, created_at DESC
|
`, formatCols, searchIdx, searchIdx, filterClauses, searchIdx)
|
||||||
LIMIT $%d OFFSET $%d
|
|
||||||
`, formatCols, filterClauses, argOffset+2, argOffset+3)
|
|
||||||
|
|
||||||
queryArgs := make([]interface{}, len(filterArgs)+3)
|
cursorArgs := []interface{}{}
|
||||||
queryArgs[0] = searchPattern
|
if cursorStr != "" {
|
||||||
copy(queryArgs[1:], filterArgs)
|
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
||||||
queryArgs[len(filterArgs)+1] = limit
|
if err != nil {
|
||||||
queryArgs[len(filterArgs)+2] = offset
|
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", searchIdx+1, searchIdx+2)
|
||||||
|
cursorArgs = append(cursorArgs, cursorCreatedAt, cursorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY match_priority DESC, relevance DESC, created_at DESC, id DESC"
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", searchIdx+1+len(cursorArgs))
|
||||||
|
|
||||||
|
queryArgs := make([]interface{}, searchIdx+1+len(cursorArgs))
|
||||||
|
copy(queryArgs[:argOffset], filterArgs)
|
||||||
|
queryArgs[argOffset] = searchPattern
|
||||||
|
for i, ca := range cursorArgs {
|
||||||
|
queryArgs[searchIdx+i] = ca
|
||||||
|
}
|
||||||
|
queryArgs[searchIdx+len(cursorArgs)] = limit
|
||||||
args = queryArgs
|
args = queryArgs
|
||||||
} else {
|
} else {
|
||||||
argOffset := len(filterArgs)
|
argOffset := len(filterArgs)
|
||||||
@@ -257,14 +290,25 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
SELECT id, url, thumbnail_url, tag_names, created_at%s, 0 as match_count, 0.0 as relevance
|
SELECT id, url, thumbnail_url, tag_names, created_at%s, 0 as match_count, 0.0 as relevance
|
||||||
FROM images
|
FROM images
|
||||||
WHERE 1=1%s
|
WHERE 1=1%s
|
||||||
ORDER BY created_at DESC
|
`, formatCols, filterClauses)
|
||||||
LIMIT $%d OFFSET $%d
|
|
||||||
`, formatCols, filterClauses, argOffset+1, argOffset+2)
|
|
||||||
|
|
||||||
queryArgs := make([]interface{}, len(filterArgs)+2)
|
if cursorStr != "" {
|
||||||
|
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", argOffset+1, argOffset+2)
|
||||||
|
filterArgs = append(filterArgs, cursorCreatedAt, cursorID)
|
||||||
|
argOffset += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC, id DESC"
|
||||||
|
query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
|
||||||
|
|
||||||
|
queryArgs := make([]interface{}, len(filterArgs)+1)
|
||||||
copy(queryArgs, filterArgs)
|
copy(queryArgs, filterArgs)
|
||||||
queryArgs[len(filterArgs)] = limit
|
queryArgs[len(filterArgs)] = limit
|
||||||
queryArgs[len(filterArgs)+1] = offset
|
|
||||||
args = queryArgs
|
args = queryArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,8 +365,18 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
images = []Image{}
|
images = []Image{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nextCursor *string
|
||||||
|
if len(images) > 0 {
|
||||||
|
last := images[len(images)-1]
|
||||||
|
cursor := last.CreatedAt.UTC().Format(time.RFC3339) + "|" + last.ID
|
||||||
|
nextCursor = &cursor
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(images)
|
json.NewEncoder(w).Encode(ImageListResponse{
|
||||||
|
Images: images,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListTags(w http.ResponseWriter, r *http.Request) {
|
func ListTags(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -794,8 +848,16 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx, err := db.DB.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to start transaction: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
var imgID string
|
var imgID string
|
||||||
err := db.DB.QueryRow(r.Context(), `
|
err = tx.QueryRow(r.Context(), `
|
||||||
INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url)
|
INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
@@ -811,7 +873,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if tag == "" {
|
if tag == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, err = db.DB.Exec(r.Context(), `
|
_, err = tx.Exec(r.Context(), `
|
||||||
INSERT INTO tags (name) VALUES ($1)
|
INSERT INTO tags (name) VALUES ($1)
|
||||||
ON CONFLICT (name) DO NOTHING
|
ON CONFLICT (name) DO NOTHING
|
||||||
`, tag)
|
`, tag)
|
||||||
@@ -820,6 +882,12 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
log.Printf("Failed to commit transaction: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(Image{
|
json.NewEncoder(w).Encode(Image{
|
||||||
ID: imgID,
|
ID: imgID,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
@@ -115,10 +116,11 @@ func TestPortfolio_ListImages(t *testing.T) {
|
|||||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var images []Image
|
var resp ImageListResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
images := resp.Images
|
||||||
|
|
||||||
if len(images) != 2 {
|
if len(images) != 2 {
|
||||||
t.Errorf("expected 2 images, got %d", len(images))
|
t.Errorf("expected 2 images, got %d", len(images))
|
||||||
@@ -147,10 +149,11 @@ func TestPortfolio_ListImages_WithTagFilter(t *testing.T) {
|
|||||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var images []Image
|
var resp ImageListResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
images := resp.Images
|
||||||
|
|
||||||
if len(images) != 1 {
|
if len(images) != 1 {
|
||||||
t.Errorf("expected 1 image, got %d", len(images))
|
t.Errorf("expected 1 image, got %d", len(images))
|
||||||
@@ -168,16 +171,246 @@ func TestPortfolio_ListImages_Empty(t *testing.T) {
|
|||||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var images []Image
|
var resp ImageListResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
images := resp.Images
|
||||||
|
|
||||||
if len(images) != 0 {
|
if len(images) != 0 {
|
||||||
t.Errorf("expected 0 images, got %d", len(images))
|
t.Errorf("expected 0 images, got %d", len(images))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPortfolio_ListImages_WithTagsFilter verifies the comma-separated `tags`
|
||||||
|
// parameter, matching images whose tag_names contain any of the given values.
|
||||||
|
func TestPortfolio_ListImages_WithTagsFilter(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO images (url, thumbnail_url, tag_names)
|
||||||
|
VALUES
|
||||||
|
('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest', 'color:green']),
|
||||||
|
('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['nature:ocean', 'color:blue']),
|
||||||
|
('https://example.com/img3.jpg', 'https://example.com/img3_thumb.jpg', ARRAY['style:classic'])
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create images: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(ListImages)
|
||||||
|
// Match images tagged 'forest' OR 'ocean'
|
||||||
|
w := makeRequest(handler, "GET", "/api/portfolio/images?tags=forest,ocean", nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ImageListResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Images) != 2 {
|
||||||
|
t.Errorf("expected 2 images matching tags, got %d", len(resp.Images))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortfolio_ListImages_WithCategoryFilter verifies the filter[...] query
|
||||||
|
// parameter, which narrows results to images whose tag_names include a
|
||||||
|
// category:value combination.
|
||||||
|
func TestPortfolio_ListImages_WithCategoryFilter(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO images (url, thumbnail_url, tag_names)
|
||||||
|
VALUES
|
||||||
|
('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['color:green', 'nature:forest']),
|
||||||
|
('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['color:blue', 'nature:ocean']),
|
||||||
|
('https://example.com/img3.jpg', 'https://example.com/img3_thumb.jpg', ARRAY['color:green', 'nature:ocean'])
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create images: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(ListImages)
|
||||||
|
w := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]=green", nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ImageListResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Images) != 2 {
|
||||||
|
t.Errorf("expected 2 images with color:green, got %d", len(resp.Images))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortfolio_ListImages_WithCategoryAndTagFilter verifies that a category
|
||||||
|
// filter can be combined with a single tag filter.
|
||||||
|
func TestPortfolio_ListImages_WithCategoryAndTagFilter(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO images (url, thumbnail_url, tag_names)
|
||||||
|
VALUES
|
||||||
|
('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['color:green', 'nature:forest']),
|
||||||
|
('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['color:green', 'nature:ocean']),
|
||||||
|
('https://example.com/img3.jpg', 'https://example.com/img3_thumb.jpg', ARRAY['color:blue', 'nature:forest'])
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create images: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(ListImages)
|
||||||
|
// Only images that are color:green AND match tag 'ocean'
|
||||||
|
w := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]=green&tag=ocean", nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ImageListResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Images) != 1 {
|
||||||
|
t.Errorf("expected 1 image with color:green and tag ocean, got %d", len(resp.Images))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortfolio_ListImages_Pagination verifies cursor-based pagination:
|
||||||
|
// requesting a small limit returns the correct number of results, and
|
||||||
|
// the response includes a next_cursor when more results are available.
|
||||||
|
func TestPortfolio_ListImages_Pagination(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
// Insert 3 images with staggered created_at so ordering is deterministic
|
||||||
|
_, err := db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO images (url, thumbnail_url, tag_names, created_at)
|
||||||
|
VALUES
|
||||||
|
('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['test'], '2025-01-03T00:00:00Z'),
|
||||||
|
('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['test'], '2025-01-02T00:00:00Z'),
|
||||||
|
('https://example.com/img3.jpg', 'https://example.com/img3_thumb.jpg', ARRAY['test'], '2025-01-01T00:00:00Z')
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create images: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(ListImages)
|
||||||
|
|
||||||
|
// First page: limit=2
|
||||||
|
w := makeRequest(handler, "GET", "/api/portfolio/images?limit=2", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var page1 ImageListResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &page1); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal first page: %v", err)
|
||||||
|
}
|
||||||
|
if len(page1.Images) != 2 {
|
||||||
|
t.Errorf("expected 2 images on first page, got %d", len(page1.Images))
|
||||||
|
}
|
||||||
|
if page1.NextCursor == nil {
|
||||||
|
t.Fatal("expected next_cursor on first page, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second page: use cursor
|
||||||
|
w2 := makeRequest(handler, "GET", "/api/portfolio/images?limit=2&cursor="+*page1.NextCursor, nil)
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for page 2, got %d. body: %s", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var page2 ImageListResponse
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &page2); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal second page: %v", err)
|
||||||
|
}
|
||||||
|
if len(page2.Images) != 1 {
|
||||||
|
t.Errorf("expected 1 image on second page, got %d", len(page2.Images))
|
||||||
|
}
|
||||||
|
// No more results -> next_cursor should be nil (only 3 images total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortfolio_ListImages_NoMoreResults verifies that when all results fit
|
||||||
|
// in one page, the cursor still exists but returns zero results on the next page.
|
||||||
|
func TestPortfolio_ListImages_NoMoreResults(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO images (url, thumbnail_url, tag_names)
|
||||||
|
VALUES
|
||||||
|
('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['test'])
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(ListImages)
|
||||||
|
w := makeRequest(handler, "GET", "/api/portfolio/images?limit=5", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var page1 ImageListResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &page1); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(page1.Images) != 1 {
|
||||||
|
t.Fatalf("expected 1 image, got %d", len(page1.Images))
|
||||||
|
}
|
||||||
|
if page1.NextCursor == nil {
|
||||||
|
t.Fatal("expected next_cursor to be present, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := makeRequest(handler, "GET", "/api/portfolio/images?limit=5&cursor="+*page1.NextCursor, nil)
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for next page, got %d. body: %s", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var page2 ImageListResponse
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &page2); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal next page: %v", err)
|
||||||
|
}
|
||||||
|
if len(page2.Images) != 0 {
|
||||||
|
t.Errorf("expected 0 images on next page, got %d", len(page2.Images))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortfolio_ListImages_InputValidation verifies that requests exceeding
|
||||||
|
// the maximum input length receive a 400 Bad Request.
|
||||||
|
func TestPortfolio_ListImages_InputValidation(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(context.Background(), `
|
||||||
|
INSERT INTO images (url, thumbnail_url, tag_names)
|
||||||
|
VALUES ('https://example.com/img.jpg', 'https://example.com/img_thumb.jpg', ARRAY['color:red'])
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(ListImages)
|
||||||
|
|
||||||
|
longTag := strings.Repeat("a", MaxInputLength+1)
|
||||||
|
|
||||||
|
w := makeRequest(handler, "GET", "/api/portfolio/images?tag="+longTag, nil)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for too-long tag param, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := makeRequest(handler, "GET", "/api/portfolio/images?filter[color]="+longTag, nil)
|
||||||
|
if w2.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for too-long filter value, got %d", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// List Tags Tests
|
// List Tags Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -784,10 +1017,11 @@ func TestPortfolio_ListImages_FormatURLs(t *testing.T) {
|
|||||||
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var images []Image
|
var resp ImageListResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
images := resp.Images
|
||||||
|
|
||||||
if len(images) != 1 {
|
if len(images) != 1 {
|
||||||
t.Fatalf("expected 1 image, got %d", len(images))
|
t.Fatalf("expected 1 image, got %d", len(images))
|
||||||
@@ -835,10 +1069,11 @@ func TestPortfolio_ListImages_LegacyFallback(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var images []Image
|
var resp ImageListResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
images := resp.Images
|
||||||
|
|
||||||
if len(images) != 1 {
|
if len(images) != 1 {
|
||||||
t.Fatalf("expected 1 image, got %d", len(images))
|
t.Fatalf("expected 1 image, got %d", len(images))
|
||||||
@@ -1094,10 +1329,11 @@ func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var images []Image
|
var resp ImageListResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
images := resp.Images
|
||||||
|
|
||||||
if len(images) != 1 {
|
if len(images) != 1 {
|
||||||
t.Fatalf("expected 1 image, got %d", len(images))
|
t.Fatalf("expected 1 image, got %d", len(images))
|
||||||
|
|||||||
Reference in New Issue
Block a user