Merge pull request #708 from andredlng/feat/upload-failure-details
Surface which files failed during photo upload
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Upload, X, Image, Loader2, Cog } from 'lucide-react';
|
import { Upload, X, Image, Loader2, Cog, AlertTriangle } from 'lucide-react';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
@@ -13,7 +13,14 @@ import { useUploadProgress } from '../../hooks/useUploadProgress';
|
|||||||
|
|
||||||
interface PhotoUploadProps {
|
interface PhotoUploadProps {
|
||||||
eventId: number;
|
eventId: number;
|
||||||
|
/** Refresh the photo grid. Called early (as bytes land) and again when
|
||||||
|
* processing finishes. Never closes the modal. */
|
||||||
onUploadComplete?: () => void;
|
onUploadComplete?: () => void;
|
||||||
|
/** Fired once the transfer stage is done, reporting whether any file
|
||||||
|
* failed. The host (modal) uses this to decide whether to auto-close:
|
||||||
|
* a clean upload closes as before; a partial failure keeps the modal
|
||||||
|
* open so the failure report stays visible. */
|
||||||
|
onUploadSettled?: (result: { hasFailures: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||||
@@ -28,7 +35,22 @@ type UploadPhase =
|
|||||||
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
|
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
|
||||||
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
|
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
|
||||||
|
|
||||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
// Why a file didn't make it into the gallery. Each maps to a distinct
|
||||||
|
// stage so the user knows whether to re-pick the file (rejected), retry
|
||||||
|
// the network (transfer), or check the source image (processing).
|
||||||
|
// - rejected: validation/queueing refused it (bad type, too large,
|
||||||
|
// corrupt) — returned per-file in the upload response.
|
||||||
|
// - transfer: the whole chunk request failed (timeout, 5xx, network).
|
||||||
|
// - processing: stored fine, but the background worker couldn't process
|
||||||
|
// it (from useUploadProgress's failedPhotos).
|
||||||
|
type UploadFailureKind = 'rejected' | 'transfer' | 'processing';
|
||||||
|
interface UploadFailure {
|
||||||
|
filename: string;
|
||||||
|
reason: string;
|
||||||
|
kind: UploadFailureKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete, onUploadSettled }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
@@ -42,11 +64,29 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
// hook merges status across all of them so the user sees one unified
|
// hook merges status across all of them so the user sees one unified
|
||||||
// progress count even when the upload spans multiple HTTP requests.
|
// progress count even when the upload spans multiple HTTP requests.
|
||||||
const [uploadIds, setUploadIds] = useState<string[]>([]);
|
const [uploadIds, setUploadIds] = useState<string[]>([]);
|
||||||
|
// Per-file failures surfaced from the transfer stage (chunk POSTs):
|
||||||
|
// validation rejections (response.errors) and whole-chunk failures.
|
||||||
|
// Processing failures are merged in from the progress hook below.
|
||||||
|
const [transferFailures, setTransferFailures] = useState<UploadFailure[]>([]);
|
||||||
|
// Processing failures are captured into state (not read live) because the
|
||||||
|
// completion effect clears uploadIds, which empties the progress hook's
|
||||||
|
// failedPhotos — reading live would make the rows vanish the instant they
|
||||||
|
// appear.
|
||||||
|
const [processingFailures, setProcessingFailures] = useState<UploadFailure[]>([]);
|
||||||
|
const [failuresDismissed, setFailuresDismissed] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
|
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
|
||||||
enabled: phase.kind === 'processing' && uploadIds.length > 0,
|
enabled: phase.kind === 'processing' && uploadIds.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single source of truth for the "which files failed" report: transfer
|
||||||
|
// stage failures (collected during handleUpload) plus processing failures
|
||||||
|
// (captured on completion). Both carry filename + reason.
|
||||||
|
const failures = useMemo<UploadFailure[]>(
|
||||||
|
() => [...transferFailures, ...processingFailures],
|
||||||
|
[transferFailures, processingFailures]
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch categories for this event
|
// Fetch categories for this event
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
@@ -162,6 +202,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadIds([]);
|
setUploadIds([]);
|
||||||
|
// Clear any prior failure report before this run.
|
||||||
|
setTransferFailures([]);
|
||||||
|
setProcessingFailures([]);
|
||||||
|
setFailuresDismissed(false);
|
||||||
|
|
||||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues.
|
// For large uploads, chunk the files by both count AND size to prevent memory/network issues.
|
||||||
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
|
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
|
||||||
@@ -195,9 +239,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTotalChunks(chunks.length);
|
setTotalChunks(chunks.length);
|
||||||
let totalUploaded = 0;
|
|
||||||
let totalReplaced = 0;
|
let totalReplaced = 0;
|
||||||
let failedFiles = [];
|
// Accumulates transfer-stage failures (per-file rejections + whole-chunk
|
||||||
|
// failures) with their reasons, so the report can name each one.
|
||||||
|
const collected: UploadFailure[] = [];
|
||||||
|
// Whether at least one chunk was accepted for background processing.
|
||||||
|
let anyQueued = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||||
@@ -258,17 +305,39 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
totalUploaded += (response.data?.successCount || chunk.length);
|
|
||||||
totalReplaced += (response.data?.replacedCount || 0);
|
totalReplaced += (response.data?.replacedCount || 0);
|
||||||
// Backend returns a per-request upload_id. Track it so the
|
// The backend accepts the request (202) but may reject individual
|
||||||
// processing-status hook can poll/stream live progress.
|
// files (bad type, too large, corrupt) and reports them in
|
||||||
if (response.data?.upload_id) {
|
// `errors: [{ filename, error }]`. Surface each one by name.
|
||||||
|
const rejected = response.data?.errors;
|
||||||
|
if (Array.isArray(rejected)) {
|
||||||
|
for (const r of rejected) {
|
||||||
|
collected.push({
|
||||||
|
filename: r?.filename || t('upload.failures.unknownFile', 'Unknown file'),
|
||||||
|
reason: r?.error || t('upload.failures.unknownReason', 'Unknown error'),
|
||||||
|
kind: 'rejected',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Track the per-request upload_id so the processing hook can poll
|
||||||
|
// for live progress — but only when photos were actually queued
|
||||||
|
// (count > 0). The backend returns an upload_id even when every
|
||||||
|
// file was rejected (count 0); tracking it there would make us wait
|
||||||
|
// for a processing phase that never starts, hanging the spinner.
|
||||||
|
if (response.data?.upload_id && (response.data?.count ?? 0) > 0) {
|
||||||
|
anyQueued = true;
|
||||||
const newId = response.data.upload_id as string;
|
const newId = response.data.upload_id as string;
|
||||||
setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId]));
|
setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId]));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
||||||
failedFiles.push(...chunk.map(f => f.name));
|
const reason =
|
||||||
|
error?.response?.data?.error ||
|
||||||
|
error?.message ||
|
||||||
|
t('upload.failures.transferReason', 'Transfer failed');
|
||||||
|
collected.push(
|
||||||
|
...chunk.map((f) => ({ filename: f.name, reason, kind: 'transfer' as const }))
|
||||||
|
);
|
||||||
|
|
||||||
// Continue with next chunk even if one fails
|
// Continue with next chunk even if one fails
|
||||||
continue;
|
continue;
|
||||||
@@ -288,10 +357,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
if (totalReplaced > 0) {
|
if (totalReplaced > 0) {
|
||||||
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
|
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
|
||||||
}
|
}
|
||||||
if (failedFiles.length > 0) {
|
// Publish transfer-stage failures to the report (rendered below with
|
||||||
|
// each filename + reason). The toast is just the headline; the list
|
||||||
|
// is where the user finds out *which* files failed.
|
||||||
|
setTransferFailures(collected);
|
||||||
|
if (collected.length > 0) {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t('upload.someFilesFailed') ||
|
t('upload.failures.toast', '{{count}} file(s) could not be uploaded — see the list below.', {
|
||||||
`Transferred ${totalUploaded} files. ${failedFiles.length} files failed to transfer.`
|
count: collected.length,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,9 +376,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
onUploadComplete();
|
onUploadComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the backend never returned an upload_id (e.g. only failures
|
// Settling (and the modal's close decision) is deferred until we know
|
||||||
// or pre-async-backend deployment), we have nothing to wait for —
|
// the WHOLE outcome, including background processing. If nothing was
|
||||||
// fall through to the finally cleanup which resets state.
|
// queued (every file rejected, or a pre-async backend), the transfer
|
||||||
|
// stage is already terminal — settle now and reset the UI. Otherwise
|
||||||
|
// the processing effect below settles once the worker finishes, so
|
||||||
|
// processing failures are included before the modal decides to close.
|
||||||
|
if (!anyQueued) {
|
||||||
|
onUploadSettled?.({ hasFailures: collected.length > 0 });
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setCurrentChunk(0);
|
||||||
|
setTotalChunks(0);
|
||||||
|
setPhase({ kind: 'idle' });
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
toast.error(error.response?.data?.error || t('toast.uploadError'));
|
toast.error(error.response?.data?.error || t('toast.uploadError'));
|
||||||
@@ -325,6 +410,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
if (!processingAggregate.isComplete) return;
|
if (!processingAggregate.isComplete) return;
|
||||||
|
|
||||||
if (processingAggregate.failed > 0) {
|
if (processingAggregate.failed > 0) {
|
||||||
|
// Persist the failed photos into the report before uploadIds is cleared
|
||||||
|
// below (which would otherwise empty the progress hook's failedPhotos).
|
||||||
|
setProcessingFailures(
|
||||||
|
processingAggregate.failedPhotos.map((p) => ({
|
||||||
|
filename: p.filename,
|
||||||
|
reason: p.error || t('upload.failures.unknownReason', 'Unknown error'),
|
||||||
|
kind: 'processing' as const,
|
||||||
|
}))
|
||||||
|
);
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t('upload.processingFailed', { count: processingAggregate.failed }) ||
|
t('upload.processingFailed', { count: processingAggregate.failed }) ||
|
||||||
`${processingAggregate.failed} photo(s) failed to process`
|
`${processingAggregate.failed} photo(s) failed to process`
|
||||||
@@ -336,6 +430,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (onUploadComplete) onUploadComplete();
|
if (onUploadComplete) onUploadComplete();
|
||||||
|
// Now the whole outcome is known — settle. The modal auto-closes only
|
||||||
|
// when nothing failed at either stage; any transfer OR processing
|
||||||
|
// failure keeps it open so the report (which lists both) stays visible.
|
||||||
|
onUploadSettled?.({
|
||||||
|
hasFailures: transferFailures.length > 0 || processingAggregate.failed > 0,
|
||||||
|
});
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setCurrentChunk(0);
|
setCurrentChunk(0);
|
||||||
@@ -487,6 +587,61 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Failure report — names every file that didn't make it into the
|
||||||
|
gallery, grouped by failure stage, so the user can act on each.
|
||||||
|
Persists until dismissed or a new upload starts. */}
|
||||||
|
{!failuresDismissed && failures.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="upload-failure-report"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="rounded-lg border border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('upload.failures.title', '{{count}} file(s) could not be uploaded', {
|
||||||
|
count: failures.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFailuresDismissed(true)}
|
||||||
|
aria-label={t('common.dismiss', 'Dismiss')}
|
||||||
|
className="p-1 -m-1 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/40 rounded"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 max-h-48 overflow-y-auto space-y-1.5">
|
||||||
|
{failures.map((f, i) => (
|
||||||
|
<li key={`${f.kind}-${f.filename}-${i}`} className="flex items-start gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'flex-shrink-0 mt-0.5 px-1.5 py-0.5 rounded font-medium whitespace-nowrap',
|
||||||
|
f.kind === 'rejected' && 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
f.kind === 'transfer' && 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
f.kind === 'processing' && 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f.kind === 'rejected' && t('upload.failures.kindRejected', 'Rejected')}
|
||||||
|
{f.kind === 'transfer' && t('upload.failures.kindTransfer', 'Transfer failed')}
|
||||||
|
{f.kind === 'processing' && t('upload.failures.kindProcessing', 'Processing failed')}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-medium text-neutral-800 dark:text-neutral-200 break-all">
|
||||||
|
{f.filename}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400"> — {f.reason}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress display — two distinct phases. Bytes-on-wire ('transferring')
|
{/* Progress display — two distinct phases. Bytes-on-wire ('transferring')
|
||||||
drives the determinate bar; the post-bytes wait ('processing') swaps
|
drives the determinate bar; the post-bytes wait ('processing') swaps
|
||||||
in an indeterminate spinner with an explanatory hint so users don't
|
in an indeterminate spinner with an explanatory hint so users don't
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Refresh the host's grid, but do NOT close here — closing is decided by
|
||||||
|
// onUploadSettled so a partial-failure upload keeps the modal (and its
|
||||||
|
// failure report) open.
|
||||||
const handleUploadComplete = () => {
|
const handleUploadComplete = () => {
|
||||||
if (onUploadComplete) {
|
onUploadComplete?.();
|
||||||
onUploadComplete();
|
};
|
||||||
|
|
||||||
|
// Auto-close only on a clean upload; keep the modal open when some files
|
||||||
|
// failed so the report stays visible until the user dismisses it.
|
||||||
|
const handleUploadSettled = ({ hasFailures }: { hasFailures: boolean }) => {
|
||||||
|
if (!hasFailures) {
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +54,10 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
onUploadComplete={handleUploadComplete}
|
onUploadComplete={handleUploadComplete}
|
||||||
|
onUploadSettled={handleUploadSettled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Coverage for the upload failure report — the "which files failed" list.
|
||||||
|
*
|
||||||
|
* Before this, a partial upload only showed a count ("some files failed"),
|
||||||
|
* and the backend's per-file `errors[]` were dropped entirely. These tests
|
||||||
|
* pin that every failure stage is named with its reason:
|
||||||
|
* - rejected: from the upload response's `errors: [{filename, error}]`
|
||||||
|
* - transfer: from a whole-chunk POST failure (the `catch` path)
|
||||||
|
* - processing: from useUploadProgress's `failedPhotos`
|
||||||
|
* plus the settle contract (hasFailures true/false) the modal relies on to
|
||||||
|
* decide whether to auto-close, and dismissal.
|
||||||
|
*/
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
import { PhotoUpload } from '../PhotoUpload';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback?: any) => (typeof fallback === 'string' ? fallback : _key),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('react-toastify', () => ({
|
||||||
|
toast: { warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const postMock = vi.fn();
|
||||||
|
vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a), get: vi.fn() } }));
|
||||||
|
|
||||||
|
// Mutable processing aggregate, swapped per test. Returned only once photos
|
||||||
|
// are queued (uploadIds non-empty), mirroring the real hook flipping from
|
||||||
|
// "nothing to track" to "complete" — the transition that fires the settle
|
||||||
|
// effect.
|
||||||
|
const clean = () => ({
|
||||||
|
total: 0, pending: 0, processing: 0, complete: 0, failed: 0,
|
||||||
|
failedPhotos: [] as { id: number; filename: string; error: string | null }[],
|
||||||
|
isComplete: false, isReady: true,
|
||||||
|
});
|
||||||
|
const hoisted = vi.hoisted(() => ({ aggregate: null as any }));
|
||||||
|
vi.mock('../../../hooks/useUploadProgress', () => ({
|
||||||
|
useUploadProgress: (ids: string[]) => ({
|
||||||
|
snapshots: {},
|
||||||
|
error: null,
|
||||||
|
aggregate: ids && ids.length > 0
|
||||||
|
? hoisted.aggregate
|
||||||
|
: { total: 0, pending: 0, processing: 0, complete: 0, failed: 0, failedPhotos: [], isComplete: false, isReady: true },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../services/categories.service', () => ({
|
||||||
|
categoriesService: { getEventCategories: vi.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/settings.service', () => ({
|
||||||
|
settingsService: { getAllSettings: vi.fn().mockResolvedValue({}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderWithClient = (ui: ReactElement) => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function uploadFile(container: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
await user.upload(fileInput, new File([new Uint8Array([1, 2, 3])], 'good-photo.png', { type: 'image/png' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /common\.upload/ }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PhotoUpload failure report', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
postMock.mockReset();
|
||||||
|
hoisted.aggregate = clean();
|
||||||
|
});
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('names rejected + processing failures and settles with hasFailures', async () => {
|
||||||
|
postMock.mockResolvedValue({
|
||||||
|
data: { successCount: 1, count: 1, upload_id: 'u1', errors: [{ filename: 'too-big.png', error: 'File too large' }] },
|
||||||
|
});
|
||||||
|
hoisted.aggregate = {
|
||||||
|
total: 2, pending: 0, processing: 0, complete: 1, failed: 1,
|
||||||
|
failedPhotos: [{ id: 5, filename: 'corrupt.jpg', error: 'Unsupported format' }],
|
||||||
|
isComplete: true, isReady: true,
|
||||||
|
};
|
||||||
|
const onUploadSettled = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = renderWithClient(<PhotoUpload eventId={1} onUploadSettled={onUploadSettled} />);
|
||||||
|
|
||||||
|
await uploadFile(container, user);
|
||||||
|
|
||||||
|
const report = await screen.findByTestId('upload-failure-report');
|
||||||
|
expect(within(report).getByText('too-big.png')).toBeInTheDocument();
|
||||||
|
expect(within(report).getByText(/File too large/)).toBeInTheDocument();
|
||||||
|
expect(within(report).getByText('Rejected')).toBeInTheDocument();
|
||||||
|
expect(within(report).getByText('corrupt.jpg')).toBeInTheDocument();
|
||||||
|
expect(within(report).getByText('Processing failed')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Contract with the modal: the real component fires onUploadSettled and,
|
||||||
|
// because something failed, asks the host NOT to auto-close.
|
||||||
|
await waitFor(() => expect(onUploadSettled).toHaveBeenCalledWith({ hasFailures: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a whole-chunk transfer failure by name', async () => {
|
||||||
|
postMock.mockRejectedValue({ response: { data: { error: 'Network error' } } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
|
||||||
|
|
||||||
|
await uploadFile(container, user);
|
||||||
|
|
||||||
|
const report = await screen.findByTestId('upload-failure-report');
|
||||||
|
expect(within(report).getByText('good-photo.png')).toBeInTheDocument();
|
||||||
|
expect(within(report).getByText(/Network error/)).toBeInTheDocument();
|
||||||
|
expect(within(report).getByText('Transfer failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles clean when nothing fails, so the modal can auto-close', async () => {
|
||||||
|
postMock.mockResolvedValue({ data: { successCount: 1, count: 1, upload_id: 'u1', errors: [] } });
|
||||||
|
hoisted.aggregate = {
|
||||||
|
total: 1, pending: 0, processing: 0, complete: 1, failed: 0,
|
||||||
|
failedPhotos: [], isComplete: true, isReady: true,
|
||||||
|
};
|
||||||
|
const onUploadSettled = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = renderWithClient(<PhotoUpload eventId={1} onUploadSettled={onUploadSettled} />);
|
||||||
|
|
||||||
|
await uploadFile(container, user);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUploadSettled).toHaveBeenCalledWith({ hasFailures: false }));
|
||||||
|
expect(screen.queryByTestId('upload-failure-report')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be dismissed', async () => {
|
||||||
|
postMock.mockResolvedValue({
|
||||||
|
data: { successCount: 0, count: 0, errors: [{ filename: 'too-big.png', error: 'File too large' }] },
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
|
||||||
|
|
||||||
|
await uploadFile(container, user);
|
||||||
|
const report = await screen.findByTestId('upload-failure-report');
|
||||||
|
|
||||||
|
await user.click(within(report).getByRole('button', { name: /Dismiss/i }));
|
||||||
|
expect(screen.queryByTestId('upload-failure-report')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* The upload modal must keep itself open when an upload partially fails, so
|
||||||
|
* the failure report stays visible; a clean upload still auto-closes. We stub
|
||||||
|
* PhotoUpload with buttons that fire its onUploadSettled callback both ways.
|
||||||
|
*/
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PhotoUploadModal } from '../PhotoUploadModal';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (_k: string, fb?: any) => (typeof fb === 'string' ? fb : _k) }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stub PhotoUpload: expose buttons that settle clean vs. with failures.
|
||||||
|
vi.mock('../PhotoUpload', () => ({
|
||||||
|
PhotoUpload: ({ onUploadSettled }: any) => (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => onUploadSettled?.({ hasFailures: false })}>settle-clean</button>
|
||||||
|
<button onClick={() => onUploadSettled?.({ hasFailures: true })}>settle-failed</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PhotoUploadModal auto-close behaviour', () => {
|
||||||
|
it('closes after a clean upload', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoUploadModal isOpen eventId={1} onClose={onClose} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('settle-clean'));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays open when some files failed', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoUploadModal isOpen eventId={1} onClose={onClose} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('settle-failed'));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,16 @@
|
|||||||
"retryFailed": "Fehlgeschlagene erneut versuchen",
|
"retryFailed": "Fehlgeschlagene erneut versuchen",
|
||||||
"uploadComplete": "Upload abgeschlossen!",
|
"uploadComplete": "Upload abgeschlossen!",
|
||||||
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
|
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
|
||||||
|
"failures": {
|
||||||
|
"title": "{{count}} Datei(en) konnten nicht hochgeladen werden",
|
||||||
|
"toast": "{{count}} Datei(en) konnten nicht hochgeladen werden – siehe Liste unten.",
|
||||||
|
"kindRejected": "Abgelehnt",
|
||||||
|
"kindTransfer": "Übertragung fehlgeschlagen",
|
||||||
|
"kindProcessing": "Verarbeitung fehlgeschlagen",
|
||||||
|
"transferReason": "Übertragung fehlgeschlagen",
|
||||||
|
"unknownReason": "Unbekannter Fehler",
|
||||||
|
"unknownFile": "Unbekannte Datei"
|
||||||
|
},
|
||||||
"replaceByName": "Vorhandene Fotos mit gleichem Namen ersetzen",
|
"replaceByName": "Vorhandene Fotos mit gleichem Namen ersetzen",
|
||||||
"uploadPhotos": "Fotos hochladen",
|
"uploadPhotos": "Fotos hochladen",
|
||||||
"uploadMedia": "Fotos & Videos hochladen",
|
"uploadMedia": "Fotos & Videos hochladen",
|
||||||
|
|||||||
@@ -169,6 +169,16 @@
|
|||||||
"retryFailed": "Retry failed",
|
"retryFailed": "Retry failed",
|
||||||
"uploadComplete": "Upload complete!",
|
"uploadComplete": "Upload complete!",
|
||||||
"someFilesFailed": "Some files failed to upload",
|
"someFilesFailed": "Some files failed to upload",
|
||||||
|
"failures": {
|
||||||
|
"title": "{{count}} file(s) could not be uploaded",
|
||||||
|
"toast": "{{count}} file(s) could not be uploaded — see the list below.",
|
||||||
|
"kindRejected": "Rejected",
|
||||||
|
"kindTransfer": "Transfer failed",
|
||||||
|
"kindProcessing": "Processing failed",
|
||||||
|
"transferReason": "Transfer failed",
|
||||||
|
"unknownReason": "Unknown error",
|
||||||
|
"unknownFile": "Unknown file"
|
||||||
|
},
|
||||||
"replaceByName": "Replace existing photos with same name",
|
"replaceByName": "Replace existing photos with same name",
|
||||||
"uploadPhotos": "Upload Photos",
|
"uploadPhotos": "Upload Photos",
|
||||||
"uploadMedia": "Upload Photos & Videos",
|
"uploadMedia": "Upload Photos & Videos",
|
||||||
|
|||||||
Reference in New Issue
Block a user