Merge branch 'feature/elements'
This commit is contained in:
commit
936855efab
14 changed files with 728 additions and 1 deletions
|
|
@ -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:
|
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 level of the collection to schedule
|
||||||
* The start date
|
* The start date
|
||||||
* The target completion date
|
* The target completion date
|
||||||
|
|
@ -47,6 +47,10 @@ For example, a user could choose to schedule:
|
||||||
* Smaller sections within each chapter
|
* Smaller sections within each chapter
|
||||||
* Any other available level in the collection structure
|
* 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
|
## Daily Progress
|
||||||
|
|
||||||
Users can view the items scheduled for each day and mark them complete as they finish them.
|
Users can view the items scheduled for each day and mark them complete as they finish them.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ these rules.
|
||||||
- Attainly is in early development.
|
- Attainly is in early development.
|
||||||
- Attainly helps users break hierarchical goals into scheduled assignments,
|
- Attainly helps users break hierarchical goals into scheduled assignments,
|
||||||
complete daily work, and track progress toward a target date.
|
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
|
- Schedule recalculation must preserve completed work while redistributing
|
||||||
unfinished assignments across the remaining dates.
|
unfinished assignments across the remaining dates.
|
||||||
- Planned features in `README.md` are future ideas, not authorized scope.
|
- Planned features in `README.md` are future ideas, not authorized scope.
|
||||||
|
|
|
||||||
15
backend/app/Element/CreateElementDto.php
Normal file
15
backend/app/Element/CreateElementDto.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Element;
|
||||||
|
|
||||||
|
use App\Set\Set;
|
||||||
|
|
||||||
|
final readonly class CreateElementDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public Set $set,
|
||||||
|
public string $name,
|
||||||
|
public string $kind,
|
||||||
|
public ?Element $parentElement,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
47
backend/app/Element/Element.php
Normal file
47
backend/app/Element/Element.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Element;
|
||||||
|
|
||||||
|
use App\Set\Set;
|
||||||
|
|
||||||
|
final readonly class Element
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private int $id,
|
||||||
|
private string $name,
|
||||||
|
private string $kind,
|
||||||
|
private Set $set,
|
||||||
|
private ?Element $parentElement,
|
||||||
|
private int $position,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/app/Element/ElementModel.php
Normal file
47
backend/app/Element/ElementModel.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Element;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $set_id
|
||||||
|
* @property string $name
|
||||||
|
* @property string $kind
|
||||||
|
* @property int|null $parent_element_id
|
||||||
|
* @property int $position
|
||||||
|
*
|
||||||
|
* @method static Builder<static>|ElementModel newModelQuery()
|
||||||
|
* @method static Builder<static>|ElementModel newQuery()
|
||||||
|
* @method static Builder<static>|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<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'set_id' => 'integer',
|
||||||
|
'parent_element_id' => 'integer',
|
||||||
|
'position' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/app/Element/ElementRepository.php
Normal file
26
backend/app/Element/ElementRepository.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Element;
|
||||||
|
|
||||||
|
use App\Set\Set;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
interface ElementRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws DomainException
|
||||||
|
*/
|
||||||
|
public function create(CreateElementDto $dto): Element;
|
||||||
|
|
||||||
|
public function find(int $id): ?Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Element>
|
||||||
|
*/
|
||||||
|
public function findTopLevelBySet(Set $set): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Element>
|
||||||
|
*/
|
||||||
|
public function findByParentElement(Element $parentElement): array;
|
||||||
|
}
|
||||||
171
backend/app/Element/EloquentElementRepository.php
Normal file
171
backend/app/Element/EloquentElementRepository.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Element;
|
||||||
|
|
||||||
|
use App\Set\Set;
|
||||||
|
use App\Set\SetRepository;
|
||||||
|
use DomainException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class EloquentElementRepository implements ElementRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SetRepository $setRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function create(CreateElementDto $dto): Element
|
||||||
|
{
|
||||||
|
$this->validateParentSet($dto);
|
||||||
|
$position = $this->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
): 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 $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,
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,8 @@ use App\Email\Emailer;
|
||||||
use App\Email\EmailFactory;
|
use App\Email\EmailFactory;
|
||||||
use App\Email\LaravelEmailer;
|
use App\Email\LaravelEmailer;
|
||||||
use App\Email\LaravelEmailFactory;
|
use App\Email\LaravelEmailFactory;
|
||||||
|
use App\Element\ElementRepository;
|
||||||
|
use App\Element\EloquentElementRepository;
|
||||||
use App\Set\EloquentSetRepository;
|
use App\Set\EloquentSetRepository;
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use App\User\EloquentUserRepository;
|
use App\User\EloquentUserRepository;
|
||||||
|
|
@ -51,6 +53,10 @@ class AppServiceProvider extends ServiceProvider
|
||||||
SetRepository::class,
|
SetRepository::class,
|
||||||
EloquentSetRepository::class,
|
EloquentSetRepository::class,
|
||||||
);
|
);
|
||||||
|
$this->app->bind(
|
||||||
|
ElementRepository::class,
|
||||||
|
EloquentElementRepository::class,
|
||||||
|
);
|
||||||
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
||||||
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
||||||
$this->app->bind(Clock::class, SystemClock::class);
|
$this->app->bind(Clock::class, SystemClock::class);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?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): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
147
backend/tests/Fakes/FakeElementRepository.php
Normal file
147
backend/tests/Fakes/FakeElementRepository.php
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, Element>
|
||||||
|
*/
|
||||||
|
private array $elements = [];
|
||||||
|
|
||||||
|
public function create(CreateElementDto $dto): Element
|
||||||
|
{
|
||||||
|
$this->validateParentSet($dto);
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Element> $elements
|
||||||
|
* @return list<Element>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
use App\Auth\CreateSessionDto;
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\Clock;
|
||||||
use App\Auth\SessionRepository;
|
use App\Auth\SessionRepository;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
@ -11,6 +12,7 @@ use App\User\UserRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\Fakes\FakeClock;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class LogoutEndpointTest extends TestCase
|
class LogoutEndpointTest extends TestCase
|
||||||
|
|
@ -23,6 +25,7 @@ class LogoutEndpointTest extends TestCase
|
||||||
'2026-07-31T12:00:00',
|
'2026-07-31T12:00:00',
|
||||||
new DateTimeZone('UTC'),
|
new DateTimeZone('UTC'),
|
||||||
);
|
);
|
||||||
|
$this->app->instance(Clock::class, new FakeClock($now));
|
||||||
$user = app(UserRepository::class)->create(new CreateUserDto(
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
passwordHash: 'hashed-password',
|
passwordHash: 'hashed-password',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
use App\Auth\CreateSessionDto;
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\Clock;
|
||||||
use App\Auth\SessionRepository;
|
use App\Auth\SessionRepository;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
@ -11,6 +12,7 @@ use App\User\UserRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\Fakes\FakeClock;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class MeEndpointTest extends TestCase
|
class MeEndpointTest extends TestCase
|
||||||
|
|
@ -23,6 +25,7 @@ class MeEndpointTest extends TestCase
|
||||||
'2026-07-31T12:00:00',
|
'2026-07-31T12:00:00',
|
||||||
new DateTimeZone('UTC'),
|
new DateTimeZone('UTC'),
|
||||||
);
|
);
|
||||||
|
$this->app->instance(Clock::class, new FakeClock($now));
|
||||||
$user = app(UserRepository::class)->create(new CreateUserDto(
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
passwordHash: 'hashed-password',
|
passwordHash: 'hashed-password',
|
||||||
|
|
|
||||||
171
backend/tests/Feature/Element/EloquentElementRepositoryTest.php
Normal file
171
backend/tests/Feature/Element/EloquentElementRepositoryTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Element;
|
||||||
|
|
||||||
|
use App\Element\CreateElementDto;
|
||||||
|
use App\Element\ElementRepository;
|
||||||
|
use App\Set\CreateSetDto;
|
||||||
|
use App\Set\Set;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class EloquentElementRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function testItPersistsNestedElementsInSiblingOrder(): void
|
||||||
|
{
|
||||||
|
$set = $this->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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
email: new EmailAddress(strtolower($name) . '@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
));
|
||||||
|
|
||||||
|
return app(SetRepository::class)->create(new CreateSetDto(
|
||||||
|
name: $name,
|
||||||
|
creator: $creator,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
backend/tests/Unit/Element/ElementTest.php
Normal file
49
backend/tests/Unit/Element/ElementTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Element;
|
||||||
|
|
||||||
|
use App\Element\Element;
|
||||||
|
use App\Set\Set;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ElementTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItExposesItsHierarchicalIdentity(): void
|
||||||
|
{
|
||||||
|
$creator = new User(
|
||||||
|
id: 7,
|
||||||
|
email: new EmailAddress('creator@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
);
|
||||||
|
$set = new Set(
|
||||||
|
id: 11,
|
||||||
|
name: 'Bible',
|
||||||
|
creator: $creator,
|
||||||
|
);
|
||||||
|
$parentElement = new Element(
|
||||||
|
id: 21,
|
||||||
|
name: 'Genesis',
|
||||||
|
kind: 'book',
|
||||||
|
set: $set,
|
||||||
|
parentElement: null,
|
||||||
|
position: 1,
|
||||||
|
);
|
||||||
|
$element = new Element(
|
||||||
|
id: 22,
|
||||||
|
name: 'Genesis',
|
||||||
|
kind: 'portion',
|
||||||
|
set: $set,
|
||||||
|
parentElement: $parentElement,
|
||||||
|
position: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue