From bdb266746da800ec4a6dec7adb567e50f5bb281f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 10 Aug 2026 20:01:28 +0300 Subject: [PATCH] test set scheduling flow --- .../tests/Fakes/FakeScheduleRepository.php | 97 ++++++ .../Feature/Schedule/ScheduleEndpointTest.php | 313 +++++++++++++++++ .../Schedule/UseCases/CreateScheduleTest.php | 314 ++++++++++++++++++ 3 files changed, 724 insertions(+) create mode 100644 backend/tests/Fakes/FakeScheduleRepository.php create mode 100644 backend/tests/Feature/Schedule/ScheduleEndpointTest.php create mode 100644 backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php new file mode 100644 index 0000000..06e281e --- /dev/null +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -0,0 +1,97 @@ + + */ + private array $schedules = []; + + public function create(CreateScheduleDto $dto): Schedule + { + $id = count($this->schedules) + 1; + $assignments = []; + + foreach ($dto->assignments as $assignmentDto) { + $assignments[] = new ScheduleAssignment( + id: count($assignments) + 1, + element: $assignmentDto->element, + scheduledDate: $assignmentDto->scheduledDate, + position: $assignmentDto->position, + ); + } + + $schedule = new Schedule( + id: $id, + user: $dto->user, + set: $dto->set, + elementKind: $dto->elementKind, + startDate: $dto->startDate, + targetDate: $dto->targetDate, + assignments: $assignments, + ); + $this->schedules[$id] = $schedule; + + return $this->copy($schedule); + } + + public function findForUser(int $id, User $user): ?Schedule + { + $schedule = $this->schedules[$id] ?? null; + if ($schedule === null || $schedule->getUser()->getId() + !== $user->getId() + ) { + return null; + } + + return $this->copy($schedule); + } + + public function findAllForUser(User $user): array + { + $schedules = array_filter( + $this->schedules, + function (Schedule $schedule) use ($user): bool { + return $schedule->getUser()->getId() === $user->getId(); + }, + ); + krsort($schedules); + + return array_map(function (Schedule $schedule): Schedule { + return $this->copy($schedule); + }, array_values($schedules)); + } + + private function copy(Schedule $schedule): Schedule + { + $assignments = array_map( + function (ScheduleAssignment $assignment): ScheduleAssignment { + return new ScheduleAssignment( + id: $assignment->getId(), + element: $assignment->getElement(), + scheduledDate: $assignment->getScheduledDate(), + position: $assignment->getPosition(), + ); + }, + $schedule->getAssignments(), + ); + + return new Schedule( + id: $schedule->getId(), + user: $schedule->getUser(), + set: $schedule->getSet(), + elementKind: $schedule->getElementKind(), + startDate: $schedule->getStartDate(), + targetDate: $schedule->getTargetDate(), + assignments: $assignments, + ); + } +} diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php new file mode 100644 index 0000000..731a221 --- /dev/null +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -0,0 +1,313 @@ +createUser('reader@example.com'); + $creator = $this->createUser('creator@example.com'); + $set = $this->createSet($creator, 'Bible'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $creation = $repository->create(new CreateElementDto( + set: $set, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $chapterOne = $repository->create(new CreateElementDto( + set: $set, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $creation, + )); + $exodus = $repository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $chapterTwo = $repository->create(new CreateElementDto( + set: $set, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $exodus, + )); + $this->createSession($user, 'valid-token'); + + $response = $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'chapter', + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-12', + ]); + + $response->assertCreated()->assertExactJson([ + 'schedule' => [ + 'id' => 1, + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Bible', + ], + 'elementKind' => 'chapter', + 'startDate' => '2026-08-10', + 'targetDate' => '2026-08-12', + 'assignmentCount' => 2, + 'days' => [ + [ + 'date' => '2026-08-10', + 'assignments' => [ + [ + 'element' => [ + 'id' => $chapterOne->getId(), + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'path' => [ + 'Genesis', + 'Creation', + 'Chapter 1', + ], + ], + ], + ], + ], + [ + 'date' => '2026-08-11', + 'assignments' => [], + ], + [ + 'date' => '2026-08-12', + 'assignments' => [ + [ + 'element' => [ + 'id' => $chapterTwo->getId(), + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'path' => [ + 'Exodus', + 'Chapter 1', + ], + ], + ], + ], + ], + ], + ], + ]); + $this->assertDatabaseHas('schedules', [ + 'user_id' => $user->getId(), + 'set_id' => $set->getId(), + 'element_kind' => 'chapter', + 'start_date' => '2026-08-10', + 'target_date' => '2026-08-12', + ]); + $this->assertDatabaseCount('schedule_assignments', 2); + + $this->credentialedGet('/api/schedules/1') + ->assertOk() + ->assertExactJson($response->json()); + } + + public function test_it_lists_only_the_users_schedules_newest_first(): void + { + $user = $this->createUser('reader@example.com'); + $otherUser = $this->createUser('other@example.com'); + $set = $this->createSet($user, 'Course'); + app(ElementRepository::class)->create(new CreateElementDto( + set: $set, + name: 'Welcome', + kind: 'lesson', + parentElement: null, + )); + $this->createSession($user, 'valid-token'); + $this->createSession($otherUser, 'other-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + ])->assertCreated(); + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-09-01', + 'targetDate' => '2026-09-01', + ])->assertCreated(); + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'other-token', + )->postJson('/api/schedules', [ + 'setId' => $set->getId(), + 'elementKind' => 'lesson', + 'startDate' => '2026-10-01', + 'targetDate' => '2026-10-01', + ])->assertCreated(); + + $this->credentialedGet('/api/schedules') + ->assertOk() + ->assertExactJson([ + 'schedules' => [ + [ + 'id' => 2, + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Course', + ], + 'elementKind' => 'lesson', + 'startDate' => '2026-09-01', + 'targetDate' => '2026-09-01', + 'assignmentCount' => 1, + ], + [ + 'id' => 1, + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Course', + ], + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + 'assignmentCount' => 1, + ], + ], + ]); + } + + public function test_it_hides_another_users_schedule(): void + { + $owner = $this->createUser('owner@example.com'); + $viewer = $this->createUser('viewer@example.com'); + $set = $this->createSet($owner, 'Course'); + app(ElementRepository::class)->create(new CreateElementDto( + set: $set, + name: 'Welcome', + kind: 'lesson', + 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(), + 'elementKind' => 'lesson', + 'startDate' => '2026-08-01', + 'targetDate' => '2026-08-01', + ])->assertCreated(); + + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'viewer-token', + )->getJson('/api/schedules/1') + ->assertNotFound() + ->assertExactJson(['error' => 'schedule not found']); + } + + public function test_it_rejects_invalid_creation_input(): void + { + $user = $this->createUser('reader@example.com'); + $this->createSession($user, 'valid-token'); + + $this->credentialedPost('/api/schedules', [ + 'setId' => 999, + 'elementKind' => 'chapter', + 'startDate' => '2026-08-12', + 'targetDate' => '2026-08-10', + ])->assertNotFound()->assertExactJson([ + 'error' => 'set not found', + ]); + } + + public function test_schedule_endpoints_require_authentication(): void + { + $this->getJson('/api/schedules')->assertStatus(401); + $this->getJson('/api/schedules/1')->assertStatus(401); + $this->postJson('/api/schedules', [])->assertStatus(401); + } + + private function createUser(string $email): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: 'hashed-password', + )); + } + + private function createSet(User $user, string $name): Set + { + return app(SetRepository::class)->create(new CreateSetDto( + name: $name, + creator: $user, + )); + } + + private function createSession(User $user, string $token): void + { + $createdAt = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $createdAt, + expiresAt: $createdAt->modify('+7 days'), + )); + } + + /** + * @param array $payload + */ + private function credentialedPost( + string $uri, + array $payload, + ): TestResponse { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->postJson($uri, $payload); + } + + private function credentialedGet(string $uri): TestResponse + { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson($uri); + } +} diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php new file mode 100644 index 0000000..3814a56 --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -0,0 +1,314 @@ +user(); + $setRepository = new FakeSetRepository; + $elementRepository = new FakeElementRepository; + $scheduleRepository = new FakeScheduleRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + $genesis = $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $exodus = $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $exodusChapterOne = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Exodus 1', + kind: 'chapter', + parentElement: $exodus, + ), + ); + $creation = $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $genesisChapterOne = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Genesis 1', + kind: 'chapter', + parentElement: $creation, + ), + ); + $genesisChapterTwo = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Genesis 2', + kind: 'chapter', + parentElement: $creation, + ), + ); + $genesisChapterThree = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Genesis 3', + kind: 'chapter', + parentElement: $creation, + ), + ); + $exodusChapterTwo = $elementRepository->create( + new CreateElementDto( + set: $set, + name: 'Exodus 2', + kind: 'chapter', + parentElement: $exodus, + ), + ); + + $schedule = (new CreateSchedule( + $setRepository, + $elementRepository, + $scheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + + $this->assertSame('chapter', $schedule->getElementKind()); + $this->assertSame('2026-08-10', $schedule->getStartDate()->format( + 'Y-m-d', + )); + $this->assertSame('2026-08-12', $schedule->getTargetDate()->format( + 'Y-m-d', + )); + $this->assertSame( + [ + $genesisChapterOne->getId(), + $genesisChapterTwo->getId(), + $genesisChapterThree->getId(), + $exodusChapterOne->getId(), + $exodusChapterTwo->getId(), + ], + array_map(function ($assignment): int { + return $assignment->getElement()->getId(); + }, $schedule->getAssignments()), + ); + $this->assertSame( + [ + '2026-08-10', + '2026-08-10', + '2026-08-11', + '2026-08-11', + '2026-08-12', + ], + array_map(function ($assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $schedule->getAssignments()), + ); + } + + public function test_it_spreads_sparse_work_across_the_full_range(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $user, + )); + + foreach (['First', 'Second', 'Third'] as $name) { + $elementRepository->create(new CreateElementDto( + set: $set, + name: $name, + kind: 'lesson', + parentElement: null, + )); + } + + $schedule = (new CreateSchedule( + $setRepository, + $elementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'lesson', + startDate: '2026-08-10', + targetDate: '2026-08-16', + )); + + $this->assertSame( + ['2026-08-10', '2026-08-13', '2026-08-16'], + array_map(function ($assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $schedule->getAssignments()), + ); + } + + public function test_it_places_one_element_on_the_start_date(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Project', + creator: $user, + )); + $elementRepository->create(new CreateElementDto( + set: $set, + name: 'Ship it', + kind: 'milestone', + parentElement: null, + )); + + $schedule = (new CreateSchedule( + $setRepository, + $elementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'milestone', + startDate: '2020-01-01', + targetDate: '2030-01-01', + )); + + $this->assertSame( + '2020-01-01', + $schedule->getAssignments()[0] + ->getScheduledDate() + ->format('Y-m-d'), + ); + } + + public function test_it_rejects_an_unknown_set(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('set not found'); + + (new CreateSchedule( + new FakeSetRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $this->user(), + setId: 999, + elementKind: 'chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_an_unavailable_element_kind(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'elementKind is not available for set', + ); + + (new CreateSchedule( + $setRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'Chapter', + startDate: '2026-08-10', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_invalid_dates(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('startDate must be a valid date'); + + (new CreateSchedule( + $setRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'chapter', + startDate: '2026-02-30', + targetDate: '2026-08-12', + )); + } + + public function test_it_rejects_a_target_before_the_start(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Bible', + creator: $user, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'targetDate must not be before startDate', + ); + + (new CreateSchedule( + $setRepository, + new FakeElementRepository, + new FakeScheduleRepository, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + elementKind: 'chapter', + startDate: '2026-08-12', + targetDate: '2026-08-10', + )); + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('reader@example.com'), + passwordHash: 'hashed-password', + ); + } +}