feat: Initial commit for Saqel Platform architecture, backend, Docker, and documentation

This commit is contained in:
Hamza-Ayed
2026-08-26 15:45:55 +03:00
commit d53b64500a
177 changed files with 19909 additions and 0 deletions
@@ -0,0 +1,165 @@
<?php
namespace Tests\Feature;
use App\Contracts\AiService;
use App\Jobs\AnalyzeStudentPerformance;
use App\Models\Lesson;
use App\Models\Question;
use App\Models\QuestionOption;
use App\Models\Quiz;
use App\Models\User;
use App\Services\ProgressTrackingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class StudentTrackingTest extends TestCase
{
use RefreshDatabase;
private User $student;
private Lesson $lesson;
private Quiz $quiz;
private Question $question;
protected function setUp(): void
{
parent::setUp();
$this->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<string, mixed> $performance
* @return array<string, mixed>
*/
public function analyzeStudentPerformance(array $performance): array
{
return [
'weak_concepts' => [['name' => 'الاشتقاق', 'reason' => 'أخطاء متكررة', 'severity' => 'high']],
'remedial_lesson_ids' => [],
'recommended_actions' => ['إعادة مشاهدة الدرس الأول'],
'motivation_note' => 'استمر',
'summary' => 'ملخص تجريبي',
];
}
/**
* @param array<string, mixed> $activity
* @return array<string, mixed>
*/
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<string, string>
*/
private function authHeaders(): array
{
return ['Authorization' => 'Bearer '.$this->student->api_token];
}
}