foundation(); Sanctum::actingAs($learner); $this->getJson('/api/v1/learner/home')->assertOk()->assertJsonCount(1, 'data.assigned')->assertJsonPath('data.assigned.0.id', $assignment->getKey()) ->assertJsonPath('data.assigned.0.lessonCount', 1)->assertJsonPath('data.assigned.0.remainingMinutes', 10) ->assertJsonPath('data.assigned.0.currentLessonTitle', 'PPE')->assertJsonPath('data.assigned.0.favorite', false); $this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}") ->assertOk()->assertJsonPath('data.lesson.presentationMode', 'flow')->assertJsonPath('data.blocks.0.id', $block->getKey()); $outsider = User::factory()->create(['organization_id' => $learner->organization_id, 'role' => UserRole::Learner]); Sanctum::actingAs($outsider); $this->getJson('/api/v1/learner/assignments/'.$assignment->getKey())->assertNotFound(); } public function test_offline_event_sync_is_idempotent_and_completion_updates_assignment(): void { [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); Sanctum::actingAs($learner); $eventId = Str::uuid()->toString(); $event = ['id' => $eventId, 'type' => 'block.completed', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'blockId' => $block->getKey(), 'occurredAt' => now()->toISOString()]; $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', false); $event['type'] = 'lesson.completed'; $event['id'] = Str::uuid()->toString(); $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.progress', 100)->assertJsonPath('data.events.0.completed', true); $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', true); $this->assertDatabaseCount('learning_events', 2); $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment->getKey(), 'user_id' => $learner->getKey(), 'status' => 'completed', 'progress' => 100]); } public function test_notes_and_bookmarks_are_private_and_tenant_scoped(): void { [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); Sanctum::actingAs($learner); $input = ['courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey()]; $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/notes', [...$input, 'body' => 'نکته شخصی'])->assertCreated(); $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/bookmarks', [...$input, 'blockId' => $block->getKey()])->assertOk()->assertJsonPath('data.bookmarked', true); $this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")->assertJsonPath('data.notes.0.body', 'نکته شخصی')->assertJsonPath('data.bookmarks.0', $block->getKey()); } public function test_highlights_favorites_discussion_replies_and_reactions_are_real_and_scoped(): void { [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); Sanctum::actingAs($learner); $context = ['courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey()]; $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/highlights', [...$context, 'blockId' => $block->getKey(), 'quote' => 'Safety first', 'color' => 'yellow'])->assertCreated(); $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/favorite')->assertOk()->assertJsonPath('data.favorite', true); $comment = $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/discussions', [...$context, 'body' => 'پرسش من'])->assertCreated()->json('data.id'); $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/discussions', [...$context, 'body' => 'پاسخ من', 'parentId' => $comment])->assertCreated(); $this->postJson('/api/v1/learner/discussions/'.$comment.'/reactions', ['reaction' => 'helpful'])->assertOk()->assertJsonPath('data.active', true); $this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")->assertJsonPath('data.favorite', true)->assertJsonPath('data.highlights.0.quote', 'Safety first')->assertJsonCount(2, 'data.discussions'); } public function test_offline_note_event_is_idempotent(): void { [$learner, $assignment, $version, $lesson] = $this->foundation(); Sanctum::actingAs($learner); $event = ['id' => Str::uuid()->toString(), 'type' => 'note.created', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'payload' => ['body' => 'یادداشت آفلاین'], 'occurredAt' => now()->toISOString()]; $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', false); $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', true); $this->assertDatabaseCount('learner_notes', 1); $this->assertDatabaseHas('learner_notes', ['learner_id' => $learner->getKey(), 'body' => 'یادداشت آفلاین']); } public function test_assessment_score_is_calculated_server_side(): void { [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); $block->update(['type' => 'single_choice', 'data' => ['prompt' => 'پاسخ؟', 'options' => ['صحیح', 'غلط'], 'answerIndex' => 0]]); Sanctum::actingAs($learner); $event = ['id' => Str::uuid()->toString(), 'type' => 'block.interacted', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'blockId' => $block->getKey(), 'payload' => ['response' => [1], 'score' => 1], 'occurredAt' => now()->toISOString()]; $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk(); $this->assertDatabaseHas('block_progress', ['block_id' => $block->getKey(), 'learner_id' => $learner->getKey(), 'score' => 0]); } public function test_progress_report_uses_real_assessments_events_and_capability_scores(): void { [$learner, $assignment, $version] = $this->foundation(); $assessment = Assessment::query()->create(['organization_id' => $learner->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'آزمون ایمنی عمومی', 'settings' => ['passScore' => 70, 'maxAttempts' => 3]]); AssessmentAttempt::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 1, 'status' => 'completed', 'score' => .75, 'started_at' => now()->subWeek()->subHour(), 'completed_at' => now()->subWeek()]); AssessmentAttempt::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 2, 'status' => 'completed', 'score' => .85, 'started_at' => now()->subHour(), 'completed_at' => now()]); $this->learningEvent($learner, $assignment, $version, now()->subWeek(), 1800, 'previous-session'); $this->learningEvent($learner, $assignment, $version, now(), 3600, 'current-session'); $taxonomyType = (string) Str::ulid(); $taxonomyNode = (string) Str::ulid(); DB::table('taxonomy_types')->insert(['id' => $taxonomyType, 'organization_id' => $learner->organization_id, 'key' => 'skill', 'name' => 'مهارت', 'status' => 'active', 'created_at' => now(), 'updated_at' => now()]); DB::table('taxonomy_nodes')->insert(['id' => $taxonomyNode, 'organization_id' => $learner->organization_id, 'taxonomy_type_id' => $taxonomyType, 'name' => 'ایمنی عملی', 'status' => 'active', 'created_at' => now(), 'updated_at' => now()]); DB::table('capability_scores')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'taxonomy_node_id' => $taxonomyNode, 'score' => 78, 'confidence' => .9, 'confidence_level' => 'high', 'evidence_count' => 4, 'last_evidence_at' => now(), 'trend' => 3, 'scoring_model_version' => 'test-v1', 'explanation' => json_encode(['source' => 'test']), 'calculated_at' => now(), 'created_at' => now(), 'updated_at' => now()]); Sanctum::actingAs($learner); $this->getJson('/api/v1/learner/progress')->assertOk() ->assertJsonPath('data.summary.averageAssessment', 80) ->assertJsonPath('data.summary.assessmentDelta', 10) ->assertJsonPath('data.summary.learningMinutes', 60) ->assertJsonPath('data.summary.learningMinutesDelta', 100) ->assertJsonPath('data.skills.0.name', 'ایمنی عملی') ->assertJsonPath('data.skills.0.score', 78) ->assertJsonPath('data.recentAssessments.0.score', 85) ->assertJsonPath('data.insight.source', 'capability_scores'); } private function learningEvent(User $learner, Assignment $assignment, CourseVersion $version, mixed $occurredAt, int $durationSeconds, string $session): void { LearningEvent::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'client_event_id' => Str::uuid()->toString(), 'event_type' => 'course.opened', 'schema_version' => 1, 'session_id' => $session, 'assignment_id' => $assignment->getKey(), 'course_version_id' => $version->getKey(), 'payload' => ['durationSeconds' => $durationSeconds], 'occurred_at' => $occurredAt, 'received_at' => $occurredAt]); } private function foundation(): array { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'status' => 'published', 'created_by' => $designer->getKey()]); $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Published, 'title' => 'Safety', 'description' => 'Safety course', 'settings' => ['language' => 'fa'], 'completion_rules' => ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]], 'published_at' => now()]); $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]); $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1, 'presentation_mode' => 'flow', 'settings' => ['durationMinutes' => 10]]); $block = Block::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Safety first', 'level' => 2], 'position' => 1, 'revision' => 1]); $assignment = Assignment::query()->create(['organization_id' => $designer->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $learner->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $designer->getKey()]); app(AssignmentResolver::class)->sync($assignment); return [$learner, $assignment, $version, $lesson, $block]; } }