withoutMiddleware(ConditionalValidateCsrfToken::class); Cache::flush(); } public function test_event_endpoint_stores_valid_academy_event(): void { $prompt = $this->createPrompt('tracked-prompt'); $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::CONTENT_VIEW, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'visitor-123', 'metadata' => ['source' => 'feature-test'], ])->assertOk()->assertJson(['ok' => true]); $this->assertDatabaseHas('academy_events', [ 'event_type' => AcademyAnalyticsEventType::CONTENT_VIEW, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'visitor-123', ]); } public function test_event_endpoint_rejects_invalid_event_type(): void { $prompt = $this->createPrompt('invalid-event-prompt'); $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => 'academy_fake_event', 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ])->assertStatus(422); } public function test_event_endpoint_rejects_invalid_content_type(): void { $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::CONTENT_VIEW, 'content_type' => 'academy_fake_content', 'content_id' => 1, ])->assertStatus(422); } public function test_event_endpoint_marks_admin_and_bot_events_without_ip_storage(): void { $admin = User::factory()->create(['role' => 'admin']); $prompt = $this->createPrompt('admin-bot-prompt'); $this->withHeader('User-Agent', 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)') ->actingAs($admin) ->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::CONTENT_VIEW, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'admin-bot-visitor', ])->assertOk(); $this->assertDatabaseHas('academy_events', [ 'content_id' => $prompt->id, 'visitor_id' => 'admin-bot-visitor', 'is_admin' => true, 'is_bot' => true, ]); $this->assertFalse(Schema::hasColumn('academy_events', 'ip_address')); } public function test_search_result_click_event_is_accepted(): void { $prompt = $this->createPrompt('clicked-prompt'); $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'search-click-visitor', 'metadata' => [ 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'position' => 3, 'source' => 'academy_search_results', ], ])->assertOk()->assertJson(['ok' => true]); $this->assertDatabaseHas('academy_events', [ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ]); } public function test_search_result_click_rejects_invalid_clicked_content_type(): void { $prompt = $this->createPrompt('invalid-click-content-type-prompt'); $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::SEARCH, 'content_id' => $prompt->id, 'metadata' => [ 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, ], ])->assertStatus(422); } public function test_search_result_click_rejects_invalid_clicked_content_id(): void { $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => 999999, 'metadata' => [ 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, ], ])->assertStatus(422); } public function test_search_result_click_updates_latest_matching_search_log(): void { $prompt = $this->createPrompt('search-log-click-target'); $clickedAlready = AcademySearchLog::query()->create([ 'visitor_id' => 'matching-search-visitor', 'query' => 'Robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'clicked_content_type' => AcademyAnalyticsContentType::PROMPT, 'clicked_content_id' => $prompt->id, 'filters' => ['difficulty' => 'beginner'], 'is_logged_in' => false, 'is_subscriber' => false, 'is_bot' => false, ]); $clickedAlready->forceFill(['created_at' => now()->subMinutes(5), 'updated_at' => now()->subMinutes(5)])->save(); $pending = AcademySearchLog::query()->create([ 'visitor_id' => 'matching-search-visitor', 'query' => 'Robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'filters' => ['difficulty' => 'beginner'], 'is_logged_in' => false, 'is_subscriber' => false, 'is_bot' => false, ]); $pending->forceFill(['created_at' => now()->subMinutes(2), 'updated_at' => now()->subMinutes(2)])->save(); $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'matching-search-visitor', 'metadata' => [ 'query' => 'Robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'position' => 2, 'filters' => ['difficulty' => 'beginner'], ], ])->assertOk(); $this->assertDatabaseHas('academy_search_logs', [ 'id' => $pending->id, 'clicked_content_type' => AcademyAnalyticsContentType::PROMPT, 'clicked_content_id' => $prompt->id, ]); } public function test_search_result_click_creates_fallback_search_log_when_none_exists(): void { $prompt = $this->createPrompt('fallback-click-target'); $this->postJson(route('academy.analytics.events.store'), [ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'fallback-search-visitor', 'metadata' => [ 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'position' => 3, 'filters' => ['difficulty' => 'beginner'], ], ])->assertOk(); $this->assertDatabaseHas('academy_search_logs', [ 'visitor_id' => 'fallback-search-visitor', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'clicked_content_type' => AcademyAnalyticsContentType::PROMPT, 'clicked_content_id' => $prompt->id, ]); } public function test_authenticated_user_can_toggle_academy_like_and_save(): void { $user = User::factory()->create(); $prompt = $this->createPrompt('liked-prompt'); $course = $this->createCourse('saved-course'); $this->actingAs($user) ->postJson(route('academy.interactions.like'), [ 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ]) ->assertOk() ->assertJson([ 'liked' => true, 'likes_count' => 1, ]); $this->actingAs($user) ->postJson(route('academy.interactions.save'), [ 'content_type' => AcademyAnalyticsContentType::COURSE, 'content_id' => $course->id, ]) ->assertOk() ->assertJson([ 'saved' => true, 'saves_count' => 1, ]); $this->actingAs($user) ->postJson(route('academy.interactions.like'), [ 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ]) ->assertOk() ->assertJson([ 'liked' => false, 'likes_count' => 0, ]); $this->assertDatabaseMissing('academy_likes', [ 'user_id' => $user->id, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ]); $this->assertDatabaseHas('academy_saves', [ 'user_id' => $user->id, 'content_type' => AcademyAnalyticsContentType::COURSE, 'content_id' => $course->id, ]); $this->assertSame(1, AcademyEvent::query() ->where('event_type', AcademyAnalyticsEventType::PROMPT_LIKE) ->where('content_id', $prompt->id) ->count()); } public function test_guest_cannot_toggle_academy_like_or_save(): void { $prompt = $this->createPrompt('guest-toggle-prompt'); $this->postJson(route('academy.interactions.like'), [ 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ])->assertStatus(401); $this->postJson(route('academy.interactions.save'), [ 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ])->assertStatus(401); } public function test_progress_endpoints_track_single_lesson_start_and_completion_event(): void { $user = User::factory()->create(); $lesson = $this->createLesson('analytics-lesson'); $this->actingAs($user) ->postJson(route('academy.progress.lesson.start'), [ 'lesson_id' => $lesson->id, ]) ->assertOk() ->assertJson([ 'ok' => true, 'status' => 'started', ]); $this->actingAs($user) ->postJson(route('academy.lessons.complete', ['lesson' => $lesson]), []) ->assertOk() ->assertJson([ 'ok' => true, 'completed' => true, ]); $this->assertDatabaseHas('academy_user_progress', [ 'user_id' => $user->id, 'lesson_id' => $lesson->id, 'status' => 'completed', 'progress_percent' => 100, ]); $this->assertSame(1, AcademyEvent::query() ->where('event_type', AcademyAnalyticsEventType::LESSON_STARTED) ->where('content_id', $lesson->id) ->count()); $this->assertSame(1, AcademyEvent::query() ->where('event_type', AcademyAnalyticsEventType::LESSON_COMPLETED) ->where('content_id', $lesson->id) ->count()); } public function test_progress_complete_endpoint_prevents_duplicate_progress_rows(): void { $user = User::factory()->create(); $lesson = $this->createLesson('analytics-lesson-deduped'); $this->actingAs($user) ->postJson(route('academy.progress.lesson.start'), [ 'lesson_id' => $lesson->id, ])->assertOk(); $this->actingAs($user) ->postJson(route('academy.progress.lesson.complete'), [ 'lesson_id' => $lesson->id, ])->assertOk(); $this->actingAs($user) ->postJson(route('academy.progress.lesson.complete'), [ 'lesson_id' => $lesson->id, ])->assertOk(); $this->assertSame(1, AcademyUserProgress::query() ->where('user_id', $user->id) ->where('lesson_id', $lesson->id) ->count()); } public function test_prompt_search_logs_are_recorded_from_index_queries(): void { $this->createPrompt('searchable-prompt', 'Lighting Prompt'); $this->get(route('academy.prompts.index', ['q' => 'Lighting'])) ->assertOk(); $this->assertDatabaseHas('academy_search_logs', [ 'normalized_query' => 'lighting', 'results_count' => 1, 'is_bot' => false, ]); } public function test_zero_result_search_logs_and_tracks_filters(): void { $this->get(route('academy.prompts.index', ['q' => 'No Match Here', 'difficulty' => 'beginner'])) ->assertOk(); $this->assertDatabaseHas('academy_search_logs', [ 'normalized_query' => 'no match here', 'results_count' => 0, ]); $searchLog = AcademySearchLog::query()->where('normalized_query', 'no match here')->latest('id')->first(); $this->assertSame('beginner', $searchLog?->filters['difficulty'] ?? null); $this->assertDatabaseHas('academy_events', [ 'event_type' => AcademyAnalyticsEventType::ZERO_SEARCH_RESULTS, 'content_type' => AcademyAnalyticsContentType::SEARCH, ]); } public function test_rollup_counts_search_result_clicks_for_clicked_content(): void { $prompt = $this->createPrompt('rollup-search-click-prompt'); $date = Carbon::parse('2026-05-12 11:15:00'); AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'rollup-search-click-visitor', 'url' => route('academy.prompts.show', ['slug' => $prompt->slug]), 'is_logged_in' => false, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => [ 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, 'position' => 3, ], 'occurred_at' => $date, ]); $this->artisan('academy:analytics-rollup', ['--date' => $date->toDateString()])->assertExitCode(0); $metric = AcademyContentMetricDaily::query() ->whereDate('date', $date->toDateString()) ->where('content_type', AcademyAnalyticsContentType::PROMPT) ->where('content_id', $prompt->id) ->first(); $this->assertNotNull($metric); $this->assertSame(1, (int) $metric->search_clicks); } public function test_rollup_excludes_bot_admin_search_result_clicks(): void { $prompt = $this->createPrompt('ignored-search-click-prompt'); $date = Carbon::parse('2026-05-13 09:00:00'); AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::SEARCH_RESULT_CLICK, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'user_id' => User::factory()->create(['role' => 'admin'])->id, 'visitor_id' => 'ignored-search-click-visitor', 'url' => route('academy.prompts.show', ['slug' => $prompt->slug]), 'is_logged_in' => true, 'is_subscriber' => false, 'is_admin' => true, 'is_bot' => true, 'is_crawler' => true, 'is_suspicious' => true, 'metadata' => [ 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 12, ], 'occurred_at' => $date, ]); $this->artisan('academy:analytics-rollup', ['--date' => $date->toDateString()])->assertExitCode(0); $metric = AcademyContentMetricDaily::query() ->whereDate('date', $date->toDateString()) ->where('content_type', AcademyAnalyticsContentType::PROMPT) ->where('content_id', $prompt->id) ->first(); $this->assertNull($metric); } public function test_rollup_command_aggregates_daily_metrics_idempotently(): void { $prompt = $this->createPrompt('rollup-prompt'); $user = User::factory()->create(); $date = Carbon::parse('2026-05-11 10:30:00'); AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::CONTENT_VIEW, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'guest-rollup', 'url' => route('academy.prompts.show', ['slug' => $prompt->slug]), 'is_logged_in' => false, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => [], 'occurred_at' => $date, ]); AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::ENGAGED_VIEW, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'user_id' => $user->id, 'visitor_id' => 'member-rollup', 'url' => route('academy.prompts.show', ['slug' => $prompt->slug]), 'is_logged_in' => true, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => ['engaged_seconds' => 32], 'occurred_at' => $date->copy()->addMinute(), ]); AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::PROMPT_COPY, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'user_id' => $user->id, 'visitor_id' => 'member-rollup', 'url' => route('academy.prompts.show', ['slug' => $prompt->slug]), 'is_logged_in' => true, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => ['copy_type' => 'main_prompt'], 'occurred_at' => $date->copy()->addMinutes(2), ]); $like = AcademyLike::query()->create([ 'user_id' => $user->id, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ]); $like->forceFill(['created_at' => $date, 'updated_at' => $date])->save(); $save = AcademySave::query()->create([ 'user_id' => $user->id, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, ]); $save->forceFill(['created_at' => $date, 'updated_at' => $date])->save(); $searchLog = AcademySearchLog::query()->create([ 'user_id' => $user->id, 'visitor_id' => 'member-rollup', 'query' => 'lighting', 'normalized_query' => 'lighting', 'results_count' => 0, 'filters' => [], 'is_logged_in' => true, 'is_subscriber' => false, 'is_bot' => false, ]); $searchLog->forceFill(['created_at' => $date, 'updated_at' => $date])->save(); $this->artisan('academy:analytics-rollup', ['--date' => $date->toDateString()])->assertExitCode(0); $this->artisan('academy:analytics-rollup', ['--date' => $date->toDateString()])->assertExitCode(0); $metric = AcademyContentMetricDaily::query() ->whereDate('date', $date->toDateString()) ->where('content_type', AcademyAnalyticsContentType::PROMPT) ->where('content_id', $prompt->id) ->first(); $this->assertNotNull($metric); $this->assertSame(1, (int) $metric->views); $this->assertSame(2, (int) $metric->unique_visitors); $this->assertSame(1, (int) $metric->engaged_views); $this->assertSame(1, (int) $metric->likes); $this->assertSame(1, (int) $metric->saves); $this->assertSame(1, (int) $metric->prompt_copies); $this->assertSame(32, (int) $metric->avg_engaged_seconds); $this->assertSame(0, (int) $metric->bounce_count); $this->assertGreaterThan(0, (float) $metric->popularity_score); $this->assertSame(1, AcademyContentMetricDaily::query() ->whereDate('date', $date->toDateString()) ->where('content_type', AcademyAnalyticsContentType::PROMPT) ->where('content_id', $prompt->id) ->count()); $searchMetric = AcademyContentMetricDaily::query() ->whereDate('date', $date->toDateString()) ->where('content_type', AcademyAnalyticsContentType::SEARCH) ->whereNull('content_id') ->first(); $this->assertNotNull($searchMetric); $this->assertSame(1, (int) $searchMetric->search_impressions); $this->assertSame(1, (int) $searchMetric->bounce_count); } public function test_health_command_runs_and_warns_when_no_events_exist(): void { $exitCode = Artisan::call('academy:analytics-health'); $output = Artisan::output(); $this->assertSame(0, $exitCode); $this->assertStringContainsString('Academy Analytics Health Check', $output); $this->assertStringContainsString('WARNING: No events received in last 24 hours.', $output); $this->assertStringContainsString('Status: WARNING', $output); } public function test_health_command_reports_latest_event_timestamp_and_supports_json(): void { $prompt = $this->createPrompt('health-command-prompt'); $eventTime = Carbon::parse('2026-05-10 14:42:00'); AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::CONTENT_VIEW, 'content_type' => AcademyAnalyticsContentType::PROMPT, 'content_id' => $prompt->id, 'visitor_id' => 'health-check-visitor', 'is_logged_in' => false, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => [], 'occurred_at' => $eventTime, ]); AcademySearchLog::query()->create([ 'visitor_id' => 'health-check-visitor', 'query' => 'robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 4, 'clicked_content_type' => AcademyAnalyticsContentType::PROMPT, 'clicked_content_id' => $prompt->id, 'filters' => [], 'is_logged_in' => false, 'is_subscriber' => false, 'is_bot' => false, ]); $this->createMetric(AcademyAnalyticsContentType::PROMPT, $prompt->id, [ 'date' => now()->toDateString(), 'views' => 1, 'unique_visitors' => 1, ]); $exitCode = Artisan::call('academy:analytics-health', ['--json' => true]); $report = json_decode(trim(Artisan::output()), true, 512, JSON_THROW_ON_ERROR); $this->assertSame(0, $exitCode); $this->assertSame('2026-05-10 14:42:00', $report['latest_event_at']); $this->assertSame(now()->toDateString(), $report['latest_rollup_date']); $this->assertSame(1, $report['search_logs']); $this->assertSame(1, $report['search_clicks']); } public function test_content_intelligence_finds_zero_result_searches_and_results_without_clicks_and_calculates_ctr(): void { $prompt = $this->createPrompt('search-gap-prompt'); AcademySearchLog::query()->create([ 'visitor_id' => 'search-gap-a', 'query' => 'Amiga pixel art prompt', 'normalized_query' => 'amiga pixel art prompt', 'results_count' => 0, 'filters' => [], 'is_logged_in' => true, 'is_subscriber' => false, 'is_bot' => false, ]); AcademySearchLog::query()->create([ 'visitor_id' => 'search-gap-b', 'query' => 'Prompt anatomy', 'normalized_query' => 'prompt anatomy', 'results_count' => 6, 'filters' => [], 'is_logged_in' => true, 'is_subscriber' => false, 'is_bot' => false, ]); AcademySearchLog::query()->create([ 'visitor_id' => 'search-gap-c', 'query' => 'Prompt anatomy', 'normalized_query' => 'prompt anatomy', 'results_count' => 5, 'filters' => [], 'is_logged_in' => false, 'is_subscriber' => false, 'is_bot' => false, ]); AcademySearchLog::query()->create([ 'visitor_id' => 'search-gap-d', 'query' => 'Robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 4, 'clicked_content_type' => AcademyAnalyticsContentType::PROMPT, 'clicked_content_id' => $prompt->id, 'filters' => [], 'is_logged_in' => true, 'is_subscriber' => true, 'is_bot' => false, ]); AcademySearchLog::query()->create([ 'visitor_id' => 'search-gap-e', 'query' => 'Robot mascot', 'normalized_query' => 'robot mascot', 'results_count' => 4, 'filters' => [], 'is_logged_in' => false, 'is_subscriber' => false, 'is_bot' => false, ]); $report = app(AcademyContentIntelligenceService::class)->getSearchGaps([ 'from' => now()->subDay(), 'to' => now()->addDay(), 'limit' => 25, ]); $rows = collect($report['rows']); $this->assertSame('Zero-result demand', $rows->firstWhere('normalized_query', 'amiga pixel art prompt')['issue']); $this->assertSame('Results with no clicks', $rows->firstWhere('normalized_query', 'prompt anatomy')['issue']); $this->assertSame(50.0, $rows->firstWhere('normalized_query', 'robot mascot')['ctr']); } public function test_content_intelligence_detects_prompt_opportunity_signals(): void { $needsWork = $this->createPrompt('needs-work-prompt', 'Needs Work Prompt'); $hiddenWinner = $this->createPrompt('hidden-winner-prompt', 'Hidden Winner Prompt'); $premiumPrompt = $this->createPrompt('premium-prompt', 'Premium Prompt'); $this->createMetric(AcademyAnalyticsContentType::PROMPT, $needsWork->id, [ 'views' => 180, 'unique_visitors' => 120, 'prompt_copies' => 4, ]); $this->createMetric(AcademyAnalyticsContentType::PROMPT, $hiddenWinner->id, [ 'views' => 20, 'unique_visitors' => 20, 'prompt_copies' => 10, ]); $this->createMetric(AcademyAnalyticsContentType::PROMPT, $premiumPrompt->id, [ 'views' => 40, 'unique_visitors' => 30, 'premium_preview_views' => 12, 'upgrade_clicks' => 4, ]); $rows = collect(app(AcademyContentIntelligenceService::class)->getPromptInsights([ 'from' => now()->subDay(), 'to' => now()->addDay(), ])['rows']); $this->assertSame('High views, low copies', $rows->firstWhere('title', 'Needs Work Prompt')['issue']); $this->assertSame('Low views, high copy rate', $rows->firstWhere('title', 'Hidden Winner Prompt')['issue']); $this->assertSame('High upgrade interest', $rows->firstWhere('title', 'Premium Prompt')['issue']); } public function test_content_intelligence_detects_lesson_dropoff_signals(): void { $lowStartLesson = $this->createLesson('low-start-lesson'); $dropoffLesson = $this->createLesson('dropoff-lesson'); $winnerLesson = $this->createLesson('winner-lesson'); $this->createMetric(AcademyAnalyticsContentType::LESSON, $lowStartLesson->id, [ 'views' => 120, 'unique_visitors' => 100, 'starts' => 10, 'completions' => 5, ]); $this->createMetric(AcademyAnalyticsContentType::LESSON, $dropoffLesson->id, [ 'views' => 90, 'unique_visitors' => 70, 'starts' => 20, 'completions' => 4, ]); $this->createMetric(AcademyAnalyticsContentType::LESSON, $winnerLesson->id, [ 'views' => 28, 'unique_visitors' => 24, 'starts' => 12, 'completions' => 9, ]); $rows = collect(app(AcademyContentIntelligenceService::class)->getLessonDropoffs([ 'from' => now()->subDay(), 'to' => now()->addDay(), ])['rows']); $this->assertSame('High views, low starts', $rows->firstWhere('content_id', $lowStartLesson->id)['issue']); $this->assertSame('High starts, low completions', $rows->firstWhere('content_id', $dropoffLesson->id)['issue']); $this->assertSame('High completions, low views', $rows->firstWhere('content_id', $winnerLesson->id)['issue']); } public function test_content_intelligence_detects_course_health_signals(): void { $stalledCourse = $this->createCourse('stalled-course'); $expandableCourse = $this->createCourse('expandable-course'); $this->createMetric(AcademyAnalyticsContentType::COURSE, $stalledCourse->id, [ 'views' => 140, 'unique_visitors' => 100, 'starts' => 20, 'completions' => 4, ]); $this->createMetric(AcademyAnalyticsContentType::COURSE, $expandableCourse->id, [ 'views' => 45, 'unique_visitors' => 35, 'starts' => 12, 'completions' => 10, ]); AcademyUserProgress::query()->create([ 'user_id' => User::factory()->create()->id, 'course_id' => $stalledCourse->id, 'status' => 'in_progress', 'progress_percent' => 35, 'started_at' => now(), 'last_seen_at' => now(), ]); AcademyUserProgress::query()->create([ 'user_id' => User::factory()->create()->id, 'course_id' => $expandableCourse->id, 'status' => 'completed', 'progress_percent' => 100, 'started_at' => now(), 'completed_at' => now(), 'last_seen_at' => now(), ]); $rows = collect(app(AcademyContentIntelligenceService::class)->getCourseHealth([ 'from' => now()->subDay(), 'to' => now()->addDay(), ])['rows']); $this->assertSame('Low course completion rate', $rows->firstWhere('content_id', $stalledCourse->id)['issue']); $this->assertSame('Expansion candidate', $rows->firstWhere('content_id', $expandableCourse->id)['issue']); $this->assertSame(35.0, $rows->firstWhere('content_id', $stalledCourse->id)['avg_progress']); } public function test_content_intelligence_detects_premium_interest_signals(): void { $strongPrompt = $this->createPrompt('strong-premium-prompt', 'Strong Premium Prompt'); $weakTeaserLesson = $this->createLesson('weak-teaser-lesson'); $this->createMetric(AcademyAnalyticsContentType::PROMPT, $strongPrompt->id, [ 'premium_preview_views' => 10, 'upgrade_clicks' => 4, ]); $this->createMetric(AcademyAnalyticsContentType::LESSON, $weakTeaserLesson->id, [ 'premium_preview_views' => 20, 'upgrade_clicks' => 1, ]); $rows = collect(app(AcademyContentIntelligenceService::class)->getPremiumInterest([ 'from' => now()->subDay(), 'to' => now()->addDay(), ])['rows']); $strongPromptRow = $rows->first(fn (array $row): bool => $row['content_type'] === AcademyAnalyticsContentType::PROMPT && $row['content_id'] === $strongPrompt->id); $weakLessonRow = $rows->first(fn (array $row): bool => $row['content_type'] === AcademyAnalyticsContentType::LESSON && $row['content_id'] === $weakTeaserLesson->id); $this->assertSame('Strong premium candidate', $strongPromptRow['issue']); $this->assertSame('Weak premium teaser', $weakLessonRow['issue']); } public function test_content_intelligence_generates_editorial_recommendations(): void { $prompt = $this->createPrompt('recommendation-prompt', 'Recommendation Prompt'); $lesson = $this->createLesson('recommendation-lesson'); AcademySearchLog::query()->create([ 'visitor_id' => 'recommendation-search-visitor', 'query' => 'amiga pixel art prompt', 'normalized_query' => 'amiga pixel art prompt', 'results_count' => 0, 'filters' => [], 'is_logged_in' => true, 'is_subscriber' => false, 'is_bot' => false, ]); $this->createMetric(AcademyAnalyticsContentType::PROMPT, $prompt->id, [ 'views' => 180, 'unique_visitors' => 120, 'prompt_copies' => 3, ]); $this->createMetric(AcademyAnalyticsContentType::LESSON, $lesson->id, [ 'views' => 90, 'unique_visitors' => 60, 'starts' => 20, 'completions' => 4, ]); $titles = collect(app(AcademyContentIntelligenceService::class)->getEditorialRecommendations([ 'from' => now()->subDay(), 'to' => now()->addDay(), ])['rows'])->pluck('title'); $this->assertTrue($titles->contains('Create content for "amiga pixel art prompt"')); $this->assertTrue($titles->contains('Review prompt "Recommendation Prompt"')); $this->assertTrue($titles->contains('Improve lesson "Tracked Lesson"')); } public function test_recalculate_popularity_command_updates_existing_scores(): void { $metric = AcademyContentMetricDaily::query()->create([ 'date' => now()->toDateString(), 'content_type' => AcademyAnalyticsContentType::COURSE, 'content_id' => $this->createCourse('popularity-course')->id, 'views' => 10, 'unique_visitors' => 5, 'guest_views' => 2, 'user_views' => 8, 'subscriber_views' => 1, 'engaged_views' => 3, 'scroll_50' => 2, 'scroll_75' => 1, 'scroll_100' => 1, 'likes' => 2, 'saves' => 1, 'prompt_copies' => 0, 'negative_prompt_copies' => 0, 'starts' => 2, 'completions' => 1, 'upgrade_clicks' => 1, 'premium_preview_views' => 0, 'search_impressions' => 0, 'search_clicks' => 0, 'bounce_count' => 1, 'avg_engaged_seconds' => 24, 'popularity_score' => 0, 'conversion_score' => 0, ]); $this->artisan('academy:analytics-recalculate-popularity', ['--days' => 1])->assertExitCode(0); $metric->refresh(); $this->assertGreaterThan(0, (float) $metric->popularity_score); $this->assertSame(20.0, (float) $metric->conversion_score); } public function test_prune_events_command_removes_only_old_raw_events(): void { $oldEvent = AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::PAGE_VIEW, 'content_type' => AcademyAnalyticsContentType::HOME, 'visitor_id' => 'old-visitor', 'is_logged_in' => false, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => [], 'occurred_at' => now()->subDays(200), ]); $freshEvent = AcademyEvent::query()->create([ 'event_type' => AcademyAnalyticsEventType::PAGE_VIEW, 'content_type' => AcademyAnalyticsContentType::HOME, 'visitor_id' => 'fresh-visitor', 'is_logged_in' => false, 'is_subscriber' => false, 'is_admin' => false, 'is_bot' => false, 'is_crawler' => false, 'is_suspicious' => false, 'metadata' => [], 'occurred_at' => now()->subDays(10), ]); $this->artisan('academy:analytics-prune-events', ['--days' => 180])->assertExitCode(0); $this->assertDatabaseMissing('academy_events', ['id' => $oldEvent->id]); $this->assertDatabaseHas('academy_events', ['id' => $freshEvent->id]); } private function createPrompt(string $slug, string $title = 'Tracked Prompt'): AcademyPromptTemplate { return AcademyPromptTemplate::query()->create([ 'title' => $title, 'slug' => $slug, 'excerpt' => 'Prompt excerpt.', 'prompt' => 'Prompt body.', 'negative_prompt' => 'Negative prompt body.', 'difficulty' => 'beginner', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinute(), ]); } private function createLesson(string $slug): AcademyLesson { return AcademyLesson::query()->create([ 'title' => 'Tracked Lesson', 'slug' => $slug, 'excerpt' => 'Lesson excerpt.', 'content' => 'Lesson body.', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); } private function createCourse(string $slug): AcademyCourse { return AcademyCourse::query()->create([ 'title' => 'Tracked Course', 'slug' => $slug, 'excerpt' => 'Course excerpt.', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'published_at' => now()->subMinute(), ]); } /** * @param array $attributes */ private function createMetric(string $contentType, ?int $contentId, array $attributes = []): AcademyContentMetricDaily { return AcademyContentMetricDaily::query()->create(array_merge([ 'date' => now()->toDateString(), 'content_type' => $contentType, 'content_id' => $contentId, 'views' => 0, 'unique_visitors' => 0, 'guest_views' => 0, 'user_views' => 0, 'subscriber_views' => 0, 'engaged_views' => 0, 'scroll_50' => 0, 'scroll_75' => 0, 'scroll_100' => 0, 'likes' => 0, 'saves' => 0, 'prompt_copies' => 0, 'negative_prompt_copies' => 0, 'starts' => 0, 'completions' => 0, 'upgrade_clicks' => 0, 'premium_preview_views' => 0, 'search_impressions' => 0, 'search_clicks' => 0, 'bounce_count' => 0, 'avg_engaged_seconds' => null, 'popularity_score' => 0, 'conversion_score' => 0, ], $attributes)); } }