From 258e4ea27a2ff32cb77e1ee42c0940553e78434f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:41:03 +0300 Subject: [PATCH] test schedule rescheduling --- .../Feature/Schedule/ScheduleEndpointTest.php | 120 ++++++++++ .../UseCases/RescheduleScheduleTest.php | 218 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index ef98f8c..a6595c4 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -558,6 +558,125 @@ class ScheduleEndpointTest extends TestCase ->assertJsonCount(2, 'assignments'); } + public function test_it_reschedules_only_unfinished_assignments(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + $repository = app(ElementRepository::class); + foreach (range(1, 4) as $number) { + $repository->create(new CreateElementDto( + name: "Lesson {$number}", + level: $lessonLevel, + parentElement: null, + )); + } + $this->createSession($user, 'valid-token'); + $completedAt = new DateTimeImmutable( + '2026-08-15T12:30:45', + new DateTimeZone('UTC'), + ); + $this->app->instance(Clock::class, new FakeClock($completedAt)); + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-13', + ])->assertCreated(); + $this->credentialedPatch('/api/assignments/1', [ + 'completed' => true, + ])->assertOk(); + + $response = $this->credentialedPatch('/api/schedules/1', [ + 'startDate' => '2026-08-20', + 'targetDate' => '2026-08-21', + 'workloadPlacement' => 'end', + ]); + + $response->assertOk() + ->assertJsonPath('schedule.startDate', '2026-08-20') + ->assertJsonPath('schedule.targetDate', '2026-08-21') + ->assertJsonPath('schedule.days.0.date', '2026-08-10') + ->assertJsonPath( + 'schedule.days.0.assignments.0.completedAt', + '2026-08-15T12:30:45+00:00', + ) + ->assertJsonPath('schedule.days.1.date', '2026-08-20') + ->assertJsonCount(1, 'schedule.days.1.assignments') + ->assertJsonPath('schedule.days.1.assignments.0.id', 2) + ->assertJsonPath('schedule.days.2.date', '2026-08-21') + ->assertJsonCount(2, 'schedule.days.2.assignments') + ->assertJsonPath('schedule.days.2.assignments.0.id', 3) + ->assertJsonPath('schedule.days.2.assignments.1.id', 4); + $this->assertDatabaseHas('schedules', [ + 'id' => 1, + 'start_date' => '2026-08-20', + 'target_date' => '2026-08-21', + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 1, + 'scheduled_date' => '2026-08-10', + 'completed_at' => '2026-08-15 12:30:45', + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 2, + 'scheduled_date' => '2026-08-20', + 'completed_at' => null, + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 3, + 'scheduled_date' => '2026-08-21', + 'completed_at' => null, + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 4, + 'scheduled_date' => '2026-08-21', + 'completed_at' => null, + ]); + } + + public function test_it_hides_another_users_schedule_when_rescheduling(): void + { + $owner = $this->createUser('owner@example.com'); + $viewer = $this->createUser('viewer@example.com'); + $set = $this->createSet($owner, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Welcome', + level: $lessonLevel, + parentElement: null, + )); + $this->createSession($owner, 'owner-token'); + $this->createSession($viewer, 'viewer-token'); + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'owner-token', + )->postJson('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-10', + ])->assertCreated(); + + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'viewer-token', + )->patchJson('/api/schedules/1', [ + 'startDate' => '2026-08-20', + 'targetDate' => '2026-08-21', + 'workloadPlacement' => 'middle', + ])->assertNotFound()->assertExactJson([ + 'error' => 'schedule not found', + ]); + $this->assertDatabaseHas('schedules', [ + 'id' => 1, + 'start_date' => '2026-08-10', + 'target_date' => '2026-08-10', + ]); + } + public function test_it_rejects_invalid_assignment_completion_input(): void { $user = $this->createUser('reader@example.com'); @@ -718,6 +837,7 @@ class ScheduleEndpointTest extends TestCase ])->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401); + $this->patchJson('/api/schedules/1', [])->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401); } diff --git a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php new file mode 100644 index 0000000..77daf80 --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php @@ -0,0 +1,218 @@ +user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + $assignments = $schedule->getAssignments(); + $firstCompletion = $this->utc('2026-08-15T10:00:00'); + $secondCompletion = $this->utc('2026-08-16T11:00:00'); + $assignments[1]->complete($firstCompletion); + $assignments[3]->complete($secondCompletion); + $repository->updateAssignment($assignments[1]); + $repository->updateAssignment($assignments[3]); + + $rescheduled = $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'end', + ), + ); + + $this->assertSame('2026-08-20', $rescheduled->getStartDate() + ->format('Y-m-d')); + $this->assertSame('2026-08-21', $rescheduled->getTargetDate() + ->format('Y-m-d')); + $this->assertSame( + [ + '2026-08-20', + '2026-08-11', + '2026-08-21', + '2026-08-13', + '2026-08-21', + ], + array_map(function (ScheduleAssignment $assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $rescheduled->getAssignments()), + ); + $this->assertSame( + [null, $firstCompletion, null, $secondCompletion, null], + array_map(function ( + ScheduleAssignment $assignment, + ): ?DateTimeImmutable { + return $assignment->getCompletedAt(); + }, $rescheduled->getAssignments()), + ); + $this->assertSame( + [1, 2, 3, 4, 5], + array_map(function (ScheduleAssignment $assignment): int { + return $assignment->getId(); + }, $rescheduled->getAssignments()), + ); + $this->assertSame( + ['Lesson 1', 'Lesson 2', 'Lesson 3', 'Lesson 4', 'Lesson 5'], + array_map(function (ScheduleAssignment $assignment): string { + return $assignment->getName(); + }, $rescheduled->getAssignments()), + ); + } + + public function test_it_rejects_a_target_before_the_start(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'targetDate must not be before startDate', + ); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-21', + targetDate: '2026-08-20', + workloadPlacement: 'middle', + ), + ); + } + + public function test_it_rejects_an_unknown_workload_placement(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'workloadPlacement must be start, middle, or end', + ); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'sideways', + ), + ); + } + + public function test_it_rejects_a_schedule_without_unfinished_work(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + foreach ($schedule->getAssignments() as $assignment) { + $assignment->complete($this->utc('2026-08-15T10:00:00')); + $repository->updateAssignment($assignment); + } + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'schedule has no unfinished assignments', + ); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'middle', + ), + ); + } + + public function test_it_hides_another_users_schedule(): void + { + $repository = new FakeScheduleRepository; + $owner = $this->user(1, 'owner@example.com'); + $schedule = $repository->create($this->schedule($owner)); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('schedule not found'); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $this->user(2, 'viewer@example.com'), + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'middle', + ), + ); + } + + private function useCase( + FakeScheduleRepository $repository, + ): RescheduleSchedule { + return new RescheduleSchedule( + $repository, + new EvenDistributionScheduler, + ); + } + + private function schedule(User $user): CreateScheduleDto + { + $assignments = []; + foreach (range(1, 5) as $number) { + $assignments[] = new CreateScheduleAssignmentDto( + name: "Lesson {$number}", + kind: 'lesson', + path: ['Course', "Lesson {$number}"], + scheduledDate: $this->utc("2026-08-1{$number}"), + position: $number, + ); + } + + return new CreateScheduleDto( + user: $user, + setName: 'Course', + elementKind: 'lesson', + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-15'), + assignments: $assignments, + ); + } + + private function user(int $id, string $email): User + { + return new User( + id: $id, + email: new EmailAddress($email), + passwordHash: 'hashed-password', + ); + } + + private function utc(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } +}