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
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class GuardianSummaryTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function guardian_can_view_linked_student_summary(): void
{
$guardian = User::factory()->guardian()->create();
$student = User::factory()->create();
$guardian->students()->attach($student->id);
$response = $this->getJson("/api/v1/guardian/students/{$student->id}/summary", [
'Authorization' => 'Bearer '.$guardian->api_token,
]);
$response->assertOk()
->assertJsonPath('student.id', $student->id)
->assertJsonStructure([
'student' => ['id', 'name'],
'lessons_progress',
'weakest_concepts',
'latest_ai_summary',
]);
}
#[Test]
public function guardian_cannot_view_unlinked_student(): void
{
$guardian = User::factory()->guardian()->create();
$otherStudent = User::factory()->create();
$this->getJson("/api/v1/guardian/students/{$otherStudent->id}/summary", [
'Authorization' => 'Bearer '.$guardian->api_token,
])->assertForbidden();
}
}
@@ -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];
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}