Merge branch 'set-element'
This commit is contained in:
commit
ff1bea92c5
22 changed files with 864 additions and 14 deletions
|
|
@ -15,11 +15,14 @@ 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.
|
||||
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.
|
||||
|
|
|
|||
24
ai/shared.md
24
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).
|
||||
|
||||
|
|
@ -95,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
|
||||
|
|
|
|||
14
backend/app/Element/CreateElementDto.php
Normal file
14
backend/app/Element/CreateElementDto.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element;
|
||||
|
||||
use App\Set\Set;
|
||||
|
||||
class CreateElementDto
|
||||
{
|
||||
public function __construct(
|
||||
public Set $set,
|
||||
public string $title,
|
||||
public ?Element $parentElement,
|
||||
) {}
|
||||
}
|
||||
35
backend/app/Element/Element.php
Normal file
35
backend/app/Element/Element.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element;
|
||||
|
||||
use App\Set\Set;
|
||||
|
||||
class Element
|
||||
{
|
||||
public function __construct(
|
||||
private int $id,
|
||||
private string $title,
|
||||
private Set $set,
|
||||
private ?Element $parentElement,
|
||||
) {}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getSet(): Set
|
||||
{
|
||||
return $this->set;
|
||||
}
|
||||
|
||||
public function getParentElement(): ?Element
|
||||
{
|
||||
return $this->parentElement;
|
||||
}
|
||||
}
|
||||
40
backend/app/Element/ElementModel.php
Normal file
40
backend/app/Element/ElementModel.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $set_id
|
||||
* @property string $title
|
||||
* @property int|null $parent_element_id
|
||||
*
|
||||
* @method static Builder<static>|ElementModel newModelQuery()
|
||||
* @method static Builder<static>|ElementModel newQuery()
|
||||
* @method static Builder<static>|ElementModel query()
|
||||
* @method static Builder<static>|ElementModel whereId($value)
|
||||
* @method static Builder<static>|ElementModel whereParentElementId($value)
|
||||
* @method static Builder<static>|ElementModel whereSetId($value)
|
||||
* @method static Builder<static>|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',
|
||||
];
|
||||
}
|
||||
17
backend/app/Element/ElementRepository.php
Normal file
17
backend/app/Element/ElementRepository.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element;
|
||||
|
||||
use App\Set\Set as DomainSet;
|
||||
|
||||
interface ElementRepository
|
||||
{
|
||||
public function create(CreateElementDto $dto): Element;
|
||||
|
||||
public function find(int $id): ?Element;
|
||||
|
||||
/**
|
||||
* @return Element[]
|
||||
*/
|
||||
public function findBySet(DomainSet $set): array;
|
||||
}
|
||||
78
backend/app/Element/EloquentElementRepository.php
Normal file
78
backend/app/Element/EloquentElementRepository.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element;
|
||||
|
||||
use App\Set\Set as DomainSet;
|
||||
use App\Set\SetRepository;
|
||||
use DomainException;
|
||||
|
||||
class EloquentElementRepository implements ElementRepository
|
||||
{
|
||||
public function __construct(private SetRepository $setRepo) {}
|
||||
|
||||
public function create(CreateElementDto $dto): Element
|
||||
{
|
||||
$model = ElementModel::create([
|
||||
'set_id' => $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 findBySet(DomainSet $set): array
|
||||
{
|
||||
$models = ElementModel::where('set_id', $set->getId())
|
||||
->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
85
backend/app/Element/UseCases/CreateElement/CreateElement.php
Normal file
85
backend/app/Element/UseCases/CreateElement/CreateElement.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\CreateElement;
|
||||
|
||||
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;
|
||||
|
||||
class CreateElement
|
||||
{
|
||||
public function __construct(
|
||||
private ElementRepository $elementRepo,
|
||||
private SetRepository $setRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws DomainException
|
||||
*/
|
||||
public function execute(CreateElementRequest $request): Element
|
||||
{
|
||||
if ($request->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($set);
|
||||
|
||||
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(DomainSet $set): void
|
||||
{
|
||||
$elements = $this->elementRepo->findBySet($set);
|
||||
foreach ($elements as $element) {
|
||||
if ($element->getParentElement() === null) {
|
||||
throw new DomainException(
|
||||
'A root element already exists for this set'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\CreateElement;
|
||||
|
||||
class CreateElementRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $setId,
|
||||
public ?string $title,
|
||||
public ?int $parentElementId,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@ namespace App\Providers;
|
|||
|
||||
use App\Auth\EloquentSessionRepository;
|
||||
use App\Auth\SessionRepository;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Element\EloquentElementRepository;
|
||||
use App\Set\EloquentSetRepository;
|
||||
use App\Set\SetRepository;
|
||||
use App\User\EloquentUserRepository;
|
||||
use App\User\UserRepository;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
|
@ -20,5 +24,13 @@ class RepositoryServiceProvider extends ServiceProvider
|
|||
SessionRepository::class,
|
||||
EloquentSessionRepository::class
|
||||
);
|
||||
$this->app->bind(
|
||||
SetRepository::class,
|
||||
EloquentSetRepository::class
|
||||
);
|
||||
$this->app->bind(
|
||||
ElementRepository::class,
|
||||
EloquentElementRepository::class
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
backend/app/Set/CreateSetDto.php
Normal file
10
backend/app/Set/CreateSetDto.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Set;
|
||||
|
||||
class CreateSetDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
) {}
|
||||
}
|
||||
41
backend/app/Set/EloquentSetRepository.php
Normal file
41
backend/app/Set/EloquentSetRepository.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Set;
|
||||
|
||||
class EloquentSetRepository implements SetRepository
|
||||
{
|
||||
public function create(CreateSetDto $dto): Set
|
||||
{
|
||||
$model = SetModel::create([
|
||||
'name' => $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
21
backend/app/Set/Set.php
Normal file
21
backend/app/Set/Set.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Set;
|
||||
|
||||
class Set
|
||||
{
|
||||
public function __construct(
|
||||
private int $id,
|
||||
private string $name,
|
||||
) {}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
27
backend/app/Set/SetModel.php
Normal file
27
backend/app/Set/SetModel.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Set;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
*
|
||||
* @method static Builder<static>|SetModel newModelQuery()
|
||||
* @method static Builder<static>|SetModel newQuery()
|
||||
* @method static Builder<static>|SetModel query()
|
||||
* @method static Builder<static>|SetModel whereId($value)
|
||||
* @method static Builder<static>|SetModel whereName($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class SetModel extends Model
|
||||
{
|
||||
protected $table = 'sets';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
}
|
||||
15
backend/app/Set/SetRepository.php
Normal file
15
backend/app/Set/SetRepository.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Set;
|
||||
|
||||
interface SetRepository
|
||||
{
|
||||
public function create(CreateSetDto $dto): Set;
|
||||
|
||||
public function find(int $id): ?Set;
|
||||
|
||||
/**
|
||||
* @return Set[]
|
||||
*/
|
||||
public function getAll(): array;
|
||||
}
|
||||
21
backend/database/migrations/2026_05_24_000000_sets_table.php
Normal file
21
backend/database/migrations/2026_05_24_000000_sets_table.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sets');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('elements', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
69
backend/tests/Fakes/FakeElementRepository.php
Normal file
69
backend/tests/Fakes/FakeElementRepository.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* @var Element[]
|
||||
*/
|
||||
private array $elementsById = [];
|
||||
|
||||
public function create(CreateElementDto $dto): Element
|
||||
{
|
||||
$id = count($this->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 findBySet(DomainSet $set): array
|
||||
{
|
||||
$elements = [];
|
||||
foreach ($this->elementsById as $element) {
|
||||
if ($element->getSet()->getId() === $set->getId()) {
|
||||
$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,
|
||||
);
|
||||
}
|
||||
}
|
||||
57
backend/tests/Fakes/FakeSetRepository.php
Normal file
57
backend/tests/Fakes/FakeSetRepository.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Fakes;
|
||||
|
||||
use App\Set\CreateSetDto;
|
||||
use App\Set\Set as DomainSet;
|
||||
use App\Set\SetRepository;
|
||||
|
||||
class FakeSetRepository implements SetRepository
|
||||
{
|
||||
/**
|
||||
* @var DomainSet[]
|
||||
*/
|
||||
private array $setsById = [];
|
||||
|
||||
public function create(CreateSetDto $dto): DomainSet
|
||||
{
|
||||
$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
|
||||
{
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
backend/tests/Unit/Element/ElementTest.php
Normal file
33
backend/tests/Unit/Element/ElementTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Element;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Set\Set as DomainSet;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ElementTest extends TestCase
|
||||
{
|
||||
public function testCreatesElementWithNullableParent(): void
|
||||
{
|
||||
$set = new DomainSet(1, 'Daily learning');
|
||||
$rootElement = new Element(
|
||||
id: 1,
|
||||
title: 'Root',
|
||||
set: $set,
|
||||
parentElement: null,
|
||||
);
|
||||
$childElement = new Element(
|
||||
id: 2,
|
||||
title: 'Child',
|
||||
set: $set,
|
||||
parentElement: $rootElement,
|
||||
);
|
||||
|
||||
$this->assertSame(2, $childElement->getId());
|
||||
$this->assertSame('Child', $childElement->getTitle());
|
||||
$this->assertSame($set, $childElement->getSet());
|
||||
$this->assertSame($rootElement, $childElement->getParentElement());
|
||||
$this->assertNull($rootElement->getParentElement());
|
||||
}
|
||||
}
|
||||
183
backend/tests/Unit/Element/UseCases/CreateElementTest.php
Normal file
183
backend/tests/Unit/Element/UseCases/CreateElementTest.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Element\UseCases;
|
||||
|
||||
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 DomainException;
|
||||
use Tests\Fakes\FakeElementRepository;
|
||||
use Tests\Fakes\FakeSetRepository;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateElementTest extends TestCase
|
||||
{
|
||||
private FakeSetRepository $setRepo;
|
||||
|
||||
private FakeElementRepository $elementRepo;
|
||||
|
||||
private CreateElement $createElement;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
17
backend/tests/Unit/Set/SetTest.php
Normal file
17
backend/tests/Unit/Set/SetTest.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Set;
|
||||
|
||||
use App\Set\Set as DomainSet;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SetTest extends TestCase
|
||||
{
|
||||
public function testCreatesSetWithName(): void
|
||||
{
|
||||
$set = new DomainSet(1, 'Daily learning');
|
||||
|
||||
$this->assertSame(1, $set->getId());
|
||||
$this->assertSame('Daily learning', $set->getName());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue