diff --git a/ai/backend-context.md b/ai/backend-context.md index 83c2081..9850c44 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -87,6 +87,8 @@ intentionally unclaimed; the built-in health endpoint is `/up`. - Put imports at the top of the file. Do not use inline fully qualified class names when a normal `use` statement is clearer. - Do not use arrow functions. Use regular anonymous functions. +- Do not use first-class callable syntax. Use regular anonymous functions for + callbacks so the invocation is explicit. - Do not add default values to function or constructor parameters. Pass every argument explicitly, including nullable arguments. - Use descriptive names for classes, methods, parameters, and local diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index fc46687..3ca103c 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -14,6 +14,11 @@ interface ElementRepository public function find(int $id): ?Element; + /** + * @return list + */ + public function findBySet(Set $set): array; + /** * @return list */ diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index cfd93f7..1f30e24 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -45,6 +45,37 @@ class EloquentElementRepository implements ElementRepository return $model === null ? null : $this->toDomain($model); } + public function findBySet(Set $set): array + { + $models = ElementModel::query() + ->where('set_id', $set->getId()) + ->orderBy('id') + ->get(); + $elements = []; + $elementsById = []; + + foreach ($models as $model) { + $parentElement = null; + if ($model->parent_element_id !== null) { + $parentElement = $elementsById[$model->parent_element_id] + ?? null; + if ($parentElement === null) { + throw new RuntimeException('element parent not found'); + } + } + + $element = $this->toDomainWithRelations( + model: $model, + set: $set, + parentElement: $parentElement, + ); + $elements[] = $element; + $elementsById[$element->getId()] = $element; + } + + return $elements; + } + public function findTopLevelBySet(Set $set): array { $models = ElementModel::query() @@ -160,10 +191,9 @@ class EloquentElementRepository implements ElementRepository private function findSet(int $id): Set { - foreach ($this->setRepository->all() as $set) { - if ($set->getId() === $id) { - return $set; - } + $set = $this->setRepository->find($id); + if ($set !== null) { + return $set; } throw new RuntimeException('element set not found'); diff --git a/backend/app/Exceptions/NotFoundException.php b/backend/app/Exceptions/NotFoundException.php new file mode 100644 index 0000000..0531457 --- /dev/null +++ b/backend/app/Exceptions/NotFoundException.php @@ -0,0 +1,7 @@ + $sets]); } + + public function show(int $setId): JsonResponse + { + try { + $layout = $this->getSetLayout->execute( + new GetSetLayoutRequest(setId: $setId), + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + $set = $layout->getSet(); + + return new JsonResponse([ + 'set' => [ + 'id' => $set->getId(), + 'name' => $set->getName(), + ], + 'elements' => array_map( + function (ElementLayoutNode $node): array { + return $this->elementPayload($node); + }, + $layout->getElements(), + ), + ]); + } + + /** + * @return array{ + * id: int, + * name: string, + * kind: string, + * children: list + * } + */ + private function elementPayload(ElementLayoutNode $node): array + { + $element = $node->getElement(); + + return [ + 'id' => $element->getId(), + 'name' => $element->getName(), + 'kind' => $element->getKind(), + 'children' => array_map( + function (ElementLayoutNode $childNode): array { + return $this->elementPayload($childNode); + }, + $node->getChildren(), + ), + ]; + } } diff --git a/backend/app/Set/EloquentSetRepository.php b/backend/app/Set/EloquentSetRepository.php index ed6ac88..3a0bdf0 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -25,6 +25,13 @@ class EloquentSetRepository implements SetRepository ); } + public function find(int $id): ?Set + { + $model = SetModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + public function all(): array { $models = SetModel::query() diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 0545ab1..a824e53 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -6,6 +6,8 @@ interface SetRepository { public function create(CreateSetDto $dto): Set; + public function find(int $id): ?Set; + /** * @return list */ diff --git a/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php b/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php new file mode 100644 index 0000000..6866e8f --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php @@ -0,0 +1,29 @@ + $children + */ + public function __construct( + private Element $element, + private array $children, + ) {} + + public function getElement(): Element + { + return $this->element; + } + + /** + * @return list + */ + public function getChildren(): array + { + return $this->children; + } +} diff --git a/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php new file mode 100644 index 0000000..39085e1 --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php @@ -0,0 +1,77 @@ +setRepository->find($request->setId); + if ($set === null) { + throw new NotFoundException('set not found'); + } + + $elementsByParentId = []; + foreach ($this->elementRepository->findBySet($set) as $element) { + $parentId = $element->getParentElement()?->getId() ?? 0; + $elementsByParentId[$parentId][] = $element; + } + + foreach ($elementsByParentId as &$siblings) { + usort($siblings, function ( + Element $first, + Element $second, + ): int { + $positionComparison = $first->getPosition() + <=> $second->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $first->getId() <=> $second->getId(); + }); + } + unset($siblings); + + return new SetLayout( + set: $set, + elements: $this->buildNodes(0, $elementsByParentId), + ); + } + + /** + * @param array> $elementsByParentId + * @return list + */ + private function buildNodes( + int $parentId, + array $elementsByParentId, + ): array { + $nodes = []; + + foreach ($elementsByParentId[$parentId] ?? [] as $element) { + $nodes[] = new ElementLayoutNode( + element: $element, + children: $this->buildNodes( + $element->getId(), + $elementsByParentId, + ), + ); + } + + return $nodes; + } +} diff --git a/backend/app/Set/UseCases/GetSetLayout/GetSetLayoutRequest.php b/backend/app/Set/UseCases/GetSetLayout/GetSetLayoutRequest.php new file mode 100644 index 0000000..899ba2d --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/GetSetLayoutRequest.php @@ -0,0 +1,8 @@ + $elements + */ + public function __construct( + private Set $set, + private array $elements, + ) {} + + public function getSet(): Set + { + return $this->set; + } + + /** + * @return list + */ + public function getElements(): array + { + return $this->elements; + } +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index 31a5820..c90217c 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -13,5 +13,6 @@ class DatabaseSeeder extends Seeder { $this->call(UserSeeder::class); $this->call(SetSeeder::class); + $this->call(ElementSeeder::class); } } diff --git a/backend/database/seeders/ElementSeeder.php b/backend/database/seeders/ElementSeeder.php new file mode 100644 index 0000000..bde3ed9 --- /dev/null +++ b/backend/database/seeders/ElementSeeder.php @@ -0,0 +1,217 @@ + [ + [ + 'name' => 'Genesis', + 'kind' => 'book', + 'children' => [ + [ + 'name' => 'Creation', + 'kind' => 'portion', + 'children' => [ + [ + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'children' => [], + ], + [ + 'name' => 'Chapter 2', + 'kind' => 'chapter', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Noah', + 'kind' => 'portion', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Exodus', + 'kind' => 'book', + 'children' => [ + [ + 'name' => 'Shemot', + 'kind' => 'portion', + 'children' => [], + ], + ], + ], + ], + 'Course' => [ + [ + 'name' => 'Foundations', + 'kind' => 'module', + 'children' => [ + [ + 'name' => 'Welcome', + 'kind' => 'lesson', + 'children' => [], + ], + [ + 'name' => 'Core Concepts', + 'kind' => 'lesson', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Applied Practice', + 'kind' => 'module', + 'children' => [ + [ + 'name' => 'Guided Exercise', + 'kind' => 'lesson', + 'children' => [], + ], + [ + 'name' => 'Final Review', + 'kind' => 'lesson', + 'children' => [], + ], + ], + ], + ], + 'Fitness Program' => [ + [ + 'name' => 'Foundation Phase', + 'kind' => 'phase', + 'children' => [ + [ + 'name' => 'Strength Day', + 'kind' => 'workout', + 'children' => [ + [ + 'name' => 'Squat', + 'kind' => 'exercise', + 'children' => [], + ], + [ + 'name' => 'Push-up', + 'kind' => 'exercise', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Mobility Day', + 'kind' => 'workout', + 'children' => [ + [ + 'name' => 'Hip Flow', + 'kind' => 'exercise', + 'children' => [], + ], + ], + ], + ], + ], + [ + 'name' => 'Build Phase', + 'kind' => 'phase', + 'children' => [ + [ + 'name' => 'Full Body Circuit', + 'kind' => 'workout', + 'children' => [], + ], + ], + ], + ], + ]; + + public function run(): void + { + $elementRepository = app(ElementRepository::class); + + foreach (app(SetRepository::class)->all() as $set) { + $definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null; + if ($definitions === null) { + continue; + } + + $this->seedChildren( + repository: $elementRepository, + set: $set, + parentElement: null, + definitions: $definitions, + ); + } + } + + /** + * @param list + * }> $definitions + */ + private function seedChildren( + ElementRepository $repository, + Set $set, + ?Element $parentElement, + array $definitions, + ): void { + $siblings = $parentElement === null + ? $repository->findTopLevelBySet($set) + : $repository->findByParentElement($parentElement); + + foreach ($definitions as $definition) { + $element = $this->findSibling( + siblings: $siblings, + name: $definition['name'], + kind: $definition['kind'], + ); + if ($element === null) { + $element = $repository->create(new CreateElementDto( + set: $set, + name: $definition['name'], + kind: $definition['kind'], + parentElement: $parentElement, + )); + $siblings[] = $element; + } + + $this->seedChildren( + repository: $repository, + set: $set, + parentElement: $element, + definitions: $definition['children'], + ); + } + } + + /** + * @param list $siblings + */ + private function findSibling( + array $siblings, + string $name, + string $kind, + ): ?Element { + foreach ($siblings as $sibling) { + if ( + $sibling->getName() === $name + && $sibling->getKind() === $kind + ) { + return $sibling; + } + } + + return null; + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 62384a9..9b70efe 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,5 +12,7 @@ Route::post('/confirm-email', [AuthController::class, 'confirmEmail']); Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); Route::get('/sets', [SetController::class, 'index']); + Route::get('/sets/{setId}', [SetController::class, 'show']) + ->whereNumber('setId'); Route::post('/logout', [AuthController::class, 'logout']); }); diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index e44cb53..1e9200d 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -39,6 +39,20 @@ class FakeElementRepository implements ElementRepository return $element === null ? null : $this->copy($element); } + public function findBySet(Set $set): array + { + $elements = array_filter( + $this->elements, + function (Element $element) use ($set): bool { + return $element->getSet()->getId() === $set->getId(); + }, + ); + + return array_map(function (Element $element): Element { + return $this->copy($element); + }, array_values($elements)); + } + public function findTopLevelBySet(Set $set): array { $elements = array_filter( diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index a4cff4c..9f808b0 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -26,6 +26,13 @@ class FakeSetRepository implements SetRepository return $this->copy($set); } + public function find(int $id): ?Set + { + $set = $this->sets[$id] ?? null; + + return $set === null ? null : $this->copy($set); + } + public function all(): array { $sets = array_values($this->sets); diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index 45a7e51..e6ce46e 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature\Database; use App\Auth\PasswordHasher; +use App\Element\Element; +use App\Element\ElementRepository; use App\Set\SetRepository; use App\Shared\ValueObject\EmailAddress; use App\User\UserRepository; @@ -60,4 +62,60 @@ class DatabaseSeederTest extends TestCase ); } } + + public function test_it_seeds_elements_for_every_set_idempotently(): void + { + $this->seed(); + $this->seed(); + + $sets = app(SetRepository::class)->all(); + $elementRepository = app(ElementRepository::class); + $expectedElements = [ + 'Bible' => [ + 'Chapter 1:chapter:Creation', + 'Chapter 2:chapter:Creation', + 'Creation:portion:Genesis', + 'Exodus:book:root', + 'Genesis:book:root', + 'Noah:portion:Genesis', + 'Shemot:portion:Exodus', + ], + 'Course' => [ + 'Applied Practice:module:root', + 'Core Concepts:lesson:Foundations', + 'Final Review:lesson:Applied Practice', + 'Foundations:module:root', + 'Guided Exercise:lesson:Applied Practice', + 'Welcome:lesson:Foundations', + ], + 'Fitness Program' => [ + 'Build Phase:phase:root', + 'Foundation Phase:phase:root', + 'Full Body Circuit:workout:Build Phase', + 'Hip Flow:exercise:Mobility Day', + 'Mobility Day:workout:Foundation Phase', + 'Push-up:exercise:Strength Day', + 'Squat:exercise:Strength Day', + 'Strength Day:workout:Foundation Phase', + ], + ]; + + $this->assertDatabaseCount('elements', 21); + + foreach ($sets as $set) { + $signatures = array_map(function (Element $element): string { + $parentName = $element->getParentElement()?->getName() + ?? 'root'; + + return "{$element->getName()}:{$element->getKind()}" + . ":{$parentName}"; + }, $elementRepository->findBySet($set)); + sort($signatures); + + $this->assertSame( + $expectedElements[$set->getName()], + $signatures, + ); + } + } } diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php index 737403a..76766c5 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -156,6 +156,45 @@ class EloquentElementRepositoryTest extends TestCase ); } + public function testItListsEveryElementForASet(): void + { + $bible = $this->createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $repository->create(new CreateElementDto( + set: $course, + name: 'Foundations', + kind: 'module', + parentElement: null, + )); + + $elements = $repository->findBySet($bible); + + $this->assertSame( + ['Genesis', 'Creation'], + array_map(function ($element): string { + return $element->getName(); + }, $elements), + ); + $this->assertNull($elements[0]->getParentElement()); + $this->assertSame( + $genesis->getId(), + $elements[1]->getParentElement()?->getId(), + ); + } + private function createSet(string $name): Set { $creator = app(UserRepository::class)->create(new CreateUserDto( diff --git a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php index 820f0b9..a1cb965 100644 --- a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php +++ b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php @@ -66,6 +66,23 @@ class EloquentSetRepositoryTest extends TestCase ); } + public function test_it_finds_a_set_by_id(): void + { + $creator = $this->createUser('creator@example.com'); + $repository = app(SetRepository::class); + $createdSet = $repository->create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + + $foundSet = $repository->find($createdSet->getId()); + + $this->assertNotNull($foundSet); + $this->assertSame($createdSet->getId(), $foundSet->getId()); + $this->assertSame('Bible', $foundSet->getName()); + $this->assertNull($repository->find(999)); + } + public function test_it_rejects_duplicate_set_names(): void { $creator = $this->createUser('creator@example.com'); diff --git a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php new file mode 100644 index 0000000..074f7f5 --- /dev/null +++ b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php @@ -0,0 +1,172 @@ +createUser(); + $set = $this->createSet($user, 'Bible'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $creation = $repository->create(new CreateElementDto( + set: $set, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $chapter = $repository->create(new CreateElementDto( + set: $set, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $creation, + )); + $this->createSession($user); + + $response = $this->credentialedGet("/api/sets/{$set->getId()}"); + + $response->assertOk()->assertExactJson([ + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Bible', + ], + 'elements' => [ + [ + 'id' => $genesis->getId(), + 'name' => 'Genesis', + 'kind' => 'book', + 'children' => [ + [ + 'id' => $creation->getId(), + 'name' => 'Creation', + 'kind' => 'portion', + 'children' => [ + [ + 'id' => $chapter->getId(), + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'children' => [], + ], + ], + ], + ], + ], + [ + 'id' => $genesis->getId() + 1, + 'name' => 'Exodus', + 'kind' => 'book', + 'children' => [], + ], + ], + ]); + } + + public function test_it_returns_an_empty_element_layout(): void + { + $user = $this->createUser(); + $set = $this->createSet($user, 'Empty set'); + $this->createSession($user); + + $response = $this->credentialedGet("/api/sets/{$set->getId()}"); + + $response->assertOk()->assertExactJson([ + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Empty set', + ], + 'elements' => [], + ]); + } + + public function test_it_returns_not_found_for_an_unknown_set(): void + { + $user = $this->createUser(); + $this->createSession($user); + + $response = $this->credentialedGet('/api/sets/999'); + + $response->assertNotFound()->assertExactJson([ + 'error' => 'set not found', + ]); + } + + public function test_it_rejects_an_unauthenticated_request(): void + { + $response = $this->getJson('/api/sets/1'); + + $response->assertStatus(401)->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + private function createUser(): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('reader@example.com'), + 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): void + { + $createdAt = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + app(SessionRepository::class)->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $createdAt, + expiresAt: $createdAt->modify('+7 days'), + )); + } + + private function credentialedGet(string $uri): TestResponse + { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson($uri); + } +} diff --git a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php new file mode 100644 index 0000000..69a7714 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php @@ -0,0 +1,144 @@ +create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + $course = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $creator, + )); + $genesis = $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $creation = $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Noah', + kind: 'portion', + parentElement: $genesis, + )); + $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $creation, + )); + $elementRepository->create(new CreateElementDto( + set: $course, + name: 'Foundations', + kind: 'module', + parentElement: null, + )); + + $layout = (new GetSetLayout( + $setRepository, + $elementRepository, + ))->execute(new GetSetLayoutRequest( + setId: $bible->getId(), + )); + + $this->assertSame('Bible', $layout->getSet()->getName()); + $this->assertSame( + ['Genesis', 'Exodus'], + $this->nodeNames($layout->getElements()), + ); + $genesisNode = $layout->getElements()[0]; + $this->assertSame( + ['Creation', 'Noah'], + $this->nodeNames($genesisNode->getChildren()), + ); + $this->assertSame( + ['Chapter 1'], + $this->nodeNames( + $genesisNode->getChildren()[0]->getChildren(), + ), + ); + } + + public function test_it_returns_an_empty_layout(): void + { + $creator = new User( + id: 7, + email: new EmailAddress('creator@example.com'), + passwordHash: 'hashed-password', + ); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Empty set', + creator: $creator, + )); + + $layout = (new GetSetLayout( + $setRepository, + new FakeElementRepository, + ))->execute(new GetSetLayoutRequest( + setId: $set->getId(), + )); + + $this->assertSame([], $layout->getElements()); + } + + public function test_it_rejects_an_unknown_set(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('set not found'); + + (new GetSetLayout( + new FakeSetRepository, + new FakeElementRepository, + ))->execute(new GetSetLayoutRequest( + setId: 999, + )); + } + + /** + * @param list $nodes + * @return list + */ + private function nodeNames(array $nodes): array + { + return array_map(function ($node): string { + return $node->getElement()->getName(); + }, $nodes); + } +} diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts index 7c0e66c..75c441c 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -24,6 +24,13 @@ describe('guest authentication pages', () => { cy.location('search').should('include', 'redirect=/dashboard') }) + it('redirects guests away from a protected set layout', () => { + cy.visit('/sets/41') + + cy.location('pathname').should('equal', '/login') + cy.location('search').should('include', 'redirect=/sets/41') + }) + it('shows the login form and links to signup', () => { cy.visit('/login') diff --git a/frontend/website/cypress/e2e/set-layout.cy.ts b/frontend/website/cypress/e2e/set-layout.cy.ts new file mode 100644 index 0000000..5430694 --- /dev/null +++ b/frontend/website/cypress/e2e/set-layout.cy.ts @@ -0,0 +1,183 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +const bibleLayout = { + set: { id: 41, name: 'Bible' }, + elements: [ + { + id: 1, + name: 'Genesis', + kind: 'book', + children: [ + { + id: 3, + name: 'Creation', + kind: 'portion', + children: [ + { + id: 4, + name: 'Chapter 1', + kind: 'chapter', + children: [], + }, + ], + }, + { id: 5, name: 'Noah', kind: 'portion', children: [] }, + ], + }, + { id: 2, name: 'Exodus', kind: 'book', children: [] }, + ], +} + +function interceptAuthenticatedUser(): void { + cy.intercept('GET', '**/api/me', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('me') +} + +describe('set element layout', () => { + beforeEach(() => { + interceptAuthenticatedUser() + }) + + it('opens a set from the dashboard and shows its full hierarchy', () => { + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [{ id: 41, name: 'Bible' }] }, + }).as('sets') + cy.intercept('GET', '**/api/sets/41', (request) => { + expect(request.headers.accept).to.equal('application/json') + request.reply({ statusCode: 200, body: bibleLayout }) + }).as('layout') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets') + cy.contains('a', 'Bible').click() + cy.wait('@layout') + + cy.location('pathname').should('equal', '/sets/41') + cy.get('h1').should('have.text', 'Bible') + cy.contains('a', 'Back to sets').should('have.attr', 'href', '/dashboard') + cy.get('ol[aria-label="Bible element layout"] > li').then(($nodes) => { + expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ + '1', + '2', + ]) + }) + cy.get('[data-element-id="1"] > .element-node__card') + .should('contain.text', 'Genesis') + .and('contain.text', 'book') + cy.get('[data-element-id="1"] > ol > li').then(($nodes) => { + expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ + '3', + '5', + ]) + }) + cy.get('[data-element-id="3"] > ol > li') + .should('have.length', 1) + .and('have.attr', 'data-element-id', '4') + cy.contains('.element-node__card', 'Chapter 1') + .should('be.visible') + .and('contain.text', 'chapter') + }) + + it('shows loading and empty layout states', () => { + cy.intercept('GET', '**/api/sets/41', { + delay: 500, + statusCode: 200, + body: { set: { id: 41, name: 'Empty set' }, elements: [] }, + }).as('layout') + + cy.visit('/sets/41') + cy.wait('@me') + cy.get('[role="status"]').should('have.text', 'Loading set layout...') + cy.wait('@layout') + + cy.get('h1').should('have.text', 'Empty set') + cy.get('[role="status"]').should( + 'have.text', + 'This set does not have any elements yet.', + ) + }) + + it('retries after a layout request fails', () => { + let requestCount = 0 + cy.intercept('GET', '**/api/sets/41', (request) => { + requestCount += 1 + request.alias = `layout${requestCount}` + + if (requestCount === 1) { + request.reply({ statusCode: 500 }) + return + } + + request.reply({ statusCode: 200, body: bibleLayout }) + }) + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@layout1') + + cy.get('[role="alert"]').should( + 'contain.text', + "We couldn't load this set's layout.", + ) + cy.contains('button', 'Try again').click() + cy.wait('@layout2') + + cy.get('h1').should('have.text', 'Bible') + }) + + it('distinguishes missing sets from malformed responses', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 404, + body: { error: 'set not found' }, + }).as('missing') + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@missing') + + cy.get('h1').should('have.text', 'Set not found') + cy.get('[role="alert"]').should( + 'contain.text', + "The set you're looking for is not available.", + ) + + cy.intercept('GET', '**/api/sets/42', { + statusCode: 200, + body: { set: { id: 42, name: 12 }, elements: [] }, + }).as('malformed') + cy.visit('/sets/42') + cy.wait('@malformed') + + cy.get('[role="alert"]').should( + 'contain.text', + "We couldn't load this set's layout.", + ) + cy.contains('button', 'Try again').should('be.visible') + }) + + it('keeps a deeply nested layout within a mobile viewport', () => { + cy.viewport(390, 844) + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: bibleLayout, + }).as('layout') + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@layout') + + cy.contains('.element-node__card', 'Chapter 1').should('be.visible') + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth, + ) + }) + }) +}) diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts index 3872b10..4b6fc2d 100644 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -15,7 +15,7 @@ describe('sets dashboard', () => { interceptAuthenticatedUser() }) - it('shows every available set by name', () => { + it('shows every available set as a detail link', () => { cy.intercept('GET', '**/api/sets', (request) => { expect(request.headers.accept).to.equal('application/json') request.reply({ @@ -46,8 +46,13 @@ describe('sets dashboard', () => { .should('not.contain.text', '41') .and('not.contain.text', '58') .and('not.contain.text', '92') - .find('a, button') - .should('not.exist') + cy.contains('a', 'Bible').should('have.attr', 'href', '/sets/41') + cy.contains('a', 'Course').should('have.attr', 'href', '/sets/58') + cy.contains('a', 'Fitness Program').should( + 'have.attr', + 'href', + '/sets/92', + ) }) it('shows loading and empty catalog states', () => { diff --git a/frontend/website/src/components/AuthenticatedHeader.vue b/frontend/website/src/components/AuthenticatedHeader.vue new file mode 100644 index 0000000..eae4d57 --- /dev/null +++ b/frontend/website/src/components/AuthenticatedHeader.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/website/src/components/ElementTree.vue b/frontend/website/src/components/ElementTree.vue new file mode 100644 index 0000000..6cc1302 --- /dev/null +++ b/frontend/website/src/components/ElementTree.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index c55b993..08f1ad0 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -63,6 +63,14 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/sets/:setId(\\d+)', + name: 'set-layout', + component: () => import('@/views/SetLayoutView.vue'), + meta: { + requiresAuth: true, + }, + }, ], }) diff --git a/frontend/website/src/stores/setLayout.ts b/frontend/website/src/stores/setLayout.ts new file mode 100644 index 0000000..919c108 --- /dev/null +++ b/frontend/website/src/stores/setLayout.ts @@ -0,0 +1,102 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { z } from 'zod' + +import { API_BASE } from '@/utils/apiBase' + +export const setElementNodeSchema = z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + kind: z.string().min(1), + get children() { + return z.array(setElementNodeSchema) + }, +}) + +export const setLayoutResponseSchema = z.object({ + set: z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + }), + elements: z.array(setElementNodeSchema), +}) + +export type SetElementNode = z.infer +export type SetLayoutResponse = z.infer + +const LOAD_ERROR = "We couldn't load this set's layout." + +export const useSetLayoutStore = defineStore('set-layout', () => { + const layout = ref(null) + const loading = ref(false) + const error = ref(null) + const notFound = ref(false) + let activeRequestId = 0 + + async function fetchSetLayout(setId: number): Promise { + const requestId = ++activeRequestId + layout.value = null + loading.value = true + error.value = null + notFound.value = false + + try { + const response = await fetch(`${API_BASE}/api/sets/${setId}`, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + if (requestId !== activeRequestId) { + return false + } + + if (response.status === 404) { + notFound.value = true + + return false + } + + if (response.status !== 200) { + error.value = LOAD_ERROR + + return false + } + + const responseBody: unknown = await response.json() + if (requestId !== activeRequestId) { + return false + } + + const parsedLayout = setLayoutResponseSchema.parse(responseBody) + if (parsedLayout.set.id !== setId) { + throw new Error('set response did not match requested set') + } + + layout.value = parsedLayout + + return true + } catch { + if (requestId === activeRequestId) { + layout.value = null + error.value = LOAD_ERROR + } + + return false + } finally { + if (requestId === activeRequestId) { + loading.value = false + } + } + } + + return { + layout, + loading, + error, + notFound, + fetchSetLayout, + } +}) diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 182def5..9c77bf4 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -1,33 +1,21 @@