diff --git a/ai/backend-context.md b/ai/backend-context.md index 9850c44..83c2081 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -87,8 +87,6 @@ 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 3ca103c..fc46687 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -14,11 +14,6 @@ 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 1f30e24..cfd93f7 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -45,37 +45,6 @@ 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() @@ -191,9 +160,10 @@ class EloquentElementRepository implements ElementRepository private function findSet(int $id): Set { - $set = $this->setRepository->find($id); - if ($set !== null) { - return $set; + foreach ($this->setRepository->all() as $set) { + if ($set->getId() === $id) { + return $set; + } } throw new RuntimeException('element set not found'); diff --git a/backend/app/Exceptions/NotFoundException.php b/backend/app/Exceptions/NotFoundException.php deleted file mode 100644 index 0531457..0000000 --- a/backend/app/Exceptions/NotFoundException.php +++ /dev/null @@ -1,7 +0,0 @@ - $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 3a0bdf0..ed6ac88 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -25,13 +25,6 @@ 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 a824e53..0545ab1 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -6,8 +6,6 @@ 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 deleted file mode 100644 index 6866e8f..0000000 --- a/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php +++ /dev/null @@ -1,29 +0,0 @@ - $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 deleted file mode 100644 index 39085e1..0000000 --- a/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 899ba2d..0000000 --- a/backend/app/Set/UseCases/GetSetLayout/GetSetLayoutRequest.php +++ /dev/null @@ -1,8 +0,0 @@ - $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 c90217c..31a5820 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -13,6 +13,5 @@ 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 deleted file mode 100644 index bde3ed9..0000000 --- a/backend/database/seeders/ElementSeeder.php +++ /dev/null @@ -1,217 +0,0 @@ - [ - [ - '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 9b70efe..62384a9 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,7 +12,5 @@ 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 1e9200d..e44cb53 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -39,20 +39,6 @@ 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 9f808b0..a4cff4c 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -26,13 +26,6 @@ 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 e6ce46e..45a7e51 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -3,8 +3,6 @@ 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; @@ -62,60 +60,4 @@ 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 76766c5..737403a 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -156,45 +156,6 @@ 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 a1cb965..820f0b9 100644 --- a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php +++ b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php @@ -66,23 +66,6 @@ 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 deleted file mode 100644 index 074f7f5..0000000 --- a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index 69a7714..0000000 --- a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php +++ /dev/null @@ -1,144 +0,0 @@ -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 75c441c..7c0e66c 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -24,13 +24,6 @@ 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 deleted file mode 100644 index 5430694..0000000 --- a/frontend/website/cypress/e2e/set-layout.cy.ts +++ /dev/null @@ -1,183 +0,0 @@ -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 4b6fc2d..3872b10 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 as a detail link', () => { + it('shows every available set by name', () => { cy.intercept('GET', '**/api/sets', (request) => { expect(request.headers.accept).to.equal('application/json') request.reply({ @@ -46,13 +46,8 @@ describe('sets dashboard', () => { .should('not.contain.text', '41') .and('not.contain.text', '58') .and('not.contain.text', '92') - 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', - ) + .find('a, button') + .should('not.exist') }) it('shows loading and empty catalog states', () => { diff --git a/frontend/website/src/components/AuthenticatedHeader.vue b/frontend/website/src/components/AuthenticatedHeader.vue deleted file mode 100644 index eae4d57..0000000 --- a/frontend/website/src/components/AuthenticatedHeader.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - - - diff --git a/frontend/website/src/components/ElementTree.vue b/frontend/website/src/components/ElementTree.vue deleted file mode 100644 index 6cc1302..0000000 --- a/frontend/website/src/components/ElementTree.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - - - diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index 08f1ad0..c55b993 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -63,14 +63,6 @@ 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 deleted file mode 100644 index 919c108..0000000 --- a/frontend/website/src/stores/setLayout.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 9c77bf4..182def5 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -1,21 +1,33 @@