355 lines
15 KiB
PHP
355 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkFeature;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Testing\AssertableInertia;
|
|
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
if (! function_exists('createControlPanelAdmin')) {
|
|
function createControlPanelAdmin(): User
|
|
{
|
|
$admin = User::factory()->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();
|
|
}); |