createUser('reader@example.com'); $creator = $this->createUser('creator@example.com'); $set = $this->createSet($creator, 'Bible'); $bookLevel = $this->createLevel($set, 'book'); $portionLevel = $this->createLevel($set, 'portion'); $chapterLevel = $this->createLevel($set, 'chapter'); $repository = app(ElementRepository::class); $genesis = $repository->create(new CreateElementDto( name: 'Genesis', level: $bookLevel, parentElement: null, )); $creation = $repository->create(new CreateElementDto( name: 'Creation', level: $portionLevel, parentElement: $genesis, )); $repository->create(new CreateElementDto( name: 'Chapter 1', level: $chapterLevel, parentElement: $creation, )); $exodus = $repository->create(new CreateElementDto( name: 'Exodus', level: $bookLevel, parentElement: null, )); $shemot = $repository->create(new CreateElementDto( name: 'Shemot', level: $portionLevel, parentElement: $exodus, )); $repository->create(new CreateElementDto( name: 'Chapter 1', level: $chapterLevel, parentElement: $shemot, )); $this->createSession($user, 'valid-token'); $response = $this->credentialedPost('/api/schedules', [ 'setId' => $set->getId(), 'levelId' => $chapterLevel->getId(), 'startDate' => '2026-08-10', 'targetDate' => '2026-08-12', ]); $response->assertCreated()->assertExactJson([ 'schedule' => [ 'id' => 1, 'set' => [ 'name' => 'Bible', ], 'elementKind' => 'chapter', 'startDate' => '2026-08-10', 'targetDate' => '2026-08-12', 'assignmentCount' => 2, 'days' => [ [ 'date' => '2026-08-10', 'assignments' => [ [ 'id' => 1, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', 'path' => [ 'Genesis', 'Creation', 'Chapter 1', ], ], ], ], ], [ 'date' => '2026-08-11', 'assignments' => [], ], [ 'date' => '2026-08-12', 'assignments' => [ [ 'id' => 2, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', 'path' => [ 'Exodus', 'Shemot', 'Chapter 1', ], ], ], ], ], ], ], ]); $this->assertDatabaseHas('schedules', [ 'user_id' => $user->getId(), 'set_name' => 'Bible', 'element_kind' => 'chapter', 'start_date' => '2026-08-10', 'target_date' => '2026-08-12', ]); $this->assertDatabaseHas('schedule_assignments', [ 'schedule_id' => 1, 'element_name' => 'Chapter 1', 'element_kind' => 'chapter', 'element_path' => json_encode([ 'Genesis', 'Creation', 'Chapter 1', ]), 'scheduled_date' => '2026-08-10', 'position' => 1, ]); $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'); $lessonLevel = $this->createLevel($set, 'lesson'); app(ElementRepository::class)->create(new CreateElementDto( name: 'Welcome', level: $lessonLevel, parentElement: null, )); $this->createSession($user, 'valid-token'); $this->createSession($otherUser, 'other-token'); $this->credentialedPost('/api/schedules', [ 'setId' => $set->getId(), 'levelId' => $lessonLevel->getId(), 'startDate' => '2026-08-01', 'targetDate' => '2026-08-01', ])->assertCreated(); $this->credentialedPost('/api/schedules', [ 'setId' => $set->getId(), 'levelId' => $lessonLevel->getId(), 'startDate' => '2026-09-01', 'targetDate' => '2026-09-01', ])->assertCreated(); $this->withCredentials() ->withUnencryptedCookie( AuthMiddleware::COOKIE_NAME, 'other-token', )->postJson('/api/schedules', [ 'setId' => $set->getId(), 'levelId' => $lessonLevel->getId(), 'startDate' => '2026-10-01', 'targetDate' => '2026-10-01', ])->assertCreated(); $this->credentialedGet('/api/schedules') ->assertOk() ->assertExactJson([ 'schedules' => [ [ 'id' => 2, 'set' => [ 'name' => 'Course', ], 'elementKind' => 'lesson', 'startDate' => '2026-09-01', 'targetDate' => '2026-09-01', 'assignmentCount' => 1, ], [ 'id' => 1, 'set' => [ 'name' => 'Course', ], 'elementKind' => 'lesson', 'startDate' => '2026-08-01', 'targetDate' => '2026-08-01', 'assignmentCount' => 1, ], ], ]); } public function test_it_lists_the_users_assignments_for_a_date(): void { $user = $this->createUser('reader@example.com'); $otherUser = $this->createUser('other@example.com'); $olderSet = $this->createSet($user, 'Older course'); $olderLevel = $this->createLevel($olderSet, 'lesson'); $olderRepository = app(ElementRepository::class); $olderRepository->create(new CreateElementDto( name: 'First lesson', level: $olderLevel, parentElement: null, )); $olderRepository->create(new CreateElementDto( name: 'Second lesson', level: $olderLevel, parentElement: null, )); $newerSet = $this->createSet($user, 'Newer course'); $newerLevel = $this->createLevel($newerSet, 'chapter'); app(ElementRepository::class)->create(new CreateElementDto( name: 'Only chapter', level: $newerLevel, parentElement: null, )); $this->createSession($user, 'valid-token'); $this->createSession($otherUser, 'other-token'); $this->credentialedPost('/api/schedules', [ 'setId' => $olderSet->getId(), 'levelId' => $olderLevel->getId(), 'startDate' => '2026-08-15', 'targetDate' => '2026-08-15', ])->assertCreated(); $this->credentialedPost('/api/schedules', [ 'setId' => $newerSet->getId(), 'levelId' => $newerLevel->getId(), 'startDate' => '2026-08-15', 'targetDate' => '2026-08-15', ])->assertCreated(); $this->withCredentials() ->withUnencryptedCookie( AuthMiddleware::COOKIE_NAME, 'other-token', )->postJson('/api/schedules', [ 'setId' => $newerSet->getId(), 'levelId' => $newerLevel->getId(), 'startDate' => '2026-08-15', 'targetDate' => '2026-08-15', ])->assertCreated(); $this->credentialedPost('/api/schedules', [ 'setId' => $olderSet->getId(), 'levelId' => $olderLevel->getId(), 'startDate' => '2026-08-16', 'targetDate' => '2026-08-16', ])->assertCreated(); $this->credentialedGet('/api/assignments?date=2026-08-15') ->assertOk() ->assertExactJson([ 'date' => '2026-08-15', 'assignments' => [ [ 'id' => 3, 'schedule' => [ 'id' => 2, 'set' => [ 'name' => 'Newer course', ], ], 'element' => [ 'name' => 'Only chapter', 'kind' => 'chapter', 'path' => ['Only chapter'], ], ], [ 'id' => 1, 'schedule' => [ 'id' => 1, 'set' => [ 'name' => 'Older course', ], ], 'element' => [ 'name' => 'First lesson', 'kind' => 'lesson', 'path' => ['First lesson'], ], ], [ 'id' => 2, 'schedule' => [ 'id' => 1, 'set' => [ 'name' => 'Older course', ], ], 'element' => [ 'name' => 'Second lesson', 'kind' => 'lesson', 'path' => ['Second lesson'], ], ], ], ]); } public function test_it_returns_an_empty_assignment_list_for_a_date(): void { $user = $this->createUser('reader@example.com'); $this->createSession($user, 'valid-token'); $this->credentialedGet('/api/assignments?date=2026-08-15') ->assertOk() ->assertExactJson([ 'date' => '2026-08-15', 'assignments' => [], ]); } public function test_it_rejects_invalid_assignment_dates(): void { $user = $this->createUser('reader@example.com'); $this->createSession($user, 'valid-token'); $this->credentialedGet('/api/assignments') ->assertBadRequest() ->assertExactJson(['error' => 'date is required']); } public function test_it_is_stable_after_sources_change_or_are_deleted(): void { $user = $this->createUser('reader@example.com'); $set = $this->createSet($user, 'Original set'); $groupLevel = $this->createLevel($set, 'group'); $taskLevel = $this->createLevel($set, 'task'); $repository = app(ElementRepository::class); $parent = $repository->create(new CreateElementDto( name: 'Original parent', level: $groupLevel, parentElement: null, )); $element = $repository->create(new CreateElementDto( name: 'Original item', level: $taskLevel, parentElement: $parent, )); $this->createSession($user, 'valid-token'); $createdResponse = $this->credentialedPost('/api/schedules', [ 'setId' => $set->getId(), 'levelId' => $taskLevel->getId(), 'startDate' => '2026-08-10', 'targetDate' => '2026-08-10', ])->assertCreated(); SetModel::query()->whereKey($set->getId())->update([ 'name' => 'Changed set', ]); SetLevelModel::query()->whereKey($taskLevel->getId())->update([ 'kind' => 'changed-kind', ]); ElementModel::query()->whereKey($parent->getId())->update([ 'name' => 'Changed parent', ]); ElementModel::query()->whereKey($element->getId())->update([ 'name' => 'Changed item', ]); $this->credentialedGet('/api/schedules/1') ->assertOk() ->assertExactJson($createdResponse->json()); ElementModel::query()->whereKey($element->getId())->delete(); ElementModel::query()->whereKey($parent->getId())->delete(); SetModel::query()->whereKey($set->getId())->delete(); $this->credentialedGet('/api/schedules/1') ->assertOk() ->assertExactJson($createdResponse->json()); } 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'); $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-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, 'levelId' => 1, '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/assignments?date=2026-08-15') ->assertStatus(401); $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 createLevel(Set $set, string $kind): SetLevel { return app(SetLevelRepository::class)->create(new CreateSetLevelDto( set: $set, kind: $kind, )); } 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('+10 years'), )); } /** * @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); } }