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' => '
Today, 1 May 2026, Skinbase begins a new chapter.
', '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' => 'Hello
', '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' => 'Hello
Visit', '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', 'Hello
Visit'); }); 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']); });