student = User::factory()->create(); $this->lesson = Lesson::factory()->create(['duration_seconds' => 600]); $this->quiz = Quiz::factory()->for($this->lesson)->create(); $this->question = Question::factory()->for($this->quiz)->create([ 'explanation' => 'لأن القانون ينص على ذلك', ]); QuestionOption::query()->create(['question_id' => $this->question->id, 'body' => 'A', 'is_correct' => true]); QuestionOption::query()->create(['question_id' => $this->question->id, 'body' => 'B', 'is_correct' => false]); } #[Test] public function heartbeat_updates_progress_and_completes_lesson(): void { $response = $this->postJson('/api/v1/progress/heartbeat', [ 'lesson_id' => $this->lesson->id, 'position_seconds' => 300, ], $this->authHeaders()); $response->assertOk() ->assertJsonPath('watched_percentage', 50) ->assertJsonPath('completed', false); $this->postJson('/api/v1/progress/heartbeat', [ 'lesson_id' => $this->lesson->id, 'position_seconds' => 580, ], $this->authHeaders())->assertOk()->assertJsonPath('completed', true); $this->assertDatabaseCount('lesson_progress', 1); } #[Test] public function quiz_attempt_is_graded_server_side_and_updates_mastery(): void { $wrongOption = QuestionOption::query()->where('is_correct', false)->firstOrFail(); $rightOption = QuestionOption::query()->where('is_correct', true)->firstOrFail(); $response = $this->postJson("/api/v1/lessons/{$this->lesson->id}/quizzes/{$this->quiz->id}/attempts", [ 'answers' => [ ['question_id' => $this->question->id, 'selected_option_id' => $wrongOption->id, 'time_spent_seconds' => 12], ], ], $this->authHeaders()); $response->assertOk() ->assertJsonPath('score', 0) ->assertJsonPath('results.0.is_correct', false) ->assertJsonPath('results.0.explanation', 'لأن القانون ينص على ذلك'); $mastery = $this->student->conceptMastery()->firstOrFail(); $this->assertSame(0.0, (float) $mastery->mastery_score); $this->assertSame(1, (int) $mastery->wrong_count); $retry = $this->postJson("/api/v1/lessons/{$this->lesson->id}/quizzes/{$this->quiz->id}/attempts", [ 'answers' => [ ['question_id' => $this->question->id, 'selected_option_id' => $rightOption->id], ], ], $this->authHeaders()); $retry->assertOk()->assertJsonPath('score', 100); $mastery->refresh(); $this->assertSame(30.0, (float) $mastery->mastery_score); $this->assertSame(1, (int) $mastery->correct_count); } #[Test] public function analysis_endpoint_queues_job(): void { Queue::fake(); $response = $this->postJson('/api/v1/students/me/analysis', [], $this->authHeaders()); $response->assertAccepted(); Queue::assertPushed(AnalyzeStudentPerformance::class); } #[Test] public function analysis_job_stores_ai_report_via_fake_service(): void { $this->app->bind(AiService::class, fn () => new class implements AiService { /** * @param array $performance * @return array */ public function analyzeStudentPerformance(array $performance): array { return [ 'weak_concepts' => [['name' => 'الاشتقاق', 'reason' => 'أخطاء متكررة', 'severity' => 'high']], 'remedial_lesson_ids' => [], 'recommended_actions' => ['إعادة مشاهدة الدرس الأول'], 'motivation_note' => 'استمر', 'summary' => 'ملخص تجريبي', ]; } /** * @param array $activity * @return array */ public function generateParentWeeklyReport(array $activity): array { return []; } }); (new AnalyzeStudentPerformance($this->student->id)) ->handle(app(ProgressTrackingService::class), app(AiService::class)); $this->assertDatabaseHas('ai_reports', [ 'user_id' => $this->student->id, 'type' => 'weakness_analysis', ]); } #[Test] public function heartbeat_requires_authentication(): void { $this->postJson('/api/v1/progress/heartbeat', [ 'lesson_id' => $this->lesson->id, 'position_seconds' => 10, ])->assertUnauthorized(); } /** * @return array */ private function authHeaders(): array { return ['Authorization' => 'Bearer '.$this->student->api_token]; } }