create(['role' => 'admin']); $admin->forceFill([ 'isAdmin' => true, 'activated' => true, ])->save(); AdminVerification::createForUser($admin->fresh()); return $admin->fresh(); } } function adminArtwork(array $attributes = []): Artwork { return Artwork::factory()->create(array_merge([ 'title' => 'Featured Artwork ' . fake()->unique()->words(2, true), 'slug' => 'featured-artwork-' . fake()->unique()->numberBetween(1000, 9999), 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subHour(), 'has_missing_thumbnails' => false, ], $attributes)); } function featureRow(Artwork $artwork, array $attributes = []): ArtworkFeature { return ArtworkFeature::query()->create(array_merge([ 'artwork_id' => $artwork->id, 'priority' => 100, 'featured_at' => now()->subHour(), 'expires_at' => null, 'is_active' => true, ], $attributes)); } function medalScore(Artwork $artwork, int $score30d): void { DB::table('artwork_medal_stats')->insert([ 'artwork_id' => $artwork->id, 'gold_count' => 0, 'silver_count' => 0, 'bronze_count' => 0, 'score_total' => $score30d, 'score_7d' => $score30d, 'score_30d' => $score30d, 'last_medaled_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); } it('blocks non staff users from the featured artworks admin area', function (): void { $user = User::factory()->create(['role' => 'user']); $user->forceFill([ 'isAdmin' => false, 'activated' => true, ])->save(); $this->actingAs($user)->actingAs($user, 'controlpanel') ->get(route('admin.cp.artworks.featured.main')) ->assertRedirect(route('cp.login')); }); 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')); $admin = createControlPanelAdmin(); $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 { $admin = createControlPanelAdmin(); $owner = User::factory()->create(['username' => 'winnermaker']); $higherMedal = adminArtwork(['user_id' => $owner->id, 'title' => 'Higher Medal Winner']); $runnerUp = adminArtwork(['user_id' => $owner->id, 'title' => 'Runner Up']); featureRow($higherMedal, ['priority' => 100, 'featured_at' => now()->subHour()]); featureRow($runnerUp, ['priority' => 100, 'featured_at' => now()->subHour()]); 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.artworks.featured.main')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->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.artworks.featured.store'))); }); it('allows admins to create featured rows', function (): void { $admin = createControlPanelAdmin(); $artwork = adminArtwork(); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->postJson(route('admin.cp.artworks.featured.store'), [ 'artwork_id' => $artwork->id, 'priority' => 220, 'featured_at' => now()->toISOString(), 'expires_at' => now()->addDay()->toISOString(), 'is_active' => true, ]) ->assertOk() ->assertJsonPath('winner.artwork.id', $artwork->id) ->assertJsonPath('stats.total', 1); $feature = ArtworkFeature::query()->firstOrFail(); expect((int) $feature->artwork_id)->toBe($artwork->id) ->and((int) $feature->priority)->toBe(220) ->and((int) $feature->created_by)->toBe($admin->id) ->and((bool) $feature->is_active)->toBeTrue(); }); it('allows admins to update featured rows', function (): void { $admin = createControlPanelAdmin(); $feature = featureRow(adminArtwork(), [ 'priority' => 50, 'featured_at' => now()->subDays(2), 'is_active' => true, ]); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [ 'priority' => 180, 'featured_at' => now()->subHour()->toISOString(), 'expires_at' => now()->addHours(6)->toISOString(), 'is_active' => false, ]) ->assertOk() ->assertJsonPath('entries.0.priority', 180) ->assertJsonPath('entries.0.is_active', false); $fresh = $feature->fresh(); expect((int) $fresh->priority)->toBe(180) ->and((bool) $fresh->is_active)->toBeFalse() ->and($fresh->expires_at)->not->toBeNull(); }); it('allows admins to activate and deactivate featured rows', function (): void { $admin = createControlPanelAdmin(); $feature = featureRow(adminArtwork(), ['is_active' => true]); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id])) ->assertOk() ->assertJsonPath('entries.0.is_active', false); expect($feature->fresh()->is_active)->toBeFalse(); }); it('allows admins to force and unforce the homepage hero from the featured pool', function (): void { $admin = createControlPanelAdmin(); $owner = User::factory()->create(['username' => 'forcehero']); $naturalWinner = adminArtwork(['user_id' => $owner->id, 'title' => 'Natural Winner']); $forcedArtwork = adminArtwork(['user_id' => $owner->id, 'title' => 'Forced Winner']); $naturalFeature = featureRow($naturalWinner, ['priority' => 300, 'featured_at' => now()->subHour()]); $forcedFeature = featureRow($forcedArtwork, ['priority' => 100, 'featured_at' => now()->subDays(2)]); medalScore($naturalWinner, 50); medalScore($forcedArtwork, 1); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id])) ->assertOk() ->assertJsonPath('winner.artwork.id', $forcedArtwork->id) ->assertJsonPath('winner.is_force_hero', true) ->assertJsonPath('winner.selection_reason', 'Forced hero override is enabled for this featured artwork.'); expect($forcedFeature->fresh()->force_hero)->toBeTrue() ->and($naturalFeature->fresh()->force_hero)->toBeFalse() ->and(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(1); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id])) ->assertOk() ->assertJsonPath('winner.artwork.id', $naturalWinner->id) ->assertJsonPath('winner.is_force_hero', false) ->assertJsonPath('winner.selection_reason', 'Highest priority among active, eligible featured artworks.'); expect($forcedFeature->fresh()->force_hero)->toBeFalse() ->and(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(0); }); it('returns a forced hero as the admin winner even when standard artwork eligibility fails', function (): void { $admin = createControlPanelAdmin(); $owner = User::factory()->create(['username' => 'forceheromissingpreview']); $naturalWinner = adminArtwork(['user_id' => $owner->id, 'title' => 'Natural Winner']); $forcedArtwork = adminArtwork(['user_id' => $owner->id, 'title' => 'Forced Missing Preview', 'has_missing_thumbnails' => true]); featureRow($naturalWinner, ['priority' => 300, 'featured_at' => now()->subHour()]); $forcedFeature = featureRow($forcedArtwork, ['priority' => 100, 'featured_at' => now()->subDays(2)]); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.force-hero', ['feature' => $forcedFeature->id])) ->assertOk() ->assertJsonPath('winner.artwork.id', $forcedArtwork->id) ->assertJsonPath('winner.is_force_hero', true) ->assertJsonPath('winner.selection_reason', 'Forced hero override is enabled for this featured artwork.'); expect(ArtworkFeature::query()->where('force_hero', true)->count())->toBe(1); }); it('allows admins to delete featured rows', function (): void { $admin = createControlPanelAdmin(); $feature = featureRow(adminArtwork()); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id])) ->assertOk() ->assertJsonPath('stats.total', 0); expect(ArtworkFeature::query()->count())->toBe(0) ->and(ArtworkFeature::withTrashed()->count())->toBe(1); }); it('marks expired and ineligible rows on the index page', function (): void { $admin = createControlPanelAdmin(); $privateArtwork = adminArtwork([ 'title' => 'Private Artwork', 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, ]); $expiredArtwork = adminArtwork(['title' => 'Expired Artwork']); 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.artworks.featured.main')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Moderation/FeaturedArtworks') ->where('entries.0.artwork.id', $privateArtwork->id) ->where('entries.0.eligibility.is_eligible', false) ->where('entries.0.eligibility.reasons.0', 'Private') ->where('entries.1.artwork.id', $expiredArtwork->id) ->where('entries.1.is_expired', true) ->where('stats.expired', 1) ->where('stats.ineligible', 2)); }); 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(); $seedHeroCache(); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->postJson(route('admin.cp.artworks.featured.store'), [ 'artwork_id' => $artwork->id, 'priority' => 100, 'featured_at' => now()->toISOString(), 'expires_at' => null, 'is_active' => true, ]) ->assertOk(); $assertHeroCacheCleared(); $feature = ArtworkFeature::query()->firstOrFail(); $seedHeroCache(); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.update', ['feature' => $feature->id]), [ 'priority' => 110, 'featured_at' => now()->addMinute()->toISOString(), 'expires_at' => now()->addDay()->toISOString(), 'is_active' => true, ]) ->assertOk(); $assertHeroCacheCleared(); $seedHeroCache(); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->patchJson(route('admin.cp.artworks.featured.toggle', ['feature' => $feature->id])) ->assertOk(); $assertHeroCacheCleared(); $seedHeroCache(); $this->actingAs($admin)->actingAs($admin, 'controlpanel') ->deleteJson(route('admin.cp.artworks.featured.delete', ['feature' => $feature->id])) ->assertOk(); $assertHeroCacheCleared(); });