seed(DatabaseSeeder::class); $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); $assignmentIds = DB::table('assignments')->where('organization_id', $designer->organization_id)->where('assignable_type', 'course')->where('assignable_id', $version->getKey())->pluck('id'); DB::table('learning_events')->where('course_version_id', $version->getKey())->delete(); DB::table('assessment_attempts')->where('course_version_id', $version->getKey())->delete(); DB::table('assignment_users')->whereIn('assignment_id', $assignmentIds)->update(['status' => 'assigned', 'progress' => 0, 'completed_at' => null]); $learnerId = DB::table('assignment_users')->whereIn('assignment_id', $assignmentIds)->value('user_id'); $this->assertNotNull($learnerId); DB::table('assignment_users')->whereIn('assignment_id', $assignmentIds)->where('user_id', $learnerId)->update(['status' => 'completed', 'progress' => 100, 'completed_at' => now()]); foreach (range(1, 2) as $index) { LearningEvent::query()->create(['organization_id' => $designer->organization_id, 'learner_id' => $learnerId, 'client_event_id' => fake()->uuid(), 'event_type' => 'course.opened', 'schema_version' => 1, 'course_version_id' => $version->getKey(), 'payload' => ['sequence' => $index], 'occurred_at' => now(), 'received_at' => now()]); } Sanctum::actingAs($designer); $response = $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics?version='.$version->getKey().'&from='.now()->subDay()->toDateString().'&to='.now()->toDateString()) ->assertOk() ->assertJsonPath('data.metrics.starts.value', 1) ->assertJsonPath('data.metrics.completions.value', 1) ->assertJsonPath('data.metrics.dropOff.value', 0) ->assertJsonPath('data.metrics.averageScore.value', null) ->assertJsonPath('data.hasEvents', true) ->assertJsonPath('data.definitions.starts', 'تعداد یادگیرندگان تخصیص‌یافته‌ای که تا پایان بازه حداقل یک رویداد یادگیری معتبر در این نسخه ثبت کرده‌اند.'); $this->assertGreaterThanOrEqual(1, $response->json('data.metrics.learners.value')); } public function test_course_analytics_applies_version_team_and_date_filters_and_exports_same_definitions(): void { $this->seed(DatabaseSeeder::class); $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); $team = DB::table('teams')->where('organization_id', $designer->organization_id)->first(); Sanctum::actingAs($designer); $query = '?version='.$version->getKey().'&team='.$team->id.'&from='.now()->subDays(30)->toDateString().'&to='.now()->toDateString().'&sort=completion&direction=asc&page=1&pageSize=5'; $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics'.$query) ->assertOk() ->assertJsonPath('data.course.versionId', $version->getKey()) ->assertJsonPath('data.content.meta.pageSize', 5) ->assertJsonStructure(['data' => ['metrics' => ['learners', 'starts', 'completions', 'averageProgress', 'averageScore', 'dropOff'], 'trend', 'funnel', 'content' => ['items', 'meta'], 'teams', 'insights', 'filters' => ['versions', 'teams']]]); $this->get('/api/v1/courses/'.$version->course_id.'/analytics/report'.$query) ->assertOk() ->assertHeader('content-type', 'text/csv; charset=UTF-8'); } public function test_course_analytics_reports_no_events_without_turning_unknown_metrics_into_zero(): void { $this->seed(DatabaseSeeder::class); $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); DB::table('learning_events')->where('course_version_id', $version->getKey())->delete(); DB::table('assessment_attempts')->where('course_version_id', $version->getKey())->delete(); Sanctum::actingAs($designer); $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics?version='.$version->getKey()) ->assertOk() ->assertJsonPath('data.hasEvents', false) ->assertJsonPath('data.metrics.starts.value', 0) ->assertJsonPath('data.metrics.averageScore.value', null) ->assertJsonPath('data.metrics.averageScore.comparisonAvailable', false); } public function test_course_analytics_can_report_the_draft_version_open_in_the_workspace(): void { $this->seed(DatabaseSeeder::class); $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); $draft = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'draft')->firstOrFail(); Sanctum::actingAs($designer); $response = $this->getJson('/api/v1/courses/'.$draft->course_id.'/analytics?version='.$draft->getKey()) ->assertOk() ->assertJsonPath('data.course.versionId', $draft->getKey()); $this->assertContains($draft->getKey(), collect($response->json('data.filters.versions'))->pluck('id')->all()); } public function test_course_analytics_is_permission_and_tenant_scoped(): void { $this->seed(DatabaseSeeder::class); $version = CourseVersion::query()->where('status', 'published')->firstOrFail(); foreach (['manager@microlearn.test', 'maryam@microlearn.test'] as $email) { Sanctum::actingAs(User::query()->where('email', $email)->firstOrFail()); $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics')->assertForbidden(); } $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); Sanctum::actingAs($designer); $other = Organization::query()->create(['name' => 'Other analytics', 'slug' => 'other-analytics', 'status' => 'active', 'default_locale' => 'fa', 'timezone' => 'Asia/Tehran']); $foreignTeam = (string) str()->ulid(); DB::table('teams')->insert(['id' => $foreignTeam, 'organization_id' => $other->getKey(), 'name' => 'Foreign', 'description' => null, 'created_at' => now(), 'updated_at' => now()]); $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics?team='.$foreignTeam)->assertNotFound(); } }