feat: add category hero/cover photo selection (#163)

Wire up the hero_photo_id column on photo_categories that was added in
the migration but never connected. Backend routes now accept and persist
hero_photo_id on category create/update, a dedicated PUT /:id/hero
endpoint is added, and the gallery API returns hero_photo_id for each
category. Frontend EventCategoryManager shows a clickable thumbnail per
category that opens a photo picker modal. Includes EN/DE i18n keys.
This commit is contained in:
Paul Nothaft
2026-02-03 15:43:23 +01:00
parent 329d224846
commit 6c30e2c2ed
11 changed files with 452 additions and 141 deletions
@@ -1,51 +1,62 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { ProtectedImage } from '../ProtectedImage';
// Mock canvas and image APIs
const mockCanvas = {
getContext: jest.fn(() => ({
clearRect: jest.fn(),
drawImage: jest.fn(),
getImageData: jest.fn(() => ({
data: new Uint8ClampedArray(4).fill(255)
})),
putImageData: jest.fn(),
fillRect: jest.fn(),
fillText: jest.fn(),
strokeText: jest.fn(),
measureText: jest.fn(() => ({ width: 100 }))
// Create a stable mock context (same reference for all getContext calls)
const mockContext = {
clearRect: vi.fn(),
drawImage: vi.fn(),
getImageData: vi.fn(() => ({
data: new Uint8ClampedArray(400).fill(255)
})),
width: 100,
height: 100,
style: {},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
putImageData: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
strokeText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
globalAlpha: 1.0,
globalCompositeOperation: 'source-over',
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
textAlign: 'center',
textBaseline: 'middle',
shadowColor: 'transparent',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
};
// Mock HTMLCanvasElement
// Mock HTMLCanvasElement.getContext to always return our stable context
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: () => mockCanvas.getContext()
value: () => mockContext,
writable: true,
});
// Mock Image constructor
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
naturalWidth = 100;
naturalHeight = 100;
width = 100;
height = 100;
crossOrigin = '';
// Default Image mock that simulates successful loading
const createSuccessImage = () => {
return class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
naturalWidth = 100;
naturalHeight = 100;
width = 100;
height = 100;
crossOrigin = '';
complete = true;
constructor() {
// Simulate image loading
setTimeout(() => {
if (this.onload) this.onload();
}, 10);
}
} as any;
constructor() {
setTimeout(() => {
if (this.onload) this.onload();
}, 10);
}
} as unknown as typeof Image;
};
global.Image = createSuccessImage();
describe('ProtectedImage', () => {
const defaultProps = {
@@ -54,28 +65,34 @@ describe('ProtectedImage', () => {
};
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
// Reset Image mock to success variant
global.Image = createSuccessImage();
});
it('renders loading state initially', () => {
it('renders canvas with loading styles initially', () => {
render(<ProtectedImage {...defaultProps} />);
expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument();
// While loading, canvas has opacity 0
expect(canvas).toHaveStyle({ opacity: '0' });
});
it('renders canvas after image loads', async () => {
render(<ProtectedImage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toHaveStyle({ opacity: '1' });
});
});
it('applies protection level classes and events', async () => {
const onViolation = jest.fn();
const onViolation = vi.fn();
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
protectionLevel="enhanced"
onProtectionViolation={onViolation}
/>
@@ -89,31 +106,32 @@ describe('ProtectedImage', () => {
// Test context menu blocking
const canvas = screen.getByRole('img', { name: 'Test image' });
fireEvent.contextMenu(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
});
it('applies watermark text when specified', async () => {
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
watermarkText="Test Watermark"
protectionLevel="standard"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify canvas context methods were called for watermark
expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
expect(mockContext.fillText).toHaveBeenCalled();
});
it('handles fragment grid rendering', async () => {
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
fragmentGrid={true}
gridSize={4}
protectionLevel="enhanced"
@@ -121,19 +139,20 @@ describe('ProtectedImage', () => {
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify multiple drawImage calls for fragments
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
expect(mockContext.drawImage).toHaveBeenCalled();
});
it('blocks interactions in maximum protection mode', async () => {
const onViolation = jest.fn();
const onViolation = vi.fn();
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
protectionLevel="maximum"
onProtectionViolation={onViolation}
/>
@@ -142,7 +161,7 @@ describe('ProtectedImage', () => {
await waitFor(() => {
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument();
// Test click blocking
fireEvent.click(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
@@ -150,26 +169,37 @@ describe('ProtectedImage', () => {
});
it('handles image loading errors gracefully', async () => {
// Mock image error
// Track how many times src is set to detect fallback attempts
let loadAttempt = 0;
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
private _src = '';
naturalWidth = 0;
naturalHeight = 0;
width = 0;
height = 0;
crossOrigin = '';
complete = false;
constructor() {
get src() { return this._src; }
set src(value: string) {
this._src = value;
loadAttempt++;
setTimeout(() => {
if (this.onerror) this.onerror();
}, 10);
}
} as any;
} as unknown as typeof Image;
const onViolation = jest.fn();
const onViolation = vi.fn();
// Render WITHOUT fallbackSrc so error state is reached immediately
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
onProtectionViolation={onViolation}
fallbackSrc="/fallback.jpg"
/>
);
@@ -182,8 +212,8 @@ describe('ProtectedImage', () => {
it('applies invisible watermark for enhanced protection', async () => {
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
watermarkText="Hidden"
invisibleWatermark={true}
protectionLevel="enhanced"
@@ -191,18 +221,19 @@ describe('ProtectedImage', () => {
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify getImageData and putImageData called for steganography
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
expect(mockContext.getImageData).toHaveBeenCalled();
expect(mockContext.putImageData).toHaveBeenCalled();
});
it('scrambles fragments when enabled', async () => {
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
fragmentGrid={true}
scrambleFragments={true}
protectionLevel="maximum"
@@ -210,27 +241,29 @@ describe('ProtectedImage', () => {
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toHaveStyle({ opacity: '1' });
});
// Fragment scrambling should result in multiple drawImage calls
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
expect(mockContext.drawImage).toHaveBeenCalled();
});
it('adds random noise in maximum protection', async () => {
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
protectionLevel="maximum"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toHaveStyle({ opacity: '1' });
});
// Noise injection requires getImageData and putImageData
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
expect(mockContext.getImageData).toHaveBeenCalled();
expect(mockContext.putImageData).toHaveBeenCalled();
});
});
});