// @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 { MediaLibraryPage } from '../../apps/dashboard/src/features/media-library/media-library-page.js'; const asset = { id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'A face-led self portrait.', confidence: 0.91, fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 640, height: 480, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, sendCount: 0, lastSentAt: null, urlPath: '/library-media/portrait.jpg', }; const secondAsset = { ...asset, id: '029fe789-1b99-7ce1-8402-b3cbc32a445b', fileName: 'second.jpg', urlPath: '/library-media/second.jpg', }; const server = setupServer(); function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); return render(); } function cardFor(fileName: string): HTMLElement { const title = screen.getByText(fileName, { selector: 'strong' }); const card = title.closest('article'); if (!card) throw new Error(`No card for ${fileName}`); return card; } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('exchange library page', () => { it('keeps images blurred and saves operator tag corrections', async () => { let update: unknown; server.use( http.get('/api/media-library', () => HttpResponse.json([asset])), http.patch('/api/media-library/:id', async ({ request }) => { update = await request.json(); return HttpResponse.json({ ...asset, ...(update as object) }); }), ); const user = userEvent.setup(); renderPage(); const image = await screen.findByRole('img', { name: /Library image tagged selfie/i }); expect(image).toHaveClass('is-blurred'); const revealToggle = screen.getByRole('button', { name: 'Reveal portrait.jpg' }); expect(revealToggle).toHaveAttribute('aria-pressed', 'false'); await user.click(revealToggle); expect(image).not.toHaveClass('is-blurred'); expect(screen.getByRole('button', { name: 'Blur portrait.jpg' })).toHaveAttribute('aria-pressed', 'true'); await user.click(screen.getByRole('checkbox', { name: 'topless' })); expect(screen.getByText('Unsaved changes')).toBeInTheDocument(); await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw'); await user.click(screen.getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true })); // Transient save feedback next to the Save button. const saved = await screen.findByText('Saved'); expect(saved).toHaveAttribute('role', 'status'); await waitFor(() => expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument()); }); it('opens a revealed library image in the enlarging preview dialog', async () => { server.use(http.get('/api/media-library', () => HttpResponse.json([asset]))); const user = userEvent.setup(); renderPage(); await screen.findByRole('img', { name: /Library image tagged selfie/i }); await user.click(screen.getByRole('button', { name: 'Reveal library image portrait.jpg' })); await user.click(screen.getByRole('button', { name: 'View portrait.jpg larger' })); const dialog = screen.getByRole('dialog', { name: 'Preview of portrait.jpg' }); expect(within(dialog).getByRole('img')).toHaveAttribute('src', '/library-media/portrait.jpg'); // Single-item mode: no prev/next controls. expect(within(dialog).queryByRole('button', { name: 'Next media' })).not.toBeInTheDocument(); await user.click(within(dialog).getByRole('button', { name: 'Close media preview' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('preserves a touched form when a refetch replaces the assets', async () => { let update: unknown; server.use( http.get('/api/media-library', () => HttpResponse.json([asset, secondAsset])), http.patch('/api/media-library/:id', async ({ request }) => { update = await request.json(); return HttpResponse.json({ ...asset, ...(update as object) }); }), ); const user = userEvent.setup(); renderPage(); await screen.findByText('portrait.jpg'); const secondCard = cardFor('second.jpg'); // Touch the second card's form without saving it. await user.click(within(secondCard).getByRole('checkbox', { name: 'topless' })); expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument(); // Saving the first card invalidates and refetches the whole list. const firstCard = cardFor('portrait.jpg'); await user.click(within(firstCard).getByRole('checkbox', { name: 'chest' })); await user.click(within(firstCard).getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'chest'], safety: 'sfw', enabled: true })); await within(firstCard).findByText('Saved'); // The refetch must not clobber the second card's unsaved edit. expect(within(secondCard).getByRole('checkbox', { name: 'topless' })).toBeChecked(); expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument(); }); it('explains why saving is disabled when no tags are selected', async () => { server.use(http.get('/api/media-library', () => HttpResponse.json([asset]))); const user = userEvent.setup(); renderPage(); await screen.findByText('portrait.jpg'); await user.click(screen.getByRole('checkbox', { name: 'selfie' })); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); expect(screen.getByText('Select at least one tag to save')).toBeInTheDocument(); }); it('arms deletion away from the Delete position and disarms it after five seconds', async () => { server.use(http.get('/api/media-library', () => HttpResponse.json([asset]))); const user = userEvent.setup(); renderPage(); await screen.findByText('portrait.jpg'); await user.click(screen.getByRole('button', { name: 'Delete' })); const actions = screen.getByRole('button', { name: 'Confirm delete' }).parentElement!; const buttons = within(actions).getAllByRole('button'); // Cancel occupies the slot Delete was in; Confirm sits after it. expect(buttons.map((button) => button.textContent)).toEqual(['Save changes', 'Cancel', 'Confirm delete']); await waitFor( () => expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(), { timeout: 7_000 }, ); expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); }, 15_000); it('requires consent in the UI and uploads the selected image for classification', async () => { let consentHeader: string | null = null; server.use( http.get('/api/media-library', () => HttpResponse.json([])), http.post('/api/media-library', ({ request }) => { consentHeader = request.headers.get('x-consent-confirmed'); return HttpResponse.json(asset, { status: 201 }); }), ); const user = userEvent.setup(); renderPage(); await screen.findByText('No exchange images yet.'); const upload = screen.getByRole('button', { name: 'Upload and classify' }); expect(upload).toBeDisabled(); const input = screen.getByLabelText(/Choose image/) as HTMLInputElement; await user.upload(input, new File(['image'], 'portrait.jpg', { type: 'image/jpeg' })); expect(upload).toBeDisabled(); await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i })); expect(upload).toBeEnabled(); await user.click(upload); await screen.findByText('Image added. Review its tags before relying on it.'); expect(consentHeader).toBe('true'); // The input resets so the same file can be picked again. expect(input.value).toBe(''); expect(screen.getByText('No image selected')).toBeInTheDocument(); }); it('rejects non-image and oversized files before uploading', async () => { server.use(http.get('/api/media-library', () => HttpResponse.json([]))); const user = userEvent.setup({ applyAccept: false }); renderPage(); await screen.findByText('No exchange images yet.'); const input = screen.getByLabelText(/Choose image/) as HTMLInputElement; await user.upload(input, new File(['nope'], 'notes.txt', { type: 'text/plain' })); expect(screen.getByRole('alert')).toHaveTextContent('Only image files can be uploaded.'); expect(screen.getByRole('button', { name: 'Upload and classify' })).toBeDisabled(); const oversized = new File(['big'], 'huge.jpg', { type: 'image/jpeg' }); Object.defineProperty(oversized, 'size', { value: 16 * 1024 * 1024 }); await user.upload(input, oversized); expect(screen.getByRole('alert')).toHaveTextContent('Images must be 15 MB or smaller.'); expect(screen.getByText('No image selected')).toBeInTheDocument(); }); it('shows a thumbnail preview of the picked image when object URLs are available', async () => { const objectUrls: string[] = []; const globalUrl = URL as unknown as Record; globalUrl.createObjectURL = vi.fn(() => { const url = `blob:preview-${objectUrls.length}`; objectUrls.push(url); return url; }); globalUrl.revokeObjectURL = vi.fn(); try { server.use(http.get('/api/media-library', () => HttpResponse.json([]))); const user = userEvent.setup(); renderPage(); await screen.findByText('No exchange images yet.'); await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' })); expect(screen.getByRole('img', { name: 'Preview of portrait.jpg' })).toHaveAttribute('src', 'blob:preview-0'); } finally { delete globalUrl.createObjectURL; delete globalUrl.revokeObjectURL; } }); });