//go:build test // Package portfolio contains tests for portfolio image management endpoints. // // Test Coverage: // - ListImages: GET /api/portfolio/images - List all images, optionally filter by tag // - ListTags: GET /api/portfolio/tags - List all unique tags, optionally search // - ListFilters: GET /api/portfolio/filters - List available filter categories // - GetImage: GET /api/portfolio/images/{id} - Get single image details by timestamp ID // - UploadImage: POST /api/portfolio/images - Upload new image (admin only) // - DeleteImage: DELETE /api/portfolio/images/{id} - Delete image (admin only) // // Authentication: Upload/Delete require admin role (403 for non-admins, 401 for unauth). // Note: Upload/Delete tests verify auth only; S3 operations not fully tested (requires mock). package portfolio import ( "bytes" "context" "encoding/json" "image" "image/color" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "crussell/internal/s3" "crussell/mw" "crussell/testutils" "github.com/go-chi/chi/v5" "github.com/kovidgoyal/imaging" ) func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { return makeRequestWithContext(handler, method, path, body, "", "", ctx) } func makeRequestWithContext(handler http.HandlerFunc, method, path string, body interface{}, userID, role string, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req = req.WithContext(ctx) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) req = req.WithContext(ctx) } // Add user context chiCtx := context.WithValue(req.Context(), mw.UserIDKey, userID) chiCtx = context.WithValue(chiCtx, mw.UserRoleKey, role) req = req.WithContext(chiCtx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // ============================================================================= // List Images Tests // ============================================================================= // TestPortfolio_ListImages verifies that listing portfolio images returns all images in the database. func TestPortfolio_ListImages(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test images _, err := tx.Exec(ctx, ` 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']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 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) } images := resp.Images if len(images) != 2 { t.Errorf("expected 2 images, got %d", len(images)) } } // TestPortfolio_ListImages_WithTagFilter verifies that images can be filtered by tag using the 'tag' query parameter. func TestPortfolio_ListImages_WithTagFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test images _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']), ('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['nature:ocean']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 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) } images := resp.Images if len(images) != 1 { t.Errorf("expected 1 image, got %d", len(images)) } } // TestPortfolio_ListImages_Empty verifies that an empty database returns an empty images array (not an error). func TestPortfolio_ListImages_Empty(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 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) } 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert 3 images with staggered created_at so ordering is deterministic _, err := tx.Exec(ctx, ` 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, ctx) 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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, ctx) 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, ctx) 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) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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, ctx) 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, ctx) if w2.Code != http.StatusBadRequest { t.Errorf("expected 400 for too-long filter value, got %d", w2.Code) } } // ============================================================================= // List Tags Tests // ============================================================================= // TestPortfolio_ListTags verifies that listing tags returns all unique tags from portfolio images. func TestPortfolio_ListTags(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test images with tag_names instead of directly into tags table _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']), ('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['nature:ocean']), ('https://example.com/img3.jpg', 'https://example.com/img3_thumb.jpg', ARRAY['color:green']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListTags) w := makeRequest(handler, "GET", "/api/portfolio/tags", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var tags []Tag if err := json.Unmarshal(w.Body.Bytes(), &tags); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(tags) != 3 { t.Errorf("expected 3 tags, got %d", len(tags)) } } // TestPortfolio_ListTags_WithQuery verifies that tags can be filtered by a query string. func TestPortfolio_ListTags_WithQuery(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test images with tag_names instead of directly into tags table _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']), ('https://example.com/img2.jpg', 'https://example.com/img2_thumb.jpg', ARRAY['nature:ocean']), ('https://example.com/img3.jpg', 'https://example.com/img3_thumb.jpg', ARRAY['color:green']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListTags) w := makeRequest(handler, "GET", "/api/portfolio/tags?q=forest", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var tags []Tag if err := json.Unmarshal(w.Body.Bytes(), &tags); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(tags) != 1 { t.Errorf("expected 1 tag, got %d", len(tags)) } } // TestPortfolio_ListTags_Empty verifies that an empty database returns an empty tags array. func TestPortfolio_ListTags_Empty(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(ListTags) w := makeRequest(handler, "GET", "/api/portfolio/tags", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var tags []Tag if err := json.Unmarshal(w.Body.Bytes(), &tags); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(tags) != 0 { t.Errorf("expected 0 tags, got %d", len(tags)) } } // ============================================================================= // List Filters Tests // ============================================================================= // TestPortfolio_ListFilters verifies that filter categories are derived from tags (e.g., 'nature', 'color' from 'nature:forest'). func TestPortfolio_ListFilters(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test images with tags _, err := tx.Exec(ctx, ` 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']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var filters []FilterCategory if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(filters) == 0 { t.Error("expected filters, got empty") } } // TestPortfolio_ListFilters_Empty verifies that an empty database returns an empty filters array. func TestPortfolio_ListFilters_Empty(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var filters []FilterCategory if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(filters) != 0 { t.Errorf("expected 0 filters, got %d", len(filters)) } } // ============================================================================= // Get Image Tests // ============================================================================= // TestPortfolio_GetImage verifies that a single image can be retrieved by its timestamp ID. func TestPortfolio_GetImage(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Use timestamp-based image URL (matches upload pattern: portfolio/{timestamp}.jpg) timestamp := "1234567890123456789" // 19 digits = valid nanosecond timestamp url := "https://example.com/portfolio/" + timestamp + ".jpg" thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.jpg" // Insert test image with timestamp-based URL var imageID string err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ($1, $2, ARRAY['nature:forest']) RETURNING id `, url, thumbURL).Scan(&imageID) if err != nil { t.Fatalf("failed to create image: %v", err) } // Create request with chi URLParam context req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", timestamp) req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetImage(w, req) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var img Image if err := json.Unmarshal(w.Body.Bytes(), &img); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if img.ID != imageID { t.Errorf("expected image ID %s, got %s", imageID, img.ID) } } // TestPortfolio_GetImage_NotFound verifies that requesting a non-existent image returns 404. func TestPortfolio_GetImage_NotFound(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetImage) w := makeRequest(handler, "GET", "/api/portfolio/images/nonexistent-id", nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Upload Image Tests (Admin Only) // ============================================================================= // TestPortfolio_Upload_Admin verifies that an admin user passes the authentication check for image upload. func TestPortfolio_Upload_Admin(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) // Create a minimal S3 client mock by setting it to nil (handler will check and return error) // The handler requires S3 client, so we test the auth check first // Since S3 client setup is complex, we test that admin gets past auth check handler := http.HandlerFunc(UploadImage) w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "admin-001", "admin", ctx) // Should not get 403 (forbidden), will get another error due to missing S3 or file // The important thing is it's not 403 for admin if w.Code == http.StatusForbidden { t.Error("admin should not get 403 - admin access should be granted") } } // TestPortfolio_Upload_NonAdmin verifies that non-admin users receive 403 Forbidden on image upload attempts. func TestPortfolio_Upload_NonAdmin(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "user-001", "verified_email", ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // TestPortfolio_Upload_Unauthenticated verifies that unauthenticated requests receive 401 Unauthorized on image upload. func TestPortfolio_Upload_Unauthenticated(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) w := makeRequest(handler, "POST", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Delete Image Tests (Admin Only) // ============================================================================= // TestPortfolio_Delete_Admin verifies that an admin user passes the authentication check for image deletion. func TestPortfolio_Delete_Admin(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test image var imageID string err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']) RETURNING id `).Scan(&imageID) if err != nil { t.Fatalf("failed to create image: %v", err) } handler := http.HandlerFunc(DeleteImage) w := makeRequestWithContext(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, "admin-001", "admin", ctx) // Should not get 403 (forbidden) - will get error due to S3 client being nil // but the important thing is admin auth passed if w.Code == http.StatusForbidden { t.Error("admin should not get 403 - admin access should be granted") } } // TestPortfolio_Delete_NonAdmin verifies that non-admin users receive 403 Forbidden on image deletion attempts. func TestPortfolio_Delete_NonAdmin(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test image var imageID string err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']) RETURNING id `).Scan(&imageID) if err != nil { t.Fatalf("failed to create image: %v", err) } handler := http.HandlerFunc(DeleteImage) w := makeRequestWithContext(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, "user-001", "verified_email", ctx) if w.Code != http.StatusForbidden { t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String()) } } // TestPortfolio_Delete_Unauthenticated verifies that unauthenticated requests receive 401 Unauthorized on image deletion. func TestPortfolio_Delete_Unauthenticated(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Insert test image var imageID string err := tx.QueryRow(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['nature:forest']) RETURNING id `).Scan(&imageID) if err != nil { t.Fatalf("failed to create image: %v", err) } handler := http.HandlerFunc(DeleteImage) w := makeRequest(handler, "DELETE", "/api/portfolio/images/"+imageID, nil, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // processImage EXIF Stripping Tests // ============================================================================= // TestPortfolio_ProcessImage_EXIFStripped verifies that processImage strips EXIF GPS data from images. func TestPortfolio_ProcessImage_EXIFStripped(t *testing.T) { // Create a simple test image using the standard library img := image.NewNRGBA(image.Rect(0, 0, 100, 100)) grayColor := color.RGBA{R: 200, G: 200, B: 200, A: 255} for y := 0; y < 100; y++ { for x := 0; x < 100; x++ { img.Set(x, y, grayColor) } } // Encode to JPEG bytes using imaging var buf bytes.Buffer err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85)) if err != nil { t.Fatalf("failed to encode test image: %v", err) } imageBytes := buf.Bytes() // Create a JPEG with embedded EXIF GPS data by appending GPS IFD marker after JPEG header // This creates a JPEG that claims to have GPS metadata gpsJpeg := createJpegWithExifMarker(imageBytes) if gpsJpeg == nil { t.Skip("Could not create JPEG with EXIF marker - using basic test") } // Verify EXIF marker is present in the input hasExifBefore := bytes.Contains(gpsJpeg, []byte{0xFF, 0xE1}) // APP1 EXIF marker if !hasExifBefore { t.Skip("Could not inject EXIF marker - skipping GPS stripping test") } // Process the image (this should strip EXIF/GPS data) result, err := processImage(gpsJpeg, 85) if err != nil { t.Fatalf("processImage failed: %v", err) } // Verify the result is a valid JPEG if len(result) == 0 { t.Fatal("processImage returned empty result") } // Verify EXIF marker is NOT present in the output hasExifAfter := bytes.Contains(result, []byte{0xFF, 0xE1}) if hasExifAfter { t.Error("EXIF data was NOT stripped by processImage - metadata still present") } } // ============================================================================= // extractKey Tests // ============================================================================= // TestExtractKey_FullPath tests extractKey with a full S3/R2 URL, verifying // it extracts the key after bucket: "portfolio/1234567890.jpg". func TestExtractKey_FullPath(t *testing.T) { url := "https://endpoint.example.com/crussell/portfolio/1234567890.jpg" expected := "portfolio/1234567890.jpg" result := extractKey(url) if result != expected { t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) } } // TestExtractKey_NestedPath tests extractKey with a nested path (thumbnail // subdirectory), verifying it extracts the full key including subdirectories. func TestExtractKey_NestedPath(t *testing.T) { url := "https://endpoint.example.com/crussell/portfolio/thumbs/1234567890.jpg" expected := "portfolio/thumbs/1234567890.jpg" result := extractKey(url) if result != expected { t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) } } // TestExtractKey_NoScheme tests extractKey with a URL missing the scheme, // which exercises the fallback path (no "://" found → returns url as-is). func TestExtractKey_NoScheme(t *testing.T) { url := "endpoint.example.com/crussell/portfolio/1234567890.jpg" expected := "endpoint.example.com/crussell/portfolio/1234567890.jpg" result := extractKey(url) if result != expected { t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) } } // TestExtractKey_PlainFilename tests extractKey with just a filename (no // URL structure at all), verifying it returns the input unchanged. func TestExtractKey_PlainFilename(t *testing.T) { url := "1234567890.jpg" expected := "1234567890.jpg" result := extractKey(url) if result != expected { t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) } } // TestExtractKey_EmptyString tests extractKey with an empty string, // verifying it returns an empty string. func TestExtractKey_EmptyString(t *testing.T) { url := "" expected := "" result := extractKey(url) if result != expected { t.Errorf("extractKey(%q) = %q, want %q", url, result, expected) } } // createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker func createJpegWithExifMarker(jpegData []byte) []byte { if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 { return nil } app1 := []byte{ 0xFF, 0xE1, 0x00, 0x22, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x25, 0x88, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, } result := make([]byte, 0, len(jpegData)+len(app1)) result = append(result, jpegData[:2]...) result = append(result, app1...) result = append(result, jpegData[2:]...) return result } func jpegBytes(t *testing.T) []byte { t.Helper() img := image.NewNRGBA(image.Rect(0, 0, 100, 100)) for y := 0; y < 100; y++ { for x := 0; x < 100; x++ { img.Set(x, y, color.RGBA{R: 200, G: 200, B: 200, A: 255}) } } var buf bytes.Buffer if err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85)); err != nil { t.Fatalf("failed to encode test image: %v", err) } return buf.Bytes() } func multipartUploadBody(t *testing.T, fields map[string][]byte, tags string) (*bytes.Buffer, string) { t.Helper() var buf bytes.Buffer w := multipart.NewWriter(&buf) for name, data := range fields { fw, err := w.CreateFormFile(name, name) if err != nil { t.Fatalf("CreateFormFile(%s): %v", name, err) } fw.Write(data) } if tags != "" { fw, _ := w.CreateFormField("tags") fw.Write([]byte(tags)) } w.Close() return &buf, w.FormDataContentType() } func adminRequest(method, path string, body *bytes.Buffer, contentType string) *http.Request { req := httptest.NewRequest(method, path, body) if contentType != "" { req.Header.Set("Content-Type", contentType) } ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin-001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") return req.WithContext(ctx) } func TestPortfolio_Upload_MissingFields(t *testing.T) { t.Parallel() _, _ = testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) jpeg := jpegBytes(t) body, ct := multipartUploadBody(t, map[string][]byte{ "file_full_avif": jpeg, "file_full_webp": jpeg, }, "") req := adminRequest("POST", "/api/portfolio/images", body, ct) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code == http.StatusForbidden { t.Error("admin should not get 403") } if w.Code == http.StatusUnauthorized { t.Error("admin should not get 401") } } func TestPortfolio_Upload_InvalidFormat(t *testing.T) { t.Parallel() _, _ = testutils.SetupTestTx(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), } body, ct := multipartUploadBody(t, fields, "") req := adminRequest("POST", "/api/portfolio/images", body, ct) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code == http.StatusForbidden { t.Error("admin should not get 403") } if w.Code == http.StatusUnauthorized { t.Error("admin should not get 401") } } func TestPortfolio_ListImages_FormatURLs(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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 ('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['test'], 'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg', 'https://example.com/full1.jxl', 'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg') `) if err != nil { t.Fatalf("failed to create image: %v", err) } handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusOK { t.Errorf("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) } images := resp.Images if len(images) != 1 { t.Fatalf("expected 1 image, got %d", len(images)) } img := images[0] if img.Full.Avif != "https://example.com/full1.avif" { t.Errorf("full.avif: got %q, want %q", img.Full.Avif, "https://example.com/full1.avif") } if img.Full.Webp != "https://example.com/full1.webp" { t.Errorf("full.webp: got %q, want %q", img.Full.Webp, "https://example.com/full1.webp") } if img.Full.Jpg != "https://example.com/full1.jpg" { t.Errorf("full.jpg: got %q, want %q", img.Full.Jpg, "https://example.com/full1.jpg") } if img.Full.Jxl != "https://example.com/full1.jxl" { t.Errorf("full.jxl: got %q, want %q", img.Full.Jxl, "https://example.com/full1.jxl") } if img.Thumb.Avif != "https://example.com/thumb1.avif" { t.Errorf("thumb.avif: got %q, want %q", img.Thumb.Avif, "https://example.com/thumb1.avif") } if img.Thumb.Webp != "https://example.com/thumb1.webp" { t.Errorf("thumb.webp: got %q, want %q", img.Thumb.Webp, "https://example.com/thumb1.webp") } if img.Thumb.Jpg != "https://example.com/thumb1.jpg" { t.Errorf("thumb.jpg: got %q, want %q", img.Thumb.Jpg, "https://example.com/thumb1.jpg") } } func TestPortfolio_ListImages_LegacyFallback(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['legacy']) `) if err != nil { t.Fatalf("failed to create image: %v", err) } handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images", nil, ctx) 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) } images := resp.Images if len(images) != 1 { t.Fatalf("expected 1 image, got %d", len(images)) } img := images[0] if img.URL != "https://example.com/img1.jpg" { t.Errorf("url: got %q, want %q", img.URL, "https://example.com/img1.jpg") } if img.ThumbnailURL != "https://example.com/img1_thumb.jpg" { t.Errorf("thumbnail_url: got %q, want %q", img.ThumbnailURL, "https://example.com/img1_thumb.jpg") } } func TestPortfolio_GetImage_FormatURLs(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) timestamp := "1234567890123456789" url := "https://example.com/portfolio/" + timestamp + ".avif" thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.webp" _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) VALUES ($1, $2, ARRAY['test'], 'https://example.com/full.avif', 'https://example.com/full.webp', 'https://example.com/full.jpg', 'https://example.com/thumb.avif', 'https://example.com/thumb.webp', 'https://example.com/thumb.jpg') `, url, thumbURL) if err != nil { t.Fatalf("failed to create image: %v", err) } req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", timestamp) req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetImage(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var img Image if err := json.Unmarshal(w.Body.Bytes(), &img); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if img.Full.Avif != "https://example.com/full.avif" { t.Errorf("full.avif: got %q, want %q", img.Full.Avif, "https://example.com/full.avif") } if img.Thumb.Webp != "https://example.com/thumb.webp" { t.Errorf("thumb.webp: got %q, want %q", img.Thumb.Webp, "https://example.com/thumb.webp") } } func TestPortfolio_GetImage_LegacyFallback(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) timestamp := "1234567890123456789" url := "https://example.com/portfolio/" + timestamp + ".jpg" thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.jpg" _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ($1, $2, ARRAY['legacy']) `, url, thumbURL) if err != nil { t.Fatalf("failed to create image: %v", err) } req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", timestamp) req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetImage(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var img Image if err := json.Unmarshal(w.Body.Bytes(), &img); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if img.URL != url { t.Errorf("url: got %q, want %q", img.URL, url) } } func TestPortfolio_DeleteImage_MultiFormat(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) var imageID string err := tx.QueryRow(ctx, ` 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 ('https://example.com/full.avif', 'https://example.com/thumb.webp', ARRAY['test'], 'https://example.com/full.avif', 'https://example.com/full.webp', 'https://example.com/full.jpg', 'https://example.com/full.jxl', 'https://example.com/thumb.avif', 'https://example.com/thumb.webp', 'https://example.com/thumb.jpg') RETURNING id `).Scan(&imageID) if err != nil { t.Fatalf("failed to create image: %v", err) } req := httptest.NewRequest("DELETE", "/api/portfolio/images/"+imageID, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", imageID) reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx) reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-001") reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin") req = req.WithContext(reqCtx) w := httptest.NewRecorder() DeleteImage(w, req) if w.Code == http.StatusForbidden { t.Error("admin should not get 403") } if w.Code == http.StatusUnauthorized { t.Error("admin should not get 401") } var count int tx.QueryRow(ctx, `SELECT COUNT(*) FROM images WHERE id = $1`, imageID).Scan(&count) if count != 1 { t.Log("image record preserved (S3 client nil in test env)") } } func TestPortfolio_Image_JSONSerialization(t *testing.T) { img := Image{ ID: "test-id", URL: "https://example.com/full.avif", ThumbnailURL: "https://example.com/thumb.webp", Full: FullFormatURLs{ Avif: "https://example.com/full.avif", Webp: "https://example.com/full.webp", Jpg: "https://example.com/full.jpg", Jxl: "https://example.com/full.jxl", }, Thumb: ThumbFormatURLs{ Avif: "https://example.com/thumb.avif", Webp: "https://example.com/thumb.webp", Jpg: "https://example.com/thumb.jpg", }, TagNames: []string{"test", "color:red"}, } data, err := json.Marshal(img) if err != nil { t.Fatalf("failed to marshal Image: %v", err) } var decoded map[string]interface{} if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("failed to unmarshal: %v", err) } full, ok := decoded["full"].(map[string]interface{}) if !ok { t.Fatal("expected 'full' to be an object") } if full["avif"] != "https://example.com/full.avif" { t.Errorf("full.avif: got %v, want %q", full["avif"], "https://example.com/full.avif") } thumb, ok := decoded["thumb"].(map[string]interface{}) if !ok { t.Fatal("expected 'thumb' to be an object") } if thumb["webp"] != "https://example.com/thumb.webp" { t.Errorf("thumb.webp: got %v, want %q", thumb["webp"], "https://example.com/thumb.webp") } } func TestPortfolio_Image_JSONOmitEmptyJxl(t *testing.T) { img := Image{ 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"}, } data, err := json.Marshal(img) if err != nil { t.Fatalf("failed to marshal: %v", err) } var decoded map[string]interface{} if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("failed to unmarshal: %v", err) } full := decoded["full"].(map[string]interface{}) if _, exists := full["jxl"]; exists { t.Error("jxl should be omitted when empty") } } func TestMimeTypeForField(t *testing.T) { tests := []struct { field string expected string }{ {"file_full_avif", "image/avif"}, {"file_full_webp", "image/webp"}, {"file_full_jpg", "image/jpeg"}, {"file_full_jxl", "image/jxl"}, {"file_thumb_avif", "image/avif"}, {"file_thumb_webp", "image/webp"}, {"file_thumb_jpg", "image/jpeg"}, {"unknown_field", "application/octet-stream"}, } for _, tc := range tests { result := mimeTypeForField(tc.field) if result != tc.expected { t.Errorf("mimeTypeForField(%q) = %q, want %q", tc.field, result, tc.expected) } } } func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) VALUES ('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['nature:forest', 'color:green'], 'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg', 'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg'), ('https://example.com/img2.avif', 'https://example.com/img2_thumb.webp', ARRAY['nature:ocean', 'color:blue'], 'https://example.com/full2.avif', 'https://example.com/full2.webp', 'https://example.com/full2.jpg', 'https://example.com/thumb2.avif', 'https://example.com/thumb2.webp', 'https://example.com/thumb2.jpg') `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil, ctx) 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) } images := resp.Images if len(images) != 1 { t.Fatalf("expected 1 image, got %d", len(images)) } if images[0].Full.Avif == "" { t.Error("expected full.avif URL to be populated") } if images[0].Thumb.Webp == "" { t.Error("expected thumb.webp URL to be populated") } } func TestPortfolio_ListTags_WithMultiFormatImages(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) VALUES ('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['nature:forest'], 'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg', 'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg') `) if err != nil { t.Fatalf("failed to create image: %v", err) } handler := http.HandlerFunc(ListTags) w := makeRequest(handler, "GET", "/api/portfolio/tags", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var tags []Tag if err := json.Unmarshal(w.Body.Bytes(), &tags); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(tags) != 1 { t.Errorf("expected 1 tag, got %d", len(tags)) } if tags[0].Name != "nature:forest" { t.Errorf("expected tag 'nature:forest', got %q", tags[0].Name) } } func TestPortfolio_ListFilters_WithMultiFormatImages(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url) VALUES ('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['nature:forest', 'color:green'], 'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg', 'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg'), ('https://example.com/img2.avif', 'https://example.com/img2_thumb.webp', ARRAY['nature:ocean', 'color:blue'], 'https://example.com/full2.avif', 'https://example.com/full2.webp', 'https://example.com/full2.jpg', 'https://example.com/thumb2.avif', 'https://example.com/thumb2.webp', 'https://example.com/thumb2.jpg') `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var filters []FilterCategory if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(filters) == 0 { t.Error("expected filters, got empty") } } func avifBytes() []byte { data := make([]byte, 12) copy(data[4:8], "ftyp") copy(data[8:12], "avif") return data } func webpBytes() []byte { data := make([]byte, 12) copy(data[0:4], "RIFF") copy(data[8:12], "WEBP") return data } func jxlBytes() []byte { data := make([]byte, 12) copy(data[4:8], "ftyp") copy(data[8:12], "jxl ") return data } // ============================================================================= // Upload Image With Mock S3 — Full Flow // ============================================================================= // TestPortfolio_Upload_Success verifies a successful image upload with mock S3, // testing the full flow: multipart form parsing, S3 upload, DB insert, and response. func TestPortfolio_Upload_Success(t *testing.T) { if s3.Client == nil { t.Skip("S3 client not initialized (requires R2_ENDPOINT)") } ctx, _ := testutils.SetupTestTx(t) jpeg := jpegBytes(t) avif := avifBytes() webp := webpBytes() jxl := jxlBytes() var b bytes.Buffer w := multipart.NewWriter(&b) // Required full format fields fw, _ := w.CreateFormFile("file_full_avif", "test.avif") fw.Write(avif) fw, _ = w.CreateFormFile("file_full_webp", "test.webp") fw.Write(webp) fw, _ = w.CreateFormFile("file_full_jpg", "test.jpg") fw.Write(jpeg) // Optional full format field fw, _ = w.CreateFormFile("file_full_jxl", "test.jxl") fw.Write(jxl) // Required thumb format fields fw, _ = w.CreateFormFile("file_thumb_avif", "thumb.avif") fw.Write(avif) fw, _ = w.CreateFormFile("file_thumb_webp", "thumb.webp") fw.Write(webp) fw, _ = w.CreateFormFile("file_thumb_jpg", "thumb.jpg") fw.Write(jpeg) // Text fields w.WriteField("category", "manicure") w.WriteField("tags", "color:red,style:art") w.Close() req := httptest.NewRequest("POST", "/api/portfolio/images", &b) req.Header.Set("Content-Type", w.FormDataContentType()) chiCtx := context.WithValue(ctx, mw.UserIDKey, "admin-001") chiCtx = context.WithValue(chiCtx, mw.UserRoleKey, "admin") req = req.WithContext(chiCtx) rec := httptest.NewRecorder() UploadImage(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", rec.Code, rec.Body.String()) } var img Image if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if img.ID == "" { t.Error("expected image ID to be non-empty") } if img.URL == "" { t.Error("expected image URL to be non-empty") } if img.ThumbnailURL == "" { t.Error("expected thumbnail URL to be non-empty") } if len(img.TagNames) != 2 { t.Errorf("expected 2 tags, got %d: %v", len(img.TagNames), img.TagNames) } // Verify full format URLs if img.Full.Avif == "" { t.Error("expected full.avif URL") } if img.Full.Webp == "" { t.Error("expected full.webp URL") } if img.Full.Jpg == "" { t.Error("expected full.jpg URL") } if img.Full.Jxl == "" { t.Error("expected full.jxl URL") } // Verify thumb format URLs if img.Thumb.Avif == "" { t.Error("expected thumb.avif URL") } if img.Thumb.Webp == "" { t.Error("expected thumb.webp URL") } if img.Thumb.Jpg == "" { t.Error("expected thumb.jpg URL") } } // TestPortfolio_Upload_NoAuth verifies that unauthenticated requests receive // 401 Unauthorized (auth check occurs before any body processing). func TestPortfolio_Upload_NoAuth(t *testing.T) { ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(UploadImage) w := makeRequest(handler, "POST", "/api/portfolio/images", nil, ctx) if w.Code != http.StatusUnauthorized { t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // List Filters — Category & Tag Filter Tests // ============================================================================= // TestPortfolio_ListFilters_WithCategoryFilter verifies that ListFilters // correctly returns both selected and unselected categories when a category // filter (e.g. ?filter[color]=green) is applied. func TestPortfolio_ListFilters_WithCategoryFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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['nature:ocean', 'color:green']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters?filter[color]=green", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var filters []FilterCategory if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(filters) == 0 { t.Fatal("expected at least one filter category") } // Build lookup maps for easier assertion catMap := make(map[string]map[string]int) for _, fc := range filters { valMap := make(map[string]int) for _, fv := range fc.Values { valMap[fv.Value] = fv.Count } catMap[fc.Category] = valMap } // The 'color' category (selected) should show all values without filter applied colorVals, ok := catMap["color"] if !ok { t.Fatal("expected 'color' category in filters") } if colorVals["green"] != 2 { t.Errorf("expected color:green count 2, got %d", colorVals["green"]) } if colorVals["blue"] != 1 { t.Errorf("expected color:blue count 1, got %d", colorVals["blue"]) } // The 'nature' category (unselected) should be filtered by color:green natureVals, ok := catMap["nature"] if !ok { t.Fatal("expected 'nature' category in filters") } if natureVals["forest"] != 1 { t.Errorf("expected nature:forest count 1, got %d", natureVals["forest"]) } if natureVals["ocean"] != 1 { t.Errorf("expected nature:ocean count 1, got %d", natureVals["ocean"]) } } // TestPortfolio_ListFilters_WithTagFilter verifies that ListFilters correctly // scopes results when a tag filter (?tag=...) is applied. func TestPortfolio_ListFilters_WithTagFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters?tag=nature", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var filters []FilterCategory if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(filters) == 0 { t.Fatal("expected at least one filter category") } catMap := make(map[string]map[string]int) for _, fc := range filters { valMap := make(map[string]int) for _, fv := range fc.Values { valMap[fv.Value] = fv.Count } catMap[fc.Category] = valMap } // Should have 'color' and 'nature' categories from images matching 'nature' tag if _, ok := catMap["color"]; !ok { t.Error("expected 'color' category in results") } if _, ok := catMap["nature"]; !ok { t.Error("expected 'nature' category in results") } // Should NOT have 'style' category (image 3 doesn't match 'nature' tag) if _, ok := catMap["style"]; ok { t.Error("did not expect 'style' category — image with style:classic does not match tag 'nature'") } } // TestPortfolio_ListFilters_WithCombinedFilter verifies that ListFilters // correctly scopes results when both a category filter and a tag filter // are applied together. func TestPortfolio_ListFilters_WithCombinedFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, ` 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['nature:forest', 'color:blue']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters?filter[color]=green&tag=nature", nil, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) } var filters []FilterCategory if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(filters) == 0 { t.Fatal("expected at least one filter category") } catMap := make(map[string]map[string]int) for _, fc := range filters { valMap := make(map[string]int) for _, fv := range fc.Values { valMap[fv.Value] = fv.Count } catMap[fc.Category] = valMap } // Tag filter 'nature' matches all 3 images. // Category filter 'color=green' narrows to images with color:green. // The 'color' category (selected) shows all color values from tag-filtered images (no color filter applied). colorVals, ok := catMap["color"] if !ok { t.Fatal("expected 'color' category in filters") } if colorVals["green"] != 1 { t.Errorf("expected color:green count 1, got %d", colorVals["green"]) } if colorVals["blue"] != 2 { t.Errorf("expected color:blue count 2, got %d", colorVals["blue"]) } // The 'nature' category (unselected) should be filtered by color:green. natureVals, ok := catMap["nature"] if !ok { t.Fatal("expected 'nature' category in filters") } if natureVals["forest"] != 1 { t.Errorf("expected nature:forest count 1, got %d", natureVals["forest"]) } // nature:ocean should not appear (img2 has color:blue, not color:green) if _, exists := natureVals["ocean"]; exists { t.Error("did not expect nature:ocean — image with nature:ocean has color:blue, not color:green") } }