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([