Send new artwork uploads through a trust-based review policy.
Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
This commit is contained in:
@@ -1,18 +1,98 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
function tagList(item) {
|
||||
return Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []
|
||||
}
|
||||
|
||||
function categoryList(item) {
|
||||
return Array.isArray(item?.categories) ? item.categories.filter(Boolean) : []
|
||||
}
|
||||
|
||||
function creatorName(item) {
|
||||
return item?.user?.username || item?.user?.name || 'Unknown creator'
|
||||
}
|
||||
|
||||
function isMature(item) {
|
||||
return Boolean(item?.nsfw || item?.is_mature)
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function PreviewImage({ src, alt, className }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false)
|
||||
}, [src])
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-white/5 text-xs text-white/40 ${className}`}>
|
||||
No preview
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ children, tone = 'slate' }) {
|
||||
const tones = {
|
||||
slate: 'border-white/10 bg-white/5 text-white/70',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${tones[tone] || tones.slate}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminUploadQueue() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notes, setNotes] = useState({})
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
|
||||
const loadPending = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await window.axios.get('/api/admin/uploads/pending')
|
||||
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
|
||||
const [legacyResponse, artworkResponse] = await Promise.all([
|
||||
window.axios.get('/api/admin/uploads/pending'),
|
||||
window.axios.get('/api/admin/artwork-review/pending'),
|
||||
])
|
||||
const legacyItems = Array.isArray(legacyResponse?.data?.data)
|
||||
? legacyResponse.data.data.map((item) => ({ ...item, queue: 'upload' }))
|
||||
: []
|
||||
const artworkItems = Array.isArray(artworkResponse?.data?.data)
|
||||
? artworkResponse.data.data.map((item) => ({ ...item, queue: 'artwork' }))
|
||||
: []
|
||||
setItems([...artworkItems, ...legacyItems])
|
||||
} catch (loadError) {
|
||||
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
|
||||
} finally {
|
||||
@@ -24,18 +104,130 @@ export default function AdminUploadQueue() {
|
||||
loadPending()
|
||||
}, [])
|
||||
|
||||
const moderate = async (id, action) => {
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSelected(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [selected])
|
||||
|
||||
const moderate = async (item, action) => {
|
||||
const id = item?.id
|
||||
if (!id || busyId) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(id)
|
||||
try {
|
||||
const payload = { note: String(notes[id] || '') }
|
||||
await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload)
|
||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||
const base = item.queue === 'artwork' ? '/api/admin/artwork-review' : '/api/admin/uploads'
|
||||
await window.axios.post(`${base}/${id}/${action}`, payload)
|
||||
setItems((prev) => prev.filter((candidate) => !(candidate.id === id && candidate.queue === item.queue)))
|
||||
setSelected((current) => (current?.id === id && current?.queue === item.queue ? null : current))
|
||||
} catch (moderateError) {
|
||||
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderMeta = (item, { compact = false } = {}) => {
|
||||
const tags = tagList(item)
|
||||
const categories = categoryList(item)
|
||||
const createdAt = formatDate(item.created_at)
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
|
||||
<Badge tone={item.queue === 'artwork' ? 'emerald' : 'slate'}>
|
||||
{item.queue === 'artwork' ? 'Artwork' : 'Draft upload'}
|
||||
</Badge>
|
||||
{isMature(item) ? <Badge tone="rose">NSFW</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/65">
|
||||
{creatorName(item)} · {item.type} · {item.id}
|
||||
</div>
|
||||
{createdAt ? <div className="mt-1 text-[11px] text-white/40">{createdAt}</div> : null}
|
||||
{item.description ? (
|
||||
<p className={`mt-2 text-xs leading-5 text-white/70 ${compact ? 'line-clamp-3' : 'whitespace-pre-wrap'}`}>
|
||||
{item.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-white/40">No description</p>
|
||||
)}
|
||||
{categories.length > 0 ? (
|
||||
<div className="mt-2 text-[11px] text-amber-200/80">{categories.join(' · ')}</div>
|
||||
) : null}
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(compact ? tags.slice(0, 10) : tags).map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-white/70">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[11px] text-white/40">No tags</div>
|
||||
)}
|
||||
{item.moderation_note ? (
|
||||
<div className="mt-2 text-[11px] text-sky-200/80">Review reasons: {item.moderation_note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderActions = (item) => (
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Moderation note for ${item.id}`}
|
||||
value={notes[item.id] || ''}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
||||
placeholder="Moderation note"
|
||||
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open details for ${item.id}`}
|
||||
onClick={() => setSelected(item)}
|
||||
className="rounded-lg border border-white/20 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Approve upload ${item.id}`}
|
||||
disabled={busyId === item.id}
|
||||
onClick={() => moderate(item, 'approve')}
|
||||
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black disabled:opacity-60"
|
||||
>
|
||||
{busyId === item.id ? 'Saving…' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Reject upload ${item.id}`}
|
||||
disabled={busyId === item.id}
|
||||
onClick={() => moderate(item, 'reject')}
|
||||
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-label="Moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
||||
<section aria-label="Moderation queue" className="mx-auto w-full max-w-6xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
|
||||
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
|
||||
@@ -48,47 +240,119 @@ export default function AdminUploadQueue() {
|
||||
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
|
||||
<div className="mt-1 text-xs text-white/65">{item.type} · {item.id}</div>
|
||||
{item.preview_path ? <div className="mt-1 text-xs text-white/55">Preview: {item.preview_path}</div> : null}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const previewSrc = item.preview_url || item.preview_lg_url || null
|
||||
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Moderation note for ${item.id}`}
|
||||
value={notes[item.id] || ''}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
|
||||
placeholder="Moderation note"
|
||||
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Approve upload ${item.id}`}
|
||||
onClick={() => moderate(item.id, 'approve')}
|
||||
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Reject upload ${item.id}`}
|
||||
onClick={() => moderate(item.id, 'reject')}
|
||||
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
return (
|
||||
<li key={`${item.queue}-${item.id}`} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open details for ${item.title || item.id}`}
|
||||
onClick={() => setSelected(item)}
|
||||
className="flex min-w-0 flex-1 items-start gap-4 text-left"
|
||||
>
|
||||
<PreviewImage
|
||||
src={previewSrc}
|
||||
alt={item.title || 'Artwork preview'}
|
||||
className="h-40 w-40 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
{renderMeta(item, { compact: true })}
|
||||
</button>
|
||||
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{selected ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Details for ${selected.title || selected.id}`}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<div
|
||||
className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-2xl border border-white/10 bg-slate-950 p-5 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">{selected.title || '(untitled upload)'}</div>
|
||||
<div className="mt-1 text-xs text-white/60">
|
||||
{creatorName(selected)} · {selected.type} · {selected.id}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close details"
|
||||
onClick={() => setSelected(null)}
|
||||
className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<PreviewImage
|
||||
src={selected.preview_lg_url || selected.preview_url}
|
||||
alt={selected.title || 'Artwork preview'}
|
||||
className="mb-4 max-h-[62vh] w-full rounded-xl bg-black/40 object-contain"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="space-y-3 text-sm text-white/80">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Description</div>
|
||||
<p className="mt-1 whitespace-pre-wrap leading-6">{selected.description || 'No description'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Categories</div>
|
||||
<p className="mt-1">{categoryList(selected).join(', ') || 'None'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Tags</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tagList(selected).length > 0 ? tagList(selected).map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-white/70">
|
||||
{tag}
|
||||
</span>
|
||||
)) : <span className="text-white/40">None</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-xs text-white/70 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Visibility</div>
|
||||
<p className="mt-1">{selected.visibility || 'n/a'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Maturity</div>
|
||||
<p className="mt-1">{isMature(selected) ? 'NSFW / mature' : (selected.maturity_status || 'safe')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">License</div>
|
||||
<p className="mt-1">{selected.license || 'n/a'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Submitted</div>
|
||||
<p className="mt-1">{formatDate(selected.created_at) || 'n/a'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{selected.moderation_note ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Review reasons</div>
|
||||
<p className="mt-1">{selected.moderation_note}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{renderActions(selected)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,41 @@ function makePendingUpload(overrides = {}) {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
title: 'Neon Skyline',
|
||||
type: 'image',
|
||||
preview_path: 'tmp/drafts/1111/preview.webp',
|
||||
description: 'A glowing city at dusk.',
|
||||
preview_url: '/api/admin/uploads/11111111-1111-1111-1111-111111111111/preview',
|
||||
preview_lg_url: '/api/admin/uploads/11111111-1111-1111-1111-111111111111/preview',
|
||||
tags: ['neon', 'city'],
|
||||
categories: ['Photography'],
|
||||
user: { id: 7, name: 'Mira', username: 'mira' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makePendingArtwork(overrides = {}) {
|
||||
return {
|
||||
id: 88,
|
||||
title: 'Forest Spirit',
|
||||
type: 'artwork',
|
||||
description: 'A mossy guardian in fog.',
|
||||
preview_url: 'https://files.skinbase.org/artworks/md/aa/bb/aabb.webp',
|
||||
preview_lg_url: 'https://files.skinbase.org/artworks/lg/aa/bb/aabb.webp',
|
||||
tags: ['forest', 'spirit'],
|
||||
categories: ['Illustration'],
|
||||
user: { id: 9, name: 'Kai', username: 'kai' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mockQueue({ uploads = [], artworks = [] } = {}) {
|
||||
window.axios.get.mockImplementation((url) => {
|
||||
if (String(url).includes('/api/admin/artwork-review/pending')) {
|
||||
return Promise.resolve({ data: { data: artworks } })
|
||||
}
|
||||
|
||||
return Promise.resolve({ data: { data: uploads } })
|
||||
})
|
||||
}
|
||||
|
||||
describe('AdminUploadQueue', () => {
|
||||
beforeEach(() => {
|
||||
window.axios = {
|
||||
@@ -28,7 +58,7 @@ describe('AdminUploadQueue', () => {
|
||||
|
||||
it('renders pending list with accessible controls', async () => {
|
||||
const upload = makePendingUpload()
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
mockQueue({ uploads: [upload] })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
@@ -36,6 +66,9 @@ describe('AdminUploadQueue', () => {
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
|
||||
expect(within(item).getByText('A glowing city at dusk.')).not.toBeNull()
|
||||
expect(within(item).getByText('neon')).not.toBeNull()
|
||||
expect(within(item).getByRole('img', { name: 'Neon Skyline' })).not.toBeNull()
|
||||
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
|
||||
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
|
||||
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
|
||||
@@ -43,7 +76,7 @@ describe('AdminUploadQueue', () => {
|
||||
|
||||
it('approves upload and removes it from queue', async () => {
|
||||
const upload = makePendingUpload()
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
mockQueue({ uploads: [upload] })
|
||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
@@ -60,7 +93,7 @@ describe('AdminUploadQueue', () => {
|
||||
|
||||
it('rejects upload with note and removes it from queue', async () => {
|
||||
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
mockQueue({ uploads: [upload] })
|
||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
@@ -85,7 +118,7 @@ describe('AdminUploadQueue', () => {
|
||||
|
||||
it('shows API failure message and keeps item when moderation action fails', async () => {
|
||||
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
|
||||
mockQueue({ uploads: [upload] })
|
||||
window.axios.post.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Moderation API failed.' } },
|
||||
})
|
||||
@@ -100,8 +133,49 @@ describe('AdminUploadQueue', () => {
|
||||
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens a details popup with title, description, tags and preview', async () => {
|
||||
const upload = makePendingUpload()
|
||||
mockQueue({ uploads: [upload] })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
|
||||
await userEvent.click(within(item).getByRole('button', { name: `Open details for ${upload.id}` }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Details for Neon Skyline' })
|
||||
expect(within(dialog).getByText('A glowing city at dusk.')).not.toBeNull()
|
||||
expect(within(dialog).getByText('Photography')).not.toBeNull()
|
||||
expect(within(dialog).getByText('neon')).not.toBeNull()
|
||||
expect(within(dialog).getByRole('img', { name: 'Neon Skyline' })).not.toBeNull()
|
||||
expect(within(dialog).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
|
||||
expect(within(dialog).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('approves artwork review items from the details popup', async () => {
|
||||
const artwork = makePendingArtwork()
|
||||
mockQueue({ artworks: [artwork] })
|
||||
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
const item = await screen.findByRole('listitem', { name: `Pending upload ${artwork.id}` })
|
||||
expect(within(item).getByText('Forest Spirit')).not.toBeNull()
|
||||
expect(within(item).getByText('forest')).not.toBeNull()
|
||||
expect(within(item).getByRole('img', { name: 'Forest Spirit' })).not.toBeNull()
|
||||
|
||||
await userEvent.click(within(item).getByRole('button', { name: `Open details for ${artwork.id}` }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Details for Forest Spirit' })
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: `Approve upload ${artwork.id}` }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listitem', { name: `Pending upload ${artwork.id}` })).toBeNull()
|
||||
})
|
||||
|
||||
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/artwork-review/${artwork.id}/approve`, { note: '' })
|
||||
})
|
||||
|
||||
it('shows empty state when no pending uploads exist', async () => {
|
||||
window.axios.get.mockResolvedValueOnce({ data: { data: [] } })
|
||||
mockQueue()
|
||||
|
||||
render(<AdminUploadQueue />)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user