CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled
106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1433 lines
47 KiB
Go
1433 lines
47 KiB
Go
//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/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 {
|
|
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)
|
|
}
|
|
|
|
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 webpBytes(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: 100, G: 150, B: 200, A: 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := imaging.Encode(&buf, img, imaging.PNG); 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")
|
|
}
|
|
}
|