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
This commit is contained in:
Paul Nothaft
2026-03-11 11:50:43 +01:00
parent e1ad4219a5
commit fc75bcdfc3
13 changed files with 625 additions and 24 deletions
+1
View File
@@ -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'));
+151
View File
@@ -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;
+66 -15
View File
@@ -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
@@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
useCanvasRendering,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
onSelectAll: selectAll,
onDeselectAll: deselectAll,
eventName,
eventLogo,
eventDate,
@@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps {
selectedPhotos?: Set<number>;
isSelectionMode?: boolean;
onPhotoSelect?: (photoId: number) => void;
onSelectAll?: () => void;
onDeselectAll?: () => void;
eventName?: string;
eventLogo?: string | null;
eventDate?: string | null;
@@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
onSelectAll,
onDeselectAll,
eventName,
eventDate,
allowDownloads = true,
@@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
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;
@@ -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<StatusTabProps> = ({
}) => {
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<StatusTabProps> = ({
</>
)}
{/* Photo Dimensions */}
{dimensionStatus && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Ruler className="w-5 h-5" />
{t('settings.photoDimensions.title')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.photoDimensions.description')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dimensionStatus.total}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.totalPhotos')}</p>
</div>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{dimensionStatus.withDimensions}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.withDimensions')}</p>
</div>
<div className={`rounded-lg p-3 text-center ${Number(dimensionStatus.withoutDimensions) > 0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}>
<p className={`text-2xl font-bold ${Number(dimensionStatus.withoutDimensions) > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.missingDimensions')}</p>
</div>
</div>
{dimensionStatus.lastResult && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.photoDimensions.resultSuccess', {
success: dimensionStatus.lastResult.success,
failed: dimensionStatus.lastResult.failed,
})}
</p>
)}
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => repairMutation.mutate()}
isLoading={repairMutation.isPending || dimensionStatus.isRunning}
disabled={Number(dimensionStatus.withoutDimensions) === 0 || dimensionStatus.isRunning}
leftIcon={<Ruler className="w-4 h-4" />}
>
{dimensionStatus.isRunning
? t('settings.photoDimensions.repairing')
: Number(dimensionStatus.withoutDimensions) === 0
? t('settings.photoDimensions.noneToRepair')
: t('settings.photoDimensions.repairButton')}
</Button>
</div>
</Card>
)}
{/* Update Notification Settings */}
<UpdateNotificationSettings />
+13
View File
@@ -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.",
+13
View File
@@ -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.",
Binary file not shown.
+115
View File
@@ -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<string> {
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);
});
});
@@ -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<string> {
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 });
});
});
+64
View File
@@ -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<string> {
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);
});
});