Current state with latest updates
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditableArtwork(array $attributes = []): Artwork
|
||||
{
|
||||
return Artwork::factory()->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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\SecurityReport;
|
||||
|
||||
use App\Models\SecurityReport;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class SecurityReportAdminTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['security-report.enabled' => 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'));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\SecurityReport;
|
||||
|
||||
use App\Services\SecurityReport\SecurityReportScanner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SecurityReportScannerTest extends TestCase
|
||||
{
|
||||
public function test_it_summarizes_npm_metadata_vulnerability_counts(): void
|
||||
{
|
||||
$scanner = new SecurityReportScanner();
|
||||
|
||||
$counts = $scanner->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user