teamContext(); [, $published] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی منتشرشده'); $this->course($manager, CourseVersionStatus::Draft, 'پیش‌نویس خصوصی'); Sanctum::actingAs($manager); $this->getJson('/api/v1/manager/assignment-contexts')->assertOk() ->assertJsonPath('data.content.0.id', $published->getKey()) ->assertJsonPath('data.teams.0.id', $team->getKey()) ->assertJsonPath('data.members.0.id', $member->getKey()) ->assertJsonMissing(['id' => $outsider->getKey()]) ->assertJsonMissing(['title' => 'پیش‌نویس خصوصی']); } public function test_manager_assignment_skips_duplicate_recipients_and_schedules_idempotent_notifications(): void { [$manager, $team, $member] = $this->teamContext(); $second = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو دوم']); $team->members()->attach($second); [, $version] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی'); $existing = Assignment::query()->create(['organization_id' => $manager->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $member->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $manager->getKey()]); $existing->users()->attach($member, ['status' => 'assigned', 'assigned_at' => now(), 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]); Sanctum::actingAs($manager); $assignment = $this->postJson('/api/v1/manager/assignments', [ 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'audienceType' => 'team', 'teamId' => $team->getKey(), 'mandatory' => true, 'dueAt' => now()->addDays(10)->toISOString(), 'notifyNow' => true, 'reminderDays' => 3, 'reminderOnDeadline' => true, 'onlyIfNotStarted' => true, ])->assertCreated()->assertJsonPath('data.result.assigned', 1)->assertJsonPath('data.result.duplicatesSkipped', 1) ->assertJsonPath('data.result.notificationsSent', 1)->assertJsonPath('data.result.notificationsScheduled', 2)->json('data.assignment.id'); $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment, 'user_id' => $second->getKey()]); $this->assertDatabaseMissing('assignment_users', ['assignment_id' => $assignment, 'user_id' => $member->getKey()]); $this->assertDatabaseHas('in_app_notifications', ['recipient_id' => $second->getKey(), 'type' => 'learning.assigned']); $this->assertDatabaseCount('notification_schedules', 2); $this->assertDatabaseHas('audit_logs', ['actor_id' => $manager->getKey(), 'action' => 'manager.assignment.created', 'entity_id' => $assignment]); } public function test_manager_cannot_target_or_modify_people_outside_their_scope(): void { [$manager, , , $outsider] = $this->teamContext(); [, $version] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی'); Sanctum::actingAs($manager); $this->postJson('/api/v1/manager/assignments', [ 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'audienceType' => 'employees', 'userIds' => [$outsider->getKey()], 'mandatory' => false, 'notifyNow' => false, 'reminderOnDeadline' => false, 'onlyIfNotStarted' => false, ])->assertUnprocessable()->assertJsonValidationErrors('userIds'); $otherManager = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Manager]); $foreign = Assignment::query()->create(['organization_id' => $manager->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $outsider->getKey(), 'status' => 'active', 'mandatory' => false, 'assigned_by' => $otherManager->getKey()]); $foreign->users()->attach($outsider, ['status' => 'assigned', 'assigned_at' => now(), 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]); $this->patchJson('/api/v1/manager/assignments/'.$foreign->getKey().'/deadline', ['dueAt' => now()->addWeek()->toISOString()])->assertNotFound(); $this->postJson('/api/v1/manager/assignments/'.$foreign->getKey().'/reminders')->assertNotFound(); } public function test_deadline_removal_cancels_schedules_and_manual_reminder_has_daily_cooldown_and_preferences(): void { [$manager, , $member] = $this->teamContext(); [, $version] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی'); Sanctum::actingAs($manager); $assignment = $this->postJson('/api/v1/manager/assignments', [ 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'audienceType' => 'employees', 'userIds' => [$member->getKey()], 'mandatory' => false, 'dueAt' => now()->addDays(8)->toISOString(), 'notifyNow' => false, 'reminderDays' => 2, 'reminderOnDeadline' => true, 'onlyIfNotStarted' => false, ])->assertCreated()->json('data.assignment.id'); $this->patchJson('/api/v1/manager/assignments/'.$assignment.'/deadline', ['dueAt' => null]) ->assertOk()->assertJsonPath('data.assignment.dueAt', null)->assertJsonPath('data.notificationsScheduled', 0); $this->assertDatabaseHas('notification_schedules', ['entity_id' => $assignment, 'status' => 'cancelled']); $this->postJson('/api/v1/manager/assignments/'.$assignment.'/reminders')->assertOk()->assertJsonPath('data.sent', 1); $this->postJson('/api/v1/manager/assignments/'.$assignment.'/reminders')->assertOk()->assertJsonPath('data.sent', 0)->assertJsonPath('data.skipped', 1); DB::table('user_preferences')->insert(['id' => (string) str()->ulid(), 'user_id' => $member->getKey(), 'preferences' => json_encode(['deadlineReminders' => false]), 'created_at' => now(), 'updated_at' => now()]); DB::table('in_app_notifications')->where('recipient_id', $member->getKey())->delete(); $this->travel(1)->day(); $this->postJson('/api/v1/manager/assignments/'.$assignment.'/reminders')->assertOk()->assertJsonPath('data.sent', 0)->assertJsonPath('data.skipped', 1); } /** @return array{User, Team, User, User} */ private function teamContext(): array { $manager = User::factory()->create(['role' => UserRole::Manager]); $member = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو مجاز']); $outsider = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو خارج محدوده']); $team = Team::query()->create(['organization_id' => $manager->organization_id, 'name' => 'عملیات', 'status' => 'active', 'created_by' => $manager->getKey()]); $team->managers()->attach($manager); $team->members()->attach($member); return [$manager, $team, $member, $outsider]; } /** @return array{Course, CourseVersion} */ private function course(User $owner, CourseVersionStatus $status, string $title): array { $course = Course::query()->create(['organization_id' => $owner->organization_id, 'title' => $title, 'slug' => str()->slug($title).'-'.str()->lower(str()->random(5)), 'status' => $status === CourseVersionStatus::Published ? 'published' : 'draft', 'created_by' => $owner->getKey()]); $version = CourseVersion::query()->create(['organization_id' => $owner->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => $status, 'title' => $title, 'settings' => [], 'completion_rules' => [], 'published_at' => $status === CourseVersionStatus::Published ? now() : null]); return [$course, $version]; } }