New_Micro_Learning/backend/tests/Feature/Phase8/PublishingAssignmentsLearni...

150 خطوط
10 KiB
PHP

<?php
namespace Tests\Feature\Phase8;
use App\Models\User;
use App\Modules\Courses\Domain\Block;
use App\Modules\Courses\Domain\Course;
use App\Modules\Courses\Domain\CourseModule;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
use App\Modules\Courses\Domain\Lesson;
use App\Modules\Identity\Domain\Enums\UserRole;
use App\Modules\Teams\Domain\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class PublishingAssignmentsLearningPathsTest extends TestCase
{
use RefreshDatabase;
public function test_ready_course_moves_through_review_publish_and_preserves_an_immutable_taxonomy_snapshot(): void
{
[$designer, $course, $version] = $this->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];
}
}