From db35a97910523458f9cfe4eb644be0224a1d9d8b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 24 May 2026 22:28:44 +0300 Subject: [PATCH 01/11] test set elements --- backend/tests/Fakes/FakeElementRepository.php | 68 +++++++ backend/tests/Fakes/FakeSetRepository.php | 57 ++++++ backend/tests/Unit/Element/ElementTest.php | 33 ++++ .../Element/UseCases/CreateElementTest.php | 171 ++++++++++++++++++ backend/tests/Unit/Set/SetTest.php | 17 ++ .../tests/Unit/Set/UseCases/CreateSetTest.php | 63 +++++++ 6 files changed, 409 insertions(+) create mode 100644 backend/tests/Fakes/FakeElementRepository.php create mode 100644 backend/tests/Fakes/FakeSetRepository.php create mode 100644 backend/tests/Unit/Element/ElementTest.php create mode 100644 backend/tests/Unit/Element/UseCases/CreateElementTest.php create mode 100644 backend/tests/Unit/Set/SetTest.php create mode 100644 backend/tests/Unit/Set/UseCases/CreateSetTest.php diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php new file mode 100644 index 0000000..d624a9a --- /dev/null +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -0,0 +1,68 @@ +elementsById) + 1; + $element = new Element( + id: $id, + title: $dto->title, + set: $dto->set, + parentElement: $dto->parentElement, + ); + $this->elementsById[$id] = $element; + + return $element; + } + + public function find(int $id): ?Element + { + if (! isset($this->elementsById[$id])) { + return null; + } + + return $this->cloneElement($this->elementsById[$id]); + } + + /** + * @return Element[] + */ + public function findBySetId(int $id): array + { + $elements = []; + foreach ($this->elementsById as $element) { + if ($element->getSet()->getId() === $id) { + $elements[] = $this->cloneElement($element); + } + } + + return $elements; + } + + private function cloneElement(Element $element): Element + { + $parentElement = $element->getParentElement(); + if ($parentElement !== null) { + $parentElement = $this->cloneElement($parentElement); + } + + return new Element( + id: $element->getId(), + title: $element->getTitle(), + set: $element->getSet(), + parentElement: $parentElement, + ); + } +} diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php new file mode 100644 index 0000000..295e619 --- /dev/null +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -0,0 +1,57 @@ +setsById) + 1; + $set = new DomainSet( + id: $id, + name: $dto->name, + ); + $this->setsById[$id] = $set; + + return $set; + } + + public function find(int $id): ?DomainSet + { + if (! isset($this->setsById[$id])) { + return null; + } + + return $this->cloneSet($this->setsById[$id]); + } + + /** + * @return DomainSet[] + */ + public function getAll(): array + { + $sets = []; + foreach ($this->setsById as $set) { + $sets[] = $this->cloneSet($set); + } + + return $sets; + } + + private function cloneSet(DomainSet $set): DomainSet + { + return new DomainSet( + id: $set->getId(), + name: $set->getName(), + ); + } +} diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php new file mode 100644 index 0000000..3e914d9 --- /dev/null +++ b/backend/tests/Unit/Element/ElementTest.php @@ -0,0 +1,33 @@ +assertSame(2, $childElement->getId()); + $this->assertSame('Child', $childElement->getTitle()); + $this->assertSame($set, $childElement->getSet()); + $this->assertSame($rootElement, $childElement->getParentElement()); + $this->assertNull($rootElement->getParentElement()); + } +} diff --git a/backend/tests/Unit/Element/UseCases/CreateElementTest.php b/backend/tests/Unit/Element/UseCases/CreateElementTest.php new file mode 100644 index 0000000..da6f024 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/CreateElementTest.php @@ -0,0 +1,171 @@ +setRepo = new FakeSetRepository(); + $this->elementRepo = new FakeElementRepository(); + $this->createElement = new CreateElement( + $this->elementRepo, + $this->setRepo, + ); + } + + public function testCreatesRootElement(): void + { + $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + + $element = $this->createElement->execute(new CreateElementRequest( + setId: $set->getId(), + title: 'Root', + parentElementId: null, + )); + + $this->assertInstanceOf(Element::class, $element); + $this->assertSame('Root', $element->getTitle()); + $this->assertSame($set->getId(), $element->getSet()->getId()); + $this->assertNull($element->getParentElement()); + } + + public function testCreatesChildElement(): void + { + $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + $rootElement = $this->createElement->execute( + new CreateElementRequest( + setId: $set->getId(), + title: 'Root', + parentElementId: null, + ) + ); + + $childElement = $this->createElement->execute( + new CreateElementRequest( + setId: $set->getId(), + title: 'Child', + parentElementId: $rootElement->getId(), + ) + ); + + $this->assertSame('Child', $childElement->getTitle()); + $this->assertSame( + $rootElement->getId(), + $childElement->getParentElement()->getId(), + ); + } + + public function testThrowsWhenSetIdMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('setId is required'); + + $this->createElement->execute(new CreateElementRequest( + setId: null, + title: 'Root', + parentElementId: null, + )); + } + + public function testThrowsWhenTitleMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('title is required'); + + $this->createElement->execute(new CreateElementRequest( + setId: 1, + title: null, + parentElementId: null, + )); + } + + public function testThrowsWhenSetDoesNotExist(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Set with id: 99 doesnt exist'); + + $this->createElement->execute(new CreateElementRequest( + setId: 99, + title: 'Root', + parentElementId: null, + )); + } + + public function testThrowsWhenParentElementDoesNotExist(): void + { + $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'Element with id: 99 doesnt exist' + ); + + $this->createElement->execute(new CreateElementRequest( + setId: $set->getId(), + title: 'Child', + parentElementId: 99, + )); + } + + public function testThrowsWhenRootElementAlreadyExists(): void + { + $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + $this->createElement->execute(new CreateElementRequest( + setId: $set->getId(), + title: 'Root', + parentElementId: null, + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'A root element already exists for this set' + ); + + $this->createElement->execute(new CreateElementRequest( + setId: $set->getId(), + title: 'Another root', + parentElementId: null, + )); + } + + public function testThrowsWhenParentBelongsToAnotherSet(): void + { + $parentSet = $this->setRepo->create(new CreateSetDto('Parent set')); + $childSet = $this->setRepo->create(new CreateSetDto('Child set')); + $parentElement = $this->createElement->execute( + new CreateElementRequest( + setId: $parentSet->getId(), + title: 'Parent root', + parentElementId: null, + ) + ); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'Parent element must belong to the same set' + ); + + $this->createElement->execute(new CreateElementRequest( + setId: $childSet->getId(), + title: 'Invalid child', + parentElementId: $parentElement->getId(), + )); + } +} diff --git a/backend/tests/Unit/Set/SetTest.php b/backend/tests/Unit/Set/SetTest.php new file mode 100644 index 0000000..a98ac5f --- /dev/null +++ b/backend/tests/Unit/Set/SetTest.php @@ -0,0 +1,17 @@ +assertSame(1, $set->getId()); + $this->assertSame('Daily learning', $set->getName()); + } +} diff --git a/backend/tests/Unit/Set/UseCases/CreateSetTest.php b/backend/tests/Unit/Set/UseCases/CreateSetTest.php new file mode 100644 index 0000000..bd3c9f4 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/CreateSetTest.php @@ -0,0 +1,63 @@ +setRepo = new FakeSetRepository(); + $this->elementRepo = new FakeElementRepository(); + $this->createSet = new CreateSet( + $this->setRepo, + $this->elementRepo, + ); + } + + public function testCreatesSet(): void + { + $set = $this->createSet->execute( + new CreateSetRequest('Daily learning') + ); + + $this->assertInstanceOf(DomainSet::class, $set); + $this->assertSame(1, $set->getId()); + $this->assertSame('Daily learning', $set->getName()); + } + + public function testCreatesRootElementForSet(): void + { + $set = $this->createSet->execute( + new CreateSetRequest('Daily learning') + ); + + $elements = $this->elementRepo->findBySetId($set->getId()); + + $this->assertCount(1, $elements); + $this->assertSame('Daily learning', $elements[0]->getTitle()); + $this->assertSame($set->getId(), $elements[0]->getSet()->getId()); + $this->assertNull($elements[0]->getParentElement()); + } + + public function testThrowsWhenNameMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('name is required'); + + $this->createSet->execute(new CreateSetRequest(null)); + } +} From 2c4cdabc151fb610f93e8ba095023a2d6bb58f2b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 24 May 2026 22:32:46 +0300 Subject: [PATCH 02/11] add set elements --- backend/app/Element/CreateElementDto.php | 14 ++++ backend/app/Element/Element.php | 35 ++++++++ backend/app/Element/ElementModel.php | 40 +++++++++ backend/app/Element/ElementRepository.php | 15 ++++ .../app/Element/EloquentElementRepository.php | 75 +++++++++++++++++ .../UseCases/CreateElement/CreateElement.php | 84 +++++++++++++++++++ .../CreateElement/CreateElementRequest.php | 12 +++ .../Providers/RepositoryServiceProvider.php | 12 +++ backend/app/Set/CreateSetDto.php | 10 +++ backend/app/Set/EloquentSetRepository.php | 41 +++++++++ backend/app/Set/Set.php | 21 +++++ backend/app/Set/SetModel.php | 27 ++++++ backend/app/Set/SetRepository.php | 15 ++++ .../app/Set/UseCases/CreateSet/CreateSet.php | 40 +++++++++ .../UseCases/CreateSet/CreateSetRequest.php | 10 +++ .../2026_05_24_000000_sets_table.php | 21 +++++ .../2026_05_24_000001_elements_table.php | 25 ++++++ 17 files changed, 497 insertions(+) create mode 100644 backend/app/Element/CreateElementDto.php create mode 100644 backend/app/Element/Element.php create mode 100644 backend/app/Element/ElementModel.php create mode 100644 backend/app/Element/ElementRepository.php create mode 100644 backend/app/Element/EloquentElementRepository.php create mode 100644 backend/app/Element/UseCases/CreateElement/CreateElement.php create mode 100644 backend/app/Element/UseCases/CreateElement/CreateElementRequest.php create mode 100644 backend/app/Set/CreateSetDto.php create mode 100644 backend/app/Set/EloquentSetRepository.php create mode 100644 backend/app/Set/Set.php create mode 100644 backend/app/Set/SetModel.php create mode 100644 backend/app/Set/SetRepository.php create mode 100644 backend/app/Set/UseCases/CreateSet/CreateSet.php create mode 100644 backend/app/Set/UseCases/CreateSet/CreateSetRequest.php create mode 100644 backend/database/migrations/2026_05_24_000000_sets_table.php create mode 100644 backend/database/migrations/2026_05_24_000001_elements_table.php diff --git a/backend/app/Element/CreateElementDto.php b/backend/app/Element/CreateElementDto.php new file mode 100644 index 0000000..db77dd7 --- /dev/null +++ b/backend/app/Element/CreateElementDto.php @@ -0,0 +1,14 @@ +id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getSet(): Set + { + return $this->set; + } + + public function getParentElement(): ?Element + { + return $this->parentElement; + } +} diff --git a/backend/app/Element/ElementModel.php b/backend/app/Element/ElementModel.php new file mode 100644 index 0000000..119d287 --- /dev/null +++ b/backend/app/Element/ElementModel.php @@ -0,0 +1,40 @@ +|ElementModel newModelQuery() + * @method static Builder|ElementModel newQuery() + * @method static Builder|ElementModel query() + * @method static Builder|ElementModel whereId($value) + * @method static Builder|ElementModel whereParentElementId($value) + * @method static Builder|ElementModel whereSetId($value) + * @method static Builder|ElementModel whereTitle($value) + * + * @mixin \Eloquent + */ +class ElementModel extends Model +{ + protected $table = 'elements'; + + public $timestamps = false; + + protected $fillable = [ + 'set_id', + 'title', + 'parent_element_id', + ]; + + protected $casts = [ + 'set_id' => 'integer', + 'parent_element_id' => 'integer', + ]; +} diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php new file mode 100644 index 0000000..7c91492 --- /dev/null +++ b/backend/app/Element/ElementRepository.php @@ -0,0 +1,15 @@ + $dto->set->getId(), + 'title' => $dto->title, + 'parent_element_id' => $dto->parentElement?->getId(), + ]); + + return new Element( + id: $model->id, + title: $dto->title, + set: $dto->set, + parentElement: $dto->parentElement, + ); + } + + public function find(int $id): ?Element + { + $model = ElementModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @return Element[] + */ + public function findBySetId(int $id): array + { + $models = ElementModel::where('set_id', $id)->orderBy('id')->get(); + $elements = []; + foreach ($models as $model) { + $elements[] = $this->toDomain($model); + } + + return $elements; + } + + private function toDomain(ElementModel $model): Element + { + $set = $this->setRepo->find($model->set_id); + if ($set === null) { + throw new DomainException( + "Set with id: {$model->set_id} doesnt exist" + ); + } + + $parentElement = null; + if ($model->parent_element_id !== null) { + $parentElement = $this->find($model->parent_element_id); + if ($parentElement === null) { + throw new DomainException( + "Element with id: {$model->parent_element_id} doesnt exist" + ); + } + } + + return new Element( + id: $model->id, + title: $model->title, + set: $set, + parentElement: $parentElement, + ); + } +} diff --git a/backend/app/Element/UseCases/CreateElement/CreateElement.php b/backend/app/Element/UseCases/CreateElement/CreateElement.php new file mode 100644 index 0000000..881bcea --- /dev/null +++ b/backend/app/Element/UseCases/CreateElement/CreateElement.php @@ -0,0 +1,84 @@ +setId === null) { + throw new BadRequestException('setId is required'); + } + if ($request->title === null || $request->title === '') { + throw new BadRequestException('title is required'); + } + + $set = $this->setRepo->find($request->setId); + if ($set === null) { + throw new DomainException( + "Set with id: {$request->setId} doesnt exist" + ); + } + + if ($request->parentElementId === null) { + $this->validateNoRootElementExists($request->setId); + + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $request->title, + parentElement: null, + )); + } + + $parentElement = $this->elementRepo->find( + $request->parentElementId + ); + if ($parentElement === null) { + throw new DomainException( + "Element with id: {$request->parentElementId} doesnt exist" + ); + } + if ($parentElement->getSet()->getId() !== $set->getId()) { + throw new DomainException( + 'Parent element must belong to the same set' + ); + } + + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $request->title, + parentElement: $parentElement, + )); + } + + /** + * @throws DomainException + */ + private function validateNoRootElementExists(int $setId): void + { + $elements = $this->elementRepo->findBySetId($setId); + foreach ($elements as $element) { + if ($element->getParentElement() === null) { + throw new DomainException( + 'A root element already exists for this set' + ); + } + } + } +} diff --git a/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php b/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php new file mode 100644 index 0000000..b9f0dbd --- /dev/null +++ b/backend/app/Element/UseCases/CreateElement/CreateElementRequest.php @@ -0,0 +1,12 @@ +app->bind( + SetRepository::class, + EloquentSetRepository::class + ); + $this->app->bind( + ElementRepository::class, + EloquentElementRepository::class + ); } } diff --git a/backend/app/Set/CreateSetDto.php b/backend/app/Set/CreateSetDto.php new file mode 100644 index 0000000..2999a88 --- /dev/null +++ b/backend/app/Set/CreateSetDto.php @@ -0,0 +1,10 @@ + $dto->name, + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?Set + { + $model = SetModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + public function getAll(): array + { + $models = SetModel::orderBy('id')->get(); + $sets = []; + foreach ($models as $model) { + $sets[] = $this->toDomain($model); + } + + return $sets; + } + + private function toDomain(SetModel $model): Set + { + return new Set( + id: $model->id, + name: $model->name, + ); + } +} diff --git a/backend/app/Set/Set.php b/backend/app/Set/Set.php new file mode 100644 index 0000000..d045e70 --- /dev/null +++ b/backend/app/Set/Set.php @@ -0,0 +1,21 @@ +id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/backend/app/Set/SetModel.php b/backend/app/Set/SetModel.php new file mode 100644 index 0000000..c30e2c9 --- /dev/null +++ b/backend/app/Set/SetModel.php @@ -0,0 +1,27 @@ +|SetModel newModelQuery() + * @method static Builder|SetModel newQuery() + * @method static Builder|SetModel query() + * @method static Builder|SetModel whereId($value) + * @method static Builder|SetModel whereName($value) + * + * @mixin \Eloquent + */ +class SetModel extends Model +{ + protected $table = 'sets'; + + public $timestamps = false; + + protected $fillable = ['name']; +} diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php new file mode 100644 index 0000000..7f1efcd --- /dev/null +++ b/backend/app/Set/SetRepository.php @@ -0,0 +1,15 @@ +name === null || $request->name === '') { + throw new BadRequestException('name is required'); + } + + $set = $this->setRepo->create(new CreateSetDto( + name: $request->name, + )); + + $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $set->getName(), + parentElement: null, + )); + + return $set; + } +} diff --git a/backend/app/Set/UseCases/CreateSet/CreateSetRequest.php b/backend/app/Set/UseCases/CreateSet/CreateSetRequest.php new file mode 100644 index 0000000..066d8ec --- /dev/null +++ b/backend/app/Set/UseCases/CreateSet/CreateSetRequest.php @@ -0,0 +1,10 @@ +id(); + $table->string('name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('sets'); + } +}; diff --git a/backend/database/migrations/2026_05_24_000001_elements_table.php b/backend/database/migrations/2026_05_24_000001_elements_table.php new file mode 100644 index 0000000..377ba38 --- /dev/null +++ b/backend/database/migrations/2026_05_24_000001_elements_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('set_id')->constrained('sets'); + $table->string('title'); + $table->foreignId('parent_element_id') + ->nullable() + ->constrained('elements'); + }); + } + + public function down(): void + { + Schema::dropIfExists('elements'); + } +}; From 24fce56c5e0b1c6c6d6bc7f1b4a76696063097f2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:06:59 +0300 Subject: [PATCH 03/11] clarify commit granularity --- ai/shared.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ai/shared.md b/ai/shared.md index c07271e..351c88b 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -16,7 +16,9 @@ guides (`backend-context.md`, `frontend-context.md`) extend these. 4. Implement the code to make the test pass 5. Run the test to confirm it passes 6. Commit the implementation -7. Repeat for each new behavior +7. Repeat this red/green commit cycle for each new behavior. Do not + batch multiple behaviors into one failing-test commit and one + implementation commit when they can be reviewed separately. ## Code style @@ -32,8 +34,8 @@ guides (`backend-context.md`, `frontend-context.md`) extend these. and fakes - if a helper accepts a value, every caller must supply it. - First, explore the codebase to understand existing patterns - look at similar files for reference before writing anything -- Never use em dashes (—) in code, comments, or docblocks - use hyphens (-) - instead +- Never use em dash characters in code, comments, or docblocks - use + hyphens (-) instead ## Git commit style @@ -51,9 +53,21 @@ guides (`backend-context.md`, `frontend-context.md`) extend these. ## Git commits - Tests should be committed first, before implementation +- Prefer small, reviewable commits. Commit each meaningful step as soon as + it is green and the pre-commit checklist passes. - One logical change per commit - a commit may span multiple files when they form a single logical unit (e.g. a use case with its request and exception, or a Vue SFC with its Pinia store and route entry) +- Split commits by behavior, public contract, migration, wiring, docs, or + mechanical formatting when those parts can be reviewed independently. +- A multi-file commit is okay only when the files form one inseparable + change. Do not use "one logical change" to justify batching adjacent work. +- Do not batch several behaviors into one "tests" commit and one + "implementation" commit. Repeat the red/green cycle per behavior. +- Do not bundle backend and frontend changes unless both are required for + the same user-facing behavior. +- Do not bundle cleanup, refactors, renames, or formatting with feature + behavior. - Keep commits focused: not one file per commit, not unrelated work batched - Make commits frequent - commit each meaningful logical step as you go - Commits are for reviewing and documenting the development of code @@ -83,6 +97,8 @@ mechanical, not aspirational - a "yes" to all is required. - [ ] On a feature branch (not master/main). - [ ] This commit is one logical change. If it spans unrelated changes, stop and split it. +- [ ] This commit has been split as far as it can be while staying + reviewable. If any part can land independently, split it. - [ ] Tests for new behavior were committed BEFORE this implementation (or this commit IS the failing-test commit). From aaa494afe4eb5426166bee26dc209c80c415e523 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:11:25 +0300 Subject: [PATCH 04/11] test find elements by set --- backend/tests/Fakes/FakeElementRepository.php | 5 +++-- backend/tests/Unit/Set/UseCases/CreateSetTest.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index d624a9a..65dbcad 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -5,6 +5,7 @@ namespace Tests\Fakes; use App\Element\CreateElementDto; use App\Element\Element; use App\Element\ElementRepository; +use App\Set\Set as DomainSet; class FakeElementRepository implements ElementRepository { @@ -39,11 +40,11 @@ class FakeElementRepository implements ElementRepository /** * @return Element[] */ - public function findBySetId(int $id): array + public function findBySet(DomainSet $set): array { $elements = []; foreach ($this->elementsById as $element) { - if ($element->getSet()->getId() === $id) { + if ($element->getSet()->getId() === $set->getId()) { $elements[] = $this->cloneElement($element); } } diff --git a/backend/tests/Unit/Set/UseCases/CreateSetTest.php b/backend/tests/Unit/Set/UseCases/CreateSetTest.php index bd3c9f4..b7c2327 100644 --- a/backend/tests/Unit/Set/UseCases/CreateSetTest.php +++ b/backend/tests/Unit/Set/UseCases/CreateSetTest.php @@ -45,7 +45,7 @@ class CreateSetTest extends TestCase new CreateSetRequest('Daily learning') ); - $elements = $this->elementRepo->findBySetId($set->getId()); + $elements = $this->elementRepo->findBySet($set); $this->assertCount(1, $elements); $this->assertSame('Daily learning', $elements[0]->getTitle()); From b44830fa5379d07a618e2f8917f806ddb0a8759e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:12:50 +0300 Subject: [PATCH 05/11] find elements by set --- backend/app/Element/ElementRepository.php | 4 +++- backend/app/Element/EloquentElementRepository.php | 7 +++++-- .../app/Element/UseCases/CreateElement/CreateElement.php | 7 ++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index 7c91492..4f39fb6 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -2,6 +2,8 @@ namespace App\Element; +use App\Set\Set as DomainSet; + interface ElementRepository { public function create(CreateElementDto $dto): Element; @@ -11,5 +13,5 @@ interface ElementRepository /** * @return Element[] */ - public function findBySetId(int $id): array; + public function findBySet(DomainSet $set): array; } diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 4951d2e..56ec2e6 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -2,6 +2,7 @@ namespace App\Element; +use App\Set\Set as DomainSet; use App\Set\SetRepository; use DomainException; @@ -35,9 +36,11 @@ class EloquentElementRepository implements ElementRepository /** * @return Element[] */ - public function findBySetId(int $id): array + public function findBySet(DomainSet $set): array { - $models = ElementModel::where('set_id', $id)->orderBy('id')->get(); + $models = ElementModel::where('set_id', $set->getId()) + ->orderBy('id') + ->get(); $elements = []; foreach ($models as $model) { $elements[] = $this->toDomain($model); diff --git a/backend/app/Element/UseCases/CreateElement/CreateElement.php b/backend/app/Element/UseCases/CreateElement/CreateElement.php index 881bcea..6656338 100644 --- a/backend/app/Element/UseCases/CreateElement/CreateElement.php +++ b/backend/app/Element/UseCases/CreateElement/CreateElement.php @@ -6,6 +6,7 @@ use App\Element\CreateElementDto; use App\Element\Element; use App\Element\ElementRepository; use App\Exceptions\BadRequestException; +use App\Set\Set as DomainSet; use App\Set\SetRepository; use DomainException; @@ -37,7 +38,7 @@ class CreateElement } if ($request->parentElementId === null) { - $this->validateNoRootElementExists($request->setId); + $this->validateNoRootElementExists($set); return $this->elementRepo->create(new CreateElementDto( set: $set, @@ -70,9 +71,9 @@ class CreateElement /** * @throws DomainException */ - private function validateNoRootElementExists(int $setId): void + private function validateNoRootElementExists(DomainSet $set): void { - $elements = $this->elementRepo->findBySetId($setId); + $elements = $this->elementRepo->findBySet($set); foreach ($elements as $element) { if ($element->getParentElement() === null) { throw new DomainException( From 3bfcdfd0cc15d455d0d22375e5b7c4717bf66559 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:14:16 +0300 Subject: [PATCH 06/11] document repository lookups --- ai/backend-context.md | 40 ++++++++++++++++++++++++++++++---------- ai/shared.md | 2 ++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/ai/backend-context.md b/ai/backend-context.md index e7825fa..cedaa42 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -20,6 +20,9 @@ intentionally omitted here - update this section as entities land. - Do not write unit tests for concrete repository implementations (e.g. `Postgres*Repository`). They are exercised by e2e tests. Use cases are tested with fake repositories. + - Repository methods that find records by a foreign key should accept + the related entity, not a raw id (e.g. `findBySet(Set $set)`, not + `findBySetId(int $setId)`). - Use cases: business logic with Request objects - When throwing exceptions, add `@throws` docblock - Fakes: in-memory implementations for testing @@ -50,16 +53,33 @@ to be added to the repo; the Nix flake already provides Constructs LLMs default to that this project forbids. Name the trap explicitly so you catch yourself before writing it. -| Anti-pattern | Forbidden | Required | -|---|---|---| -| Arrow function | `fn ($x) => $x->getId()` | `function ($x) { return $x->getId(); }` | -| Inline FQCN type | `function f(): \Psr\Http\Message\ResponseInterface` | `use Psr\Http\Message\ResponseInterface;` then `function f(): ResponseInterface` | -| Inline `::class` | `Container::get(\App\Foo\Bar::class)` | `use App\Foo\Bar;` then `Container::get(Bar::class)` | -| Default param | `function f(int $count = 10)` | `function f(int $count)` | -| Default in fake | `public function create(Dto $dto, bool $strict = true)` | no default; every caller passes a value | -| Lookup returns stored ref | `return $this->items[$id] ?? null;` | rebuild a new instance with the stored fields | -| Short variable name | `$t`, `$n`, `$res`, `$req`, `$e` | `$text`, `$node`, `$response`, `$request`, `$exception` | -| Em dash | `// fetches user — by email` | `// fetches user - by email` | +- Arrow function: + - Forbidden: `fn ($node) => $node->getId()` + - Required: `function ($node) { return $node->getId(); }` +- Inline FQCN type: + - Forbidden: `function f(): \Psr\Http\Message\ResponseInterface` + - Required: import `ResponseInterface`, then return `ResponseInterface` +- Inline `::class`: + - Forbidden: `Container::get(\App\Foo\Bar::class)` + - Required: import `Bar`, then call `Container::get(Bar::class)` +- Default param: + - Forbidden: `function f(int $count = 10)` + - Required: `function f(int $count)` +- Default in fake: + - Forbidden: `public function create(Dto $dto, bool $strict = true)` + - Required: no default; every caller passes a value +- Lookup returns stored ref: + - Forbidden: `return $this->items[$id] ?? null;` + - Required: rebuild a new instance with the stored fields +- FK lookup by id: + - Forbidden: `findBySetId(int $setId)` + - Required: `findBySet(Set $set)` +- Short variable name: + - Forbidden: one-letter or abbreviated names (`$t`, `$n`, `$res`) + - Required: explicit names (`$text`, `$node`, `$response`) +- Em dash: + - Forbidden: comment or docblock containing an em dash character + - Required: use `-` When generating code, scan the diff for these patterns before writing it to disk. If you catch one mid-write, rewrite that line. diff --git a/ai/shared.md b/ai/shared.md index 351c88b..3d0522e 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -111,6 +111,8 @@ mechanical, not aspirational - a "yes" to all is required. (PHP or TS). - [ ] Find/lookup repository methods return new instances, not stored references. +- [ ] Backend repository methods that find by a foreign key accept the + related entity, not a raw id. - [ ] No em dashes (use hyphens). - [ ] Variable names are explicit (no `$t`, `n`, `res`, `req`, `e`, etc.). - [ ] No `any` in TypeScript - use concrete types or `unknown` with a From f62b05bc37a0d87d84e88faf693c9a3f0bad9520 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:24:31 +0300 Subject: [PATCH 07/11] test seeded sets --- backend/tests/Fakes/FakeSetRepository.php | 12 +--- .../Element/UseCases/CreateElementTest.php | 25 +++++--- .../tests/Unit/Set/UseCases/CreateSetTest.php | 63 ------------------- 3 files changed, 20 insertions(+), 80 deletions(-) delete mode 100644 backend/tests/Unit/Set/UseCases/CreateSetTest.php diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index 295e619..87ed0fd 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -2,7 +2,6 @@ namespace Tests\Fakes; -use App\Set\CreateSetDto; use App\Set\Set as DomainSet; use App\Set\SetRepository; @@ -13,16 +12,9 @@ class FakeSetRepository implements SetRepository */ private array $setsById = []; - public function create(CreateSetDto $dto): DomainSet + public function store(DomainSet $set): void { - $id = count($this->setsById) + 1; - $set = new DomainSet( - id: $id, - name: $dto->name, - ); - $this->setsById[$id] = $set; - - return $set; + $this->setsById[$set->getId()] = $set; } public function find(int $id): ?DomainSet diff --git a/backend/tests/Unit/Element/UseCases/CreateElementTest.php b/backend/tests/Unit/Element/UseCases/CreateElementTest.php index da6f024..26f13a8 100644 --- a/backend/tests/Unit/Element/UseCases/CreateElementTest.php +++ b/backend/tests/Unit/Element/UseCases/CreateElementTest.php @@ -6,7 +6,7 @@ use App\Element\Element; use App\Element\UseCases\CreateElement\CreateElement; use App\Element\UseCases\CreateElement\CreateElementRequest; use App\Exceptions\BadRequestException; -use App\Set\CreateSetDto; +use App\Set\Set as DomainSet; use DomainException; use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeSetRepository; @@ -30,9 +30,20 @@ class CreateElementTest extends TestCase ); } + private function seedSet(int $id, string $name): DomainSet + { + $set = new DomainSet( + id: $id, + name: $name, + ); + $this->setRepo->store($set); + + return $set; + } + public function testCreatesRootElement(): void { - $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + $set = $this->seedSet(1, 'Daily learning'); $element = $this->createElement->execute(new CreateElementRequest( setId: $set->getId(), @@ -48,7 +59,7 @@ class CreateElementTest extends TestCase public function testCreatesChildElement(): void { - $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + $set = $this->seedSet(1, 'Daily learning'); $rootElement = $this->createElement->execute( new CreateElementRequest( setId: $set->getId(), @@ -110,7 +121,7 @@ class CreateElementTest extends TestCase public function testThrowsWhenParentElementDoesNotExist(): void { - $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + $set = $this->seedSet(1, 'Daily learning'); $this->expectException(DomainException::class); $this->expectExceptionMessage( @@ -126,7 +137,7 @@ class CreateElementTest extends TestCase public function testThrowsWhenRootElementAlreadyExists(): void { - $set = $this->setRepo->create(new CreateSetDto('Daily learning')); + $set = $this->seedSet(1, 'Daily learning'); $this->createElement->execute(new CreateElementRequest( setId: $set->getId(), title: 'Root', @@ -147,8 +158,8 @@ class CreateElementTest extends TestCase public function testThrowsWhenParentBelongsToAnotherSet(): void { - $parentSet = $this->setRepo->create(new CreateSetDto('Parent set')); - $childSet = $this->setRepo->create(new CreateSetDto('Child set')); + $parentSet = $this->seedSet(1, 'Parent set'); + $childSet = $this->seedSet(2, 'Child set'); $parentElement = $this->createElement->execute( new CreateElementRequest( setId: $parentSet->getId(), diff --git a/backend/tests/Unit/Set/UseCases/CreateSetTest.php b/backend/tests/Unit/Set/UseCases/CreateSetTest.php deleted file mode 100644 index b7c2327..0000000 --- a/backend/tests/Unit/Set/UseCases/CreateSetTest.php +++ /dev/null @@ -1,63 +0,0 @@ -setRepo = new FakeSetRepository(); - $this->elementRepo = new FakeElementRepository(); - $this->createSet = new CreateSet( - $this->setRepo, - $this->elementRepo, - ); - } - - public function testCreatesSet(): void - { - $set = $this->createSet->execute( - new CreateSetRequest('Daily learning') - ); - - $this->assertInstanceOf(DomainSet::class, $set); - $this->assertSame(1, $set->getId()); - $this->assertSame('Daily learning', $set->getName()); - } - - public function testCreatesRootElementForSet(): void - { - $set = $this->createSet->execute( - new CreateSetRequest('Daily learning') - ); - - $elements = $this->elementRepo->findBySet($set); - - $this->assertCount(1, $elements); - $this->assertSame('Daily learning', $elements[0]->getTitle()); - $this->assertSame($set->getId(), $elements[0]->getSet()->getId()); - $this->assertNull($elements[0]->getParentElement()); - } - - public function testThrowsWhenNameMissing(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('name is required'); - - $this->createSet->execute(new CreateSetRequest(null)); - } -} From 914cbd37a19ed97dc0c55a0434d97088117df7ec Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:25:17 +0300 Subject: [PATCH 08/11] remove set creation --- backend/app/Set/CreateSetDto.php | 10 ----- backend/app/Set/EloquentSetRepository.php | 9 ----- backend/app/Set/SetRepository.php | 2 - .../app/Set/UseCases/CreateSet/CreateSet.php | 40 ------------------- .../UseCases/CreateSet/CreateSetRequest.php | 10 ----- 5 files changed, 71 deletions(-) delete mode 100644 backend/app/Set/CreateSetDto.php delete mode 100644 backend/app/Set/UseCases/CreateSet/CreateSet.php delete mode 100644 backend/app/Set/UseCases/CreateSet/CreateSetRequest.php diff --git a/backend/app/Set/CreateSetDto.php b/backend/app/Set/CreateSetDto.php deleted file mode 100644 index 2999a88..0000000 --- a/backend/app/Set/CreateSetDto.php +++ /dev/null @@ -1,10 +0,0 @@ - $dto->name, - ]); - - return $this->toDomain($model); - } - public function find(int $id): ?Set { $model = SetModel::find($id); diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 7f1efcd..075173f 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -4,8 +4,6 @@ namespace App\Set; interface SetRepository { - public function create(CreateSetDto $dto): Set; - public function find(int $id): ?Set; /** diff --git a/backend/app/Set/UseCases/CreateSet/CreateSet.php b/backend/app/Set/UseCases/CreateSet/CreateSet.php deleted file mode 100644 index 551f01c..0000000 --- a/backend/app/Set/UseCases/CreateSet/CreateSet.php +++ /dev/null @@ -1,40 +0,0 @@ -name === null || $request->name === '') { - throw new BadRequestException('name is required'); - } - - $set = $this->setRepo->create(new CreateSetDto( - name: $request->name, - )); - - $this->elementRepo->create(new CreateElementDto( - set: $set, - title: $set->getName(), - parentElement: null, - )); - - return $set; - } -} diff --git a/backend/app/Set/UseCases/CreateSet/CreateSetRequest.php b/backend/app/Set/UseCases/CreateSet/CreateSetRequest.php deleted file mode 100644 index 066d8ec..0000000 --- a/backend/app/Set/UseCases/CreateSet/CreateSetRequest.php +++ /dev/null @@ -1,10 +0,0 @@ - Date: Mon, 25 May 2026 08:25:42 +0300 Subject: [PATCH 09/11] update dto example --- ai/backend-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai/backend-context.md b/ai/backend-context.md index cedaa42..f3b5ddd 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -15,7 +15,7 @@ intentionally omitted here - update this section as entities land. - Look at similar entities for reference before writing anything new - Entities: constructor with properties, getters -- DTOs: simple data containers for creation (e.g. `CreateSetDto`) +- DTOs: simple data containers for creation (e.g. `CreateElementDto`) - Repositories: interfaces that define data access - Do not write unit tests for concrete repository implementations (e.g. `Postgres*Repository`). They are exercised by e2e tests. From 4897cc2e20c04320ba6e37c047584e59b5ee94d7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:36:16 +0300 Subject: [PATCH 10/11] test set creation repo --- backend/tests/Fakes/FakeSetRepository.php | 12 +++++- .../Element/UseCases/CreateElementTest.php | 37 ++++++++++--------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index 87ed0fd..295e619 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -2,6 +2,7 @@ namespace Tests\Fakes; +use App\Set\CreateSetDto; use App\Set\Set as DomainSet; use App\Set\SetRepository; @@ -12,9 +13,16 @@ class FakeSetRepository implements SetRepository */ private array $setsById = []; - public function store(DomainSet $set): void + public function create(CreateSetDto $dto): DomainSet { - $this->setsById[$set->getId()] = $set; + $id = count($this->setsById) + 1; + $set = new DomainSet( + id: $id, + name: $dto->name, + ); + $this->setsById[$id] = $set; + + return $set; } public function find(int $id): ?DomainSet diff --git a/backend/tests/Unit/Element/UseCases/CreateElementTest.php b/backend/tests/Unit/Element/UseCases/CreateElementTest.php index 26f13a8..2d01fc6 100644 --- a/backend/tests/Unit/Element/UseCases/CreateElementTest.php +++ b/backend/tests/Unit/Element/UseCases/CreateElementTest.php @@ -6,7 +6,7 @@ use App\Element\Element; use App\Element\UseCases\CreateElement\CreateElement; use App\Element\UseCases\CreateElement\CreateElementRequest; use App\Exceptions\BadRequestException; -use App\Set\Set as DomainSet; +use App\Set\CreateSetDto; use DomainException; use Tests\Fakes\FakeElementRepository; use Tests\Fakes\FakeSetRepository; @@ -30,20 +30,11 @@ class CreateElementTest extends TestCase ); } - private function seedSet(int $id, string $name): DomainSet - { - $set = new DomainSet( - id: $id, - name: $name, - ); - $this->setRepo->store($set); - - return $set; - } - public function testCreatesRootElement(): void { - $set = $this->seedSet(1, 'Daily learning'); + $set = $this->setRepo->create( + new CreateSetDto('Daily learning') + ); $element = $this->createElement->execute(new CreateElementRequest( setId: $set->getId(), @@ -59,7 +50,9 @@ class CreateElementTest extends TestCase public function testCreatesChildElement(): void { - $set = $this->seedSet(1, 'Daily learning'); + $set = $this->setRepo->create( + new CreateSetDto('Daily learning') + ); $rootElement = $this->createElement->execute( new CreateElementRequest( setId: $set->getId(), @@ -121,7 +114,9 @@ class CreateElementTest extends TestCase public function testThrowsWhenParentElementDoesNotExist(): void { - $set = $this->seedSet(1, 'Daily learning'); + $set = $this->setRepo->create( + new CreateSetDto('Daily learning') + ); $this->expectException(DomainException::class); $this->expectExceptionMessage( @@ -137,7 +132,9 @@ class CreateElementTest extends TestCase public function testThrowsWhenRootElementAlreadyExists(): void { - $set = $this->seedSet(1, 'Daily learning'); + $set = $this->setRepo->create( + new CreateSetDto('Daily learning') + ); $this->createElement->execute(new CreateElementRequest( setId: $set->getId(), title: 'Root', @@ -158,8 +155,12 @@ class CreateElementTest extends TestCase public function testThrowsWhenParentBelongsToAnotherSet(): void { - $parentSet = $this->seedSet(1, 'Parent set'); - $childSet = $this->seedSet(2, 'Child set'); + $parentSet = $this->setRepo->create( + new CreateSetDto('Parent set') + ); + $childSet = $this->setRepo->create( + new CreateSetDto('Child set') + ); $parentElement = $this->createElement->execute( new CreateElementRequest( setId: $parentSet->getId(), From 8ec7af8a1f17f469a133d5233b0337f62c0dee68 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 25 May 2026 08:37:07 +0300 Subject: [PATCH 11/11] restore set creation --- backend/app/Set/CreateSetDto.php | 10 ++++++++++ backend/app/Set/EloquentSetRepository.php | 9 +++++++++ backend/app/Set/SetRepository.php | 2 ++ 3 files changed, 21 insertions(+) create mode 100644 backend/app/Set/CreateSetDto.php diff --git a/backend/app/Set/CreateSetDto.php b/backend/app/Set/CreateSetDto.php new file mode 100644 index 0000000..2999a88 --- /dev/null +++ b/backend/app/Set/CreateSetDto.php @@ -0,0 +1,10 @@ + $dto->name, + ]); + + return $this->toDomain($model); + } + public function find(int $id): ?Set { $model = SetModel::find($id); diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 075173f..7f1efcd 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -4,6 +4,8 @@ namespace App\Set; interface SetRepository { + public function create(CreateSetDto $dto): Set; + public function find(int $id): ?Set; /**