1286 lines
50 KiB
PHP
1286 lines
50 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\AcademyPromptPack;
|
|
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 Inertia\Testing\AssertableInertia;
|
|
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_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');
|
|
$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_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([
|
|
'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));
|
|
}
|
|
}
|