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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user