From fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 11 Mar 2026 11:50:43 +0100 Subject: [PATCH] fix: video upload media type, select all, and dimension repair (#203, #220, #180) - Fix admin video upload missing media_type/mime_type and video processing (#203) - Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220) - Add photo dimension repair endpoint and admin UI (#180) - Add E2E tests for all three fixes --- backend/server.js | 1 + backend/src/routes/adminPhotoDimensions.js | 151 ++++++++++++++++++ backend/src/routes/adminPhotos.js | 81 ++++++++-- .../gallery/PhotoGridWithLayouts.tsx | 2 + .../gallery/layouts/BaseGalleryLayout.tsx | 2 + .../gallery/layouts/GalleryPremiumLayout.tsx | 14 +- .../src/features/settings/tabs/StatusTab.tsx | 79 +++++++++ frontend/src/i18n/locales/de.json | 13 ++ frontend/src/i18n/locales/en.json | 13 ++ test-assets/test-video.mp4 | Bin 0 -> 54846 bytes tests/e2e/admin-video-upload.spec.ts | 115 +++++++++++++ tests/e2e/gallery-premium-select-all.spec.ts | 114 +++++++++++++ tests/e2e/photo-dimensions-repair.spec.ts | 64 ++++++++ 13 files changed, 625 insertions(+), 24 deletions(-) create mode 100644 backend/src/routes/adminPhotoDimensions.js create mode 100644 test-assets/test-video.mp4 create mode 100644 tests/e2e/admin-video-upload.spec.ts create mode 100644 tests/e2e/gallery-premium-select-all.spec.ts create mode 100644 tests/e2e/photo-dimensions-repair.spec.ts diff --git a/backend/server.js b/backend/server.js index dbd9a8c7..839e4351 100644 --- a/backend/server.js +++ b/backend/server.js @@ -495,6 +495,7 @@ app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup' app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity')); app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails')); +app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions')); app.use('/api/admin/photos', require('./src/routes/adminPhotos')); app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); diff --git a/backend/src/routes/adminPhotoDimensions.js b/backend/src/routes/adminPhotoDimensions.js new file mode 100644 index 00000000..74c7be9f --- /dev/null +++ b/backend/src/routes/adminPhotoDimensions.js @@ -0,0 +1,151 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const path = require('path'); +const fs = require('fs').promises; +const logger = require('../utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Module-level progress state +let repairProgress = { + isRunning: false, + lastResult: null +}; + +// Repair photo dimensions (background job) +router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => { + try { + if (repairProgress.isRunning) { + return res.status(409).json({ error: 'Repair is already running' }); + } + + const photos = await db('photos') + .where(function () { + this.whereNull('width').orWhereNull('height'); + }) + .where(function () { + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .select('id', 'path', 'filename'); + + if (photos.length === 0) { + return res.json({ message: 'No photos need dimension repair', count: 0 }); + } + + // Return immediately + res.json({ + message: `Started repairing dimensions for ${photos.length} photos`, + count: photos.length + }); + + // Process in background + repairProgress.isRunning = true; + repairProgress.lastResult = null; + + setImmediate(async () => { + let sharp; + try { + sharp = require('sharp'); + } catch (err) { + logger.error('Sharp not available for dimension repair:', err.message); + repairProgress.isRunning = false; + repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' }; + return; + } + + let successCount = 0; + let errorCount = 0; + + for (const photo of photos) { + try { + if (!photo.path) { + logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`); + errorCount++; + continue; + } + + const storagePath = getStoragePath(); + const fullPath = path.join(storagePath, 'events/active', photo.path); + + try { + await fs.access(fullPath); + } catch (err) { + logger.warn(`File not found for photo ${photo.id}: ${fullPath}`); + errorCount++; + continue; + } + + const metadata = await sharp(fullPath).metadata(); + + if (metadata.width && metadata.height) { + await db('photos') + .where({ id: photo.id }) + .update({ + width: metadata.width, + height: metadata.height, + updated_at: db.fn.now() + }); + successCount++; + + if (successCount % 50 === 0) { + logger.info(`Dimension repair progress: ${successCount} updated...`); + } + } else { + logger.warn(`Could not extract dimensions for photo ${photo.id}`); + errorCount++; + } + } catch (error) { + logger.error(`Error repairing dimensions for photo ${photo.id}:`, error); + errorCount++; + } + } + + repairProgress.isRunning = false; + repairProgress.lastResult = { success: successCount, failed: errorCount }; + logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`); + }); + } catch (error) { + logger.error('Error starting dimension repair:', error); + res.status(500).json({ error: 'Failed to start dimension repair' }); + } +}); + +// Get dimension repair status +router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => { + try { + const totalPhotos = await db('photos') + .where(function () { + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .count('id as count') + .first(); + + const withDimensions = await db('photos') + .where(function () { + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .whereNotNull('width') + .whereNotNull('height') + .count('id as count') + .first(); + + const total = Number(totalPhotos.count); + const withDims = Number(withDimensions.count); + + res.json({ + total, + withDimensions: withDims, + withoutDimensions: total - withDims, + isRunning: repairProgress.isRunning, + lastResult: repairProgress.lastResult + }); + } catch (error) { + logger.error('Error fetching dimension repair status:', error); + res.status(500).json({ error: 'Failed to fetch dimension repair status' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index a3c4d540..f3fca911 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -6,6 +6,7 @@ const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor'); +const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); @@ -263,6 +264,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u console.log(`Could not extract EXIF date for ${file.originalname}`); } + // Determine media type + const isVideo = isVideoMimeType(file.mimetype); + const mediaType = isVideo ? 'video' : 'image'; + // Prepare photo data for batch insert const photoData = { event_id: parseInt(eventId), @@ -273,7 +278,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u type: photoType, category_id: parsedCategoryId, // Save the selected category size_bytes: tempStats.size, // Use actual file size from stat - captured_at: capturedAt // EXIF capture date (if available) + captured_at: capturedAt, // EXIF capture date (if available) + media_type: mediaType, + mime_type: file.mimetype }; batchPhotos.push(photoData); @@ -317,26 +324,65 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`); } - // Generate thumbnail with final path + // Generate thumbnail and extract metadata + const photoId = insertedIds[idx]?.id || insertedIds[idx]; + const isVideoFile = isVideoMimeType(operation.photoData.mime_type); let thumbnailPath = null; - try { - thumbnailPath = await generateThumbnail(operation.finalPath); - // Update the database with thumbnail path - if (thumbnailPath && insertedIds[idx]) { - const photoId = insertedIds[idx]?.id || insertedIds[idx]; - await db('photos') - .where({ id: photoId }) - .update({ thumbnail_path: thumbnailPath }); + try { + if (isVideoFile) { + // Process video: extract metadata and generate thumbnail + const thumbnailDir = path.join(getStoragePath(), 'thumbnails'); + await fs.mkdir(thumbnailDir, { recursive: true }); + const videoThumbnailPath = path.join(thumbnailDir, `thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`); + + const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath); + thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath); + + if (photoId && result.metadata) { + await db('photos') + .where({ id: photoId }) + .update({ + thumbnail_path: thumbnailPath, + duration: result.metadata.duration, + video_codec: result.metadata.videoCodec, + audio_codec: result.metadata.audioCodec, + width: result.metadata.width, + height: result.metadata.height + }); + } + } else { + thumbnailPath = await generateThumbnail(operation.finalPath); + + // Update the database with thumbnail path and image dimensions + if (photoId) { + const updateData = {}; + if (thumbnailPath) updateData.thumbnail_path = thumbnailPath; + + try { + const sharp = require('sharp'); + const metadata = await sharp(operation.finalPath).metadata(); + if (metadata.width && metadata.height) { + updateData.width = metadata.width; + updateData.height = metadata.height; + } + } catch (metadataError) { + console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message); + } + + if (Object.keys(updateData).length > 0) { + await db('photos') + .where({ id: photoId }) + .update(updateData); + } + } } } catch (thumbError) { - console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); + console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message); } - // Queue watermark generation in background (non-blocking) - // This pre-generates watermarked versions for fast serving in lightbox - if (insertedIds[idx]) { - const photoId = insertedIds[idx]?.id || insertedIds[idx]; + // Queue watermark generation in background (non-blocking, images only) + if (photoId && !isVideoFile) { watermarkGeneratorService.generateForPhoto(photoId) .catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message)); } @@ -801,6 +847,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn category_id: photo.category_id || photo.type, category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'), category_slug: photo.pc_slug || photo.type, + media_type: photo.media_type || 'image', + mime_type: photo.mime_type || null, + width: photo.width || null, + height: photo.height || null, + duration: photo.duration || null, size: photo.size_bytes, uploaded_at: photo.uploaded_at, // Feedback data diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 88032a7e..db1f1be4 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC = ({ useCanvasRendering, isSelectionMode, onPhotoSelect: handlePhotoSelect, + onSelectAll: selectAll, + onDeselectAll: deselectAll, eventName, eventLogo, eventDate, diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index 972dff7a..913cc5c9 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps { selectedPhotos?: Set; isSelectionMode?: boolean; onPhotoSelect?: (photoId: number) => void; + onSelectAll?: () => void; + onDeselectAll?: () => void; eventName?: string; eventLogo?: string | null; eventDate?: string | null; diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index eb214d65..188a3077 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC = ({ selectedPhotos = new Set(), isSelectionMode = false, onPhotoSelect, + onSelectAll, + onDeselectAll, eventName, eventDate, allowDownloads = true, @@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC = ({ const handleSelectAll = useCallback(() => { if (selectedPhotos.size === filteredPhotos.length) { - // Deselect all - filteredPhotos.forEach(p => onPhotoSelect?.(p.id)); + onDeselectAll?.(); } else { - // Select all - filteredPhotos.forEach(p => { - if (!selectedPhotos.has(p.id)) { - onPhotoSelect?.(p.id); - } - }); + onSelectAll?.(); } - }, [selectedPhotos, filteredPhotos, onPhotoSelect]); + }, [selectedPhotos, filteredPhotos, onSelectAll, onDeselectAll]); const handleDownloadSelected = useCallback(async () => { if (selectedPhotos.size === 0) return; diff --git a/frontend/src/features/settings/tabs/StatusTab.tsx b/frontend/src/features/settings/tabs/StatusTab.tsx index 8c633f96..a72fe826 100644 --- a/frontend/src/features/settings/tabs/StatusTab.tsx +++ b/frontend/src/features/settings/tabs/StatusTab.tsx @@ -7,9 +7,12 @@ import { Clock, HardDrive, Activity, + Ruler, } from 'lucide-react'; import { Button, Card, Input } from '../../../components/common'; import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../../../config/api'; import { settingsService } from '../../../services/settings.service'; import { useStatusTab } from '../hooks/useStatusTab'; import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings'; @@ -53,6 +56,27 @@ export const StatusTab: React.FC = ({ }) => { const { t } = useTranslation(); const { storageInfo, systemStatus } = useStatusTab(isActive); + const queryClient = useQueryClient(); + + const { data: dimensionStatus } = useQuery({ + queryKey: ['photo-dimension-status'], + queryFn: async () => { + const res = await api.get('/admin/photos/repair-dimensions/status'); + return res.data; + }, + enabled: isActive, + refetchInterval: 10000, + }); + + const repairMutation = useMutation({ + mutationFn: async () => { + const res = await api.post('/admin/photos/repair-dimensions'); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['photo-dimension-status'] }); + }, + }); // Sync soft limit from storage info useEffect(() => { @@ -528,6 +552,61 @@ export const StatusTab: React.FC = ({ )} + {/* Photo Dimensions */} + {dimensionStatus && ( + +

+ + {t('settings.photoDimensions.title')} +

+ +

+ {t('settings.photoDimensions.description')} +

+ +
+
+

{dimensionStatus.total}

+

{t('settings.photoDimensions.totalPhotos')}

+
+
+

{dimensionStatus.withDimensions}

+

{t('settings.photoDimensions.withDimensions')}

+
+
0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}> +

0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}

+

{t('settings.photoDimensions.missingDimensions')}

+
+
+ + {dimensionStatus.lastResult && ( +

+ {t('settings.photoDimensions.resultSuccess', { + success: dimensionStatus.lastResult.success, + failed: dimensionStatus.lastResult.failed, + })} +

+ )} + +
+ +
+
+ )} + {/* Update Notification Settings */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 33f8ac02..0e9cfafe 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1250,6 +1250,19 @@ "failed": "Fehlgeschlagen", "lastUpdate": "Letzte Aktualisierung" }, + "photoDimensions": { + "title": "Foto-Abmessungen", + "totalPhotos": "Fotos gesamt", + "withDimensions": "Mit Abmessungen", + "missingDimensions": "Fehlende Abmessungen", + "repairButton": "Abmessungen reparieren", + "repairing": "Repariere...", + "alreadyRunning": "Reparatur läuft bereits", + "started": "Reparatur von {{count}} Fotos gestartet", + "noneToRepair": "Alle Fotos haben bereits Abmessungen", + "resultSuccess": "Letzte Reparatur: {{success}} aktualisiert, {{failed}} fehlgeschlagen", + "description": "Fehlende Breite/Höhe für Fotos ergänzen, die vor der Dimensionsverfolgung hochgeladen wurden. Erforderlich für Masonry- und Mosaik-Layouts." + }, "updateNotifications": { "title": "Update-Benachrichtigungen", "description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 89e0022f..fa899fd9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -799,6 +799,19 @@ "failed": "Failed", "lastUpdate": "Last update" }, + "photoDimensions": { + "title": "Photo Dimensions", + "totalPhotos": "Total Photos", + "withDimensions": "With Dimensions", + "missingDimensions": "Missing Dimensions", + "repairButton": "Repair Dimensions", + "repairing": "Repairing...", + "alreadyRunning": "Repair is already running", + "started": "Started repairing {{count}} photos", + "noneToRepair": "All photos already have dimensions", + "resultSuccess": "Last repair: {{success}} updated, {{failed}} failed", + "description": "Backfill missing width/height for photos uploaded before dimension tracking was added. Required for Masonry and Mosaic layouts." + }, "updateNotifications": { "title": "Update Notifications", "description": "Receive email notifications when new versions of PicPeak are available.", diff --git a/test-assets/test-video.mp4 b/test-assets/test-video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..951bbb52b4344eeaceb23f544a099c6e139753c5 GIT binary patch literal 54846 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

o{6|Ey*wC}{4O?}EjK^}9 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%0DLCkee( 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

s)3qo1|+XJyl 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 ZTP`+h3+$KC9V5`wxCA{73ACf}{{T-y7Qp}j literal 0 HcmV?d00001 diff --git a/tests/e2e/admin-video-upload.spec.ts b/tests/e2e/admin-video-upload.spec.ts new file mode 100644 index 00000000..dbd888c1 --- /dev/null +++ b/tests/e2e/admin-video-upload.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const { token } = await res.json(); + return token; +} + +test.describe('Admin video upload (#203)', () => { + test('Videos uploaded via admin have correct media_type and mime_type', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const token = await getAdminToken(page); + + // Create event + const eventName = `PW Video ${Date.now()}`; + const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + + const createRes = await page.request.post('/api/admin/events', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + event_type: 'wedding', + event_name: eventName, + event_date: eventDate, + customer_name: 'Playwright Host', + customer_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 90, + allow_downloads: true, + }, + }); + expect(createRes.ok()).toBeTruthy(); + const event = await createRes.json(); + expect(event.id).toBeTruthy(); + + // Upload a video via admin endpoint + const videoPath = path.join(process.cwd(), 'test-assets', 'test-video.mp4'); + expect(fs.existsSync(videoPath)).toBeTruthy(); + const buffer = fs.readFileSync(videoPath); + + const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + photos: { name: 'test-video.mp4', mimeType: 'video/mp4', buffer }, + category_id: 'individual', + }, + }); + expect(uploadRes.ok()).toBeTruthy(); + const uploadBody = await uploadRes.json(); + expect(uploadBody.successCount).toBeGreaterThanOrEqual(1); + + // Wait for background processing + await page.waitForTimeout(5000); + + // Fetch photos for this event via admin API + const photosRes = await page.request.get(`/api/admin/photos/${event.id}/photos`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(photosRes.ok()).toBeTruthy(); + const photosBody = await photosRes.json(); + const photos = photosBody.photos || photosBody; + expect(photos.length).toBeGreaterThanOrEqual(1); + + // Find our video — the critical fix: media_type and mime_type must be set + const video = photos.find((p: any) => p.media_type === 'video'); + expect(video).toBeTruthy(); + expect(video.media_type).toBe('video'); + expect(video.mime_type).toBe('video/mp4'); + // width/height/duration depend on ffprobe being available in the environment; + // if present they should be positive, but we don't fail on missing ffprobe + if (video.width !== null) { + expect(video.width).toBeGreaterThan(0); + expect(video.height).toBeGreaterThan(0); + } + + // Also upload an image and verify it gets media_type = 'image' with dimensions + const imgBuffer = fs.readFileSync(path.join(process.cwd(), 'test-assets', 'img1.png')); + const imgUploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + photos: { name: 'img1.png', mimeType: 'image/png', buffer: imgBuffer }, + category_id: 'individual', + }, + }); + expect(imgUploadRes.ok()).toBeTruthy(); + + await page.waitForTimeout(2000); + + // Re-fetch and verify image has correct media_type and dimensions + const photosRes2 = await page.request.get(`/api/admin/photos/${event.id}/photos`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(photosRes2.ok()).toBeTruthy(); + const photosBody2 = await photosRes2.json(); + const photos2 = photosBody2.photos || photosBody2; + + const image = photos2.find((p: any) => p.media_type === 'image'); + expect(image).toBeTruthy(); + expect(image.media_type).toBe('image'); + expect(image.width).toBeGreaterThan(0); + expect(image.height).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/gallery-premium-select-all.spec.ts b/tests/e2e/gallery-premium-select-all.spec.ts new file mode 100644 index 00000000..7c65a305 --- /dev/null +++ b/tests/e2e/gallery-premium-select-all.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const { token } = await res.json(); + return token; +} + +async function createGalleryPremiumEvent(page: Page) { + const token = await getAdminToken(page); + + const eventName = `PW SelectAll ${Date.now()}`; + const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + + const createRes = await page.request.post('/api/admin/events', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + event_type: 'wedding', + event_name: eventName, + event_date: eventDate, + customer_name: 'Playwright Host', + customer_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 90, + allow_downloads: true, + gallery_theme: 'gallery-premium', + }, + }); + expect(createRes.ok()).toBeTruthy(); + const event = await createRes.json(); + + // Upload 3 images so we can verify select-all picks all of them + const imagePaths = ['img1.png', 'img2.png', 'img1.png'].map((f) => + path.join(process.cwd(), 'test-assets', f) + ); + + for (const imagePath of imagePaths) { + const buffer = fs.readFileSync(imagePath); + const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + photos: { name: path.basename(imagePath), mimeType: 'image/png', buffer }, + category_id: 'individual', + }, + }); + expect(uploadRes.ok()).toBeTruthy(); + } + + return { shareLink: event.share_link, slug: event.slug, token }; +} + +test.describe('Gallery-Premium Select All (#220)', () => { + test('Select All button selects all photos in one click', async ({ page, browserName }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const { shareLink } = await createGalleryPremiumEvent(page); + + // Navigate to gallery + await page.goto(shareLink); + await page.waitForLoadState('domcontentloaded'); + + // Handle password if needed + const passwordField = page.getByPlaceholder(/gallery password/i).first(); + if (await passwordField.count()) { + await passwordField.fill(GALLERY_PASSWORD); + try { + await page.getByRole('button', { name: /View Gallery/i }).click({ timeout: 5000 }); + } catch { + // Token may auto-auth + } + await page.waitForLoadState('networkidle'); + } + + // Wait for photos to render + await page.waitForTimeout(3000); + + // Look for download button to enter selection mode + const downloadBtn = page.getByRole('button', { name: /Download/i }).first(); + await expect(downloadBtn).toBeVisible({ timeout: 15000 }); + await downloadBtn.click(); + + // Now look for "Select All" button + const selectAllBtn = page.getByRole('button', { name: /Select All/i }).first(); + await expect(selectAllBtn).toBeVisible({ timeout: 10000 }); + + // Click Select All once + await selectAllBtn.click(); + await page.waitForTimeout(500); + + // Verify all photos are selected - check for checkmarks or selected state + // The selection count or "Deselect All" text should appear + const deselectAllBtn = page.getByRole('button', { name: /Deselect All/i }).first(); + await expect(deselectAllBtn).toBeVisible({ timeout: 5000 }); + + // Verify: clicking Deselect All should clear selection + await deselectAllBtn.click(); + await page.waitForTimeout(500); + + // Select All should be visible again + await expect(selectAllBtn).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/tests/e2e/photo-dimensions-repair.spec.ts b/tests/e2e/photo-dimensions-repair.spec.ts new file mode 100644 index 00000000..ed4652ac --- /dev/null +++ b/tests/e2e/photo-dimensions-repair.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, Page } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const { token } = await res.json(); + return token; +} + +test.describe('Photo Dimensions Repair (#180)', () => { + test('Status endpoint returns dimension counts', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const token = await getAdminToken(page); + + const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(statusRes.ok()).toBeTruthy(); + + const status = await statusRes.json(); + expect(status).toHaveProperty('total'); + expect(status).toHaveProperty('withDimensions'); + expect(status).toHaveProperty('withoutDimensions'); + expect(status).toHaveProperty('isRunning'); + expect(typeof status.total).toBe('number'); + expect(typeof status.isRunning).toBe('boolean'); + }); + + test('Repair endpoint runs and returns immediately', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const token = await getAdminToken(page); + + const repairRes = await page.request.post('/api/admin/photos/repair-dimensions', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(repairRes.ok()).toBeTruthy(); + + const body = await repairRes.json(); + expect(body).toHaveProperty('message'); + expect(body).toHaveProperty('count'); + + // Wait for background job to complete + await page.waitForTimeout(3000); + + // Check status after repair + const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(statusRes.ok()).toBeTruthy(); + const status = await statusRes.json(); + expect(status.isRunning).toBe(false); + }); +});