readyCourse(); Sanctum::actingAs($designer); $this->putJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/completion-rules", [ 'mode' => 'all', 'rules' => [['type' => 'all_required_lessons']], ])->assertOk(); $this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/readiness") ->assertOk()->assertJsonPath('data.ready', true)->assertJsonCount(6, 'data.checks'); $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/review")->assertOk()->assertJsonPath('data.status', 'in_review'); $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/publish", ['reassignMode' => 'none']) ->assertOk()->assertJsonPath('data.status', 'published')->assertJsonPath('data.taxonomySnapshotCount', 0); $this->patchJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}", ['title' => 'Changed'])->assertUnprocessable(); } public function test_readiness_points_the_designer_to_the_exact_empty_lesson(): void { [$designer, $course, $version] = $this->readyCourse(); $lesson = $version->lessons()->firstOrFail(); $version->blocks()->delete(); Sanctum::actingAs($designer); $this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/readiness") ->assertOk() ->assertJsonPath('data.ready', false) ->assertJsonPath('data.checks.2.key', 'lesson_content') ->assertJsonPath('data.checks.2.status', 'error') ->assertJsonPath('data.checks.2.target.type', 'lesson') ->assertJsonPath('data.checks.2.target.lessonId', $lesson->getKey()); } public function test_team_assignment_resolves_members_and_syncs_a_new_member(): void { [$designer, , $version] = $this->readyCourse(CourseVersionStatus::Published); $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); $newLearner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); $team = Team::query()->create(['organization_id' => $designer->organization_id, 'name' => 'Operations', 'status' => 'active', 'created_by' => $designer->getKey()]); $team->members()->attach($learner); Sanctum::actingAs($designer); $assignment = $this->postJson('/api/v1/assignments', [ 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'targetType' => 'team', 'targetId' => $team->getKey(), 'mandatory' => true, 'reminderDays' => 3, 'escalationEnabled' => true, ])->assertCreated()->assertJsonPath('data.recipientCount', 1)->json('data.id'); $startsAt = now()->addDay()->startOfMinute(); $dueAt = now()->addDays(10)->startOfMinute(); $this->patchJson('/api/v1/assignments/'.$assignment, [ 'mandatory' => false, 'startsAt' => $startsAt->toISOString(), 'dueAt' => $dueAt->toISOString(), 'reminderDays' => 5, 'escalationEnabled' => false, ])->assertOk()->assertJsonPath('data.mandatory', false)->assertJsonPath('data.reminderDays', 5); $this->assertDatabaseHas('assignments', ['id' => $assignment, 'mandatory' => false, 'reminder_days' => 5]); $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment, 'user_id' => $learner->getKey(), 'starts_at' => $startsAt, 'due_at' => $dueAt]); $this->putJson("/api/v1/teams/{$team->getKey()}/members", ['userId' => $newLearner->getKey()])->assertOk(); $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment, 'user_id' => $newLearner->getKey(), 'status' => 'assigned']); } public function test_course_workspace_assignments_expose_summary_progress_details_and_bulk_actions(): void { [$designer, , $version] = $this->readyCourse(CourseVersionStatus::Published); $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); $team = Team::query()->create(['organization_id' => $designer->organization_id, 'name' => 'Operations', 'status' => 'active', 'created_by' => $designer->getKey()]); $team->members()->attach($learner); Sanctum::actingAs($designer); $assignment = $this->postJson('/api/v1/assignments', [ 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'targetType' => 'team', 'targetId' => $team->getKey(), 'mandatory' => true, 'dueAt' => now()->addDays(5)->toISOString(), ])->assertCreated()->json('data.id'); DB::table('assignment_users')->where('assignment_id', $assignment)->update(['progress' => 40]); $this->getJson('/api/v1/assignments?workspace=1&courseVersionId='.$version->getKey()) ->assertOk()->assertJsonPath('data.summary.total', 1)->assertJsonPath('data.summary.active', 1) ->assertJsonPath('data.summary.dueSoon', 1)->assertJsonPath('data.items.0.progress', 40) ->assertJsonPath('data.items.0.targetLabel', 'Operations'); $this->getJson('/api/v1/assignments/'.$assignment) ->assertOk()->assertJsonPath('data.id', $assignment)->assertJsonPath('data.reminderHistory', []); $newDueAt = now()->addDays(12)->startOfMinute(); $this->postJson('/api/v1/assignments/bulk', ['ids' => [$assignment], 'action' => 'extend', 'dueAt' => $newDueAt->toISOString()]) ->assertOk()->assertJsonPath('data.affected', 1); $this->assertDatabaseHas('assignments', ['id' => $assignment, 'due_at' => $newDueAt]); } public function test_learning_path_orders_published_courses_then_versions_without_mutating_history(): void { [$designer, , $courseVersion] = $this->readyCourse(CourseVersionStatus::Published); Sanctum::actingAs($designer); $path = $this->postJson('/api/v1/learning-paths', ['title' => 'مسیر ایمنی', 'description' => 'مسیر پایه', 'enforceOrder' => true])->assertCreated()->json('data'); $item = $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/items", [ 'courseVersionId' => $courseVersion->getKey(), 'completionType' => 'minimum_score', 'minimumScore' => 70, ])->assertCreated()->assertJsonPath('data.position', 1)->json('data.id'); $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/review")->assertOk(); $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/publish")->assertOk()->assertJsonPath('data.status', 'published'); $fork = $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/fork")->assertCreated()->json('data'); $this->assertSame(2, $fork['number']); $this->assertSame($item, $this->getJson('/api/v1/learning-paths/'.$path['id'].'?versionId='.$path['versionId'])->json('data.version.items.0.id')); } public function test_bulk_assignment_audience_can_be_resolved_from_csv_without_cross_tenant_users(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); $outsider = User::factory()->create(['role' => UserRole::Learner]); Sanctum::actingAs($designer); $file = UploadedFile::fake()->createWithContent('audience.csv', "Email\n{$learner->email}\n{$outsider->email}\nmissing@example.test\n"); $this->post('/api/v1/assignment-audience-import', ['file' => $file], ['Accept' => 'application/json']) ->assertOk()->assertJsonPath('data.matched', 1)->assertJsonPath('data.userIds.0', $learner->getKey()) ->assertJsonCount(2, 'data.missingEmails'); } private function readyCourse(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', 'status' => $status === CourseVersionStatus::Published ? 'published' : 'draft', 'created_by' => $designer->getKey()]); $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => $status, 'title' => 'Safety', 'description' => 'Complete safety course', 'settings' => ['language' => 'fa'], 'completion_rules' => ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]], 'published_at' => $status === CourseVersionStatus::Published ? now() : null]); $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]); 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]); return [$designer, $course, $version]; } }