Preview artwork
@@ -2102,7 +2102,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
@@ -2135,8 +2135,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
-
-
+
+
{!promptHasFullAccess && (promptPreviewImage || promptPublicExamples.length) ? (
@@ -2228,7 +2228,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
/>
) : null}
- {promptBody || 'Prompt text is not available yet.'}
+ {promptBody || 'Prompt text is not available yet.'}
{!promptHasFullAccess ? (
{promptUnlockTitle || 'Unlock the full prompt'}
@@ -2251,7 +2251,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
metadata={{ copy_type: 'negative_prompt', source: 'prompt_body' }}
/>
- {item.negative_prompt}
+ {item.negative_prompt}
) : null}
@@ -2401,7 +2401,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
) : null}
-
+
{lessonTags.length ? (
Microtags
diff --git a/resources/js/Pages/Admin/System/SecurityReportIndex.jsx b/resources/js/Pages/Admin/System/SecurityReportIndex.jsx
new file mode 100644
index 00000000..43e356ae
--- /dev/null
+++ b/resources/js/Pages/Admin/System/SecurityReportIndex.jsx
@@ -0,0 +1,216 @@
+import React from 'react'
+import { Head, router, usePage } from '@inertiajs/react'
+import AdminLayout from '../../../Layouts/AdminLayout'
+
+function formatTimestamp(value) {
+ if (!value) {
+ return 'Not finished'
+ }
+
+ return new Intl.DateTimeFormat('en-GB', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value))
+}
+
+function SummaryCard({ label, value, tone }) {
+ const tones = {
+ red: 'border-rose-400/15 bg-rose-500/10 text-rose-100',
+ orange: 'border-orange-400/15 bg-orange-500/10 text-orange-100',
+ yellow: 'border-amber-400/15 bg-amber-500/10 text-amber-100',
+ blue: 'border-sky-400/15 bg-sky-500/10 text-sky-100',
+ slate: 'border-white/[0.07] bg-white/[0.02] text-white',
+ }
+
+ return (
+
+ )
+}
+
+function RiskBadge({ label }) {
+ const tone = label === 'Critical'
+ ? 'border-rose-400/20 bg-rose-500/10 text-rose-200'
+ : label === 'High'
+ ? 'border-orange-400/20 bg-orange-500/10 text-orange-200'
+ : label === 'Medium'
+ ? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
+ : label === 'Low'
+ ? 'border-sky-400/20 bg-sky-500/10 text-sky-200'
+ : 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
+
+ return {label}
+}
+
+export default function SecurityReportIndex({ latest, reports, canRunScan }) {
+ const flash = usePage().props.flash ?? {}
+ const items = reports?.data ?? []
+
+ const runScan = () => {
+ router.post('/moderation/system/security-report/run')
+ }
+
+ return (
+
+
+
+
+ {flash.success ? (
+
+ {flash.success}
+
+ ) : null}
+
+ {latest ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
Latest scan
+
+
+ {latest.status}
+
+
+ {canRunScan ? (
+
+ Run Scan
+
+ ) : null}
+
+
+
+
+
Started
+ {formatTimestamp(latest.started_at)}
+
+
+
Finished
+ {formatTimestamp(latest.finished_at)}
+
+
+
Triggered by
+ {latest.triggered_by || 'Unknown'}
+
+
+
Composer outdated
+ {latest.composer_outdated_count}
+
+
+
npm outdated
+ {latest.npm_outdated_count}
+
+
+
Requested by
+ {latest.user ? (latest.user.username ? `@${latest.user.username}` : latest.user.name) : 'System'}
+
+
+
+
+
+ >
+ ) : (
+
+
No security report has been generated yet.
+
Run php artisan security:scan --notify or use the manual scan button after the queue worker is running.
+ {canRunScan ? (
+
+ Run Scan
+
+ ) : null}
+
+ )}
+
+
+
+
Scan History
+
+
+
+
+
+
+ ID
+ Finished
+ Status
+ Risk
+ Critical
+ High
+ Medium
+ Low
+ Outdated
+
+
+
+
+ {items.length === 0 ? (
+
+ No reports recorded yet.
+
+ ) : items.map((report) => (
+
+ #{report.id}
+ {formatTimestamp(report.finished_at)}
+ {report.status}
+
+ {report.total_critical}
+ {report.total_high}
+ {report.total_medium}
+ {report.total_low}
+ Composer: {report.composer_outdated_count}, npm: {report.npm_outdated_count}
+
+ Details
+
+
+ ))}
+
+
+
+
+ {reports?.last_page > 1 ? (
+
+
Showing {reports.from}–{reports.to} of {reports.total} reports
+
+ {reports.links.map((link, index) => (
+ link.url ? (
+ router.get(link.url, {}, { preserveScroll: true, preserveState: true })}
+ className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
+ dangerouslySetInnerHTML={{ __html: link.label }}
+ />
+ ) : (
+
+ )
+ ))}
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/resources/js/Pages/Admin/System/SecurityReportShow.jsx b/resources/js/Pages/Admin/System/SecurityReportShow.jsx
new file mode 100644
index 00000000..a8cac02c
--- /dev/null
+++ b/resources/js/Pages/Admin/System/SecurityReportShow.jsx
@@ -0,0 +1,177 @@
+import React from 'react'
+import { Head } from '@inertiajs/react'
+import AdminLayout from '../../../Layouts/AdminLayout'
+
+function formatTimestamp(value) {
+ if (!value) {
+ return 'Not recorded'
+ }
+
+ return new Intl.DateTimeFormat('en-GB', {
+ dateStyle: 'medium',
+ timeStyle: 'medium',
+ }).format(new Date(value))
+}
+
+function JsonPanel({ title, value }) {
+ return (
+
+
{title}
+
+ {JSON.stringify(value ?? {}, null, 2)}
+
+
+ )
+}
+
+function FindingTable({ title, rows, columns }) {
+ return (
+
+
+
{title}
+
+
+
+
+
+ {columns.map((column) => (
+ {column.label}
+ ))}
+
+
+
+ {rows.length === 0 ? (
+
+ No entries in this section.
+
+ ) : rows.map((row, index) => (
+
+ {columns.map((column) => (
+
+ {row[column.key] || '—'}
+
+ ))}
+
+ ))}
+
+
+
+
+ )
+}
+
+export default function SecurityReportShow({ report }) {
+ return (
+
+
+
+
+
+
+
+
Status
+
{report.status}
+
+
+
Risk
+
{report.risk_label}
+
+
+
Started
+
{formatTimestamp(report.started_at)}
+
+
+
Finished
+
{formatTimestamp(report.finished_at)}
+
+
+
Triggered by
+
{report.triggered_by || 'Unknown'}
+
+
+
Requested by
+
{report.user ? (report.user.username ? `@${report.user.username}` : report.user.name) : 'System'}
+
+
+
Composer outdated
+
{report.composer_outdated_count}
+
+
+
npm outdated
+
{report.npm_outdated_count}
+
+
+
+ {report.error_message ? (
+
+ {report.error_message}
+
+ ) : null}
+
+
+
+
+
+
+
Critical
+
{report.total_critical}
+
+
+
High
+
{report.total_high}
+
+
+
Medium
+
{report.total_medium}
+
+
+
Low
+
{report.total_low}
+
+
+
Unknown
+
{report.total_unknown}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx b/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx
index ed5d8014..ef7b0846 100644
--- a/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx
+++ b/resources/js/Pages/Collection/FeaturedArtworksAdmin.jsx
@@ -324,7 +324,6 @@ export default function FeaturedArtworksAdmin() {
const { props } = usePage()
const composerRef = React.useRef(null)
const rosterRef = React.useRef(null)
- const loadMoreRef = React.useRef(null)
const endpoints = props.endpoints || {}
const capabilities = props.capabilities || {}
const seo = props.seo || {}
@@ -344,7 +343,7 @@ export default function FeaturedArtworksAdmin() {
const [selectedArtwork, setSelectedArtwork] = React.useState(null)
const [editingId, setEditingId] = React.useState(null)
const [form, setForm] = React.useState(emptyForm())
- const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
+ const [currentPage, setCurrentPage] = React.useState(1)
React.useEffect(() => {
setEntries(Array.isArray(props.entries) ? props.entries : [])
@@ -353,7 +352,7 @@ export default function FeaturedArtworksAdmin() {
}, [props.entries, props.stats, props.winner])
React.useEffect(() => {
- setVisibleCount(PAGE_SIZE)
+ setCurrentPage(1)
}, [deferredListQuery, filter, sortDirection, sortKey])
function scrollToSection(ref) {
@@ -631,27 +630,13 @@ export default function FeaturedArtworksAdmin() {
.sort((left, right) => compareEntries(left, right, sortKey, sortDirection))
}, [deferredListQuery, entries, filter, sortDirection, sortKey])
- const visibleEntries = React.useMemo(() => filteredEntries.slice(0, visibleCount), [filteredEntries, visibleCount])
- const hasMoreEntries = visibleEntries.length < filteredEntries.length
+ const totalPages = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
+ const visibleEntries = React.useMemo(() => filteredEntries.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), [filteredEntries, currentPage])
- React.useEffect(() => {
- if (!hasMoreEntries || typeof IntersectionObserver === 'undefined' || !loadMoreRef.current) {
- return undefined
- }
-
- const observer = new IntersectionObserver((observerEntries) => {
- if (observerEntries.some((observerEntry) => observerEntry.isIntersecting)) {
- setVisibleCount((current) => Math.min(current + PAGE_SIZE, filteredEntries.length))
- }
- }, { rootMargin: '320px 0px' })
-
- observer.observe(loadMoreRef.current)
-
- return () => observer.disconnect()
- }, [filteredEntries.length, hasMoreEntries, visibleCount])
-
- function loadMoreEntries() {
- setVisibleCount((current) => Math.min(current + PAGE_SIZE, filteredEntries.length))
+ function goToPage(next) {
+ const page = Math.max(1, Math.min(totalPages, next))
+ setCurrentPage(page)
+ if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
@@ -900,20 +885,27 @@ export default function FeaturedArtworksAdmin() {
))}
- {hasMoreEntries ? (
-
-
Loading more rows as you reach the bottom.
+ {filteredEntries.length > PAGE_SIZE ? (
+
goToPage(currentPage - 1)}
+ disabled={currentPage === 1}
+ className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed hover:border-white/20 hover:bg-white/5"
>
- Load 24 more
+ Previous
+
+
+
Page {currentPage} of {totalPages}
+
+
goToPage(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed hover:border-white/20 hover:bg-white/5"
+ >
+ Next
-
- ) : filteredEntries.length > PAGE_SIZE ? (
-
- All matching rows loaded
) : null}
diff --git a/resources/views/academy.blade.php b/resources/views/academy.blade.php
index b2bcada1..4c5279e4 100644
--- a/resources/views/academy.blade.php
+++ b/resources/views/academy.blade.php
@@ -4,7 +4,7 @@
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
@endif
- @vite(['resources/js/academy.jsx'])
+
diff --git a/resources/views/layouts/nova/toolbar.blade.php b/resources/views/layouts/nova/toolbar.blade.php
index 43be18d6..abbdf555 100644
--- a/resources/views/layouts/nova/toolbar.blade.php
+++ b/resources/views/layouts/nova/toolbar.blade.php
@@ -52,6 +52,7 @@
request()->is(...array_merge(['browse', 'tags', 'tags/*'], $toolbarContentTypeSlugs)) => 'browse',
request()->is('groups', 'groups/*') => 'groups',
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following', 'leaderboard') => 'creators',
+ request()->is('academy', 'academy/*') => 'academy',
request()->is('forum', 'forum/*', 'news', 'news/*') => 'community',
default => null,
};
@@ -193,6 +194,40 @@
+ @if(config('academy.enabled', true))
+
+ @endif
+
{{-- COMMUNITY --}}
+ @if(config('academy.enabled', true))
+
+ @endif
+
Community
diff --git a/routes/api.php b/routes/api.php
index 020d8999..bb0396aa 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -61,7 +61,7 @@ Route::middleware(['web', 'throttle:300,1'])
->whereNumber('id')
->name('api.art.similar');
-Route::middleware(['web', 'throttle:120,1'])
+Route::middleware(['web', 'throttle:vector-search'])
->get('art/{id}/similar-ai', \App\Http\Controllers\Api\SimilarAiArtworksController::class)
->whereNumber('id')
->name('api.art.similar-ai');
diff --git a/routes/console.php b/routes/console.php
index dd9174d4..8219acf7 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -192,7 +192,7 @@ Schedule::command('nova:recalculate-heat')
// active scheduler in this app is defined in routes/console.php, not Kernel.
// Generate static sitemap XML files that nginx can serve directly without PHP.
-// The generate command writes public/sitemap.xml + public/sitemaps/{name}.xml.
+// The generate command writes public/sitemaps/sitemap.xml + public/sitemaps/{name}.xml.
Schedule::command('skinbase:sitemaps:generate')
->cron('30 10,22 * * *')
->name('sitemaps-generate')
@@ -211,6 +211,14 @@ Schedule::command('skinbase:sitemaps:validate')
->withoutOverlapping()
->runInBackground();
+if ((bool) config('security-report.enabled', true)) {
+ Schedule::command('security:scan --notify')
+ ->weeklyOn(1, '05:15')
+ ->name('security-report-weekly-scan')
+ ->withoutOverlapping()
+ ->runInBackground();
+}
+
// Keep the old release-pipeline cleanup running so stale release artifacts are pruned.
Schedule::job(new \App\Jobs\Sitemaps\CleanupSitemapReleasesJob())
diff --git a/routes/web.php b/routes/web.php
index 0ec5bf48..ee27ec67 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -419,6 +419,7 @@ Route::get('/collections/program/{programKey}', [\App\Http\Controllers\Web\Colle
Route::get('/collections/recommended', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'recommended'])
->name('collections.recommended');
Route::get('/collections/search', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'search'])
+ ->middleware('throttle:search')
->name('collections.search');
Route::get('/groups', [\App\Http\Controllers\GroupController::class, 'index'])->name('groups.index');
@@ -1046,7 +1047,9 @@ require __DIR__.'/auth.php';
require __DIR__.'/legacy.php';
// ── SEARCH ────────────────────────────────────────────────────────────────────
-Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])->name('search');
+Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])
+ ->middleware('throttle:search')
+ ->name('search');
// ── MISC ──────────────────────────────────────────────────────────────────────
Route::view('/data-deletion', 'privacy.data-deletion')->name('privacy.data_deletion');
@@ -1108,6 +1111,14 @@ Route::middleware(['auth', 'admin.access'])
Route::get('/uploads', [AdminController::class, 'uploadQueue'])->name('uploads');
Route::get('/settings', [AdminController::class, 'settings'])->name('settings');
Route::middleware('admin.role')->get('/auth-audit', [AdminController::class, 'authAudit'])->name('auth-audit');
+ Route::middleware('admin.role')
+ ->prefix('system/security-report')
+ ->name('system.security-report.')
+ ->group(function (): void {
+ Route::get('/', [\App\Http\Controllers\Admin\SecurityReportController::class, 'index'])->name('index');
+ Route::post('/run', [\App\Http\Controllers\Admin\SecurityReportController::class, 'run'])->name('run');
+ Route::get('/{securityReport}', [\App\Http\Controllers\Admin\SecurityReportController::class, 'show'])->whereNumber('securityReport')->name('show');
+ });
Route::middleware(['artwork.maturity.access'])
->prefix('ai-biography')
@@ -1280,6 +1291,7 @@ Route::middleware(['auth'])
->name('feed.saved');
Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController::class, 'index'])
+ ->middleware('throttle:search')
->name('feed.search');
// ── CONTENT BROWSER (artwork / category universal router) ─────────────────────
diff --git a/tests/Feature/ArtworkAwardTest.php b/tests/Feature/ArtworkAwardTest.php
index 2fe602c9..18828f43 100644
--- a/tests/Feature/ArtworkAwardTest.php
+++ b/tests/Feature/ArtworkAwardTest.php
@@ -412,6 +412,10 @@ test('removing a medal dispatches artwork reindexing', function () {
});
test('cache invalidation occurs after medal updates', function () {
+ // HomepageService::clearFeaturedAndMedalCaches() forgets homepage.hero.{segment},
+ // homepage.community-favorites.8.{segment} and homepage.hall-of-fame.8.{segment} for
+ // each viewer-visibility segment (see HomepageService::viewerCacheSegment()) — not the
+ // bare, unsegmented keys this test originally asserted against pre-segmentation.
$homepage = app(HomepageService::class);
$service = app(ArtworkAwardService::class);
$user = User::factory()->create(['created_at' => now()->subDays(30), 'email_verified_at' => now()]);
@@ -420,15 +424,24 @@ test('cache invalidation occurs after medal updates', function () {
Config::set('homepage.guest_payload_key', $guestPayloadKey);
- Cache::put('homepage.hero', ['stale' => true], 600);
- Cache::put('homepage.community-favorites.8', ['stale' => true], 600);
- Cache::put('homepage.hall-of-fame.8', ['stale' => true], 600);
+ $segments = ['visibility-hide', 'visibility-blur', 'visibility-show'];
+ $segmentedKeys = collect($segments)
+ ->flatMap(fn (string $segment): array => [
+ "homepage.hero.{$segment}",
+ "homepage.community-favorites.8.{$segment}",
+ "homepage.hall-of-fame.8.{$segment}",
+ ])
+ ->all();
+
+ foreach ($segmentedKeys as $key) {
+ Cache::put($key, ['stale' => true], 600);
+ }
Cache::store($homepage->guestPayloadCacheStoreName())->put($guestPayloadKey, ['stale' => true], 600);
$service->award($artwork, $user, 'gold');
- expect(Cache::get('homepage.hero'))->toBeNull()
- ->and(Cache::get('homepage.community-favorites.8'))->toBeNull()
- ->and(Cache::get('homepage.hall-of-fame.8'))->toBeNull()
- ->and(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
+ foreach ($segmentedKeys as $key) {
+ expect(Cache::get($key))->toBeNull();
+ }
+ expect(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull();
});
diff --git a/tests/Feature/Artworks/ArtworkFeaturedThumbnailStateTest.php b/tests/Feature/Artworks/ArtworkFeaturedThumbnailStateTest.php
new file mode 100644
index 00000000..8feedc06
--- /dev/null
+++ b/tests/Feature/Artworks/ArtworkFeaturedThumbnailStateTest.php
@@ -0,0 +1,160 @@
+create(array_merge([
+ 'user_id' => User::factory()->create()->id,
+ 'hash' => str_repeat('a', 64),
+ 'file_ext' => 'png',
+ 'thumb_ext' => 'webp',
+ ], $attributes));
+}
+
+test('null featured_thumbnail_variants_json means not audited yet', function () {
+ $artwork = makeAuditableArtwork();
+
+ expect($artwork->featured_thumbnail_variants_json)->toBeNull()
+ ->and($artwork->featuredThumbnailAuditState())->toBe(['status' => 'not_audited', 'variants' => []])
+ ->and($artwork->hasFeaturedThumbnail())->toBeFalse()
+ ->and($artwork->hasFeaturedThumbnail('desktop'))->toBeFalse();
+});
+
+test('empty array means audited with no featured variants available', function () {
+ $artwork = makeAuditableArtwork();
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => [],
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
+ $fresh = $artwork->fresh();
+
+ expect($fresh->featured_thumbnail_variants_json)->toBe([])
+ ->and($fresh->featuredThumbnailAuditState())->toBe(['status' => 'no_variants', 'variants' => []])
+ ->and($fresh->hasFeaturedThumbnail())->toBeFalse();
+});
+
+test('a populated array means audited with those variants known to be available', function () {
+ $artwork = makeAuditableArtwork();
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => ['desktop', 'desktop_xl'],
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
+ $fresh = $artwork->fresh();
+
+ expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'available', 'variants' => ['desktop', 'desktop_xl']])
+ ->and($fresh->hasFeaturedThumbnail('desktop'))->toBeTrue()
+ ->and($fresh->hasFeaturedThumbnail('desktop_xl'))->toBeTrue()
+ ->and($fresh->hasFeaturedThumbnail('mobile'))->toBeFalse();
+});
+
+test('malformed json in the column is treated as not audited rather than throwing', function () {
+ $artwork = makeAuditableArtwork();
+
+ // Bypass the array cast to write a raw, non-JSON-array value directly, simulating
+ // legacy or corrupted data that predates this column's introduction.
+ DB::table('artworks')->where('id', $artwork->id)->update([
+ 'featured_thumbnail_variants_json' => '"not-an-array"',
+ ]);
+
+ $fresh = $artwork->fresh();
+
+ expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'not_audited', 'variants' => []])
+ ->and($fresh->hasFeaturedThumbnail())->toBeFalse();
+});
+
+test('non-string entries in the variants array are ignored rather than matched', function () {
+ $artwork = makeAuditableArtwork();
+
+ DB::table('artworks')->where('id', $artwork->id)->update([
+ 'featured_thumbnail_variants_json' => json_encode(['desktop', 42, null, ['nested' => true]]),
+ ]);
+
+ $fresh = $artwork->fresh();
+
+ expect($fresh->featuredThumbnailAuditState())->toBe(['status' => 'available', 'variants' => ['desktop']])
+ ->and($fresh->hasFeaturedThumbnail('desktop'))->toBeTrue()
+ ->and($fresh->hasFeaturedThumbnail('tablet'))->toBeFalse();
+});
+
+test('an artwork without a hash never reports a featured thumbnail regardless of audit state', function () {
+ $artwork = makeAuditableArtwork(['hash' => null, 'file_ext' => null]);
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => ['desktop'],
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
+ expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeFalse();
+});
+
+test('changing an artwork hash resets stale featured thumbnail audit state and queues regeneration for featured artworks', function () {
+ Queue::fake();
+
+ $artwork = makeAuditableArtwork([
+ 'hash' => str_repeat('b', 64),
+ 'is_public' => true,
+ 'is_approved' => true,
+ 'published_at' => now()->subHour(),
+ ]);
+
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => ['desktop', 'desktop_xl'],
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
+ DB::table('artwork_features')->insert([
+ 'artwork_id' => $artwork->id,
+ 'featured_at' => now()->subHour(),
+ 'expires_at' => null,
+ 'priority' => 500,
+ 'label' => null,
+ 'note' => null,
+ 'is_active' => true,
+ 'created_by' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ 'deleted_at' => null,
+ ]);
+
+ // Old hash reported 2 available variants — that state is now stale because the
+ // object paths it referred to were computed from the old hash.
+ expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeTrue();
+
+ $artwork->hash = str_repeat('c', 64);
+ $artwork->save();
+
+ $fresh = $artwork->fresh();
+
+ expect($fresh->featured_thumbnail_variants_json)->toBeNull()
+ ->and($fresh->featured_thumbnails_checked_at)->toBeNull()
+ ->and($fresh->hasFeaturedThumbnail('desktop'))->toBeFalse();
+
+ Queue::assertPushed(GenerateFeaturedArtworkThumbnailsJob::class);
+});
+
+test('changing hash on an artwork with no active feature row does not queue regeneration', function () {
+ Queue::fake();
+
+ $artwork = makeAuditableArtwork(['hash' => str_repeat('d', 64)]);
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => ['desktop'],
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
+ $artwork->hash = str_repeat('e', 64);
+ $artwork->save();
+
+ expect($artwork->fresh()->featured_thumbnail_variants_json)->toBeNull();
+ Queue::assertNotPushed(GenerateFeaturedArtworkThumbnailsJob::class);
+});
diff --git a/tests/Feature/FeaturedArtworkAdminTest.php b/tests/Feature/FeaturedArtworkAdminTest.php
index 63740d59..252dcf64 100644
--- a/tests/Feature/FeaturedArtworkAdminTest.php
+++ b/tests/Feature/FeaturedArtworkAdminTest.php
@@ -10,8 +10,6 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
use Klevze\ControlPanel\Models\Admin\AdminVerification;
-use Klevze\ControlPanel\Core\Structs\MenuRootItem;
-use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
uses(RefreshDatabase::class);
@@ -81,20 +79,35 @@ it('blocks non staff users from the featured artworks admin area', function ():
->assertRedirect(route('cp.login'));
});
-it('registers the featured artworks entry in the cpad menu', function (): void {
- $sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
+it('resolves featured artwork admin routes to the current moderation surface, not the legacy cpad menu target', function (): void {
+ // The original version of this test asserted that the legacy cpad sidebar (built by
+ // packages/klevze/Plugins/Artworks/ServiceProvider::boot()) had a "Featured Artworks"
+ // item under the "Artworks" root pointing at admin.cp.artworks.featured.main. That
+ // item was removed once featured-artwork management moved to the /moderation surface
+ // (routes/web.php, name admin.artworks.featured.*) — admin.cp.artworks.featured.main
+ // now exists only as a redirect alias to /moderation/artworks/featured
+ // (packages/klevze/Plugins/Artworks/Routes/admin.php:24), and the new surface's
+ // navigation is owned by the React/Inertia frontend, not this PHP-built sidebar.
+ //
+ // The sidebar itself can't be asserted on here at all: ServiceProvider::boot() only
+ // registers menu items when shouldRegisterControlPanelUi() is true, which explicitly
+ // returns false whenever app()->runningInConsole() is true — and `php artisan test`
+ // always runs in console, so getSidebarMenu() is empty for every test in this suite,
+ // regardless of this change. That's an environment property of the legacy menu
+ // builder, not something this test can meaningfully exercise.
+ //
+ // What's left to verify, and is exercised by other tests in this file (see "renders
+ // the featured artworks admin index..." and "clears homepage hero cache..."), is that
+ // the legacy route still redirects to the canonical surface rather than 404ing or
+ // rendering the retired page directly.
+ expect(route('admin.artworks.featured.main'))->toBe(url('/moderation/artworks/featured'));
+ expect(route('admin.cp.artworks.featured.main'))->toBe(url('/cp/artworks/featured'));
- $editorialRoot = $sidebarMenu
- ->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
+ $admin = createControlPanelAdmin();
- expect($editorialRoot)->toBeInstanceOf(MenuRootItem::class);
-
- $featuredItem = collect($editorialRoot->getItems())
- ->first(fn ($item): bool => ($item->name ?? null) === 'Featured Artworks');
-
- expect($featuredItem)->not->toBeNull()
- ->and($featuredItem->mainRoute)->toBe('admin.cp.artworks.featured.main')
- ->and($featuredItem->icon)->toBe('fas fa-star');
+ $this->actingAs($admin)->actingAs($admin, 'controlpanel')
+ ->get(route('admin.cp.artworks.featured.main'))
+ ->assertRedirect('/moderation/artworks/featured');
});
it('renders the featured artworks admin index with the current winner summary', function (): void {
@@ -108,17 +121,20 @@ it('renders the featured artworks admin index with the current winner summary',
medalScore($higherMedal, 12);
medalScore($runnerUp, 3);
+ // admin.cp.artworks.featured.main (legacy cpad alias) now just redirects to the
+ // canonical /moderation surface (packages/klevze/Plugins/Artworks/Routes/admin.php:24),
+ // so the page itself is rendered at admin.artworks.featured.main.
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
- ->get(route('admin.cp.artworks.featured.main'))
+ ->get(route('admin.artworks.featured.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
- ->component('Collection/FeaturedArtworksAdmin')
+ ->component('Moderation/FeaturedArtworks')
->where('winner.artwork.id', $higherMedal->id)
->where('winner.medals.score_30d', 12)
->where('winner.selection_reason', 'Tied on priority, won on higher 30-day medal score.')
->where('entries.0.is_winner', true)
->where('entries.0.artwork.id', $higherMedal->id)
- ->where('endpoints.store', route('admin.cp.artworks.featured.store')));
+ ->where('endpoints.store', route('admin.artworks.featured.store')));
});
it('allows admins to create featured rows', function (): void {
@@ -259,11 +275,12 @@ it('marks expired and ineligible rows on the index page', function (): void {
featureRow($privateArtwork, ['priority' => 300]);
featureRow($expiredArtwork, ['priority' => 200, 'expires_at' => now()->subMinute()]);
+ // See note above: the cpad alias redirects, so the page renders at admin.artworks.featured.main.
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
- ->get(route('admin.cp.artworks.featured.main'))
+ ->get(route('admin.artworks.featured.main'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
- ->component('Collection/FeaturedArtworksAdmin')
+ ->component('Moderation/FeaturedArtworks')
->where('entries.0.artwork.id', $privateArtwork->id)
->where('entries.0.eligibility.is_eligible', false)
->where('entries.0.eligibility.reasons.0', 'Private')
@@ -274,10 +291,30 @@ it('marks expired and ineligible rows on the index page', function (): void {
});
it('clears homepage hero cache after create update toggle and delete actions', function (): void {
+ // HomepageService::clearFeaturedAndMedalCaches() forgets homepage.hero.{segment} for
+ // each viewer-visibility segment (not a bare "homepage.hero" key) — the segmentation
+ // was introduced by a prior commit (see HomepageService::viewerCacheSegment()). Assert
+ // against the real segmented keys instead of the pre-segmentation key this test used.
+ $heroCacheKeys = [
+ 'homepage.hero.visibility-hide',
+ 'homepage.hero.visibility-blur',
+ 'homepage.hero.visibility-show',
+ ];
+ $seedHeroCache = function () use ($heroCacheKeys): void {
+ foreach ($heroCacheKeys as $key) {
+ Cache::put($key, ['stale' => true], 600);
+ }
+ };
+ $assertHeroCacheCleared = function () use ($heroCacheKeys): void {
+ foreach ($heroCacheKeys as $key) {
+ expect(Cache::has($key))->toBeFalse();
+ }
+ };
+
$admin = createControlPanelAdmin();
$artwork = adminArtwork();
- Cache::put('homepage.hero', ['stale' => true], 600);
+ $seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->postJson(route('admin.cp.artworks.featured.store'), [
@@ -289,11 +326,11 @@ it('clears homepage hero cache after create update toggle and delete actions', f
])
->assertOk();
- expect(Cache::has('homepage.hero'))->toBeFalse();
+ $assertHeroCacheCleared();
$feature = ArtworkFeature::query()->firstOrFail();
- Cache::put('homepage.hero', ['stale' => true], 600);
+ $seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [
'priority' => 110,
@@ -302,17 +339,17 @@ it('clears homepage hero cache after create update toggle and delete actions', f
'is_active' => true,
])
->assertOk();
- expect(Cache::has('homepage.hero'))->toBeFalse();
+ $assertHeroCacheCleared();
- Cache::put('homepage.hero', ['stale' => true], 600);
+ $seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id]))
->assertOk();
- expect(Cache::has('homepage.hero'))->toBeFalse();
+ $assertHeroCacheCleared();
- Cache::put('homepage.hero', ['stale' => true], 600);
+ $seedHeroCache();
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id]))
->assertOk();
- expect(Cache::has('homepage.hero'))->toBeFalse();
+ $assertHeroCacheCleared();
});
\ No newline at end of file
diff --git a/tests/Feature/HomepageFeaturedMedalsTest.php b/tests/Feature/HomepageFeaturedMedalsTest.php
index 4242be67..acf529a7 100644
--- a/tests/Feature/HomepageFeaturedMedalsTest.php
+++ b/tests/Feature/HomepageFeaturedMedalsTest.php
@@ -6,6 +6,7 @@ use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\HomepageService;
+use App\Services\Images\FeaturedArtworkThumbnailGenerator;
use App\Support\ArtworkFeaturedImagePath;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
@@ -303,6 +304,7 @@ test('homepage hero payload uses the forced hero artwork when one is set', funct
test('homepage renders featured hero picture and preload from dedicated featured thumbnails', function () {
Cache::flush();
+ app(HomepageService::class)->clearFeaturedAndMedalCaches();
Storage::fake('s3');
config([
'uploads.object_storage.disk' => 's3',
@@ -335,10 +337,19 @@ test('homepage renders featured hero picture and preload from dedicated featured
$paths = app(ArtworkFeaturedImagePath::class);
+ // Featured-thumbnail existence is precomputed by FeaturedArtworkThumbnailGenerator
+ // (queued off the request path) and persisted on the artwork row; the homepage
+ // request only reads that state, so seed both the fake disk (for URL building)
+ // and the DB column here rather than relying on a live Storage::exists() check.
foreach ($paths->variantNames() as $variant) {
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
}
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => $paths->variantNames(),
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
$desktopUrl = $paths->url($artwork, 'desktop');
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
$mobileXsUrl = $paths->url($artwork, 'mobile_xs');
@@ -355,6 +366,125 @@ test('homepage renders featured hero picture and preload from dedicated featured
->assertSee('fetchpriority="high"', false);
});
+test('featured hero image resolution never checks the remote disk', function () {
+ Cache::flush();
+ app(HomepageService::class)->clearFeaturedAndMedalCaches();
+ Storage::fake('s3');
+ config([
+ 'uploads.object_storage.disk' => 's3',
+ 'cdn.files_url' => 'https://files.skinbase.org',
+ ]);
+
+ $owner = User::factory()->create();
+ $artwork = makeFeaturedArtwork([
+ 'user_id' => $owner->id,
+ 'title' => 'Hero Without Live Storage Checks',
+ 'hash' => str_repeat('e', 64),
+ 'file_ext' => 'png',
+ 'thumb_ext' => 'webp',
+ ]);
+
+ DB::table('artwork_features')->insert([
+ 'artwork_id' => $artwork->id,
+ 'featured_at' => now()->subHour(),
+ 'expires_at' => null,
+ 'priority' => 900,
+ 'label' => null,
+ 'note' => null,
+ 'is_active' => true,
+ 'force_hero' => true,
+ 'created_by' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ 'deleted_at' => null,
+ ]);
+
+ $paths = app(ArtworkFeaturedImagePath::class);
+
+ $artwork->forceFill([
+ 'featured_thumbnail_variants_json' => $paths->variantNames(),
+ 'featured_thumbnails_checked_at' => now(),
+ ])->saveQuietly();
+
+ // Note: no files are ever written to the fake "s3" disk. If hasFeaturedThumbnail()
+ // fell back to a live Storage::exists() check, these URLs would resolve to null;
+ // since it reads the DB column instead, they resolve correctly with zero disk I/O.
+ $hero = app(HomepageService::class)->getHeroArtwork();
+
+ expect($hero)->not->toBeNull()
+ ->and($hero['id'])->toBe($artwork->id)
+ ->and($hero['featured_image']['variants']['desktop'])->toBe($paths->url($artwork, 'desktop'))
+ ->and($hero['featured_image']['variants']['desktop_xl'])->toBe($paths->url($artwork, 'desktop_xl'));
+
+ expect($artwork->fresh()->hasFeaturedThumbnail('desktop'))->toBeTrue();
+});
+
+test('generating featured thumbnail metadata invalidates the stale hero cache so the next resolution uses it', function () {
+ Cache::flush();
+ app(HomepageService::class)->clearFeaturedAndMedalCaches();
+ Storage::fake('s3');
+ config([
+ 'uploads.object_storage.disk' => 's3',
+ 'cdn.files_url' => 'https://files.skinbase.org',
+ ]);
+
+ $owner = User::factory()->create();
+ $artwork = makeFeaturedArtwork([
+ 'user_id' => $owner->id,
+ 'title' => 'Hero Metadata Refresh',
+ 'hash' => str_repeat('f', 64),
+ 'file_ext' => 'png',
+ 'thumb_ext' => 'webp',
+ ]);
+
+ DB::table('artwork_features')->insert([
+ 'artwork_id' => $artwork->id,
+ 'featured_at' => now()->subHour(),
+ 'expires_at' => null,
+ 'priority' => 900,
+ 'label' => null,
+ 'note' => null,
+ 'is_active' => true,
+ 'force_hero' => true,
+ 'created_by' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ 'deleted_at' => null,
+ ]);
+
+ $homepage = app(HomepageService::class);
+ $paths = app(ArtworkFeaturedImagePath::class);
+
+ // 1. Hero cache is built while featured metadata is unavailable: no variants have
+ // been persisted yet, so hasFeaturedThumbnail() reports false for every variant and
+ // the payload falls back to the non-featured thumbnail.
+ $heroBeforeGeneration = $homepage->getHeroArtwork();
+ expect($heroBeforeGeneration)->not->toBeNull()
+ ->and($heroBeforeGeneration['id'])->toBe($artwork->id)
+ ->and($heroBeforeGeneration['featured_image']['variants']['desktop'])->toBeNull();
+
+ // 2. Featured-thumbnail metadata is generated. The generator's plan() (also used by
+ // the skinbase:featured-thumbnails:generate command and GenerateFeaturedArtworkThumbnailsJob)
+ // discovers the now-present files and persists featured_thumbnail_variants_json via
+ // saveQuietly() — which does NOT fire ArtworkObserver/ArtworkFeatureObserver, so any
+ // cache invalidation has to come from the generator itself.
+ foreach ($paths->variantNames() as $variant) {
+ Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
+ }
+ app(FeaturedArtworkThumbnailGenerator::class)->plan($artwork->fresh());
+
+ // 3. The relevant hero cache (all three viewer-visibility segments) was invalidated as
+ // a side effect of that generation, without a full application cache flush.
+ foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
+ expect(Cache::has("homepage.hero.{$segment}"))->toBeFalse();
+ }
+
+ // 4. The next homepage resolution rebuilds the cache and now uses the featured thumbnail.
+ $heroAfterGeneration = $homepage->getHeroArtwork();
+ expect($heroAfterGeneration['featured_image']['variants']['desktop'])
+ ->toBe($paths->url($artwork, 'desktop'));
+});
+
test('community favorites returns artworks ordered by recent medal score', function () {
$owner = User::factory()->create();
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
diff --git a/tests/Feature/SecurityReport/SecurityReportAdminTest.php b/tests/Feature/SecurityReport/SecurityReportAdminTest.php
new file mode 100644
index 00000000..f04371c8
--- /dev/null
+++ b/tests/Feature/SecurityReport/SecurityReportAdminTest.php
@@ -0,0 +1,88 @@
+ true]);
+ }
+
+ public function test_guest_cannot_open_security_report_page(): void
+ {
+ $this->get('/moderation/system/security-report')
+ ->assertRedirect(route('login'));
+ }
+
+ public function test_normal_user_cannot_open_security_report_page(): void
+ {
+ $user = User::factory()->create(['role' => 'user']);
+
+ $this->actingAs($user)
+ ->get('/moderation/system/security-report')
+ ->assertRedirect(route('index'));
+ }
+
+ public function test_staff_non_admin_cannot_open_security_report_page(): void
+ {
+ $manager = User::factory()->create(['role' => 'manager']);
+
+ $this->actingAs($manager)
+ ->get('/moderation/system/security-report')
+ ->assertForbidden();
+ }
+
+ public function test_admin_can_open_security_report_index_and_detail(): void
+ {
+ $admin = User::factory()->create(['role' => 'admin']);
+ $report = SecurityReport::query()->create([
+ 'status' => 'completed_with_findings',
+ 'started_at' => now()->subMinute(),
+ 'finished_at' => now(),
+ 'total_critical' => 1,
+ 'total_high' => 2,
+ 'total_medium' => 3,
+ 'total_low' => 4,
+ 'total_unknown' => 0,
+ 'composer_outdated_count' => 5,
+ 'npm_outdated_count' => 6,
+ 'triggered_by' => 'artisan',
+ 'summary' => [
+ 'total' => ['critical' => 1, 'high' => 2, 'medium' => 3, 'low' => 4, 'unknown' => 0],
+ ],
+ 'composer_audit' => ['advisories' => ['laravel/framework' => [['severity' => 'high', 'title' => 'Test advisory']]]],
+ 'npm_audit' => ['vulnerabilities' => ['vite' => ['severity' => 'moderate', 'via' => [['title' => 'Moderate issue']]]]],
+ ]);
+
+ $this->actingAs($admin)
+ ->get('/moderation/system/security-report')
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('Admin/System/SecurityReportIndex')
+ ->where('latest.id', $report->id)
+ ->where('latest.total_critical', 1)
+ ->where('reports.data.0.id', $report->id));
+
+ $this->actingAs($admin)
+ ->get('/moderation/system/security-report/' . $report->id)
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('Admin/System/SecurityReportShow')
+ ->where('report.id', $report->id)
+ ->where('report.total_high', 2)
+ ->where('report.composer_advisories.0.package', 'laravel/framework'));
+ }
+}
diff --git a/tests/Feature/SitemapTest.php b/tests/Feature/SitemapTest.php
index d9c01494..d78acb71 100644
--- a/tests/Feature/SitemapTest.php
+++ b/tests/Feature/SitemapTest.php
@@ -3,6 +3,12 @@
declare(strict_types=1);
use App\Models\Artwork;
+use App\Models\AcademyChallenge;
+use App\Models\AcademyCourse;
+use App\Models\AcademyCourseLesson;
+use App\Models\AcademyLesson;
+use App\Models\AcademyPromptPack;
+use App\Models\AcademyPromptTemplate;
use App\Models\Category;
use App\Models\Collection;
use App\Models\ContentType;
@@ -46,12 +52,24 @@ it('renders the sitemap index and every child sitemap endpoint', function (): vo
expect($indexXml)->not->toBeFalse();
expect($indexResponse->getContent())
->toContain(url('/sitemaps/artworks.xml'))
+ ->toContain(url('/sitemaps/academy-pages.xml'))
+ ->toContain(url('/sitemaps/academy-courses.xml'))
+ ->toContain(url('/sitemaps/academy-lessons.xml'))
+ ->toContain(url('/sitemaps/academy-prompts.xml'))
+ ->toContain(url('/sitemaps/academy-packs.xml'))
+ ->toContain(url('/sitemaps/academy-challenges.xml'))
->toContain(url('/sitemaps/static-pages.xml'))
->toContain(url('/sitemaps/forum-threads.xml'))
->toContain(url('/sitemaps/news-google.xml'));
foreach ([
'artworks',
+ 'academy-pages',
+ 'academy-courses',
+ 'academy-lessons',
+ 'academy-prompts',
+ 'academy-packs',
+ 'academy-challenges',
'users',
'tags',
'categories',
@@ -139,8 +157,36 @@ it('includes only public canonical urls and exposes the sitemap in robots txt',
->toContain('http://skinbase26.test/')
->toContain('/about')
->toContain('/pages/community-handbook')
+ ->not->toContain('/academy')
+ ->not->toContain('/academy/pricing')
->not->toContain('/pages/about');
+ $academyPagesXml = $this->get('/sitemaps/academy-pages.xml')->assertOk()->getContent();
+ expect($academyPagesXml)
+ ->toContain(route('academy.index'))
+ ->toContain(route('academy.pricing'));
+
+ $academyCoursesXml = $this->get('/sitemaps/academy-courses.xml')->assertOk()->getContent();
+ expect($academyCoursesXml)
+ ->toContain($fixtures['academy_course_url']);
+
+ $academyLessonsXml = $this->get('/sitemaps/academy-lessons.xml')->assertOk()->getContent();
+ expect($academyLessonsXml)
+ ->toContain($fixtures['academy_lesson_url'])
+ ->not->toContain($fixtures['academy_course_lesson_url']);
+
+ $academyPromptsXml = $this->get('/sitemaps/academy-prompts.xml')->assertOk()->getContent();
+ expect($academyPromptsXml)
+ ->toContain(route('academy.prompts.popular'))
+ ->toContain($fixtures['academy_prompt_url'])
+ ->not->toContain(route('academy.prompts.popular', ['period' => '7d']));
+
+ $academyPacksXml = $this->get('/sitemaps/academy-packs.xml')->assertOk()->getContent();
+ expect($academyPacksXml)->toContain($fixtures['academy_pack_url']);
+
+ $academyChallengesXml = $this->get('/sitemaps/academy-challenges.xml')->assertOk()->getContent();
+ expect($academyChallengesXml)->toContain($fixtures['academy_challenge_url']);
+
$robots = $this->get('/robots.txt')
->assertOk()
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
@@ -149,6 +195,38 @@ it('includes only public canonical urls and exposes the sitemap in robots txt',
expect($robots)->toContain('Sitemap: http://skinbase26.test/sitemap.xml');
});
+it('includes only canonical academy urls in academy sitemap families', function (): void {
+ $fixtures = seedSitemapFixtures();
+
+ $academyCoursesXml = $this->get('/sitemaps/academy-courses.xml')->assertOk()->getContent();
+ expect($academyCoursesXml)
+ ->toContain(route('academy.courses.index'))
+ ->toContain($fixtures['academy_course_url']);
+
+ $academyLessonsXml = $this->get('/sitemaps/academy-lessons.xml')->assertOk()->getContent();
+ expect($academyLessonsXml)
+ ->toContain(route('academy.lessons.index'))
+ ->toContain($fixtures['academy_lesson_url'])
+ ->not->toContain($fixtures['academy_course_lesson_url']);
+
+ $academyPromptsXml = $this->get('/sitemaps/academy-prompts.xml')->assertOk()->getContent();
+ expect($academyPromptsXml)
+ ->toContain(route('academy.prompts.index'))
+ ->toContain(route('academy.prompts.popular'))
+ ->toContain($fixtures['academy_prompt_url'])
+ ->not->toContain(route('academy.prompts.popular', ['period' => '7d']));
+
+ $academyPacksXml = $this->get('/sitemaps/academy-packs.xml')->assertOk()->getContent();
+ expect($academyPacksXml)
+ ->toContain(route('academy.packs.index'))
+ ->toContain($fixtures['academy_pack_url']);
+
+ $academyChallengesXml = $this->get('/sitemaps/academy-challenges.xml')->assertOk()->getContent();
+ expect($academyChallengesXml)
+ ->toContain(route('academy.challenges.index'))
+ ->toContain($fixtures['academy_challenge_url']);
+});
+
it('returns 404 for unknown sitemap names', function (): void {
$this->get('/sitemaps/not-a-real-sitemap.xml')->assertNotFound();
});
@@ -547,6 +625,71 @@ function seedSitemapFixtures(): array
'last_post_at' => now()->subHours(2),
]);
+ $academyCourse = AcademyCourse::query()->create([
+ 'title' => 'Sitemap Academy Course',
+ 'slug' => 'sitemap-academy-course',
+ 'excerpt' => 'An academy course for sitemap coverage.',
+ 'description' => 'Course sitemap description',
+ 'access_level' => 'free',
+ 'difficulty' => 'beginner',
+ 'status' => 'published',
+ 'published_at' => now()->subMinute(),
+ ]);
+
+ $academyLesson = AcademyLesson::query()->create([
+ 'title' => 'Sitemap Academy Lesson',
+ 'slug' => 'sitemap-academy-lesson',
+ 'excerpt' => 'An academy lesson for sitemap coverage.',
+ 'content' => 'Lesson sitemap content',
+ 'difficulty' => 'beginner',
+ 'access_level' => 'free',
+ 'lesson_type' => 'article',
+ 'active' => true,
+ 'published_at' => now()->subMinute(),
+ ]);
+
+ AcademyCourseLesson::query()->create([
+ 'course_id' => $academyCourse->id,
+ 'lesson_id' => $academyLesson->id,
+ 'order_num' => 0,
+ 'is_required' => true,
+ ]);
+
+ $academyPrompt = AcademyPromptTemplate::query()->create([
+ 'title' => 'Sitemap Academy Prompt',
+ 'slug' => 'sitemap-academy-prompt',
+ 'excerpt' => 'An academy prompt for sitemap coverage.',
+ 'prompt' => 'Create a cinematic moonlit portrait.',
+ 'difficulty' => 'beginner',
+ 'access_level' => 'free',
+ 'active' => true,
+ 'published_at' => now()->subMinute(),
+ ]);
+
+ $academyPack = AcademyPromptPack::query()->create([
+ 'title' => 'Sitemap Academy Pack',
+ 'slug' => 'sitemap-academy-pack',
+ 'excerpt' => 'An academy pack for sitemap coverage.',
+ 'description' => 'Pack sitemap description',
+ 'access_level' => 'free',
+ 'active' => true,
+ 'published_at' => now()->subMinute(),
+ ]);
+
+ $academyChallenge = AcademyChallenge::query()->create([
+ 'title' => 'Sitemap Academy Challenge',
+ 'slug' => 'sitemap-academy-challenge',
+ 'excerpt' => 'An academy challenge for sitemap coverage.',
+ 'description' => 'Challenge sitemap description',
+ 'brief' => 'Create a hero image.',
+ 'rules' => 'Keep it public.',
+ 'access_level' => 'free',
+ 'status' => AcademyChallenge::STATUS_ACTIVE,
+ 'starts_at' => now()->subDay(),
+ 'ends_at' => now()->addDay(),
+ 'active' => true,
+ ]);
+
return [
'artwork_url' => route('art.show', [
'id' => $artwork->id,
@@ -556,6 +699,12 @@ function seedSitemapFixtures(): array
'profile_url' => route('profile.show', ['username' => 'sitemapuser']),
'collection_url' => route('profile.collections.show', ['username' => 'sitemapuser', 'slug' => 'showcase-set']),
'card_url' => route('cards.show', ['slug' => 'clarity-card', 'id' => $card->id]),
+ 'academy_course_url' => route('academy.courses.show', ['course' => $academyCourse->slug]),
+ 'academy_lesson_url' => route('academy.lessons.show', ['slug' => $academyLesson->slug]),
+ 'academy_course_lesson_url' => route('academy.courses.lessons.show', ['course' => $academyCourse->slug, 'lesson' => $academyLesson->slug]),
+ 'academy_prompt_url' => route('academy.prompts.show', ['slug' => $academyPrompt->slug]),
+ 'academy_pack_url' => route('academy.packs.show', ['slug' => $academyPack->slug]),
+ 'academy_challenge_url' => route('academy.challenges.show', ['slug' => $academyChallenge->slug]),
];
}
@@ -696,4 +845,4 @@ function extractUrlLocs(string $xml): array
static fn ($node): string => trim((string) $node),
$nodes,
)));
-}
\ No newline at end of file
+}
diff --git a/tests/Unit/SecurityReport/SecurityReportScannerTest.php b/tests/Unit/SecurityReport/SecurityReportScannerTest.php
new file mode 100644
index 00000000..6b47e5e9
--- /dev/null
+++ b/tests/Unit/SecurityReport/SecurityReportScannerTest.php
@@ -0,0 +1,59 @@
+summarizeNpmAudit([
+ 'metadata' => [
+ 'vulnerabilities' => [
+ 'critical' => 1,
+ 'high' => 2,
+ 'moderate' => 3,
+ 'low' => 4,
+ 'info' => 5,
+ ],
+ ],
+ ]);
+
+ self::assertSame(1, $counts['critical']);
+ self::assertSame(2, $counts['high']);
+ self::assertSame(3, $counts['moderate']);
+ self::assertSame(4, $counts['low']);
+ self::assertSame(5, $counts['info']);
+ }
+
+ public function test_it_summarizes_composer_advisory_severity_counts(): void
+ {
+ $scanner = new SecurityReportScanner();
+
+ $counts = $scanner->summarizeComposerAudit([
+ 'advisories' => [
+ 'laravel/framework' => [
+ ['severity' => 'critical'],
+ ['severity' => 'high'],
+ ],
+ 'symfony/http-foundation' => [
+ ['severity' => 'medium'],
+ ['severity' => 'low'],
+ ['severity' => 'unexpected'],
+ ],
+ ],
+ ]);
+
+ self::assertSame(1, $counts['critical']);
+ self::assertSame(1, $counts['high']);
+ self::assertSame(1, $counts['medium']);
+ self::assertSame(1, $counts['low']);
+ self::assertSame(1, $counts['unknown']);
+ }
+}