Files
Crussell/backend/handlers/portfolio/images_test.go
T
2026-03-02 10:16:55 +00:00

509 lines
15 KiB
Go

//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
//go:build test
// +build test
package portfolio
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crussell/db"
"crussell/mw"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/go-chi/chi/v5"
)
func setupTestDB(t *testing.T) func() {
t.Helper()
pool := testdb.Pool(t)
testdb.Migrate(t, pool)
// Truncate tables to ensure clean state
testdb.TruncateTables(t, pool)
originalDB := db.DB
db.DB = pool
jwt.Init()
return func() {
db.DB = originalDB
pool.Close()
}
}
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
// =============================================================================
func TestPortfolio_ListImages(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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))
}
}
func TestPortfolio_ListImages_WithTagFilter(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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))
}
}
func TestPortfolio_ListImages_Empty(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
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
// =============================================================================
func TestPortfolio_ListTags(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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))
}
}
func TestPortfolio_ListTags_WithQuery(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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))
}
}
func TestPortfolio_ListTags_Empty(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
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
// =============================================================================
func TestPortfolio_ListFilters(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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")
}
}
func TestPortfolio_ListFilters_Empty(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
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
// =============================================================================
func TestPortfolio_GetImage(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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)
}
}
func TestPortfolio_GetImage_NotFound(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
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)
// =============================================================================
func TestPortfolio_Upload_Admin(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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")
}
}
func TestPortfolio_Upload_NonAdmin(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
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())
}
}
func TestPortfolio_Upload_Unauthenticated(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
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)
// =============================================================================
func TestPortfolio_Delete_Admin(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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")
}
}
func TestPortfolio_Delete_NonAdmin(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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())
}
}
func TestPortfolio_Delete_Unauthenticated(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// 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())
}
}