From 768470204bf211656ceb3df27ad031547fb43bf4 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 15:04:25 +0100 Subject: [PATCH] feat: add private media dashboard slice --- apps/dashboard/src/features/media/api.ts | 43 +++ apps/dashboard/src/features/media/filters.ts | 33 +++ .../src/features/media/full-sync-dialog.tsx | 33 +++ .../src/features/media/media-card.tsx | 45 ++++ .../src/features/media/media-page.tsx | 208 +++++++++++++++ apps/dashboard/src/features/media/media.css | 35 +++ apps/dashboard/src/features/media/queries.ts | 41 +++ tests/dashboard/media.test.tsx | 250 ++++++++++++++++++ 8 files changed, 688 insertions(+) create mode 100644 apps/dashboard/src/features/media/api.ts create mode 100644 apps/dashboard/src/features/media/filters.ts create mode 100644 apps/dashboard/src/features/media/full-sync-dialog.tsx create mode 100644 apps/dashboard/src/features/media/media-card.tsx create mode 100644 apps/dashboard/src/features/media/media-page.tsx create mode 100644 apps/dashboard/src/features/media/media.css create mode 100644 apps/dashboard/src/features/media/queries.ts create mode 100644 tests/dashboard/media.test.tsx diff --git a/apps/dashboard/src/features/media/api.ts b/apps/dashboard/src/features/media/api.ts new file mode 100644 index 0000000..2e7356d --- /dev/null +++ b/apps/dashboard/src/features/media/api.ts @@ -0,0 +1,43 @@ +import type { ZodType } from 'zod'; +import { + ErrorResponseSchema, + MediaResponseSchema, + MediaSyncStatusSchema, + MediaSyncTriggerResponseSchema, + type MediaItem, + type MediaSyncStatus, +} from '../../../../../packages/api-contracts/src/index.js'; + +async function parseResponse(response: Response, schema: ZodType): Promise { + const payload: unknown = await response.json(); + if (!response.ok) { + const parsedError = ErrorResponseSchema.safeParse(payload); + throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`); + } + return schema.parse(payload); +} + +function request(path: string, init?: RequestInit): Promise { + return globalThis.fetch(path, { credentials: 'same-origin', ...init }); +} + +export async function fetchMedia(): Promise { + return parseResponse(await request('/api/media'), MediaResponseSchema); +} + +export async function fetchMediaSyncStatus(): Promise { + return parseResponse(await request('/api/media/sync'), MediaSyncStatusSchema); +} + +export async function triggerMediaSync(isFull: boolean): Promise { + const path = isFull ? '/api/media/sync/full' : '/api/media/sync'; + const response = await parseResponse( + await request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: isFull ? undefined : JSON.stringify({ full: false }), + }), + MediaSyncTriggerResponseSchema, + ); + if (!response.success) throw new Error(response.reason); +} diff --git a/apps/dashboard/src/features/media/filters.ts b/apps/dashboard/src/features/media/filters.ts new file mode 100644 index 0000000..d2d7630 --- /dev/null +++ b/apps/dashboard/src/features/media/filters.ts @@ -0,0 +1,33 @@ +import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js'; + +export interface MediaFilters { + conversation: string; + sender: string; + type: 'all' | 'image' | 'gif'; + dateRange: 'all' | '24h' | '7d' | '30d'; +} + +export function mediaConversation(item: MediaItem): string { + return item.chatUsername ?? item.chatRoomId ?? 'unknown'; +} + +export function mediaType(item: MediaItem): 'image' | 'gif' { + return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image'; +} + +export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] { + const rangeMs = { + all: null, + '24h': 86_400_000, + '7d': 7 * 86_400_000, + '30d': 30 * 86_400_000, + }[filters.dateRange]; + + return items.filter((item) => { + if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false; + if (filters.sender !== 'all' && item.sender !== filters.sender) return false; + if (filters.type !== 'all' && mediaType(item) !== filters.type) return false; + if (rangeMs !== null && item.timestamp < now - rangeMs) return false; + return true; + }); +} diff --git a/apps/dashboard/src/features/media/full-sync-dialog.tsx b/apps/dashboard/src/features/media/full-sync-dialog.tsx new file mode 100644 index 0000000..2e2faa4 --- /dev/null +++ b/apps/dashboard/src/features/media/full-sync-dialog.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; + +interface FullSyncDialogProps { + isPending: boolean; + onCancel(): void; + onConfirm(): void; +} + +export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) { + const cancelButtonRef = useRef(null); + + useEffect(() => { + cancelButtonRef.current?.focus(); + }, []); + + return ( +
+
+ Confirm expensive action +

Run full media sync?

+

This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.

+
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/features/media/media-card.tsx b/apps/dashboard/src/features/media/media-card.tsx new file mode 100644 index 0000000..1f14a21 --- /dev/null +++ b/apps/dashboard/src/features/media/media-card.tsx @@ -0,0 +1,45 @@ +import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js'; +import { mediaConversation, mediaType } from './filters.js'; + +interface MediaCardProps { + item: MediaItem; + isRevealed: boolean; + onToggleReveal(): void; +} + +function formatMediaDate(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); +} + +export function MediaCard({ item, isRevealed, onToggleReveal }: MediaCardProps) { + return ( +
+
+ {`Media + {!isRevealed ? Private media hidden : null} +
+
+
+ {item.sender} + {mediaConversation(item)} +
+
+
Type
{mediaType(item) === 'gif' ? 'GIF' : 'Image'}
+
Received
{formatMediaDate(item.timestamp)}
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/features/media/media-page.tsx b/apps/dashboard/src/features/media/media-page.tsx new file mode 100644 index 0000000..3ffd1a6 --- /dev/null +++ b/apps/dashboard/src/features/media/media-page.tsx @@ -0,0 +1,208 @@ +import { useMemo, useState } from 'react'; +import { filterMedia, mediaConversation, type MediaFilters } from './filters.js'; +import { FullSyncDialog } from './full-sync-dialog.js'; +import { MediaCard } from './media-card.js'; +import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js'; +import './media.css'; + +const initialFilters: MediaFilters = { + conversation: 'all', + sender: 'all', + type: 'all', + dateRange: 'all', +}; + +function errorMessage(error: Error | null): string { + return error?.message ?? 'Something went wrong.'; +} + +function formatLastSync(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); +} + +export function MediaPage() { + const mediaQuery = useMediaQuery(); + const statusQuery = useMediaSyncStatusQuery(); + const syncMutation = useTriggerMediaSyncMutation(); + const [filters, setFilters] = useState(initialFilters); + const [revealedIds, setRevealedIds] = useState>(() => new Set()); + const [isRevealAll, setIsRevealAll] = useState(false); + const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false); + + const items = mediaQuery.data ?? []; + const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]); + const conversations = useMemo( + () => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)), + [items], + ); + const senders = useMemo( + () => [...new Set(items.map((item) => item.sender))].sort((left, right) => left.localeCompare(right)), + [items], + ); + const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending; + + function updateFilter(key: Key, value: MediaFilters[Key]) { + setFilters((current) => ({ ...current, [key]: value })); + } + + function toggleItem(itemId: string) { + if (isRevealAll) { + setIsRevealAll(false); + setRevealedIds(new Set(items.filter((item) => item.id !== itemId).map((item) => item.id))); + return; + } + setRevealedIds((current) => { + const next = new Set(current); + if (next.has(itemId)) next.delete(itemId); + else next.add(itemId); + return next; + }); + } + + function runSync(isFull: boolean) { + syncMutation.mutate(isFull, { + onSuccess: () => { + if (isFull) setIsFullSyncDialogOpen(false); + }, + onError: () => { + if (isFull) setIsFullSyncDialogOpen(false); + }, + }); + } + + if (mediaQuery.isPending) { + return
Loading media…
; + } + + if (mediaQuery.isError) { + return ( +
+ Media unavailable + {errorMessage(mediaQuery.error)} + +
+ ); + } + + return ( +
+
+
+ Private gallery +

Media

+

Browse authenticated conversation media. Everything stays blurred until you choose to reveal it.

+
+
+ {items.length > 0 ? ( + + ) : null} + + +
+
+ +
+ {statusQuery.isPending ? Loading sync status… : null} + {statusQuery.isError ? ( +
+ {errorMessage(statusQuery.error)} + +
+ ) : null} + {statusQuery.data ? ( + statusQuery.data.isSyncing ? ( +
Media sync in progress
+ ) : ( +
+
+ ) + ) : null} + {statusQuery.data?.lastSyncAt ? {formatLastSync(statusQuery.data.lastSyncAt)} : null} +
+ + {statusQuery.data?.lastError ?
Last sync failed{statusQuery.data.lastError}
: null} + {syncMutation.isSuccess ?
{syncMutation.variables ? 'Full media sync completed.' : 'Media sync completed.'}
: null} + {syncMutation.isError ?
{errorMessage(syncMutation.error)}
: null} + + {items.length === 0 ? ( +
No media yet.Use Sync new media to check recent conversations.
+ ) : ( + <> +
+ + + + +
+ + {visibleItems.length === 0 ? ( +
No media matches these filters.Try broadening one or more filters.
+ ) : ( +
+ {visibleItems.map((item) => ( + toggleItem(item.id)} + /> + ))} +
+ )} + + )} + + {isFullSyncDialogOpen ? ( + setIsFullSyncDialogOpen(false)} + onConfirm={() => runSync(true)} + /> + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/features/media/media.css b/apps/dashboard/src/features/media/media.css new file mode 100644 index 0000000..0380a9a --- /dev/null +++ b/apps/dashboard/src/features/media/media.css @@ -0,0 +1,35 @@ +.media-page { max-width: 86rem; } +.media-sync-status { min-height: 4.4rem; } +.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; } +.media-sync-status .spinner { width: 1rem; height: 1rem; } +.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; } +.media-notice.success { color: #aaf0c4; background: #163a26; border: 1px solid #28593d; } +.media-notice.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; } +.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; } +.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; } +.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; } +.media-filters select:focus-visible, .media-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; } +.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; } +.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; } +.media-image { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; } +.media-image.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); } +.privacy-label { position: absolute; padding: .45rem .65rem; color: white; background: rgba(0, 0, 0, .72); border-radius: 999px; font-size: .75rem; font-weight: 800; } +.media-card-body { display: grid; gap: .8rem; padding: .9rem; } +.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; } +.media-card-body > div:first-child span { color: var(--muted); } +.media-card-body dl { display: grid; gap: .4rem; margin: 0; } +.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; } +.media-card-body dt { color: var(--muted); } +.media-card-body dd { margin: 0; text-align: right; } +.media-reveal { width: 100%; } + +@media (min-width: 40rem) { + .media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (min-width: 72rem) { + .media-filters { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} diff --git a/apps/dashboard/src/features/media/queries.ts b/apps/dashboard/src/features/media/queries.ts new file mode 100644 index 0000000..58dc368 --- /dev/null +++ b/apps/dashboard/src/features/media/queries.ts @@ -0,0 +1,41 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { fetchMedia, fetchMediaSyncStatus, triggerMediaSync } from './api.js'; + +export const mediaQueryKeys = { + items: ['dashboard', 'media'] as const, + syncStatus: ['dashboard', 'media', 'sync-status'] as const, +}; + +export function useMediaQuery() { + return useQuery({ queryKey: mediaQueryKeys.items, queryFn: fetchMedia }); +} + +export function useMediaSyncStatusQuery() { + return useQuery({ + queryKey: mediaQueryKeys.syncStatus, + queryFn: fetchMediaSyncStatus, + refetchInterval(query) { + return query.state.data?.isSyncing ? 1_000 : 15_000; + }, + }); +} + +export function useTriggerMediaSyncMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn(isFull: boolean) { + const syncRequest = triggerMediaSync(isFull); + void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus }); + return syncRequest; + }, + async onSuccess() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: mediaQueryKeys.items }), + queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus }), + ]); + }, + onError() { + void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus }); + }, + }); +} diff --git a/tests/dashboard/media.test.tsx b/tests/dashboard/media.test.tsx new file mode 100644 index 0000000..5f5149c --- /dev/null +++ b/tests/dashboard/media.test.tsx @@ -0,0 +1,250 @@ +// @vitest-environment jsdom + +import '@testing-library/jest-dom/vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { MediaPage } from '../../apps/dashboard/src/features/media/media-page.js'; + +const now = Date.now(); +const mediaItems = [ + { + id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice', + timestamp: now - 60_000, originalUrl: 'https://media.example/one.jpg', fileName: 'one.jpg', + relativePath: 'one.jpg', createdAt: now - 50_000, urlPath: '/media/one.jpg', + }, + { + id: 'two', chatUsername: 'bob', chatRoomId: 'room-b', sender: 'charlie', + timestamp: now - (3 * 86_400_000), originalUrl: 'https://media.example/two.gif', fileName: 'two.gif', + relativePath: 'two.gif', createdAt: now - (3 * 86_400_000), urlPath: '/media/two.gif', + }, + { + id: 'three', chatUsername: null, chatRoomId: 'room-c', sender: 'dave', + timestamp: now - (40 * 86_400_000), originalUrl: 'https://media.example/three.png', fileName: 'three.png', + relativePath: 'three.png', createdAt: now - (40 * 86_400_000), urlPath: '/media/three.png', + }, +]; + +const idleStatus = { + isSyncing: false, + lastSyncAt: now - 120_000, + lastSyncDurationMs: 2_400, + lastSyncChats: 3, + lastError: null, + nextSyncAt: now + 1_800_000, +}; + +const server = setupServer(); + +function installMediaApi(items = mediaItems) { + server.use( + http.get('/api/media', () => HttpResponse.json(items)), + http.get('/api/media/sync', () => HttpResponse.json(idleStatus)), + ); +} + +function renderMedia() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const view = render( + + + , + ); + return { ...view, queryClient }; +} + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + cleanup(); + server.resetHandlers(); + vi.restoreAllMocks(); +}); +afterAll(() => server.close()); + +describe('media page', () => { + it('loads authenticated media, keeps it blurred, and supports item and session-local reveals', async () => { + let resolveMedia: ((response: Response) => void) | undefined; + const credentials: RequestCredentials[] = []; + server.use( + http.get('/api/media', async ({ request }) => { + credentials.push(request.credentials); + return new Promise((resolve) => { resolveMedia = resolve; }); + }), + http.get('/api/media/sync', ({ request }) => { + credentials.push(request.credentials); + return HttpResponse.json(idleStatus); + }), + ); + const user = userEvent.setup(); + const firstView = renderMedia(); + + expect(screen.getByRole('status', { name: 'Loading media' })).toBeInTheDocument(); + await waitFor(() => expect(resolveMedia).toBeTypeOf('function')); + resolveMedia?.(HttpResponse.json(mediaItems)); + + const aliceImage = await screen.findByRole('img', { name: 'Media from alice' }); + const charlieImage = screen.getByRole('img', { name: 'Media from charlie' }); + expect(aliceImage).toHaveClass('is-blurred'); + expect(charlieImage).toHaveClass('is-blurred'); + expect(credentials.every((value) => value === 'same-origin')).toBe(true); + + await user.click(screen.getByRole('button', { name: 'Reveal media from alice' })); + expect(aliceImage).not.toHaveClass('is-blurred'); + expect(charlieImage).toHaveClass('is-blurred'); + + await user.click(screen.getByRole('button', { name: 'Reveal all media' })); + expect(charlieImage).not.toHaveClass('is-blurred'); + + firstView.unmount(); + installMediaApi(); + renderMedia(); + expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred'); + }); + + it('filters by conversation, sender, media type, and age with an explicit no-match state', async () => { + installMediaApi(); + const user = userEvent.setup(); + renderMedia(); + expect(await screen.findAllByRole('img')).toHaveLength(3); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice'); + expect(screen.getAllByRole('img')).toHaveLength(1); + expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie'); + expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif'); + expect(screen.getAllByRole('img')).toHaveLength(1); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'all'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Date range' }), '7d'); + expect(screen.getAllByRole('img')).toHaveLength(2); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'room-c'); + expect(screen.getByText('No media matches these filters.')).toBeInTheDocument(); + }); + + it('shows empty and recoverable error states', async () => { + let requests = 0; + server.use( + http.get('/api/media', () => { + requests += 1; + return requests === 1 + ? HttpResponse.json({ error: 'Media index unavailable' }, { status: 500 }) + : HttpResponse.json([]); + }), + http.get('/api/media/sync', () => HttpResponse.json(idleStatus)), + ); + const user = userEvent.setup(); + renderMedia(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Media index unavailable'); + await user.click(within(alert).getByRole('button', { name: 'Retry media' })); + expect(await screen.findByText('No media yet.')).toBeInTheDocument(); + expect(requests).toBe(2); + }); + + it('runs delta sync as the primary action and surfaces query-driven status and mutation outcomes', async () => { + installMediaApi(); + let deltaRequests = 0; + server.use( + http.post('/api/media/sync', ({ request }) => { + deltaRequests += 1; + expect(request.credentials).toBe('same-origin'); + return HttpResponse.json({ success: true }); + }), + ); + const user = userEvent.setup(); + renderMedia(); + + const deltaButton = await screen.findByRole('button', { name: 'Sync new media' }); + expect(deltaButton).toHaveClass('primary'); + expect(screen.getByText('Last synced 3 conversations')).toBeInTheDocument(); + await user.click(deltaButton); + + expect(await screen.findByText('Media sync completed.')).toBeInTheDocument(); + expect(deltaRequests).toBe(1); + + server.use( + http.post('/api/media/sync', () => HttpResponse.json({ success: false, reason: 'Sync already in progress' })), + ); + await user.click(screen.getByRole('button', { name: 'Sync new media' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress'); + }); + + it('refreshes shared sync status as soon as a long-running mutation starts', async () => { + let statusRequests = 0; + let resolveSync: ((response: Response) => void) | undefined; + server.use( + http.get('/api/media', () => HttpResponse.json(mediaItems)), + http.get('/api/media/sync', () => { + statusRequests += 1; + return HttpResponse.json(statusRequests === 1 ? idleStatus : { ...idleStatus, isSyncing: true }); + }), + http.post('/api/media/sync', async () => + new Promise((resolve) => { resolveSync = resolve; }), + ), + ); + const user = userEvent.setup(); + renderMedia(); + + await user.click(await screen.findByRole('button', { name: 'Sync new media' })); + expect(await screen.findByText('Media sync in progress')).toBeInTheDocument(); + expect(statusRequests).toBeGreaterThan(1); + + resolveSync?.(HttpResponse.json({ success: true })); + await waitFor(() => expect(screen.getByText('Media sync completed.')).toBeInTheDocument()); + }); + + it('guards a full sync with explicit confirmation', async () => { + installMediaApi(); + let fullRequests = 0; + server.use( + http.post('/api/media/sync/full', () => { + fullRequests += 1; + return HttpResponse.json({ success: true }); + }), + ); + const user = userEvent.setup(); + renderMedia(); + + await user.click(await screen.findByRole('button', { name: 'Full sync' })); + const dialog = screen.getByRole('dialog', { name: 'Run full media sync?' }); + expect(dialog).toHaveTextContent('every conversation'); + expect(fullRequests).toBe(0); + + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(fullRequests).toBe(0); + + await user.click(screen.getByRole('button', { name: 'Full sync' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Run full sync' })); + expect(await screen.findByText('Full media sync completed.')).toBeInTheDocument(); + expect(fullRequests).toBe(1); + }); + + it('closes the full-sync dialog so a failed mutation is clearly visible', async () => { + installMediaApi(); + server.use( + http.post('/api/media/sync/full', () => + HttpResponse.json({ error: 'Full sync could not reach Reddit' }, { status: 500 }), + ), + ); + const user = userEvent.setup(); + renderMedia(); + + await user.click(await screen.findByRole('button', { name: 'Full sync' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Run full sync' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Full sync could not reach Reddit'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +});