Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -10,6 +10,7 @@ use App\Models\AcademyCourse;
use App\Models\AcademyEvent;
use App\Models\AcademyLesson;
use App\Models\AcademyLike;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
@@ -23,6 +24,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
final class AcademyAnalyticsTest extends TestCase
@@ -406,6 +408,80 @@ final class AcademyAnalyticsTest extends TestCase
]);
}
public function test_prompt_library_index_exposes_dedicated_page_analytics_content_type(): void
{
$this->createPrompt('library-analytics-prompt');
$this->get(route('academy.prompts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.contentType', AcademyAnalyticsContentType::PROMPT_LIBRARY)
->where('analytics.contentId', null)
->where('analytics.pageName', 'academy_prompts_index')
);
}
public function test_popular_prompts_page_exposes_dedicated_page_analytics_content_type(): void
{
$prompt = $this->createPrompt('popular-page-prompt');
$this->createMetric(AcademyAnalyticsContentType::PROMPT, $prompt->id, [
'views' => 18,
'prompt_copies' => 4,
'popularity_score' => 51.2,
]);
$this->get(route('academy.prompts.popular'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.contentType', AcademyAnalyticsContentType::PROMPT_POPULAR)
->where('analytics.contentId', null)
->where('analytics.pageName', 'academy_prompts_popular')
);
}
public function test_popular_prompts_page_exposes_selected_period_in_analytics_metadata(): void
{
$prompt = $this->createPrompt('popular-period-analytics-prompt');
$this->createMetric(AcademyAnalyticsContentType::PROMPT, $prompt->id, [
'views' => 28,
'prompt_copies' => 6,
'popularity_score' => 64.4,
]);
$this->get(route('academy.prompts.popular', ['period' => '7d']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.metadata.period', '7d')
->where('analytics.metadata.period_days', 7)
->where('analytics.trackingKey', 'period:7d')
);
}
public function test_prompt_pack_library_index_exposes_dedicated_page_analytics_content_type(): void
{
AcademyPromptPack::query()->create([
'title' => 'Starter Pack',
'slug' => 'starter-pack',
'excerpt' => 'A free pack.',
'description' => 'Pack description.',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$this->get(route('academy.packs.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('analytics.contentType', AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY)
->where('analytics.contentId', null)
->where('analytics.pageName', 'academy_packs_index')
);
}
public function test_rollup_counts_search_result_clicks_for_clicked_content(): void
{
$prompt = $this->createPrompt('rollup-search-click-prompt');
@@ -948,6 +1024,151 @@ final class AcademyAnalyticsTest extends TestCase
$this->assertSame(20.0, (float) $metric->conversion_score);
}
public function test_admin_prompt_library_analytics_page_uses_dedicated_prompt_library_filter(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->get(route('admin.academy.analytics.prompt-library'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsContent')
->where('title', 'Prompt library analytics')
->where('filters.content_type', AcademyAnalyticsContentType::PROMPT_LIBRARY)
->where('contentTypeOptions', fn ($options): bool => collect($options)->contains(
fn (array $option): bool => ($option['value'] ?? null) === AcademyAnalyticsContentType::PROMPT_LIBRARY
&& ($option['label'] ?? null) === 'Prompt library'
))
);
}
public function test_admin_prompt_library_analytics_page_includes_prompt_library_summary(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->createMetric(AcademyAnalyticsContentType::PROMPT_LIBRARY, null, [
'views' => 42,
'unique_visitors' => 18,
'engaged_views' => 9,
'scroll_50' => 12,
'scroll_100' => 4,
'avg_engaged_seconds' => 31,
]);
$this->actingAs($admin)
->get(route('admin.academy.analytics.prompt-library'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsContent')
->where('summary.views', 42)
->where('summary.uniqueVisitors', 18)
->where('summary.engagedViews', 9)
->where('summary.avgEngagedSeconds', 31)
->where('summary.engagementRate', 50)
->where('summary.deepScrollRate', 22.2)
);
}
public function test_admin_analytics_overview_includes_prompt_library_trend(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$today = now()->toDateString();
$yesterday = now()->subDay()->toDateString();
$this->createMetric(AcademyAnalyticsContentType::PROMPT_LIBRARY, null, [
'date' => $today,
'views' => 40,
'unique_visitors' => 20,
'engaged_views' => 10,
'popularity_score' => 55,
]);
$this->createMetric(AcademyAnalyticsContentType::PROMPT_LIBRARY, null, [
'date' => $yesterday,
'views' => 20,
'unique_visitors' => 10,
'engaged_views' => 5,
'popularity_score' => 25,
]);
$this->actingAs($admin)
->get(route('admin.academy.analytics.overview', [
'range' => 'custom',
'from' => $today,
'to' => $today,
]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsOverview')
->where('promptLibraryTrend.current.views', 40)
->where('promptLibraryTrend.current.uniqueVisitors', 20)
->where('promptLibraryTrend.current.engagementRate', 50)
->where('promptLibraryTrend.previous.views', 20)
->where('promptLibraryTrend.deltas.views', 100)
->where('promptLibraryTrend.deltas.engagementRate', 0)
);
}
public function test_admin_analytics_overview_includes_popular_prompt_period_usage(): void
{
$admin = User::factory()->create(['role' => 'admin']);
AcademyEvent::query()->create([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'visitor_id' => 'visitor-30-a',
'is_logged_in' => false,
'is_subscriber' => false,
'is_admin' => false,
'is_bot' => false,
'is_crawler' => false,
'is_suspicious' => false,
'metadata' => ['period' => '30d', 'period_days' => 30],
'occurred_at' => now()->subHour(),
]);
AcademyEvent::query()->create([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'visitor_id' => 'visitor-30-b',
'is_logged_in' => false,
'is_subscriber' => false,
'is_admin' => false,
'is_bot' => false,
'is_crawler' => false,
'is_suspicious' => false,
'metadata' => ['period' => '30d', 'period_days' => 30],
'occurred_at' => now()->subMinutes(30),
]);
AcademyEvent::query()->create([
'event_type' => AcademyAnalyticsEventType::PAGE_VIEW,
'content_type' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'visitor_id' => 'visitor-7-a',
'is_logged_in' => false,
'is_subscriber' => false,
'is_admin' => false,
'is_bot' => false,
'is_crawler' => false,
'is_suspicious' => false,
'metadata' => ['period' => '7d', 'period_days' => 7],
'occurred_at' => now()->subMinutes(10),
]);
$this->actingAs($admin)
->get(route('admin.academy.analytics.overview'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsOverview')
->where('popularPromptPeriodUsage.totalViews', 3)
->where('popularPromptPeriodUsage.totalVisitors', 3)
->where('popularPromptPeriodUsage.periods.0.period', '30d')
->where('popularPromptPeriodUsage.periods.0.views', 2)
->where('popularPromptPeriodUsage.periods.0.share', 66.7)
->where('popularPromptPeriodUsage.periods.1.period', '7d')
->where('popularPromptPeriodUsage.periods.1.views', 1));
}
public function test_prune_events_command_removes_only_old_raw_events(): void
{
$oldEvent = AcademyEvent::query()->create([

View File

@@ -8,6 +8,7 @@ use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyChallenge;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseEnrollment;
use App\Models\AcademyCourseLesson;
@@ -15,15 +16,19 @@ use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonProgress;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
use Laravel\Cashier\Subscription;
use Laravel\Cashier\SubscriptionItem;
use Tests\TestCase;
final class AcademyFeatureTest extends TestCase
@@ -43,7 +48,53 @@ final class AcademyFeatureTest extends TestCase
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/Index')
->where('featureFlags.paymentsEnabled', false));
->where('links.promptPopular', route('academy.prompts.popular'))
->where('academyAccess.signedIn', false)
->where('academyAccess.status', 'guest')
->where('academyAccess.billingUrl', route('academy.pricing')));
}
public function test_academy_homepage_exposes_access_summary_for_active_paid_user(): void
{
config()->set('academy_billing.plans', [
'pro_monthly' => [
'tier' => 'pro',
'stripe_price_id' => 'price_pro_test',
],
]);
$user = User::factory()->create();
$subscription = Subscription::query()->create([
'user_id' => $user->id,
'type' => 'academy',
'stripe_id' => 'sub_pro_test',
'stripe_status' => 'active',
'stripe_price' => 'price_pro_test',
'quantity' => 1,
'ends_at' => null,
]);
SubscriptionItem::query()->create([
'subscription_id' => $subscription->id,
'stripe_id' => 'si_pro_test',
'stripe_product' => 'prod_pro_test',
'stripe_price' => 'price_pro_test',
'quantity' => 1,
]);
$this->actingAs($user)
->get('/academy')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/Index')
->where('academyAccess.signedIn', true)
->where('academyAccess.tier', 'pro')
->where('academyAccess.tierLabel', 'Pro')
->where('academyAccess.status', 'active')
->where('academyAccess.statusLabel', 'Renews automatically')
->where('academyAccess.renewsAutomatically', true)
->where('academyAccess.billingUrl', route('academy.billing.account'))
->where('academyAccess.source', 'subscription'));
}
public function test_academy_routes_are_hidden_when_feature_is_disabled(): void
@@ -1149,6 +1200,271 @@ final class AcademyFeatureTest extends TestCase
]);
}
public function test_prompt_library_index_exposes_breadcrumbs_and_discovery_payloads(): void
{
$featured = AcademyPromptTemplate::query()->create([
'title' => 'Featured Prompt',
'slug' => 'featured-prompt',
'excerpt' => 'Featured prompt excerpt.',
'prompt' => 'Featured prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'featured' => true,
'active' => true,
'published_at' => now()->subMinute(),
]);
$popular = AcademyPromptTemplate::query()->create([
'title' => 'Popular Prompt',
'slug' => 'popular-prompt',
'excerpt' => 'Popular prompt excerpt.',
'prompt' => 'Popular prompt body',
'difficulty' => 'intermediate',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(2),
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $popular->id,
'views' => 42,
'prompt_copies' => 9,
'popularity_score' => 88.5,
]);
Cache::flush();
$this->get(route('academy.prompts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('breadcrumbs.0.label', 'Academy')
->where('breadcrumbs.0.href', route('academy.index'))
->where('breadcrumbs.1.label', 'Prompt Library')
->where('coursesUrl', route('academy.courses.index'))
->where('packsUrl', route('academy.packs.index'))
->where('featuredPrompts.0.slug', $featured->slug)
->where('featuredPrompts.0.spotlight.eyebrow', 'Featured pick')
->where('popularPrompts.0.slug', $popular->slug)
->where('popularPrompts.0.spotlight.eyebrow', '9 copies this month')
->where('academyAccess.signedIn', false)
->where('academyAccess.status', 'guest')
->where('academyAccess.billingUrl', route('academy.pricing')));
}
public function test_prompt_library_index_exposes_current_access_summary_for_grace_period_subscription(): void
{
config()->set('academy_billing.plans', [
'creator_monthly' => [
'tier' => 'creator',
'stripe_price_id' => 'price_creator_test',
],
]);
$user = User::factory()->create();
$subscription = Subscription::query()->create([
'user_id' => $user->id,
'type' => 'academy',
'stripe_id' => 'sub_creator_test',
'stripe_status' => 'active',
'stripe_price' => 'price_creator_test',
'quantity' => 1,
'ends_at' => now()->addDays(12),
]);
SubscriptionItem::query()->create([
'subscription_id' => $subscription->id,
'stripe_id' => 'si_creator_test',
'stripe_product' => 'prod_creator_test',
'stripe_price' => 'price_creator_test',
'quantity' => 1,
]);
$this->actingAs($user)
->get(route('academy.prompts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('academyAccess.signedIn', true)
->where('academyAccess.tier', 'creator')
->where('academyAccess.tierLabel', 'Creator')
->where('academyAccess.status', 'grace_period')
->where('academyAccess.statusLabel', 'Cancels soon')
->where('academyAccess.dateLabel', 'Access ends')
->where('academyAccess.renewsAutomatically', false)
->where('academyAccess.source', 'subscription')
->where('academyAccess.billingUrl', route('academy.billing.account'))
->where('academyAccess.expiresAt', $subscription->ends_at?->toISOString()));
}
public function test_popular_prompts_page_displays_ranked_prompt_payloads(): void
{
$featured = AcademyPromptTemplate::query()->create([
'title' => 'Featured Prompt',
'slug' => 'featured-popular-page-prompt',
'excerpt' => 'Featured prompt excerpt.',
'prompt' => 'Featured prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'featured' => true,
'active' => true,
'published_at' => now()->subMinute(),
]);
$topPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Top Prompt',
'slug' => 'top-prompt',
'excerpt' => 'Top prompt excerpt.',
'prompt' => 'Top prompt body',
'difficulty' => 'advanced',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(2),
]);
$runnerUpPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Runner Up Prompt',
'slug' => 'runner-up-prompt',
'excerpt' => 'Runner up prompt excerpt.',
'prompt' => 'Runner up prompt body',
'difficulty' => 'intermediate',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(3),
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $topPrompt->id,
'views' => 74,
'prompt_copies' => 11,
'popularity_score' => 128.7,
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $runnerUpPrompt->id,
'views' => 48,
'prompt_copies' => 5,
'popularity_score' => 91.4,
]);
Cache::flush();
$this->get(route('academy.prompts.popular'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('promptView', 'popular')
->where('popularPeriod.value', '30d')
->where('breadcrumbs.2.label', 'Popular Prompts')
->where('promptLibraryUrl', route('academy.prompts.index'))
->where('items.data.0.slug', $topPrompt->slug)
->where('items.data.0.ranking.rank', 1)
->where('items.data.0.ranking.prompt_copies', 11)
->where('items.data.1.slug', $runnerUpPrompt->slug)
->where('items.data.1.ranking.rank', 2)
->where('popularPeriods.0.value', '7d')
->where('popularPeriods.1.active', true)
->where('featuredPrompts.0.slug', $featured->slug));
}
public function test_popular_prompts_page_can_filter_to_last_seven_days(): void
{
$recentPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Recent Prompt',
'slug' => 'recent-prompt',
'excerpt' => 'Recent prompt excerpt.',
'prompt' => 'Recent prompt body',
'difficulty' => 'advanced',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$olderPrompt = AcademyPromptTemplate::query()->create([
'title' => 'Older Prompt',
'slug' => 'older-prompt',
'excerpt' => 'Older prompt excerpt.',
'prompt' => 'Older prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinutes(2),
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $recentPrompt->id,
'views' => 31,
'prompt_copies' => 7,
'popularity_score' => 79.5,
]);
AcademyContentMetricDaily::query()->create([
'date' => now()->subDays(20)->toDateString(),
'content_type' => 'academy_prompt',
'content_id' => $olderPrompt->id,
'views' => 200,
'prompt_copies' => 22,
'popularity_score' => 240.1,
]);
Cache::flush();
$this->get(route('academy.prompts.popular', ['period' => '7d']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('popularPeriod.value', '7d')
->where('popularPeriod.label', '7 days')
->where('items.data.0.slug', $recentPrompt->slug)
->where('items.data.0.spotlight.eyebrow', '7 copies in the last 7 days')
->missing('items.data.1')
->where('popularPeriods.0.active', true)
->where('popularPeriods.1.active', false));
}
public function test_prompt_pack_index_does_not_include_nested_prompts_until_pack_detail(): void
{
$prompt = AcademyPromptTemplate::query()->create([
'title' => 'Pack Prompt',
'slug' => 'pack-prompt',
'excerpt' => 'Prompt excerpt.',
'prompt' => 'Pack prompt body',
'difficulty' => 'beginner',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$pack = AcademyPromptPack::query()->create([
'title' => 'Starter Prompt Pack',
'slug' => 'starter-prompt-pack',
'excerpt' => 'Pack excerpt.',
'description' => 'Pack description.',
'access_level' => 'free',
'active' => true,
'published_at' => now()->subMinute(),
]);
$pack->prompts()->attach($prompt->id, ['order_num' => 0]);
$this->get(route('academy.packs.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/List')
->where('items.data.0.slug', 'starter-prompt-pack')
->where('items.data.0.prompts', [])
->where('analytics.contentType', 'academy_prompt_pack_library')
);
}
public function test_logged_in_user_can_submit_artwork_to_active_challenge(): void
{
$user = User::factory()->create();