Compare commits
No commits in common. "ff1bea92c540dc4f66b2f6362911080819df9791" and "104909bcf507a183d2d44dc88049c74721f90f28" have entirely different histories.
ff1bea92c5
...
104909bcf5
22 changed files with 14 additions and 864 deletions
|
|
@ -15,14 +15,11 @@ intentionally omitted here - update this section as entities land.
|
||||||
|
|
||||||
- Look at similar entities for reference before writing anything new
|
- Look at similar entities for reference before writing anything new
|
||||||
- Entities: constructor with properties, getters
|
- Entities: constructor with properties, getters
|
||||||
- DTOs: simple data containers for creation (e.g. `CreateElementDto`)
|
- DTOs: simple data containers for creation (e.g. `CreateSetDto`)
|
||||||
- Repositories: interfaces that define data access
|
- Repositories: interfaces that define data access
|
||||||
- Do not write unit tests for concrete repository implementations
|
- Do not write unit tests for concrete repository implementations
|
||||||
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
|
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
|
||||||
Use cases are tested with fake repositories.
|
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
|
- Use cases: business logic with Request objects
|
||||||
- When throwing exceptions, add `@throws` docblock
|
- When throwing exceptions, add `@throws` docblock
|
||||||
- Fakes: in-memory implementations for testing
|
- Fakes: in-memory implementations for testing
|
||||||
|
|
@ -53,33 +50,16 @@ to be added to the repo; the Nix flake already provides
|
||||||
Constructs LLMs default to that this project forbids. Name the trap
|
Constructs LLMs default to that this project forbids. Name the trap
|
||||||
explicitly so you catch yourself before writing it.
|
explicitly so you catch yourself before writing it.
|
||||||
|
|
||||||
- Arrow function:
|
| Anti-pattern | Forbidden | Required |
|
||||||
- Forbidden: `fn ($node) => $node->getId()`
|
|---|---|---|
|
||||||
- Required: `function ($node) { return $node->getId(); }`
|
| Arrow function | `fn ($x) => $x->getId()` | `function ($x) { return $x->getId(); }` |
|
||||||
- Inline FQCN type:
|
| Inline FQCN type | `function f(): \Psr\Http\Message\ResponseInterface` | `use Psr\Http\Message\ResponseInterface;` then `function f(): ResponseInterface` |
|
||||||
- Forbidden: `function f(): \Psr\Http\Message\ResponseInterface`
|
| Inline `::class` | `Container::get(\App\Foo\Bar::class)` | `use App\Foo\Bar;` then `Container::get(Bar::class)` |
|
||||||
- Required: import `ResponseInterface`, then return `ResponseInterface`
|
| Default param | `function f(int $count = 10)` | `function f(int $count)` |
|
||||||
- Inline `::class`:
|
| Default in fake | `public function create(Dto $dto, bool $strict = true)` | no default; every caller passes a value |
|
||||||
- Forbidden: `Container::get(\App\Foo\Bar::class)`
|
| Lookup returns stored ref | `return $this->items[$id] ?? null;` | rebuild a new instance with the stored fields |
|
||||||
- Required: import `Bar`, then call `Container::get(Bar::class)`
|
| Short variable name | `$t`, `$n`, `$res`, `$req`, `$e` | `$text`, `$node`, `$response`, `$request`, `$exception` |
|
||||||
- Default param:
|
| Em dash | `// fetches user — by email` | `// fetches user - by email` |
|
||||||
- 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
|
When generating code, scan the diff for these patterns before writing it
|
||||||
to disk. If you catch one mid-write, rewrite that line.
|
to disk. If you catch one mid-write, rewrite that line.
|
||||||
|
|
|
||||||
24
ai/shared.md
24
ai/shared.md
|
|
@ -16,9 +16,7 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
|
||||||
4. Implement the code to make the test pass
|
4. Implement the code to make the test pass
|
||||||
5. Run the test to confirm it passes
|
5. Run the test to confirm it passes
|
||||||
6. Commit the implementation
|
6. Commit the implementation
|
||||||
7. Repeat this red/green commit cycle for each new behavior. Do not
|
7. Repeat for each new behavior
|
||||||
batch multiple behaviors into one failing-test commit and one
|
|
||||||
implementation commit when they can be reviewed separately.
|
|
||||||
|
|
||||||
## Code style
|
## Code style
|
||||||
|
|
||||||
|
|
@ -34,8 +32,8 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
|
||||||
and fakes - if a helper accepts a value, every caller must supply it.
|
and fakes - if a helper accepts a value, every caller must supply it.
|
||||||
- First, explore the codebase to understand existing patterns - look at similar
|
- First, explore the codebase to understand existing patterns - look at similar
|
||||||
files for reference before writing anything
|
files for reference before writing anything
|
||||||
- Never use em dash characters in code, comments, or docblocks - use
|
- Never use em dashes (—) in code, comments, or docblocks - use hyphens (-)
|
||||||
hyphens (-) instead
|
instead
|
||||||
|
|
||||||
## Git commit style
|
## Git commit style
|
||||||
|
|
||||||
|
|
@ -53,21 +51,9 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
|
||||||
## Git commits
|
## Git commits
|
||||||
|
|
||||||
- Tests should be committed first, before implementation
|
- 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
|
- 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,
|
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)
|
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
|
- Keep commits focused: not one file per commit, not unrelated work batched
|
||||||
- Make commits frequent - commit each meaningful logical step as you go
|
- Make commits frequent - commit each meaningful logical step as you go
|
||||||
- Commits are for reviewing and documenting the development of code
|
- Commits are for reviewing and documenting the development of code
|
||||||
|
|
@ -97,8 +83,6 @@ mechanical, not aspirational - a "yes" to all is required.
|
||||||
- [ ] On a feature branch (not master/main).
|
- [ ] On a feature branch (not master/main).
|
||||||
- [ ] This commit is one logical change. If it spans unrelated changes,
|
- [ ] This commit is one logical change. If it spans unrelated changes,
|
||||||
stop and split it.
|
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
|
- [ ] Tests for new behavior were committed BEFORE this implementation
|
||||||
(or this commit IS the failing-test commit).
|
(or this commit IS the failing-test commit).
|
||||||
|
|
||||||
|
|
@ -111,8 +95,6 @@ mechanical, not aspirational - a "yes" to all is required.
|
||||||
(PHP or TS).
|
(PHP or TS).
|
||||||
- [ ] Find/lookup repository methods return new instances, not stored
|
- [ ] Find/lookup repository methods return new instances, not stored
|
||||||
references.
|
references.
|
||||||
- [ ] Backend repository methods that find by a foreign key accept the
|
|
||||||
related entity, not a raw id.
|
|
||||||
- [ ] No em dashes (use hyphens).
|
- [ ] No em dashes (use hyphens).
|
||||||
- [ ] Variable names are explicit (no `$t`, `n`, `res`, `req`, `e`, etc.).
|
- [ ] Variable names are explicit (no `$t`, `n`, `res`, `req`, `e`, etc.).
|
||||||
- [ ] No `any` in TypeScript - use concrete types or `unknown` with a
|
- [ ] No `any` in TypeScript - use concrete types or `unknown` with a
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element;
|
|
||||||
|
|
||||||
use App\Set\Set;
|
|
||||||
|
|
||||||
class CreateElementDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public Set $set,
|
|
||||||
public string $title,
|
|
||||||
public ?Element $parentElement,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
<?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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<?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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\CreateElement;
|
|
||||||
|
|
||||||
class CreateElementRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $setId,
|
|
||||||
public ?string $title,
|
|
||||||
public ?int $parentElementId,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -4,10 +4,6 @@ namespace App\Providers;
|
||||||
|
|
||||||
use App\Auth\EloquentSessionRepository;
|
use App\Auth\EloquentSessionRepository;
|
||||||
use App\Auth\SessionRepository;
|
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\EloquentUserRepository;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
@ -24,13 +20,5 @@ class RepositoryServiceProvider extends ServiceProvider
|
||||||
SessionRepository::class,
|
SessionRepository::class,
|
||||||
EloquentSessionRepository::class
|
EloquentSessionRepository::class
|
||||||
);
|
);
|
||||||
$this->app->bind(
|
|
||||||
SetRepository::class,
|
|
||||||
EloquentSetRepository::class
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
|
||||||
ElementRepository::class,
|
|
||||||
EloquentElementRepository::class
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
class CreateSetDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $name,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?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'];
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
interface SetRepository
|
|
||||||
{
|
|
||||||
public function create(CreateSetDto $dto): Set;
|
|
||||||
|
|
||||||
public function find(int $id): ?Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Set[]
|
|
||||||
*/
|
|
||||||
public function getAll(): array;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<?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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
<?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(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?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