diff --git a/backend/app/Post/EloquentPostRepository.php b/backend/app/Post/EloquentPostRepository.php new file mode 100644 index 0000000..b8c90b5 --- /dev/null +++ b/backend/app/Post/EloquentPostRepository.php @@ -0,0 +1,83 @@ + $dto->userId, + 'title' => $dto->title, + 'body' => $dto->body, + 'created_at' => $dto->createdAt, + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?Post + { + $model = PostModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @return Post[] + */ + public function findByUserId(int $userId): array + { + $models = PostModel::query() + ->where('user_id', $userId) + ->orderBy('created_at', 'desc') + ->get(); + + return $models->map( + function (PostModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + + /** + * @return Post[] + */ + public function findRecent(int $limit): array + { + $models = PostModel::query() + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + return $models->map( + function (PostModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + + public function delete(int $id): void + { + PostModel::query()->where('id', $id)->delete(); + } + + private function toDomain(PostModel $model): Post + { + $utc = new DateTimeZone('UTC'); + + return new Post( + id: $model->id, + userId: $model->user_id, + title: $model->title, + body: $model->body, + createdAt: new DateTimeImmutable( + $model->created_at->toDateTimeString(), + $utc, + ), + ); + } +} diff --git a/backend/app/Post/PostModel.php b/backend/app/Post/PostModel.php new file mode 100644 index 0000000..357347b --- /dev/null +++ b/backend/app/Post/PostModel.php @@ -0,0 +1,40 @@ +|PostModel newModelQuery() + * @method static Builder|PostModel newQuery() + * @method static Builder|PostModel query() + * @method static Builder|PostModel whereId($value) + * @method static Builder|PostModel whereUserId($value) + * + * @mixin \Eloquent + */ +class PostModel extends Model +{ + protected $table = 'posts'; + + public $timestamps = false; + + protected $fillable = [ + 'user_id', + 'title', + 'body', + 'created_at', + ]; + + protected $casts = [ + 'created_at' => 'datetime', + ]; +} diff --git a/backend/database/migrations/2026_05_06_000002_create_posts_table.php b/backend/database/migrations/2026_05_06_000002_create_posts_table.php new file mode 100644 index 0000000..da968a1 --- /dev/null +++ b/backend/database/migrations/2026_05_06_000002_create_posts_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('title'); + $table->text('body'); + $table->dateTime('created_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/backend/phpstan.neon b/backend/phpstan.neon index 0ef48ce..c2504d4 100644 --- a/backend/phpstan.neon +++ b/backend/phpstan.neon @@ -7,6 +7,15 @@ parameters: treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: false + ignoreErrors: + # Eloquent's fluent builder triggers staticMethod.dynamicCall in + # phpstan-strict-rules because Builder methods declared static on + # the model become instance calls after the first chain link. This + # is the documented Laravel idiom; suppress the false positive. + - + identifier: staticMethod.dynamicCall + path: app/*/Eloquent*Repository.php + paths: - app diff --git a/backend/tests/Fakes/FakePostRepository.php b/backend/tests/Fakes/FakePostRepository.php new file mode 100644 index 0000000..ed64137 --- /dev/null +++ b/backend/tests/Fakes/FakePostRepository.php @@ -0,0 +1,103 @@ +getNextId(); + $post = new Post( + id: $id, + userId: $dto->userId, + title: $dto->title, + body: $dto->body, + createdAt: $dto->createdAt, + ); + $this->existingPosts[$id] = $post; + + return $post; + } + + public function find(int $id): ?Post + { + $post = $this->existingPosts[$id] ?? null; + if ($post === null) { + return null; + } + + return $this->copy($post); + } + + /** + * @return Post[] + */ + public function findByUserId(int $userId): array + { + $matching = []; + foreach ($this->existingPosts as $post) { + if ($post->getUserId() === $userId) { + $matching[] = $this->copy($post); + } + } + usort( + $matching, + function (Post $left, Post $right) { + return $right->getCreatedAt() <=> $left->getCreatedAt(); + }, + ); + + return $matching; + } + + /** + * @return Post[] + */ + public function findRecent(int $limit): array + { + $all = array_map( + function (Post $post) { + return $this->copy($post); + }, + array_values($this->existingPosts), + ); + usort( + $all, + function (Post $left, Post $right) { + return $right->getCreatedAt() <=> $left->getCreatedAt(); + }, + ); + + return array_slice($all, 0, $limit); + } + + public function delete(int $id): void + { + unset($this->existingPosts[$id]); + } + + private function copy(Post $post): Post + { + return new Post( + id: $post->getId(), + userId: $post->getUserId(), + title: $post->getTitle(), + body: $post->getBody(), + createdAt: $post->getCreatedAt(), + ); + } + + private function getNextId(): int + { + return count($this->existingPosts) + 1; + } +}