From 72f0a6bccbb49a36abfbe56c686f46d1990c4a48 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:15:59 +0300 Subject: [PATCH 01/11] document whole-set scheduling --- README.md | 6 +++++- ai/shared.md | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e800d97..d4ded6f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The structure is flexible and is not limited to reading plans. Attainly can be u When creating a schedule, the user selects: -* The collection or portion they want to complete +* The set they want to complete * The level of the collection to schedule * The start date * The target completion date @@ -47,6 +47,10 @@ For example, a user could choose to schedule: * Smaller sections within each chapter * Any other available level in the collection structure +Schedules always cover a whole set. To schedule only a subset of an existing +set, the user creates a separate set containing that subset and gives it its +own name. + ## Daily Progress Users can view the items scheduled for each day and mark them complete as they finish them. diff --git a/ai/shared.md b/ai/shared.md index bf52fe5..b696249 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -9,6 +9,9 @@ these rules. - Attainly is in early development. - Attainly helps users break hierarchical goals into scheduled assignments, complete daily work, and track progress toward a target date. +- Schedules target whole sets. Users choose an element kind as the assignment + granularity. To schedule a subset, create a separate named set containing + that subset. - Schedule recalculation must preserve completed work while redistributing unfinished assignments across the remaining dates. - Planned features in `README.md` are future ideas, not authorized scope. From 52a2bd6e6b32b4f1d8ab055ee602a4cb38d42d43 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:17:03 +0300 Subject: [PATCH 02/11] test element domain --- backend/tests/Unit/Element/ElementTest.php | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 backend/tests/Unit/Element/ElementTest.php diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php new file mode 100644 index 0000000..30a5ce8 --- /dev/null +++ b/backend/tests/Unit/Element/ElementTest.php @@ -0,0 +1,49 @@ +assertSame(22, $element->getId()); + $this->assertSame('Genesis', $element->getName()); + $this->assertSame('portion', $element->getKind()); + $this->assertSame($set, $element->getSet()); + $this->assertSame($parentElement, $element->getParentElement()); + $this->assertSame(2, $element->getPosition()); + } +} From 9029eb5ff8e17ae84c146e0ac5620c2e9a0199c2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:17:23 +0300 Subject: [PATCH 03/11] add element domain --- backend/app/Element/Element.php | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 backend/app/Element/Element.php diff --git a/backend/app/Element/Element.php b/backend/app/Element/Element.php new file mode 100644 index 0000000..5e4c6bd --- /dev/null +++ b/backend/app/Element/Element.php @@ -0,0 +1,47 @@ +id; + } + + public function getName(): string + { + return $this->name; + } + + public function getKind(): string + { + return $this->kind; + } + + public function getSet(): Set + { + return $this->set; + } + + public function getParentElement(): ?Element + { + return $this->parentElement; + } + + public function getPosition(): int + { + return $this->position; + } +} From dd5f35f5071f2feee740f0f2aee4f39c43e2a685 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:18:46 +0300 Subject: [PATCH 04/11] format element test --- backend/tests/Unit/Element/ElementTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php index 30a5ce8..1a99b31 100644 --- a/backend/tests/Unit/Element/ElementTest.php +++ b/backend/tests/Unit/Element/ElementTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase; class ElementTest extends TestCase { - public function test_it_exposes_its_hierarchical_identity(): void + public function testItExposesItsHierarchicalIdentity(): void { $creator = new User( id: 7, From aaab0e53793c6076ab1e72b2c58d9436d6dd133b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:18:58 +0300 Subject: [PATCH 05/11] test element persistence --- .../Element/EloquentElementRepositoryTest.php | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 backend/tests/Feature/Element/EloquentElementRepositoryTest.php diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php new file mode 100644 index 0000000..edddeac --- /dev/null +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -0,0 +1,86 @@ +createSet('Bible'); + $repository = app(ElementRepository::class); + $genesisBook = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $exodusBook = $repository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $genesisPortion = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'portion', + parentElement: $genesisBook, + )); + $noahPortion = $repository->create(new CreateElementDto( + set: $set, + name: 'Noah', + kind: 'portion', + parentElement: $genesisBook, + )); + + $this->assertSame(1, $genesisBook->getPosition()); + $this->assertSame(2, $exodusBook->getPosition()); + $this->assertSame(1, $genesisPortion->getPosition()); + $this->assertSame(2, $noahPortion->getPosition()); + $this->assertDatabaseHas('elements', [ + 'id' => $genesisPortion->getId(), + 'set_id' => $set->getId(), + 'name' => 'Genesis', + 'kind' => 'portion', + 'parent_element_id' => $genesisBook->getId(), + 'position' => 1, + ]); + + $foundElement = $repository->find($noahPortion->getId()); + + $this->assertNotNull($foundElement); + $this->assertSame('Noah', $foundElement->getName()); + $this->assertSame('portion', $foundElement->getKind()); + $this->assertSame($set->getId(), $foundElement->getSet()->getId()); + $this->assertSame( + $genesisBook->getId(), + $foundElement->getParentElement()?->getId(), + ); + } + + private function createSet(string $name): Set + { + $creator = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress(strtolower($name) . '@example.com'), + passwordHash: 'hashed-password', + )); + + return app(SetRepository::class)->create(new CreateSetDto( + name: $name, + creator: $creator, + )); + } +} From fcc6ade8f3555d67153e0affe030e7ebd457557c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:21:25 +0300 Subject: [PATCH 06/11] add element persistence --- backend/app/Element/CreateElementDto.php | 15 +++ backend/app/Element/ElementModel.php | 47 +++++++++ backend/app/Element/ElementRepository.php | 10 ++ .../app/Element/EloquentElementRepository.php | 98 +++++++++++++++++++ backend/app/Providers/AppServiceProvider.php | 6 ++ ...026_08_08_000000_create_elements_table.php | 35 +++++++ backend/tests/Fakes/FakeElementRepository.php | 78 +++++++++++++++ 7 files changed, 289 insertions(+) create mode 100644 backend/app/Element/CreateElementDto.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/database/migrations/2026_08_08_000000_create_elements_table.php create mode 100644 backend/tests/Fakes/FakeElementRepository.php diff --git a/backend/app/Element/CreateElementDto.php b/backend/app/Element/CreateElementDto.php new file mode 100644 index 0000000..6f7df77 --- /dev/null +++ b/backend/app/Element/CreateElementDto.php @@ -0,0 +1,15 @@ +|ElementModel newModelQuery() + * @method static Builder|ElementModel newQuery() + * @method static Builder|ElementModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'set_id', + 'name', + 'kind', + 'parent_element_id', + 'position', +])] +class ElementModel extends Model +{ + protected $table = 'elements'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'set_id' => 'integer', + 'parent_element_id' => 'integer', + 'position' => 'integer', + ]; + } +} diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php new file mode 100644 index 0000000..b9ad71f --- /dev/null +++ b/backend/app/Element/ElementRepository.php @@ -0,0 +1,10 @@ +nextPosition( + $dto->set, + $dto->parentElement, + ); + $model = ElementModel::create([ + 'set_id' => $dto->set->getId(), + 'name' => $dto->name, + 'kind' => $dto->kind, + 'parent_element_id' => $dto->parentElement?->getId(), + 'position' => $position, + ]); + + return new Element( + id: $model->id, + name: $model->name, + kind: $model->kind, + set: $dto->set, + parentElement: $dto->parentElement, + position: $model->position, + ); + } + + public function find(int $id): ?Element + { + $model = ElementModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + private function nextPosition( + Set $set, + ?Element $parentElement, + ): int { + $query = ElementModel::query() + ->where('set_id', $set->getId()); + if ($parentElement === null) { + $query->whereNull('parent_element_id'); + } else { + $query->where('parent_element_id', $parentElement->getId()); + } + + $currentMaximum = $query->max('position'); + if ($currentMaximum === null) { + return 1; + } + + return (int) $currentMaximum + 1; + } + + private function toDomain(ElementModel $model): Element + { + $set = $this->findSet($model->set_id); + + $parentElement = null; + if ($model->parent_element_id !== null) { + $parentElement = $this->find($model->parent_element_id); + if ($parentElement === null) { + throw new RuntimeException('element parent not found'); + } + } + + return new Element( + id: $model->id, + name: $model->name, + kind: $model->kind, + set: $set, + parentElement: $parentElement, + position: $model->position, + ); + } + + private function findSet(int $id): 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/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index c9490ba..8d883fe 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Email\Emailer; use App\Email\EmailFactory; use App\Email\LaravelEmailer; use App\Email\LaravelEmailFactory; +use App\Element\ElementRepository; +use App\Element\EloquentElementRepository; use App\Set\EloquentSetRepository; use App\Set\SetRepository; use App\User\EloquentUserRepository; @@ -51,6 +53,10 @@ class AppServiceProvider extends ServiceProvider SetRepository::class, EloquentSetRepository::class, ); + $this->app->bind( + ElementRepository::class, + EloquentElementRepository::class, + ); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); diff --git a/backend/database/migrations/2026_08_08_000000_create_elements_table.php b/backend/database/migrations/2026_08_08_000000_create_elements_table.php new file mode 100644 index 0000000..196c2c5 --- /dev/null +++ b/backend/database/migrations/2026_08_08_000000_create_elements_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('set_id') + ->constrained('sets') + ->restrictOnDelete(); + $table->string('name'); + $table->string('kind'); + $table->foreignId('parent_element_id') + ->nullable() + ->constrained('elements') + ->restrictOnDelete(); + $table->unsignedInteger('position'); + $table->index([ + 'set_id', + 'parent_element_id', + 'position', + ]); + }); + } + + public function down(): void + { + Schema::dropIfExists('elements'); + } +}; diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php new file mode 100644 index 0000000..4746bf3 --- /dev/null +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -0,0 +1,78 @@ + + */ + private array $elements = []; + + public function create(CreateElementDto $dto): Element + { + $id = count($this->elements) + 1; + $element = new Element( + id: $id, + name: $dto->name, + kind: $dto->kind, + set: $dto->set, + parentElement: $dto->parentElement, + position: $this->nextPosition($dto), + ); + $this->elements[$id] = $element; + + return $this->copy($element); + } + + public function find(int $id): ?Element + { + $element = $this->elements[$id] ?? null; + + return $element === null ? null : $this->copy($element); + } + + private function nextPosition(CreateElementDto $dto): int + { + $requestedParentId = $dto->parentElement?->getId(); + $maximumPosition = 0; + + foreach ($this->elements as $element) { + if ($element->getSet()->getId() !== $dto->set->getId()) { + continue; + } + + $elementParentId = $element->getParentElement()?->getId(); + if ($elementParentId !== $requestedParentId) { + continue; + } + + $maximumPosition = max( + $maximumPosition, + $element->getPosition(), + ); + } + + return $maximumPosition + 1; + } + + private function copy(Element $element): Element + { + $parentElement = $element->getParentElement(); + + return new Element( + id: $element->getId(), + name: $element->getName(), + kind: $element->getKind(), + set: $element->getSet(), + parentElement: $parentElement === null + ? null + : $this->copy($parentElement), + position: $element->getPosition(), + ); + } +} From eccfed235077d2978b8a5bc7ece5bd65b7adbe72 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:21:49 +0300 Subject: [PATCH 07/11] test element parent scope --- .../Element/EloquentElementRepositoryTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php index edddeac..c339903 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -10,6 +10,7 @@ use App\Set\SetRepository; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; use App\User\UserRepository; +use DomainException; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -71,6 +72,31 @@ class EloquentElementRepositoryTest extends TestCase ); } + public function testItRejectsAParentFromAnotherSet(): void + { + $bible = $this->createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(ElementRepository::class); + $book = $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage( + 'parent element must belong to the same set', + ); + + $repository->create(new CreateElementDto( + set: $course, + name: 'Invalid lesson', + kind: 'lesson', + parentElement: $book, + )); + } + private function createSet(string $name): Set { $creator = app(UserRepository::class)->create(new CreateUserDto( From 588a3ecb40eb91044cfe4ec99d9244a91a98166d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:22:25 +0300 Subject: [PATCH 08/11] enforce element parent scope --- backend/app/Element/ElementRepository.php | 5 +++++ .../app/Element/EloquentElementRepository.php | 19 +++++++++++++++++++ backend/tests/Fakes/FakeElementRepository.php | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index b9ad71f..86631bd 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -2,8 +2,13 @@ namespace App\Element; +use DomainException; + interface ElementRepository { + /** + * @throws DomainException + */ public function create(CreateElementDto $dto): Element; public function find(int $id): ?Element; diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index a1a2709..3b8bc7b 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -4,6 +4,7 @@ namespace App\Element; use App\Set\Set; use App\Set\SetRepository; +use DomainException; use RuntimeException; class EloquentElementRepository implements ElementRepository @@ -14,6 +15,7 @@ class EloquentElementRepository implements ElementRepository public function create(CreateElementDto $dto): Element { + $this->validateParentSet($dto); $position = $this->nextPosition( $dto->set, $dto->parentElement, @@ -43,6 +45,23 @@ class EloquentElementRepository implements ElementRepository return $model === null ? null : $this->toDomain($model); } + /** + * @throws DomainException + */ + private function validateParentSet(CreateElementDto $dto): void + { + $parentElement = $dto->parentElement; + if ($parentElement === null) { + return; + } + + if ($parentElement->getSet()->getId() !== $dto->set->getId()) { + throw new DomainException( + 'parent element must belong to the same set', + ); + } + } + private function nextPosition( Set $set, ?Element $parentElement, diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 4746bf3..ebe197d 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 DomainException; class FakeElementRepository implements ElementRepository { @@ -15,6 +16,7 @@ class FakeElementRepository implements ElementRepository public function create(CreateElementDto $dto): Element { + $this->validateParentSet($dto); $id = count($this->elements) + 1; $element = new Element( id: $id, @@ -36,6 +38,23 @@ class FakeElementRepository implements ElementRepository return $element === null ? null : $this->copy($element); } + /** + * @throws DomainException + */ + private function validateParentSet(CreateElementDto $dto): void + { + $parentElement = $dto->parentElement; + if ($parentElement === null) { + return; + } + + if ($parentElement->getSet()->getId() !== $dto->set->getId()) { + throw new DomainException( + 'parent element must belong to the same set', + ); + } + } + private function nextPosition(CreateElementDto $dto): int { $requestedParentId = $dto->parentElement?->getId(); From 88c8a018adc2d014af3ed41ae0c9730ad5282d6e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:22:55 +0300 Subject: [PATCH 09/11] test ordered element queries --- .../Element/EloquentElementRepositoryTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php index c339903..737403a 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -97,6 +97,65 @@ class EloquentElementRepositoryTest extends TestCase )); } + public function testItListsOrderedElementsWithinTheirTreeScope(): void + { + $bible = $this->createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(ElementRepository::class); + $genesisBook = $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $exodusBook = $repository->create(new CreateElementDto( + set: $bible, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $course, + name: 'Module 1', + kind: 'module', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'portion', + parentElement: $genesisBook, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Noah', + kind: 'portion', + parentElement: $genesisBook, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Shemot', + kind: 'portion', + parentElement: $exodusBook, + )); + + $topLevelElements = $repository->findTopLevelBySet($bible); + $childElements = $repository->findByParentElement($genesisBook); + + $this->assertSame( + ['Genesis', 'Exodus'], + array_map(function ($element): string { + return $element->getName(); + }, $topLevelElements), + ); + $this->assertSame( + ['Genesis', 'Noah'], + array_map(function ($element): string { + return $element->getName(); + }, $childElements), + ); + } + private function createSet(string $name): Set { $creator = app(UserRepository::class)->create(new CreateUserDto( From 503d868e296b06793f20e4fb76c6a72a52684724 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:24:02 +0300 Subject: [PATCH 10/11] add ordered element queries --- backend/app/Element/ElementRepository.php | 11 ++++ .../app/Element/EloquentElementRepository.php | 54 +++++++++++++++++++ backend/tests/Fakes/FakeElementRepository.php | 50 +++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index 86631bd..fc46687 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -2,6 +2,7 @@ namespace App\Element; +use App\Set\Set; use DomainException; interface ElementRepository @@ -12,4 +13,14 @@ interface ElementRepository public function create(CreateElementDto $dto): Element; public function find(int $id): ?Element; + + /** + * @return list + */ + public function findTopLevelBySet(Set $set): array; + + /** + * @return list + */ + public function findByParentElement(Element $parentElement): array; } diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 3b8bc7b..cfd93f7 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -45,6 +45,48 @@ class EloquentElementRepository implements ElementRepository return $model === null ? null : $this->toDomain($model); } + public function findTopLevelBySet(Set $set): array + { + $models = ElementModel::query() + ->where('set_id', $set->getId()) + ->whereNull('parent_element_id') + ->orderBy('position') + ->orderBy('id') + ->get(); + $elements = []; + + foreach ($models as $model) { + $elements[] = $this->toDomainWithRelations( + model: $model, + set: $set, + parentElement: null, + ); + } + + return $elements; + } + + public function findByParentElement(Element $parentElement): array + { + $models = ElementModel::query() + ->where('set_id', $parentElement->getSet()->getId()) + ->where('parent_element_id', $parentElement->getId()) + ->orderBy('position') + ->orderBy('id') + ->get(); + $elements = []; + + foreach ($models as $model) { + $elements[] = $this->toDomainWithRelations( + model: $model, + set: $parentElement->getSet(), + parentElement: $parentElement, + ); + } + + return $elements; + } + /** * @throws DomainException */ @@ -94,6 +136,18 @@ class EloquentElementRepository implements ElementRepository } } + return $this->toDomainWithRelations( + model: $model, + set: $set, + parentElement: $parentElement, + ); + } + + private function toDomainWithRelations( + ElementModel $model, + Set $set, + ?Element $parentElement, + ): Element { return new Element( id: $model->id, name: $model->name, diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index ebe197d..e44cb53 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; use DomainException; class FakeElementRepository implements ElementRepository @@ -38,6 +39,34 @@ class FakeElementRepository implements ElementRepository return $element === null ? null : $this->copy($element); } + public function findTopLevelBySet(Set $set): array + { + $elements = array_filter( + $this->elements, + function (Element $element) use ($set): bool { + return $element->getSet()->getId() === $set->getId() + && $element->getParentElement() === null; + }, + ); + + return $this->orderedCopies($elements); + } + + public function findByParentElement(Element $parentElement): array + { + $elements = array_filter( + $this->elements, + function (Element $element) use ($parentElement): bool { + return $element->getSet()->getId() + === $parentElement->getSet()->getId() + && $element->getParentElement()?->getId() + === $parentElement->getId(); + }, + ); + + return $this->orderedCopies($elements); + } + /** * @throws DomainException */ @@ -79,6 +108,27 @@ class FakeElementRepository implements ElementRepository return $maximumPosition + 1; } + /** + * @param array $elements + * @return list + */ + private function orderedCopies(array $elements): array + { + usort($elements, function (Element $first, Element $second): int { + $positionComparison = $first->getPosition() + <=> $second->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $first->getId() <=> $second->getId(); + }); + + return array_map(function (Element $element): Element { + return $this->copy($element); + }, $elements); + } + private function copy(Element $element): Element { $parentElement = $element->getParentElement(); From 7fb0adbea6c6979dc78514ad53d65960f4f1f969 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 22:26:53 +0300 Subject: [PATCH 11/11] stabilize auth endpoint tests --- backend/tests/Feature/Auth/LogoutEndpointTest.php | 3 +++ backend/tests/Feature/Auth/MeEndpointTest.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/backend/tests/Feature/Auth/LogoutEndpointTest.php b/backend/tests/Feature/Auth/LogoutEndpointTest.php index 3986cb9..b593664 100644 --- a/backend/tests/Feature/Auth/LogoutEndpointTest.php +++ b/backend/tests/Feature/Auth/LogoutEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Auth; use App\Auth\CreateSessionDto; +use App\Auth\Clock; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; @@ -11,6 +12,7 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\Fakes\FakeClock; use Tests\TestCase; class LogoutEndpointTest extends TestCase @@ -23,6 +25,7 @@ class LogoutEndpointTest extends TestCase '2026-07-31T12:00:00', new DateTimeZone('UTC'), ); + $this->app->instance(Clock::class, new FakeClock($now)); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), passwordHash: 'hashed-password', diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php index 3cabaf1..e776adb 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Auth; use App\Auth\CreateSessionDto; +use App\Auth\Clock; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; @@ -11,6 +12,7 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\Fakes\FakeClock; use Tests\TestCase; class MeEndpointTest extends TestCase @@ -23,6 +25,7 @@ class MeEndpointTest extends TestCase '2026-07-31T12:00:00', new DateTimeZone('UTC'), ); + $this->app->instance(Clock::class, new FakeClock($now)); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), passwordHash: 'hashed-password',