foundation(); Sanctum::actingAs($designer); $bankId = $this->postJson('/api/v1/question-bank', [ 'type' => 'single_choice', 'prompt' => 'کدام گزینه الزامی است؟', 'topic' => 'ایمنی', 'difficulty' => 'intermediate', 'tags' => ['PPE'], 'explanation' => 'کلاه ایمنی الزامی است.', 'configuration' => ['options' => [ ['id' => 'a', 'text' => 'کلاه ایمنی', 'correct' => true, 'feedback' => 'درست'], ['id' => 'b', 'text' => 'عینک آفتابی', 'correct' => false, 'feedback' => 'نادرست'], ]], ])->assertCreated()->assertJsonPath('data.usageCount', 0)->json('data.id'); $assessmentId = $this->postJson('/api/v1/assessments', [ 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'title' => 'آزمون ایمنی', 'settings' => $this->settings(), ])->assertCreated()->assertJsonPath('data.settings.passingScore', 80)->json('data.id'); $this->postJson("/api/v1/assessments/{$assessmentId}/questions", ['sourceQuestionId' => $bankId]) ->assertCreated()->assertJsonPath('data.sourceQuestionId', $bankId)->assertJsonPath('data.position', 1); $this->getJson('/api/v1/question-bank?topic=ایمنی')->assertOk() ->assertJsonPath('data.0.id', $bankId)->assertJsonPath('data.0.usageCount', 1); $this->getJson("/api/v1/assessments/{$assessmentId}")->assertOk() ->assertJsonPath('data.questionCount', 1)->assertJsonPath('data.questions.0.prompt', 'کدام گزینه الزامی است؟'); } public function test_question_schema_rejects_invalid_answers_and_broken_scenario_graph(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $this->postJson('/api/v1/question-bank', [ 'type' => 'single_choice', 'prompt' => 'Invalid', 'configuration' => ['options' => [ ['id' => 'a', 'text' => 'A', 'correct' => true], ['id' => 'b', 'text' => 'B', 'correct' => true], ]], ])->assertUnprocessable()->assertJsonValidationErrors('configuration.options'); $this->postJson('/api/v1/question-bank', [ 'type' => 'branching_scenario', 'prompt' => 'Broken graph', 'configuration' => [ 'startNodeId' => 'start', 'nodes' => [ ['id' => 'start', 'type' => 'scene', 'title' => 'شروع', 'choices' => [['text' => 'ادامه', 'targetNodeId' => 'missing']]], ['id' => 'result', 'type' => 'result', 'title' => 'پایان', 'choices' => []], ], ], ])->assertUnprocessable()->assertJsonValidationErrors('configuration.nodes'); } public function test_designer_creates_two_level_question_categories_and_filters_bank(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $categoryId = $this->postJson('/api/v1/question-categories', ['name' => 'مهارت'])->assertCreated()->json('data.id'); $subcategoryId = $this->postJson('/api/v1/question-categories', ['name' => 'اکسل', 'parentId' => $categoryId])->assertCreated()->assertJsonPath('data.parentId', $categoryId)->json('data.id'); $questionId = $this->postJson('/api/v1/question-bank', [ 'type' => 'true_false', 'prompt' => 'تابع SUM برای جمع است.', 'categoryId' => $categoryId, 'subcategoryId' => $subcategoryId, 'configuration' => ['answer' => true, 'feedback' => 'درست'], ])->assertCreated()->assertJsonPath('data.categoryName', 'مهارت')->assertJsonPath('data.subcategoryName', 'اکسل')->json('data.id'); $this->getJson('/api/v1/question-categories')->assertOk()->assertJsonPath('data.0.children.0.name', 'اکسل'); $this->getJson('/api/v1/question-bank?categoryId='.$categoryId)->assertOk()->assertJsonPath('data.0.id', $questionId); $this->getJson('/api/v1/question-bank?subcategoryId='.$subcategoryId)->assertOk()->assertJsonPath('data.0.id', $questionId); $this->deleteJson('/api/v1/question-categories/'.$subcategoryId)->assertStatus(409); } public function test_published_assessments_are_immutable_and_tenant_scoped(): void { [$designer, $version, $lesson] = $this->foundation(CourseVersionStatus::Published); $assessment = Assessment::query()->create([ 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'title' => 'Published', 'settings' => $this->settings(), ]); Sanctum::actingAs($designer); $this->patchJson('/api/v1/assessments/'.$assessment->getKey(), ['title' => 'Changed']) ->assertUnprocessable()->assertJsonValidationErrors('courseVersionId'); $foreignDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($foreignDesigner); $this->getJson('/api/v1/assessments/'.$assessment->getKey())->assertNotFound(); } /** @return array{User, CourseVersion, Lesson} */ private function foundation(CourseVersionStatus $status = CourseVersionStatus::Draft): array { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'created_by' => $designer->getKey()]); $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => $status, 'title' => 'Safety']); $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Module', 'position' => 1]); $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'Lesson', 'position' => 1]); return [$designer, $version, $lesson]; } /** @return array */ private function settings(): array { return ['randomSelection' => false, 'questionPoolSize' => null, 'shuffleQuestions' => true, 'shuffleOptions' => true, 'passingScore' => 80, 'attemptLimit' => 2, 'feedbackMode' => 'after_submission', 'timeLimitSeconds' => 600]; } }