Files
SkinbaseNova/tests/Feature/Academy/AcademyAnalyticsTest.php

1065 lines
41 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Academy;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse;
use App\Models\AcademyEvent;
use App\Models\AcademyLesson;
use App\Models\AcademyLike;
use App\Models\AcademyPromptTemplate;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Models\User;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class AcademyAnalyticsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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<string, mixed> $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));
}
}