diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go index 1d75d8b..19a7911 100644 --- a/backend/handlers/portfolio/images.go +++ b/backend/handlers/portfolio/images.go @@ -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, diff --git a/backend/handlers/portfolio/images_test.go b/backend/handlers/portfolio/images_test.go index 4664ee4..ea90d6f 100644 --- a/backend/handlers/portfolio/images_test.go +++ b/backend/handlers/portfolio/images_test.go @@ -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))