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:
2026-06-18 16:26:37 +01:00
co-authored by Sisyphus
parent aeab289be9
commit 6ff35a5cc7
2 changed files with 360 additions and 56 deletions
+102 -34
View File
@@ -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,
+248 -12
View File
@@ -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))