//go:build test // +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" "net/http" "net/http/httptest" "os" "testing" "crussell/db" "crussell/mw" "crussell/testutils/jwt" "crussell/testutils/testdb" "github.com/go-chi/chi/v5" "github.com/kovidgoyal/imaging" ) func TestMain(m *testing.M) { pool, err := testdb.NewPool("") if err != nil { os.Exit(1) } testdb.Migrate(&testing.T{}, pool) db.DB = pool jwt.Init() code := m.Run() pool.Close() os.Exit(code) } func resetTestData(t *testing.T) { t.Helper() testdb.TruncateTables(t, db.DB) } func makeRequest(handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func makeRequestWithContext(handler http.HandlerFunc, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } // Add user context ctx := req.Context() ctx = context.WithValue(ctx, mw.UserIDKey, userID) ctx = context.WithValue(ctx, mw.UserRoleKey, role) req = req.WithContext(ctx) 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) { resetTestData(t) // Insert test images _, 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']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images", nil) if w.Code != http.StatusOK { 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 { t.Fatalf("failed to unmarshal response: %v", err) } 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) { resetTestData(t) // Insert test images _, 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']), ('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) if w.Code != http.StatusOK { 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 { t.Fatalf("failed to unmarshal response: %v", err) } 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) { resetTestData(t) handler := http.HandlerFunc(ListImages) w := makeRequest(handler, "GET", "/api/portfolio/images", nil) if w.Code != http.StatusOK { 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 { t.Fatalf("failed to unmarshal response: %v", err) } if len(images) != 0 { t.Errorf("expected 0 images, got %d", len(images)) } } // ============================================================================= // List Tags Tests // ============================================================================= // TestPortfolio_ListTags verifies that listing tags returns all unique tags from portfolio images. func TestPortfolio_ListTags(t *testing.T) { resetTestData(t) // Insert test images with tag_names instead of directly into tags table _, 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']), ('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) 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) { resetTestData(t) // Insert test images with tag_names instead of directly into tags table _, 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']), ('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) 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) { resetTestData(t) handler := http.HandlerFunc(ListTags) w := makeRequest(handler, "GET", "/api/portfolio/tags", nil) 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) { resetTestData(t) // Insert test images with tags _, 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']) `) if err != nil { t.Fatalf("failed to create images: %v", err) } handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters", nil) 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) { resetTestData(t) handler := http.HandlerFunc(ListFilters) w := makeRequest(handler, "GET", "/api/portfolio/filters", nil) 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) { resetTestData(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 := db.DB.QueryRow(context.Background(), ` 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(req.Context(), 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) { resetTestData(t) handler := http.HandlerFunc(GetImage) w := makeRequest(handler, "GET", "/api/portfolio/images/nonexistent-id", nil) 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) { resetTestData(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") // 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) { resetTestData(t) handler := http.HandlerFunc(UploadImage) w := makeRequestWithContext(handler, "POST", "/api/portfolio/images", nil, "user-001", "verified_email") 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) { resetTestData(t) handler := http.HandlerFunc(UploadImage) w := makeRequest(handler, "POST", "/api/portfolio/images", nil) 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) { resetTestData(t) // Insert test image var imageID string err := db.DB.QueryRow(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']) 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") // 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) { resetTestData(t) // Insert test image var imageID string err := db.DB.QueryRow(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']) 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") 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) { resetTestData(t) // Insert test image var imageID string err := db.DB.QueryRow(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']) 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) 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") } } // 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 } // Create a minimal APP1 EXIF marker with GPS IFD tag (0x8825) // APP1 marker: FF E1, length, "Exif\0\0", byte order, magic, IFD offset app1 := []byte{ 0xFF, 0xE1, // APP1 marker 0x00, 0x22, // Length: 34 bytes // Exif header 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // "Exif\0\0" 0x49, 0x49, // Byte order: little-endian 0x2A, 0x00, // Magic number 0x08, 0x00, 0x00, 0x00, // Offset to first IFD // Main IFD with GPS IFD pointer 0x01, 0x00, // Number of entries: 1 0x25, 0x88, // GPS IFD tag (0x8825) 0x04, 0x00, // Type: LONG 0x01, 0x00, 0x00, 0x00, // Count: 1 0x10, 0x00, 0x00, 0x00, // Offset: 16 (to GPS IFD) 0x00, 0x00, 0x00, 0x00, // Next IFD: none } // Insert APP1 after SOI marker (FF D8) result := make([]byte, 0, len(jpegData)+len(app1)) result = append(result, jpegData[:2]...) result = append(result, app1...) result = append(result, jpegData[2:]...) return result }