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"`
}
// 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) {
tagFilter := r.URL.Query().Get("tag")
tagsFilter := r.URL.Query().Get("tags")
@@ -138,18 +145,13 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
}
limit := 20
offset := 0
cursorStr := r.URL.Query().Get("cursor")
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
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
allowedCategories, err := getAllowedCategories(r.Context())
@@ -184,7 +186,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
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)
}
}
@@ -199,7 +201,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
tagList := strings.Split(tagsFilter, ",")
cleanTags := make([]string, len(tagList))
for i, t := range tagList {
cleanTags[i] = strings.TrimSpace(t)
cleanTags[i] = "%" + strings.TrimSpace(t) + "%"
}
conditions := make([]string, len(cleanTags))
for i := range cleanTags {
@@ -219,37 +221,68 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
%s as relevance
FROM images, unnest(tag_names) as t
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
ORDER BY match_count DESC, relevance DESC, created_at DESC
LIMIT $%d OFFSET $%d
`, formatCols, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
GROUP BY id
`, formatCols, similaritySum, whereClause, filterClauses)
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)
for i, t := range cleanTags {
queryArgs[len(filterArgs)+i] = t
}
queryArgs[len(filterArgs)+len(cleanTags)] = limit
queryArgs[len(filterArgs)+len(cleanTags)+1] = offset
for i, ca := range cursorArgs {
queryArgs[len(filterArgs)+len(cleanTags)+i] = ca
}
queryArgs[len(filterArgs)+len(cleanTags)+len(cursorArgs)] = limit
args = queryArgs
} else if tagFilter != "" {
argOffset := len(filterArgs)
searchPattern := "%" + tagFilter + "%"
searchIdx := argOffset + 1
query = fmt.Sprintf(`
SELECT id, url, thumbnail_url, tag_names, created_at%s,
CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority,
similarity(t, $1) as relevance
CASE WHEN t = $%d THEN 2 ELSE 1 END as match_priority,
similarity(t, $%d) as relevance
FROM images, unnest(tag_names) as t
WHERE t ILIKE '%%' || $1 || '%%%s'
ORDER BY match_priority DESC, relevance DESC, created_at DESC
LIMIT $%d OFFSET $%d
`, formatCols, filterClauses, argOffset+2, argOffset+3)
WHERE 1=1%s AND t ILIKE '%%' || $%d || '%%'
`, formatCols, searchIdx, searchIdx, filterClauses, searchIdx)
queryArgs := make([]interface{}, len(filterArgs)+3)
queryArgs[0] = searchPattern
copy(queryArgs[1:], filterArgs)
queryArgs[len(filterArgs)+1] = limit
queryArgs[len(filterArgs)+2] = offset
cursorArgs := []interface{}{}
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)", 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
} else {
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
FROM images
WHERE 1=1%s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d
`, formatCols, filterClauses, argOffset+1, argOffset+2)
`, formatCols, filterClauses)
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)
queryArgs[len(filterArgs)] = limit
queryArgs[len(filterArgs)+1] = offset
args = queryArgs
}
@@ -321,8 +365,18 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
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")
json.NewEncoder(w).Encode(images)
json.NewEncoder(w).Encode(ImageListResponse{
Images: images,
NextCursor: nextCursor,
})
}
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
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
@@ -811,7 +873,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
if tag == "" {
continue
}
_, err = db.DB.Exec(r.Context(), `
_, err = tx.Exec(r.Context(), `
INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO NOTHING
`, 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")
json.NewEncoder(w).Encode(Image{
ID: imgID,
+258 -22
View File
@@ -25,6 +25,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"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())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
var resp ImageListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
images := resp.Images
if len(images) != 2 {
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())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
var resp ImageListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
images := resp.Images
if len(images) != 1 {
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())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
var resp ImageListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
images := resp.Images
if len(images) != 0 {
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
// =============================================================================
@@ -739,13 +972,13 @@ func TestPortfolio_Upload_InvalidFormat(t *testing.T) {
handler := http.HandlerFunc(UploadImage)
fields := map[string][]byte{
"file_full_avif": []byte("not an image"),
"file_full_webp": jpegBytes(t),
"file_full_jpg": jpegBytes(t),
"file_full_jxl": jpegBytes(t),
"file_thumb_avif": jpegBytes(t),
"file_thumb_webp": jpegBytes(t),
"file_thumb_jpg": jpegBytes(t),
"file_full_avif": []byte("not an image"),
"file_full_webp": jpegBytes(t),
"file_full_jpg": jpegBytes(t),
"file_full_jxl": jpegBytes(t),
"file_thumb_avif": jpegBytes(t),
"file_thumb_webp": jpegBytes(t),
"file_thumb_jpg": jpegBytes(t),
}
body, ct := multipartUploadBody(t, fields, "")
@@ -784,10 +1017,11 @@ func TestPortfolio_ListImages_FormatURLs(t *testing.T) {
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
var resp ImageListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
images := resp.Images
if len(images) != 1 {
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())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
var resp ImageListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
images := resp.Images
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))
@@ -992,7 +1227,7 @@ func TestPortfolio_Image_JSONSerialization(t *testing.T) {
Webp: "https://example.com/thumb.webp",
Jpg: "https://example.com/thumb.jpg",
},
TagNames: []string{"test", "color:red"},
TagNames: []string{"test", "color:red"},
}
data, err := json.Marshal(img)
@@ -1024,8 +1259,8 @@ func TestPortfolio_Image_JSONSerialization(t *testing.T) {
func TestPortfolio_Image_JSONOmitEmptyJxl(t *testing.T) {
img := Image{
ID: "test-id",
Full: FullFormatURLs{Avif: "a.avif", Webp: "a.webp", Jpg: "a.jpg"},
ID: "test-id",
Full: FullFormatURLs{Avif: "a.avif", Webp: "a.webp", Jpg: "a.jpg"},
Thumb: ThumbFormatURLs{Avif: "t.avif", Webp: "t.webp", Jpg: "t.jpg"},
}
@@ -1094,10 +1329,11 @@ func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
var resp ImageListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
images := resp.Images
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))