Add homepage announcement module
This commit is contained in:
97
tests/Feature/HomepageAnnouncementServiceTest.php
Normal file
97
tests/Feature/HomepageAnnouncementServiceTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\HomepageAnnouncement;
|
||||
use App\Services\HomepageAnnouncementSanitizer;
|
||||
use App\Services\HomepageAnnouncementService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
app(HomepageAnnouncementService::class)->clearActiveCache();
|
||||
});
|
||||
|
||||
function homepageAnnouncement(array $overrides = []): HomepageAnnouncement
|
||||
{
|
||||
return HomepageAnnouncement::query()->create(array_merge([
|
||||
'title' => 'Skinbase Nova is live.',
|
||||
'subtitle' => 'A new chapter for the Skinbase creative community.',
|
||||
'badge_text' => 'Launch Day · 1 May 2026',
|
||||
'content_html' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
|
||||
'type' => HomepageAnnouncement::TYPE_LAUNCH,
|
||||
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
|
||||
'is_active' => true,
|
||||
'starts_at' => now()->subHour(),
|
||||
'ends_at' => null,
|
||||
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
|
||||
'priority' => 100,
|
||||
'is_dismissible' => true,
|
||||
'dismiss_version' => 1,
|
||||
'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA,
|
||||
'theme_preset' => 'launch',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
it('returns the visible published active homepage announcement', function (): void {
|
||||
$expected = homepageAnnouncement();
|
||||
|
||||
$active = app(HomepageAnnouncementService::class)->getActiveForHomepage();
|
||||
|
||||
expect($active?->id)->toBe($expected->id);
|
||||
});
|
||||
|
||||
it('does not return a future announcement', function (): void {
|
||||
homepageAnnouncement([
|
||||
'starts_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not return an expired announcement', function (): void {
|
||||
homepageAnnouncement([
|
||||
'starts_at' => now()->subDays(2),
|
||||
'ends_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not return a draft announcement', function (): void {
|
||||
homepageAnnouncement([
|
||||
'status' => HomepageAnnouncement::STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull();
|
||||
});
|
||||
|
||||
it('returns the highest priority visible announcement', function (): void {
|
||||
$lower = homepageAnnouncement([
|
||||
'priority' => 10,
|
||||
'title' => 'Lower priority',
|
||||
]);
|
||||
|
||||
$higher = homepageAnnouncement([
|
||||
'priority' => 900,
|
||||
'title' => 'Higher priority',
|
||||
]);
|
||||
|
||||
$active = app(HomepageAnnouncementService::class)->getActiveForHomepage();
|
||||
|
||||
expect($active?->id)
|
||||
->not->toBe($lower->id)
|
||||
->toBe($higher->id);
|
||||
});
|
||||
|
||||
it('sanitizes announcement html content', function (): void {
|
||||
$sanitized = app(HomepageAnnouncementSanitizer::class)->sanitizeHtml('<p>Hello<script>alert(1)</script></p><a href="javascript:alert(1)" onclick="boom()">Click</a><h2 style="color:red">Title</h2>');
|
||||
|
||||
expect($sanitized)
|
||||
->toContain('<p>Hello</p>')
|
||||
->toContain('<h2>Title</h2>')
|
||||
->not->toContain('<script')
|
||||
->not->toContain('javascript:')
|
||||
->not->toContain('onclick=');
|
||||
});
|
||||
286
tests/Unit/HomepageAnnouncementModuleTest.php
Normal file
286
tests/Unit/HomepageAnnouncementModuleTest.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\HomepageAnnouncement;
|
||||
use App\Models\User;
|
||||
use App\Services\HomepageAnnouncementService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('homepage_announcements');
|
||||
Schema::dropIfExists('user_profiles');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('username')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->string('role')->default('user');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('homepage_announcements', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('title', 180);
|
||||
$table->string('subtitle', 255)->nullable();
|
||||
$table->string('badge_text', 100)->nullable();
|
||||
$table->longText('content_html')->nullable();
|
||||
$table->string('type', 40)->default('announcement');
|
||||
$table->string('status', 40)->default('draft');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->dateTime('starts_at')->nullable();
|
||||
$table->dateTime('ends_at')->nullable();
|
||||
$table->string('primary_link_label', 80)->nullable();
|
||||
$table->string('primary_link_type', 40)->nullable();
|
||||
$table->string('primary_link_url', 2048)->nullable();
|
||||
$table->string('primary_link_target_type', 40)->nullable();
|
||||
$table->unsignedBigInteger('primary_link_target_id')->nullable();
|
||||
$table->string('secondary_link_label', 80)->nullable();
|
||||
$table->string('secondary_link_type', 40)->nullable();
|
||||
$table->string('secondary_link_url', 2048)->nullable();
|
||||
$table->string('secondary_link_target_type', 40)->nullable();
|
||||
$table->unsignedBigInteger('secondary_link_target_id')->nullable();
|
||||
$table->string('background_type', 40)->nullable();
|
||||
$table->string('background_image', 2048)->nullable();
|
||||
$table->string('gradient_preset', 80)->nullable();
|
||||
$table->string('theme_preset', 80)->nullable();
|
||||
$table->string('text_color', 32)->nullable();
|
||||
$table->unsignedTinyInteger('overlay_opacity')->nullable();
|
||||
$table->string('placement', 80)->default('homepage_after_featured');
|
||||
$table->integer('priority')->default(0);
|
||||
$table->boolean('is_dismissible')->default(true);
|
||||
$table->unsignedInteger('dismiss_version')->default(1);
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('user_profiles', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->unique();
|
||||
$table->string('avatar_hash')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
app(HomepageAnnouncementService::class)->clearActiveCache();
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Schema::dropIfExists('homepage_announcements');
|
||||
Schema::dropIfExists('user_profiles');
|
||||
Schema::dropIfExists('users');
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function announcementPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'title' => 'Skinbase Nova is live.',
|
||||
'subtitle' => 'A new chapter for the Skinbase creative community.',
|
||||
'badge_text' => 'Launch Day · 1 May 2026',
|
||||
'content_html' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
|
||||
'type' => HomepageAnnouncement::TYPE_LAUNCH,
|
||||
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
|
||||
'is_active' => true,
|
||||
'starts_at' => now()->subHour(),
|
||||
'ends_at' => null,
|
||||
'primary_link_label' => null,
|
||||
'primary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
|
||||
'primary_link_url' => null,
|
||||
'primary_link_target_type' => null,
|
||||
'primary_link_target_id' => null,
|
||||
'secondary_link_label' => null,
|
||||
'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
|
||||
'secondary_link_url' => null,
|
||||
'secondary_link_target_type' => null,
|
||||
'secondary_link_target_id' => null,
|
||||
'background_type' => null,
|
||||
'background_image' => null,
|
||||
'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA,
|
||||
'theme_preset' => 'launch',
|
||||
'text_color' => null,
|
||||
'overlay_opacity' => 55,
|
||||
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
|
||||
'priority' => 100,
|
||||
'is_dismissible' => true,
|
||||
'dismiss_version' => 1,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function createAnnouncement(array $overrides = []): HomepageAnnouncement
|
||||
{
|
||||
return HomepageAnnouncement::query()->create(announcementPayload($overrides));
|
||||
}
|
||||
|
||||
function adminUser(): User
|
||||
{
|
||||
return User::query()->create([
|
||||
'name' => 'Admin User',
|
||||
'username' => 'adminuser',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'role' => 'admin',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
it('returns the visible published active homepage announcement', function (): void {
|
||||
$expected = createAnnouncement();
|
||||
|
||||
$active = app(HomepageAnnouncementService::class)->getActiveForHomepage();
|
||||
|
||||
expect($active?->id)->toBe($expected->id);
|
||||
});
|
||||
|
||||
it('does not return a future announcement', function (): void {
|
||||
createAnnouncement([
|
||||
'starts_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not return an expired announcement', function (): void {
|
||||
createAnnouncement([
|
||||
'starts_at' => now()->subDays(2),
|
||||
'ends_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not return a draft announcement', function (): void {
|
||||
createAnnouncement([
|
||||
'status' => HomepageAnnouncement::STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull();
|
||||
});
|
||||
|
||||
it('returns the highest priority visible announcement', function (): void {
|
||||
createAnnouncement([
|
||||
'priority' => 10,
|
||||
'title' => 'Lower priority',
|
||||
]);
|
||||
|
||||
$higher = createAnnouncement([
|
||||
'priority' => 900,
|
||||
'title' => 'Higher priority',
|
||||
]);
|
||||
|
||||
$active = app(HomepageAnnouncementService::class)->getActiveForHomepage();
|
||||
|
||||
expect($active?->id)->toBe($higher->id);
|
||||
});
|
||||
|
||||
it('homepage payload includes the announcement prop', function (): void {
|
||||
$html = view('web.home', [
|
||||
'seo' => [],
|
||||
'useUnifiedSeo' => true,
|
||||
'meta' => [],
|
||||
'props' => [
|
||||
'hero' => [],
|
||||
'announcement' => [
|
||||
'id' => 42,
|
||||
'dismiss_version' => 1,
|
||||
'title' => 'Skinbase Nova is live.',
|
||||
'subtitle' => 'A new chapter.',
|
||||
'badge_text' => 'Launch',
|
||||
'content_html' => '<p>Hello</p>',
|
||||
'gradient_preset' => 'nova_aurora',
|
||||
'theme_preset' => 'launch',
|
||||
'background_image_url' => null,
|
||||
'is_dismissible' => true,
|
||||
'overlay_opacity' => 55,
|
||||
'primary_link' => null,
|
||||
'secondary_link' => null,
|
||||
],
|
||||
'community_favorites' => [],
|
||||
'hall_of_fame' => [],
|
||||
'rising' => [],
|
||||
'trending' => [],
|
||||
'fresh' => [],
|
||||
'collections_featured' => [],
|
||||
'collections_trending' => [],
|
||||
'collections_editorial' => [],
|
||||
'collections_community' => [],
|
||||
'world_spotlight' => null,
|
||||
'groups' => [],
|
||||
'tags' => [],
|
||||
'creators' => [],
|
||||
'news' => [],
|
||||
],
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain('"announcement":{"id":42');
|
||||
});
|
||||
|
||||
it('preview sanitizes html content', function (): void {
|
||||
$admin = adminUser();
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\HandleInertiaRequests::class);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.homepage-announcements.preview'), [
|
||||
'title' => 'Preview announcement',
|
||||
'subtitle' => 'Unsafe html should be stripped.',
|
||||
'badge_text' => 'Preview',
|
||||
'content_html' => '<p>Hello<script>alert(1)</script></p><a href="https://skinbase.top" onclick="evil()">Visit</a>',
|
||||
'type' => HomepageAnnouncement::TYPE_NOTICE,
|
||||
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
|
||||
'is_active' => true,
|
||||
'starts_at' => now()->toIso8601String(),
|
||||
'priority' => 10,
|
||||
'is_dismissible' => true,
|
||||
'dismiss_version' => 1,
|
||||
'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA,
|
||||
'theme_preset' => 'announcement',
|
||||
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
|
||||
'overlay_opacity' => 55,
|
||||
'primary_link_type' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL,
|
||||
'primary_link_label' => 'Open',
|
||||
'primary_link_url' => '/explore',
|
||||
'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('announcement.content_html', '<p>Hello</p><a href="https://skinbase.top" rel="noopener noreferrer" target="_blank">Visit</a>');
|
||||
});
|
||||
|
||||
it('preview rejects unsafe custom links', function (): void {
|
||||
$admin = adminUser();
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\HandleInertiaRequests::class);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->from(route('admin.homepage-announcements.create'))
|
||||
->post(route('admin.homepage-announcements.preview'), [
|
||||
'title' => 'Unsafe CTA',
|
||||
'type' => HomepageAnnouncement::TYPE_NOTICE,
|
||||
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
|
||||
'is_active' => true,
|
||||
'priority' => 0,
|
||||
'is_dismissible' => true,
|
||||
'dismiss_version' => 1,
|
||||
'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA,
|
||||
'theme_preset' => 'announcement',
|
||||
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
|
||||
'overlay_opacity' => 55,
|
||||
'primary_link_type' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL,
|
||||
'primary_link_label' => 'Do not allow',
|
||||
'primary_link_url' => 'javascript:alert(1)',
|
||||
'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE,
|
||||
])
|
||||
->assertSessionHasErrors(['primary_link_url']);
|
||||
});
|
||||
Reference in New Issue
Block a user