+ */
+ public function toArray(): array
+ {
+ return array_map(
+ fn(PostEntity $post) => [
+ 'id' => $post->getId()?->getValue(),
+ 'userId' => $post->getUserId()->getValue(),
+ 'content' => $post->getContent(),
+ 'mediaPath' => $post->getMediaPath(),
+ 'visibility' => $post->getPostVisibility()->getValue()
+ ],
+ $this->posts
+ );
+ }
+
+ /**
+ * @return PostEntity[]
+ */
+ public function getPosts(): array
+ {
+ return $this->posts;
+ }
+
+
+ /**
+ * @param PostEntity $post
+ */
+ public function addPost(PostEntity $post): void
+ {
+ $this->posts[] = $post;
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Domain/EntityFactory/PostFromModelEntityFactory.php b/src/app/Post/Domain/EntityFactory/PostFromModelEntityFactory.php
index 1713e4a..7d1063b 100644
--- a/src/app/Post/Domain/EntityFactory/PostFromModelEntityFactory.php
+++ b/src/app/Post/Domain/EntityFactory/PostFromModelEntityFactory.php
@@ -18,7 +18,8 @@ public static function build(array $request): PostEntity
'content' => $request['content'],
'mediaPath' => $request['media_path'] ?? null,
'visibility' => new PostVisibility(
- $request['visibility']),
+ PostVisibilityEnum::from((int)($request['visibility'] ?? PostVisibilityEnum::PUBLIC))
+ ),
]);
}
}
\ No newline at end of file
diff --git a/src/app/Post/Infrastructure/InfrastructureTest/GetAllUserPostQueryServiceTest.php b/src/app/Post/Infrastructure/InfrastructureTest/GetAllUserPostQueryServiceTest.php
new file mode 100644
index 0000000..104637c
--- /dev/null
+++ b/src/app/Post/Infrastructure/InfrastructureTest/GetAllUserPostQueryServiceTest.php
@@ -0,0 +1,151 @@
+refresh();
+ $this->user = new User();
+ User::create([
+ 'first_name' => 'Sergio',
+ 'last_name' => 'Ramos',
+ 'email' => 'real-madrid15@test.com',
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ]);
+
+ $this->post = new Post();
+ $this->createDummyPosts();
+
+ $this->queryService = new GetPostQueryService(
+ $this->post,
+ $this->user
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ Post::truncate();
+ User::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createDummyPosts(): void
+ {
+ $posts = [];
+ $baseTime = Carbon::now();
+
+ for ($i = 1; $i <= 50; $i++) {
+ $time = $baseTime->copy()->addSeconds($i);
+
+ $posts[] = [
+ 'user_id' => $this->user->id,
+ 'content' => "ダミーポスト{$i}",
+ 'media_path' => $i % 2 === 0 ? null : "https://example.com/image{$i}.jpg",
+ 'visibility' => $i % 2 === 0 ? 1 : 0,
+ 'created_at' => $time,
+ 'updated_at' => $time,
+ ];
+ }
+
+ Post::insert($posts);
+ }
+
+ public function test_check_pagination_type(): void
+ {
+ $result = $this->queryService->getAllUserPosts(
+ $this->user->id,
+ $this->perPage,
+ $this->currentPage
+ );
+
+ $this->assertInstanceOf(
+ Pagination::class,
+ $result
+ );
+ }
+
+ public function test_check_pagination_data_value(): void
+ {
+ $result = $this->queryService->getAllUserPosts(
+ $this->user->id,
+ $this->perPage,
+ $this->currentPage
+ );
+
+ foreach ($result->getData() as $post) {
+ $this->assertInstanceOf(
+ PostEntity::class,
+ $post
+ );
+ }
+ }
+
+ public function test_check_pagination_current_page_value(): void
+ {
+ $result = $this->queryService->getAllUserPosts(
+ $this->user->id,
+ $this->perPage,
+ $this->currentPage = 30
+ );
+
+ $this->assertEquals(
+ $this->currentPage,
+ $result->getCurrentPage()
+ );
+ }
+
+ public function test_check_pagination_per_page_value(): void
+ {
+ $result = $this->queryService->getAllUserPosts(
+ $this->user->id,
+ $this->perPage = 20,
+ $this->currentPage
+ );
+
+ $this->assertEquals(
+ $this->perPage,
+ $result->getPerPage()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Infrastructure/InfrastructureTest/GetEachPostQueryServiceTest.php b/src/app/Post/Infrastructure/InfrastructureTest/GetEachPostQueryServiceTest.php
new file mode 100644
index 0000000..d74c1a8
--- /dev/null
+++ b/src/app/Post/Infrastructure/InfrastructureTest/GetEachPostQueryServiceTest.php
@@ -0,0 +1,148 @@
+refresh();
+ $this->user = User::create(([
+ 'first_name' => 'Sergio',
+ 'last_name' => 'Ramos',
+ 'email' => 'real-madrid15@test.com',
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ]));
+
+ $this->post = $this->createPostData();
+ $this->queryService = new GetPostQueryService(
+ $this->post,
+ $this->user
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function createPostData(): Post
+ {
+ $post = Post::create([
+ 'user_id' => $this->user->id,
+ 'content' => 'Cristiano Ronaldo is a legendary footballer known for his incredible skills and goal-scoring ability.',
+ 'media_path' => 'https://example.com/media.jpg',
+ 'visibility' => 'public',
+ ]);
+
+ return $post;
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ Post::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function mockEntity(): PostEntity
+ {
+ $mock = Mockery::mock(PostEntity::class);
+
+ $mock
+ ->shouldReceive('getId')
+ ->andReturn(new PostId($this->post->id));
+
+ $mock
+ ->shouldReceive('getUserId')
+ ->andReturn(new UserId($this->user->id));
+
+ $mock
+ ->shouldReceive('getContent')
+ ->andReturn($this->createPostData()['content']);
+
+ $mock
+ ->shouldReceive('getMediaPath')
+ ->andReturn($this->createPostData()['media_path']);
+
+ $mock
+ ->shouldReceive('getPostVisibility')
+ ->andReturn(new Postvisibility(
+ PostVisibilityEnum::from($this->createPostData()['visibility'])
+ ));
+
+ return $mock;
+ }
+
+ public function test_check_method_return_type(): void
+ {
+ $result = $this->queryService->getEachUserPost(
+ new UserId($this->user->id),
+ new PostId($this->post->id)
+ );
+
+ $this->assertInstanceOf(
+ PostEntity::class,
+ $result
+ );
+ }
+
+ public function test_check_method_return_value(): void
+ {
+ $result = $this->queryService->getEachUserPost(
+ new UserId($this->user->id),
+ new PostId($this->post->id)
+ );
+
+ $this->assertEquals(
+ $this->mockEntity()->getId(),
+ $result->getId()
+ );
+
+ $this->assertEquals(
+ $this->mockEntity()->getUserId(),
+ $result->getUserId()
+ );
+
+ $this->assertEquals(
+ $this->mockEntity()->getContent(),
+ $result->getContent()
+ );
+
+ $this->assertEquals(
+ $this->mockEntity()->getMediaPath(),
+ $result->getMediaPath()
+ );
+
+ $this->assertEquals(
+ $this->mockEntity()->getPostVisibility(),
+ $result->getPostVisibility()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Infrastructure/InfrastructureTest/GetOthersAllPostsQueryServiceTest.php b/src/app/Post/Infrastructure/InfrastructureTest/GetOthersAllPostsQueryServiceTest.php
new file mode 100644
index 0000000..5796440
--- /dev/null
+++ b/src/app/Post/Infrastructure/InfrastructureTest/GetOthersAllPostsQueryServiceTest.php
@@ -0,0 +1,120 @@
+refresh();
+ $this->user = $this->createUsersWithPosts();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ Post::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUsersWithPosts(): User
+ {
+ $users = [];
+
+ for ($i = 1; $i <= 3; $i++) {
+ $user = User::create([
+ 'first_name' => "User{$i}",
+ 'last_name' => "Test{$i}",
+ 'email' => "user{$i}@example.com",
+ 'password' => bcrypt('password123'),
+ 'bio' => "This is user {$i}",
+ 'location' => "City{$i}",
+ 'skills' => ['Laravel', 'Vue'],
+ 'profile_image' => 'https://example.com/user.jpg',
+ ]);
+
+ $visibility = $i % 2 === 0 ? 0 : 1;
+ for ($j = 1; $j <= 20; $j++) {
+ Post::create([
+ 'user_id' => $user->id,
+ 'content' => "Post {$j} by User{$i}",
+ 'media_path' => null,
+ 'visibility' => $visibility,
+ 'created_at' => now()->subMinutes(rand(0, 1000)),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ $users[] = $user;
+ }
+
+ return $users[0];
+ }
+
+ public function test_check_query_service_return_type(): void
+ {
+ $queryService = new GetPostQueryService(
+ new Post(),
+ new User(),
+ );
+
+ $result = $queryService->getOthersAllPosts(
+ $this->user->id,
+ $this->perPage,
+ $this->currentPage,
+ );
+
+ $this->assertInstanceOf(
+ PaginationDto::class,
+ $result
+ );
+ }
+
+ public function test_check_query_service_return_value(): void
+ {
+ $queryService = new GetPostQueryService(
+ new Post(),
+ new User(),
+ );
+
+ $result = $queryService->getOthersAllPosts(
+ $this->user->id,
+ $this->perPage,
+ $this->currentPage,
+ );
+
+ $this->assertEquals($this->currentPage, $result->getCurrentPage());
+ $this->assertEquals($this->perPage, $result->getPerPage());
+ foreach ($result->getData() as $post) {
+ $this->assertInstanceOf(
+ PostEntity::class,
+ $post
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Infrastructure/InfrastructureTest/PostRepositoryTest.php b/src/app/Post/Infrastructure/InfrastructureTest/PostRepository_addTest.php
similarity index 100%
rename from src/app/Post/Infrastructure/InfrastructureTest/PostRepositoryTest.php
rename to src/app/Post/Infrastructure/InfrastructureTest/PostRepository_addTest.php
diff --git a/src/app/Post/Infrastructure/QueryService/GetPostQueryService.php b/src/app/Post/Infrastructure/QueryService/GetPostQueryService.php
new file mode 100644
index 0000000..737019a
--- /dev/null
+++ b/src/app/Post/Infrastructure/QueryService/GetPostQueryService.php
@@ -0,0 +1,116 @@
+exists()) {
+ throw new ErrorException($userId);
+ }
+
+ $userPosts = $this->post
+ ->where('user_id', $userId)
+ ->orderBy('created_at', 'desc')
+ ->paginate(
+ $perPage,
+ ['*'],
+ 'page',
+ $currentPage
+ );
+
+
+
+ return $this->paginationDto($userPosts->toArray());
+ }
+
+ public function getEachUserPost(
+ UserId $userId,
+ PostId $postId
+ ): ?PostEntity
+ {
+ $post = $this->post
+ ->where('user_id', $userId->getValue())
+ ->where('id', $postId->getValue())
+ ->first();
+
+ if (!$post) {
+ return null;
+ }
+
+ return PostFromModelEntityFactory::build($post->toArray());
+ }
+
+ private function paginationDto(
+ array $data
+ ): PaginationDto
+ {
+ $dtos = array_map(
+ fn($item) => GetUserEachPostDto::buildFromEntity(
+ PostFromModelEntityFactory::build($item)
+ ),
+ $data['data']
+ );
+
+ return new PaginationDto(
+ $dtos,
+ currentPage: $data['current_page'] ?? null,
+ from: $data['from'] ?? null,
+ to: $data['to'] ?? null,
+ perPage: $data['per_page'] ?? null,
+ path: $data['path'] ?? null,
+ lastPage: $data['last_page'] ?? null,
+ total: $data['total'] ?? null,
+ firstPageUrl: $data['first_page_url'] ?? null,
+ lastPageUrl: $data['last_page_url'] ?? null,
+ nextPageUrl: $data['next_page_url'] ?? null,
+ prevPageUrl: $data['prev_page_url'] ?? null,
+ links: $data['links'] ?? null
+ );
+ }
+
+ public function getOthersAllPosts(
+ int $userId,
+ int $perPage,
+ int $currentPage
+ ): ?PaginationDto {
+
+ $getPosts = $this->post
+ ->where('user_id', '!=', $userId)
+ ->where('visibility', PostVisibilityEnum::PUBLIC->value)
+ ->paginate(
+ $perPage,
+ ['*'],
+ 'page',
+ $currentPage
+ );
+
+ if (empty($getPosts)) {
+ return null;
+ }
+
+ return $this->paginationDto($getPosts->toArray());
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Infrastructure/Repository/PostRepository.php b/src/app/Post/Infrastructure/Repository/PostRepository.php
index 6ec10bb..321db2d 100644
--- a/src/app/Post/Infrastructure/Repository/PostRepository.php
+++ b/src/app/Post/Infrastructure/Repository/PostRepository.php
@@ -39,6 +39,7 @@ public function editById(PostEntity $entity): PostEntity
$targetPost->fill([
'content' => $entity->getContent(),
+ 'user_id' => $entity->getUserId()->getValue(),
'media_path' => $entity->getMediaPath(),
'visibility' => $entity->getPostVisibility()->getValue(),
])->save();
diff --git a/src/app/Post/Presentation/Controller/PostController.php b/src/app/Post/Presentation/Controller/PostController.php
index c8d066a..c37bf9d 100644
--- a/src/app/Post/Presentation/Controller/PostController.php
+++ b/src/app/Post/Presentation/Controller/PostController.php
@@ -4,12 +4,21 @@
use App\Http\Controllers\Controller;
use App\Post\Application\UseCase\CreateUseCase;
+use App\Post\Application\UseCase\GetOthersAllPostsUseCase;
+use App\Post\Application\UseCase\GetUserEachPostUseCase;
use App\Post\Presentation\ViewModel\CreatePostViewModel;
use App\Post\Application\UseCommand\CreatePostUseCommand;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Throwable;
+use App\Post\Application\UseCase\GetAllUserPostUseCase;
+use App\Common\Presentation\ViewModelFactory\PaginationFactory as PaginationViewModelFactory;
+use App\Post\Application\Dto\GetUserEachPostDto;
+use App\Post\Presentation\ViewModel\GetAllUserPostViewModel;
+use App\Post\Application\UseCase\EditUseCase;
+use App\Post\Application\UseCommand\EditPostUseCommand;
+use App\Post\Presentation\ViewModel\EditPostViewModel;
class PostController extends Controller
{
@@ -45,4 +54,146 @@ public function create(
], 500);
}
}
+
+ public function getAllPosts(
+ int $userId,
+ Request $request,
+ GetAllUserPostUseCase $useCase
+ ): JsonResponse
+ {
+ try {
+ $perPage = $request->query('per_page', 15);
+ $currentPage = $request->query('current_page', 1);
+
+ $dto = $useCase->handle(
+ userId: $userId,
+ perPage: $perPage,
+ currentPage: $currentPage
+ );
+
+ $viewModels = array_map(
+ fn(GetUserEachPostDto $dto) => GetAllUserPostViewModel::build($dto)->toArray(),
+ $dto->getData()
+ );
+
+ $paginationViewModel = PaginationViewModelFactory::build(
+ $dto,
+ $viewModels
+ )->toArray();
+
+ return response()->json([
+ 'status' => 'success',
+ 'data' => $paginationViewModel['data'],
+ 'meta' => $paginationViewModel['meta'],
+ ]);
+
+ } catch (Throwable $e) {
+ return response()->json([
+ 'status' => 'error',
+ 'message' => $e->getMessage(),
+ ], 500);
+ }
+ }
+
+ public function getEachPost(
+ int $userId,
+ int $postId,
+ GetUserEachPostUseCase $useCase
+ ): JsonResponse
+ {
+ try {
+ $dto = $useCase->handle(
+ userId: $userId,
+ postId: $postId
+ );
+
+ $viewModelArray = GetAllUserPostViewModel::build($dto)->toArray();
+
+ return response()->json([
+ 'status' => 'success',
+ 'data' => $viewModelArray,
+ ], 200);
+
+ } catch (Throwable $e) {
+ return response()->json([
+ 'status' => 'error',
+ 'message' => $e->getMessage(),
+ ], 500);
+ }
+ }
+
+ public function edit(
+ Request $request,
+ int $userId,
+ int $postId,
+ EditUseCase $useCase
+ ): JsonResponse {
+ DB::connection('mysql')->beginTransaction();
+ try {
+ $command = EditPostUseCommand::build(
+ array_merge(
+ $request->toArray(),
+ ['userId' => $userId],
+ ['id' => $postId]
+ )
+ );
+
+ $dto = $useCase->handle($command);
+ $viewModel = new EditPostViewModel($dto);
+
+ DB::connection('mysql')->commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'data' => $viewModel->toArray(),
+ ], 200);
+ } catch (Throwable $e) {
+ DB::connection('mysql')->rollBack();
+ return response()->json([
+ 'status' => 'error',
+ 'message' => $e->getMessage(),
+ ], 500);
+ }
+ }
+
+ public function getOthersPosts(
+ Request $request,
+ int $userId,
+ GetOthersAllPostsUseCase $useCase
+ ): JsonResponse
+ {
+ try {
+ $userId = $request->route('userId', $userId);
+ $perPage = $request->get('per_page', 15);
+ $currentPage = $request->get('current_page', 1);
+
+ $data = $useCase->handle(
+ userId: $userId,
+ perPage: $perPage,
+ currentPage: $currentPage
+ );
+
+ $viewModels = array_map(
+ fn(GetUserEachPostDto $dto) => GetAllUserPostViewModel::build($dto)->toArray(),
+ $data->getData()
+ );
+
+ $paginationViewModel = PaginationViewModelFactory::build(
+ $data,
+ $viewModels
+ )->toArray();
+
+ return response()->json([
+ 'status' => 'success',
+ 'data' => $paginationViewModel['data'],
+ 'meta' => $paginationViewModel['meta'],
+ ], 200);
+
+ } catch (Throwable $e) {
+ return response()->json([
+ 'status' => 'error',
+ 'message' => $e->getMessage(),
+ ], 500);
+ }
+ }
}
diff --git a/src/app/Post/Presentation/PresentationTest/Controller/PostController_editTest.php b/src/app/Post/Presentation/PresentationTest/Controller/PostController_editTest.php
new file mode 100644
index 0000000..4bfff27
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/Controller/PostController_editTest.php
@@ -0,0 +1,137 @@
+controller = new PostController();
+ $this->userId = 1;
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockUseCommand(): EditPostUseCommand
+ {
+ $command = Mockery::mock(EditPostUseCommand::class);
+
+ $command
+ ->shouldReceive('getId')
+ ->andReturn($this->arrayData()['id']);
+
+ $command
+ ->shouldReceive('getUserId')
+ ->andReturn($this->arrayData()['userId']);
+
+ $command
+ ->shouldReceive('getContent')
+ ->andReturn($this->arrayData()['content']);
+
+ $command
+ ->shouldReceive('getMediaPath')
+ ->andReturn($this->arrayData()['mediaPath']);
+
+ $command
+ ->shouldReceive('getVisibility')
+ ->andReturn(new Postvisibility(PostVisibilityEnum::fromString($this->arrayData()['visibility'])));
+
+ $command
+ ->shouldReceive('toArray')
+ ->andReturn($this->arrayData());
+
+ return $command;
+ }
+
+ private function mockDto(): EditPostDto
+ {
+ $dto = Mockery::mock(EditPostDto::class);
+
+ $dto
+ ->shouldReceive('getId')
+ ->andReturn(new PostId($this->arrayData()['id']));
+
+ $dto
+ ->shouldReceive('getUserId')
+ ->andReturn(new UserId(1));
+
+ $dto
+ ->shouldReceive('getContent')
+ ->andReturn($this->arrayData()['content']);
+
+ $dto
+ ->shouldReceive('getMediaPath')
+ ->andReturn($this->arrayData()['mediaPath']);
+
+ $dto
+ ->shouldReceive('getVisibility')
+ ->andReturn(new Postvisibility(PostVisibilityEnum::fromString($this->arrayData()['visibility'])));
+
+ return $dto;
+ }
+
+ private function mockUseCase(): EditUseCase
+ {
+ $useCase = Mockery::mock(EditUseCase::class);
+
+ $useCase
+ ->shouldReceive('handle')
+ ->with(Mockery::type(EditPostUseCommand::class))
+ ->andReturn($this->mockDto());
+
+ return $useCase;
+ }
+
+ private function arrayData(): array
+ {
+ return [
+ 'id' => 1,
+ 'userId' => $this->userId,
+ 'content' => 'Updated content',
+ 'mediaPath' => 'https://example.com/updated_media.jpg',
+ 'visibility' => 'public',
+ ];
+ }
+
+ private function mockRequest(): Request
+ {
+ $request = Mockery::mock(Request::class);
+
+ $request
+ ->shouldReceive('all')
+ ->andReturn($this->arrayData());
+
+ return $request;
+ }
+
+ public function test_controller(): void
+ {
+ $result = $this->controller->edit(
+ $this->mockRequest(),
+ $this->userId,
+ $this->arrayData()['id'],
+ $this->mockUseCase()
+ );
+
+ $this->assertInstanceOf(JsonResponse::class, $result);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/PresentationTest/Controller/PostController_getAllUserPostTest.php b/src/app/Post/Presentation/PresentationTest/Controller/PostController_getAllUserPostTest.php
new file mode 100644
index 0000000..f754044
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/Controller/PostController_getAllUserPostTest.php
@@ -0,0 +1,115 @@
+controller = new PostController();
+ $this->userId = 1;
+ $this->perPage = 10;
+ $this->currentPage = 1;
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockUseCase(): GetAllUserPostUseCase
+ {
+ $mock = Mockery::mock(GetAllUserPostUseCase::class);
+
+ $mock
+ ->shouldReceive('handle')
+ ->with(
+ Mockery::type('int'),
+ Mockery::type('int'),
+ Mockery::type('int')
+ )
+ ->andReturn($this->mockPaginationDto());
+
+ return $mock;
+ }
+
+ private function mockPaginationDto(): PaginationDto
+ {
+ $mock = Mockery::mock(PaginationDto::class);
+
+ $mock
+ ->shouldReceive('getCurrentPage')
+ ->andReturn($this->currentPage);
+
+ $mock
+ ->shouldReceive('getPerPage')
+ ->andReturn($this->perPage);
+
+ $mock
+ ->shouldReceive('getData')
+ ->andReturn([
+ $this->mockViewModel(),
+ ]);
+
+ return $mock;
+ }
+
+ private function mockViewModel(): GetAllUserPostViewModel
+ {
+ $mock = Mockery::mock(GetAllUserPostViewModel::class);
+
+ $mock
+ ->shouldReceive('build')
+ ->with(Mockery::type(PaginationDto::class))
+ ->andReturn($mock);
+
+ $mock
+ ->shouldReceive('toArray')
+ ->andReturn([]);
+
+ return $mock;
+ }
+
+ private function mockRequest(): Request
+ {
+ $mock = Mockery::mock(Request::class);
+
+ $mock
+ ->shouldReceive('query')
+ ->with('current_page', 1)
+ ->andReturn($this->currentPage);
+
+ $mock
+ ->shouldReceive('query')
+ ->with('per_page', 10)
+ ->andReturn($this->perPage);
+
+ return $mock;
+ }
+
+ public function test_check_response_type(): void
+ {
+ $response = $this->controller->getAllPosts(
+ $this->userId,
+ $this->mockRequest(),
+ $this->mockUseCase(),
+ );
+
+ $this->assertInstanceOf(JsonResponse::class, $response);
+ }
+}
diff --git a/src/app/Post/Presentation/PresentationTest/Controller/PostController_getEachUserPostTest.php b/src/app/Post/Presentation/PresentationTest/Controller/PostController_getEachUserPostTest.php
new file mode 100644
index 0000000..b8466d5
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/Controller/PostController_getEachUserPostTest.php
@@ -0,0 +1,87 @@
+controller = new PostController();
+ $this->userId = 1;
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockUseCase(): GetUserEachPostUseCase
+ {
+ $useCase = Mockery::mock(GetUserEachPostUseCase::class);
+
+ $useCase
+ ->shouldReceive('handle')
+ ->with(
+ Mockery::type('int'),
+ Mockery::type('int'),
+ )
+ ->andReturn($this->mockViewModel());
+
+ return $useCase;
+ }
+
+ private function mockViewModel(): GetAllUserPostViewModel
+ {
+ $viewModel = Mockery::mock(GetAllUserPostViewModel::class);
+
+ $viewModel
+ ->shouldReceive('toArray')
+ ->andReturn([]);
+
+ return $viewModel;
+ }
+
+ private function mockDto(): GetUserEachPostDto
+ {
+ $dto = Mockery::mock(GetUserEachPostDto::class);
+
+ $dto
+ ->shouldReceive('toArray')
+ ->andReturn($this->arrayData());
+
+ return $dto;
+ }
+
+ private function arrayData(): array
+ {
+ return [
+ 'id' => 1,
+ 'userId' => $this->userId,
+ 'content' => 'Sample post content',
+ 'mediaPath' => 'https://example.com/media.jpg',
+ 'visibility' => 'public',
+ ];
+ }
+
+ public function test_controller_method(): void
+ {
+ $response = $this->controller->getEachPost(
+ $this->userId,
+ $this->arrayData()['id'],
+ $this->mockUseCase()
+ );
+
+ $this->assertInstanceOf(JsonResponse::class, $response);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/PresentationTest/Controller/PostController_getOthersPostsTest.php b/src/app/Post/Presentation/PresentationTest/Controller/PostController_getOthersPostsTest.php
new file mode 100644
index 0000000..3105cbe
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/Controller/PostController_getOthersPostsTest.php
@@ -0,0 +1,87 @@
+currentPage = 1;
+ $this->perPage = 10;
+ $this->controller = new PostController();
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockUseCase(): GetOthersAllPostsUseCase
+ {
+ $useCase = Mockery::mock(GetOthersAllPostsUseCase::class);
+
+ $useCase->shouldReceive('handle')
+ ->with(
+ Mockery::type('int'),
+ Mockery::type($this->perPage),
+ Mockery::type($this->currentPage)
+ )
+ ->andReturn($this->mockPagination());
+
+ return $useCase;
+ }
+
+ private function mockPagination(): PaginationDto
+ {
+ $paginationDto = Mockery::mock(PaginationDto::class);
+
+ $paginationDto->shouldReceive('getCurrentPage')
+ ->andReturn($this->currentPage);
+
+ $paginationDto->shouldReceive('getPerPage')
+ ->andReturn($this->perPage);
+
+ return $paginationDto;
+ }
+
+ private function mockRequest(): Request
+ {
+ $request = Mockery::mock(Request::class);
+
+ $request->shouldReceive('input')
+ ->with('perPage')
+ ->andReturn($this->perPage);
+
+ $request->shouldReceive('input')
+ ->with('currentPage')
+ ->andReturn($this->currentPage);
+
+ return $request;
+ }
+
+ public function test_controller_type_check(): void
+ {
+ $result = $this->controller->getOthersPosts(
+ $this->mockRequest(),
+ 1,
+ $this->mockUseCase()
+ );
+
+ $this->assertInstanceOf(JsonResponse::class, $result);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/PresentationTest/EditPostViewModelTest.php b/src/app/Post/Presentation/PresentationTest/EditPostViewModelTest.php
new file mode 100644
index 0000000..3a4d670
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/EditPostViewModelTest.php
@@ -0,0 +1,84 @@
+ 1,
+ 'userId' => 1,
+ 'content' => 'Updated content',
+ 'mediaPath' => 'https://example.com/updated_media.jpg',
+ 'visibility' => 'public',
+ ];
+ }
+
+ private function mockDto(): EditPostDto
+ {
+ $dto = Mockery::mock(EditPostDto::class);
+
+ $dto
+ ->shouldReceive('getId')
+ ->andReturn(new PostId($this->arrayData()['id']));
+
+ $dto
+ ->shouldReceive('getUserid')
+ ->andReturn(new UserId($this->arrayData()['userId']));
+
+ $dto
+ ->shouldReceive('getContent')
+ ->andReturn($this->arrayData()['content']);
+
+ $dto
+ ->shouldReceive('getMediaPath')
+ ->andReturn($this->arrayData()['mediaPath']);
+
+ $dto
+ ->shouldReceive('getVisibility')
+ ->andReturn(new Postvisibility(EnumPostVisibility::fromString($this->arrayData()['visibility'])));
+
+ return $dto;
+ }
+
+ public function test_view_model_check_type(): void
+ {
+ $result = new EditPostViewModel($this->mockDto());
+
+ $this->assertInstanceOf(EditPostViewModel::class, $result);
+ }
+
+ public function test_view_model_get_values(): void
+ {
+ $result = new EditPostViewModel($this->mockDto());
+
+ $this->assertEquals($this->arrayData()['id'], $result->toArray()['id']);
+ $this->assertEquals($this->arrayData()['userId'], $result->toArray()['userId']);
+ $this->assertEquals($this->arrayData()['content'], $result->toArray()['content']);
+ $this->assertEquals($this->arrayData()['mediaPath'], $result->toArray()['mediaPath']);
+ $this->assertEquals(
+ EnumPostVisibility::fromString($this->arrayData()['visibility'])->toLabel(),
+ $result->toArray()['visibility']
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/PresentationTest/GetAllUserPostViewModelTest.php b/src/app/Post/Presentation/PresentationTest/GetAllUserPostViewModelTest.php
new file mode 100644
index 0000000..2d76ea5
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/GetAllUserPostViewModelTest.php
@@ -0,0 +1,76 @@
+userId = 1;
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function arrayMultiData(): array
+ {
+ return [
+ [
+ 'id' => 1,
+ 'userId' => $this->userId,
+ 'content' => 'Sample post content',
+ 'mediaPath' => 'path/to/media.jpg',
+ 'visibility' => 'public',
+ ],
+ [
+ 'id' => 2,
+ 'userId' => $this->userId,
+ 'content' => 'Another post content',
+ 'mediaPath' => 'path/to/another_media.jpg',
+ 'visibility' => 'private',
+ ],
+ ];
+ }
+
+ private function mockDto(): GetUserEachPostDto
+ {
+ $mock = Mockery::mock(GetUserEachPostDto::class);
+
+ $mock
+ ->shouldReceive('toArray')
+ ->andReturn($this->arrayMultiData()[0]);
+
+ return $mock;
+ }
+
+ public function test_view_model_check_type(): void
+ {
+ $result = GetAllUserPostViewModel::build(
+ $this->mockDto()
+ );
+
+ $this->assertInstanceOf(GetAllUserPostViewModel::class, $result);
+ }
+
+ public function test_view_model_check_value(): void
+ {
+ $result = GetAllUserPostViewModel::build(
+ $this->mockDto()
+ );
+
+ $this->assertSame($this->arrayMultiData()[0]['id'], $result->toArray()['id']);
+ $this->assertSame($this->arrayMultiData()[0]['userId'], $result->toArray()['userId']);
+ $this->assertSame($this->arrayMultiData()[0]['content'], $result->toArray()['content']);
+ $this->assertSame($this->arrayMultiData()[0]['mediaPath'], $result->toArray()['mediaPath']);
+ $this->assertSame($this->arrayMultiData()[0]['visibility'], $result->toArray()['visibility']);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/PresentationTest/GetPostViewModelCollectionTest.php b/src/app/Post/Presentation/PresentationTest/GetPostViewModelCollectionTest.php
new file mode 100644
index 0000000..f8cc774
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/GetPostViewModelCollectionTest.php
@@ -0,0 +1,110 @@
+currentPage = 1;
+ $this->perPage = 10;
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockPagination(): PaginationDto
+ {
+ $pagination = Mockery::mock(PaginationDto::class);
+
+ $pagination->shouldReceive('getCurrentPage')
+ ->andReturn($this->currentPage);
+
+ $pagination->shouldReceive('getPerPage')
+ ->andReturn($this->perPage);
+
+ return $pagination;
+ }
+
+ private function mockDtoCollection(): GetAllUserPostDtoCollection
+ {
+ $dto1 = new GetUserEachPostDto(
+ id: 1,
+ userId: 1,
+ content: 'Sample content',
+ mediaPath: 'https://example.com/media.jpg',
+ visibility: 'public'
+ );
+
+ $dto2 = new GetUserEachPostDto(
+ id: 2,
+ userId: 1,
+ content: 'Another content',
+ mediaPath: 'https://example.com/another_media.jpg',
+ visibility: 'private'
+ );
+
+ $collection = Mockery::mock(GetAllUserPostDtoCollection::class);
+ $collection->shouldReceive('getPosts')->andReturn([$dto1, $dto2]);
+
+ return $collection;
+ }
+
+ private function arrayData(): array
+ {
+ return [
+ [
+ 'id' => 1,
+ 'userId' => 1,
+ 'content' => 'Sample content',
+ 'mediaPath' => 'https://example.com/media.jpg',
+ 'visibility' => 'public'
+ ],
+ [
+ 'id' => 2,
+ 'userId' => 1,
+ 'content' => 'Another content',
+ 'mediaPath' => 'https://example.com/another_media.jpg',
+ 'visibility' => 'private'
+ ]
+ ];
+ }
+
+ public function test_view_model_collection_check_type(): void
+ {
+ $collection = new GetPostsViewModelCollection(
+ $this->mockDtoCollection()
+ );
+
+ $this->assertInstanceOf(
+ GetPostsViewModelCollection::class,
+ $collection
+ );
+ }
+
+ public function test_view_model_collection_check_value(): void
+ {
+ $collection = new GetPostsViewModelCollection(
+ $this->mockDtoCollection()
+ );
+
+ $this->assertEquals(
+ $this->arrayData(),
+ $collection->toArray()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/PresentationTest/GetPostViewModelTest.php b/src/app/Post/Presentation/PresentationTest/GetPostViewModelTest.php
new file mode 100644
index 0000000..8314a63
--- /dev/null
+++ b/src/app/Post/Presentation/PresentationTest/GetPostViewModelTest.php
@@ -0,0 +1,109 @@
+currentPage = 1;
+ $this->perPage = 10;
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockPagination(): PaginationDto
+ {
+ $dto = Mockery::mock(PaginationDto::class);
+
+ $dto->shouldReceive('getCurrentPage')
+ ->andReturn($this->currentPage);
+
+ $dto->shouldReceive('getPerPage')
+ ->andReturn($this->perPage);
+
+ $dto->shouldReceive('getData')
+ ->andReturn($this->arrayData());
+
+ return $dto;
+ }
+
+ private function mockDto(): GetUserEachPostDto
+ {
+ $dto = Mockery::mock(GetUserEachPostDto::class);
+
+ $dto->shouldReceive('getId')
+ ->andReturn(1);
+
+ $dto->shouldReceive('getUserId')
+ ->andReturn(1);
+
+ $dto->shouldReceive('getContent')
+ ->andReturn('Sample content');
+
+ $dto->shouldReceive('getMediaPath')
+ ->andReturn('https://example.com/media.jpg');
+
+ $dto->shouldReceive('getVisibility')
+ ->andReturn(0);
+
+ return $dto;
+ }
+
+ private function arrayData(): array
+ {
+ return [
+ 'id' => 1,
+ 'userId' => 1,
+ 'content' => 'Sample content',
+ 'mediaPath' => 'https://example.com/media.jpg',
+ 'visibility' => 0,
+ ];
+ }
+
+ public function test_view_model_check_type(): void
+ {
+ $viewModel = new GetPostViewModel(
+ $this->arrayData()['id'],
+ $this->arrayData()['userId'],
+ $this->arrayData()['content'],
+ $this->arrayData()['mediaPath'],
+ $this->arrayData()['visibility']
+ );
+
+ $this->assertInstanceOf(GetPostViewModel::class, $viewModel);
+ }
+
+ public function test_view_model_check_value(): void
+ {
+ $viewModel = new GetPostViewModel(
+ $this->arrayData()['id'],
+ $this->arrayData()['userId'],
+ $this->arrayData()['content'],
+ $this->arrayData()['mediaPath'],
+ $this->arrayData()['visibility']
+ );
+
+ $this->assertEquals($viewModel->toArray()['id'], $this->mockPagination()->getData()['id']);
+ $this->assertEquals($viewModel->toArray()['userId'], $this->mockPagination()->getData()['userId']);
+ $this->assertEquals($viewModel->toArray()['content'], $this->mockPagination()->getData()['content']);
+ $this->assertEquals($viewModel->toArray()['mediaPath'], $this->mockPagination()->getData()['mediaPath']);
+ $this->assertEquals(
+ $viewModel->toArray()['visibility'],
+ $this->mockPagination()->getData()['visibility']
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/ViewModel/EditPostViewModel.php b/src/app/Post/Presentation/ViewModel/EditPostViewModel.php
new file mode 100644
index 0000000..51964ce
--- /dev/null
+++ b/src/app/Post/Presentation/ViewModel/EditPostViewModel.php
@@ -0,0 +1,23 @@
+ $this->dto->getId()->getValue(),
+ 'user_id' => $this->dto->getUserid()->getValue(),
+ 'content' => $this->dto->getContent(),
+ 'media_path' => $this->dto->getMediaPath(),
+ 'visibility' => strtolower($this->dto->getVisibility()->getValue()->toLabel())
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/ViewModel/GetAllUserPostViewModel.php b/src/app/Post/Presentation/ViewModel/GetAllUserPostViewModel.php
new file mode 100644
index 0000000..39652ca
--- /dev/null
+++ b/src/app/Post/Presentation/ViewModel/GetAllUserPostViewModel.php
@@ -0,0 +1,39 @@
+toArray();
+ return new self(
+ id: $dtoArray['id'],
+ userId: $dtoArray['userId'],
+ content: $dtoArray['content'],
+ mediaPath: $dtoArray['mediaPath'] ?? null,
+ visibility: $dtoArray['visibility']
+ );
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'userId' => $this->userId,
+ 'content' => $this->content,
+ 'mediaPath' => $this->mediaPath,
+ 'visibility' => $this->visibility
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Presentation/ViewModel/GetPostViewModel.php b/src/app/Post/Presentation/ViewModel/GetPostViewModel.php
new file mode 100644
index 0000000..9b39fca
--- /dev/null
+++ b/src/app/Post/Presentation/ViewModel/GetPostViewModel.php
@@ -0,0 +1,38 @@
+id,
+ userId: $dto->userId,
+ content: $dto->content,
+ mediaPath: $dto->mediaPath,
+ visibility: $dto->visibility
+ );
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'userId' => $this->userId,
+ 'content' => $this->content,
+ 'mediaPath' => $this->mediaPath,
+ 'visibility' => $this->visibility
+ ];
+ }
+}
diff --git a/src/app/Post/Presentation/ViewModel/GetPostsViewModelCollection.php b/src/app/Post/Presentation/ViewModel/GetPostsViewModelCollection.php
new file mode 100644
index 0000000..921d235
--- /dev/null
+++ b/src/app/Post/Presentation/ViewModel/GetPostsViewModelCollection.php
@@ -0,0 +1,26 @@
+viewModels = array_map(
+ fn($dto) => GetPostViewModel::build($dto),
+ $dtoCollection->getPosts()
+ );
+ }
+
+ public function toArray(): array
+ {
+ return array_map(
+ fn(GetPostViewModel $vm) => $vm->toArray(),
+ $this->viewModels
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Tests/GetAllUserPostsTest.php b/src/app/Post/Tests/GetAllUserPostsTest.php
new file mode 100644
index 0000000..2907af8
--- /dev/null
+++ b/src/app/Post/Tests/GetAllUserPostsTest.php
@@ -0,0 +1,113 @@
+refresh();
+ $user = User::create([
+ 'first_name' => 'Cristiano',
+ 'last_name' => 'Ronaldo',
+ 'email' => 'manchester_united7@test.com',
+ 'password' => 'test1234',
+ 'bio' => null,
+ 'location' => null,
+ 'skills' => ['Laravel', 'React'],
+ 'profile_image' => null,
+ ]);
+ $this->userId = $user->id;
+
+ $this->createDummyPosts();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ Post::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createDummyPosts(): void
+ {
+ $posts = [];
+
+ for ($i = 1; $i <= 50; $i++) {
+ $posts[] = [
+ 'user_id' => $this->userId,
+ 'content' => "Portugal wins in World cup for {$i}times",
+ 'media_path' => $i % 2 === 0 ? null : "https://example.com/image{$i}.jpg",
+ 'visibility' => $i % 2 === 0
+ ? PostVisibilityEnum::PRIVATE->value
+ : PostVisibilityEnum::PUBLIC->value,
+ 'created_at' => now()->subSeconds(50 - $i),
+ 'updated_at' => now()->subSeconds(50 - $i),
+ ];
+ }
+
+ Post::insert($posts);
+ }
+
+ public function test_api_routing_is_correct(): void
+ {
+ $idWithEndpoint = str_replace('{userId}', $this->userId, $this->endpoint);
+ $current_page = 1;
+ $per_page = 15;
+
+ $response = $this->getJson($idWithEndpoint, [
+ 'page' => $current_page,
+ 'per_page' => $per_page,
+ ]);
+
+ $response->assertStatus(200);
+ }
+
+ public function test_get_all_user_posts_check_value(): void
+ {
+ $idWithEndpoint = str_replace('{userId}', $this->userId, $this->endpoint);
+ $current_page = 1;
+ $per_page = 15;
+
+ $response = $this->getJson($idWithEndpoint, [
+ 'page' => $current_page,
+ 'per_page' => $per_page,
+ ]);
+
+ foreach ($response->json('data') as $post) {
+ $this->assertArrayHasKey('id', $post);
+ $this->assertArrayHasKey('userId', $post);
+ $this->assertArrayHasKey('content', $post);
+ $this->assertArrayHasKey('mediaPath', $post);
+ $this->assertArrayHasKey('visibility', $post);
+ }
+ }
+
+ public function test_get_all_user_posts_with_invalid_user_id(): void
+ {
+ $invalidUserId = 9999;
+ $idWithEndpoint = str_replace('{userId}', $invalidUserId, $this->endpoint);
+
+ $response = $this->getJson($idWithEndpoint);
+
+ $response->assertStatus(500);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Tests/GetEachUserPostTest.php b/src/app/Post/Tests/GetEachUserPostTest.php
new file mode 100644
index 0000000..e2a6e21
--- /dev/null
+++ b/src/app/Post/Tests/GetEachUserPostTest.php
@@ -0,0 +1,98 @@
+refresh();
+ $user = User::create([
+ 'first_name' => 'Cristiano',
+ 'last_name' => 'Ronaldo',
+ 'email' => 'manchester_united7@test.com',
+ 'password' => 'test1234',
+ 'bio' => null,
+ 'location' => null,
+ 'skills' => ['Laravel', 'React'],
+ 'profile_image' => null,
+ ]);
+ $this->userId = $user->id;
+ $this->endpoint = str_replace('{userId}', $this->userId, $this->endpoint);
+
+ $this->postId = $this->createDummyPosts();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ Post::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createDummyPosts(): int
+ {
+ $posts = [];
+
+ for ($i = 1; $i <= 10; $i++) {
+ $posts[] = [
+ 'user_id' => $this->userId,
+ 'content' => "Portugal wins in World cup for {$i}times",
+ 'media_path' => $i % 2 === 0 ? null : "https://example.com/image{$i}.jpg",
+ 'visibility' => $i % 2 === 0 ? 1 : 0,
+ 'created_at' => now()->subSeconds(10 - $i),
+ 'updated_at' => now()->subSeconds(10 - $i),
+ ];
+ }
+
+ Post::insert($posts);
+
+ return Post::where('user_id', $this->userId)->first()->id;
+ }
+
+ public function test_api_routing_is_correct(): void
+ {
+ $newEndPoint = str_replace('{postId}', $this->postId, $this->endpoint);
+
+ $response = $this->getJson($newEndPoint);
+
+ $this->assertEquals(200, $response->status());
+ }
+
+ public function test_get_each_user_post(): void
+ {
+ $newEndPoint = str_replace('{postId}', $this->postId, $this->endpoint);
+
+ $response = $this->getJson($newEndPoint);
+
+ $response->assertStatus(200)
+ ->assertJsonStructure([
+ 'status',
+ 'data' => [
+ 'id',
+ 'userId',
+ 'content',
+ 'mediaPath',
+ 'visibility',
+ ],
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Tests/GetOthersUserPostsTest.php b/src/app/Post/Tests/GetOthersUserPostsTest.php
new file mode 100644
index 0000000..91c1e90
--- /dev/null
+++ b/src/app/Post/Tests/GetOthersUserPostsTest.php
@@ -0,0 +1,95 @@
+refresh();
+
+ $user = User::create([
+ 'first_name' => 'Cristiano',
+ 'last_name' => 'Ronaldo',
+ 'email' => 'manchester_united7@test.com',
+ 'password' => 'test1234',
+ 'bio' => null,
+ 'location' => null,
+ 'skills' => ['Laravel', 'React'],
+ 'profile_image' => null,
+ ]);
+ $this->userId = $user->id;
+
+ $this->createDummyPosts($user->id);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ Post::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createDummyPosts(): void
+ {
+ for ($userIndex = 1; $userIndex <= 3; $userIndex++) {
+ if ($userIndex === 1) {
+ continue;
+ }
+
+ $user = User::create([
+ 'first_name' => "User{$userIndex}",
+ 'last_name' => "Test{$userIndex}",
+ 'email' => "user{$userIndex}@example.com",
+ 'password' => bcrypt('password123'),
+ 'bio' => "This is user {$userIndex}",
+ 'location' => "City{$userIndex}",
+ 'skills' => ['Laravel', 'Vue'],
+ 'profile_image' => 'https://example.com/user.jpg',
+ ]);
+
+ for ($i = 1; $i <= 20; $i++) {
+ Post::create([
+ 'user_id' => $user->id,
+ 'content' => "Post {$i} by User{$userIndex}",
+ 'media_path' => $i % 2 === 0 ? null : "https://example.com/image{$i}.jpg",
+ 'visibility' => $i % 2 === 0 ? 0 : 1,
+ 'created_at' => now()->subMinutes(rand(0, 500)),
+ 'updated_at' => now(),
+ ]);
+ }
+ }
+ }
+ public function test_feature_api(): void
+ {
+ $newEndpoint = str_replace('{userId}', intval($this->userId), $this->endpoint);
+
+ $response = $this->getJson($newEndpoint);
+
+ $data = $response->json('data');
+ $meta = $response->json('meta');
+ $this->assertEquals(200, $response->status());
+ $this->assertCount(15, $data);
+ $this->assertEquals(20, $meta['total']);
+ $this->assertEquals(1, $meta['currentPage']);
+ $this->assertEquals(2, $meta['lastPage']);
+ $this->assertEquals(15, $meta['perPage']);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Post/Tests/Post_CreateTest.php b/src/app/Post/Tests/Post_CreateTest.php
index 1dc367c..f758d5a 100644
--- a/src/app/Post/Tests/Post_CreateTest.php
+++ b/src/app/Post/Tests/Post_CreateTest.php
@@ -2,6 +2,7 @@
namespace App\Post\Tests;
+use App\Common\Domain\Enum\PostVisibility as PostVisibilityEnum;
use App\Models\Post;
use App\Models\User;
use Tests\TestCase;
@@ -31,11 +32,7 @@ protected function refresh()
}
}
- /**
- * @test
- * @testdox Post creation test successfully
- */
- public function test_feature_test(): void
+ public function test_create_post(): void
{
$request = [
'first_name' => 'Cristiano',
@@ -54,7 +51,7 @@ public function test_feature_test(): void
'title' => 'Portugal Wins Nations League in 2025',
'content' => 'Vamos Portugal! The team has shown incredible skill and determination.',
'media_path' => 'https://example.com/media.jpg',
- 'visibility' => 'public',
+ 'visibility' => PostVisibilityEnum::PUBLIC->value,
];
$response = $this->post(
diff --git a/src/app/Post/Tests/Post_EditTest.php b/src/app/Post/Tests/Post_EditTest.php
new file mode 100644
index 0000000..877cc60
--- /dev/null
+++ b/src/app/Post/Tests/Post_EditTest.php
@@ -0,0 +1,80 @@
+refresh();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ protected function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ Post::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ public function test_feature_test(): void
+ {
+ $request = [
+ 'first_name' => 'Cristiano',
+ 'last_name' => 'Ronaldo',
+ 'email' => 'manchester_united7@test.com',
+ 'password' => 'test1234',
+ 'bio' => null,
+ 'location' => null,
+ 'skills' => ['Laravel', 'React'],
+ 'profile_image' => null,
+ ];
+
+ $userId = User::create($request)->id;
+
+ $postRequest = [
+ 'content' => 'Vamos Portugal! The team has shown incredible skill and determination.',
+ 'media_path' => 'https://example.com/media.jpg',
+ 'visibility' => PostVisibilityEnum::PUBLIC->value,
+ ];
+
+ $postId = Post::create(array_merge($postRequest, ['user_id' => $userId]))->id;
+
+ $editRequest = [
+ 'content' => 'Vamos Portugal! The team has shown incredible skill and determination. Updated content.',
+ 'media_path' => 'https://example.com/media_updated.jpg',
+ 'visibility' => PostVisibilityEnum::PRIVATE->value,
+ ];
+
+ $response = $this->putJson(
+ "api/users/{$userId}/posts/{$postId}",
+ $editRequest
+ );
+
+ $response->assertJson([
+ 'status' => 'success',
+ 'data' => [
+ 'id' => $postId,
+ 'user_id' => $userId,
+ 'content' => $editRequest['content'],
+ 'media_path' => $editRequest['media_path'],
+ 'visibility' => PostVisibilityEnum::PRIVATE->toLabel(),
+ ]
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
index dc093f0..c80ab6e 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -2,17 +2,28 @@
namespace App\Providers;
+use App\Post\Application\QueryServiceInterface\GetPostQueryServiceInterface;
use App\Post\Domain\RepositoryInterface\PostRepositoryInterface;
use App\Post\Infrastructure\Repository\PostRepository;
use App\User\Domain\RepositoryInterface\PasswordHasherInterface;
use App\User\Domain\RepositoryInterface\UserRepositoryInterface;
+use App\User\Domain\Service\PasswordResetGenerateTokenServiceInterface;
+use App\User\Domain\Service\PasswordResetNotificationServiceInterface;
+use App\User\Domain\Service\PasswordResetRequestLimitationServiceInterface;
+use App\User\Domain\Service\PasswordResetTokenValidatorInterface;
+use App\User\Domain\Service\ThrottlePasswordResetRequestServiceInterface;
use App\User\Infrastructure\Repository\UserRepository;
use App\User\Infrastructure\Service\JwtAuthService;
+use App\User\Infrastructure\Service\PasswordResetGenerateTokenService;
+use App\User\Infrastructure\Service\PasswordResetNotificationService;
+use App\User\Infrastructure\Service\PasswordResetTokenValidatorService;
+use App\User\Infrastructure\Service\ThrottlePasswordResetRequestService;
use Illuminate\Support\ServiceProvider;
use App\User\Infrastructure\Service\BcryptPasswordHasher;
use App\User\Domain\Service\GenerateTokenInterface;
use App\User\Infrastructure\Service\GenerateTokenService;
use App\User\Domain\Service\AuthServiceInterface;
+use App\Post\Infrastructure\QueryService\GetPostQueryService;
class AppServiceProvider extends ServiceProvider
{
@@ -50,6 +61,31 @@ public function register(): void
PostRepositoryInterface::class,
PostRepository::class
);
+
+ $this->app->bind(
+ GetPostQueryServiceInterface::class,
+ GetPostQueryService::class
+ );
+
+ $this->app->bind(
+ PasswordResetGenerateTokenServiceInterface::class,
+ PasswordResetGenerateTokenService::class
+ );
+
+ $this->app->bind(
+ PasswordResetNotificationServiceInterface::class,
+ PasswordResetNotificationService::class
+ );
+
+ $this->app->bind(
+ ThrottlePasswordResetRequestServiceInterface::class,
+ ThrottlePasswordResetRequestService::class
+ );
+
+ $this->app->bind(
+ PasswordResetTokenValidatorInterface::class,
+ PasswordResetTokenValidatorService::class
+ );
}
/**
diff --git a/src/app/User/Application/ApplicationTest/PasswordResetUseCaseTest.php b/src/app/User/Application/ApplicationTest/PasswordResetUseCaseTest.php
new file mode 100644
index 0000000..d44b8d7
--- /dev/null
+++ b/src/app/User/Application/ApplicationTest/PasswordResetUseCaseTest.php
@@ -0,0 +1,78 @@
+userId = 1;
+ $this->token = bin2hex(random_bytes(32));
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockRepository(): UserRepositoryInterface
+ {
+ $repository = Mockery::mock(UserRepositoryInterface::class);
+
+ $repository
+ ->shouldReceive('resetPassword')
+ ->with(
+ Mockery::type(UserId::class),
+ Mockery::type(PasswordResetToken::class),
+ Mockery::type('string')
+ )
+ ->andReturn(true);
+
+ return $repository;
+ }
+
+ private function mockTokenValidator(): PasswordResetTokenValidatorInterface
+ {
+ $tokenValidator = Mockery::mock(PasswordResetTokenValidatorInterface::class);
+
+ $tokenValidator
+ ->shouldReceive('validate')
+ ->with(
+ Mockery::type('string'),
+ Mockery::type('string')
+ )
+ ->andReturn(true);
+
+ return $tokenValidator;
+ }
+
+ public function test_use_case_ok(): void
+ {
+ $useCase = new PasswordResetUseCase(
+ $this->mockRepository(),
+ $this->mockTokenValidator()
+ );
+
+ $useCase->handle(
+ $this->userId,
+ $this->token,
+ 'new-password-1234'
+ );
+
+ $this->assertTrue(true, 'Password reset use case executed successfully');
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Application/ApplicationTest/RequestUserPasswordResetUseCaseTest.php b/src/app/User/Application/ApplicationTest/RequestUserPasswordResetUseCaseTest.php
new file mode 100644
index 0000000..97095a6
--- /dev/null
+++ b/src/app/User/Application/ApplicationTest/RequestUserPasswordResetUseCaseTest.php
@@ -0,0 +1,171 @@
+ 'Andres',
+ 'last_name' => 'Iniesta',
+ 'bio' => 'Soccer player',
+ 'email' => 'barcelona8@test.com',
+ 'location' => 'Spain',
+ 'skills' => json_encode(['dribbling', 'passing']),
+ 'profile_image' => 'https://example.com/profile.jpg',
+ ];
+ }
+
+ private function mockUserEntity(): UserEntity
+ {
+ $factory = Mockery::mock(
+ 'alias' . UserFromModelEntityFactory::class
+ );
+
+ $entity = Mockery::mock(UserEntity::class);
+
+ $factory
+ ->shouldReceive('build')
+ ->andReturn($entity);
+
+ $entity
+ ->shouldReceive('getUserId')
+ ->andReturn(new UserId(1));
+
+ $entity
+ ->shouldReceive('getFirstName')
+ ->andReturn($this->arrayData()['first_name']);
+
+ $entity
+ ->shouldReceive('getLastName')
+ ->andReturn($this->arrayData()['last_name']);
+
+ $entity
+ ->shouldReceive('getEmail')
+ ->andReturn(new Email($this->arrayData()['email']));
+
+ $entity
+ ->shouldReceive('getBio')
+ ->andReturn($this->arrayData()['bio']);
+
+ $entity
+ ->shouldReceive('getLocation')
+ ->andReturn($this->arrayData()['location']);
+
+ $entity
+ ->shouldReceive('getSkills')
+ ->andReturn(json_decode($this->arrayData()['skills'], true));
+
+ $entity
+ ->shouldReceive('getProfileImage')
+ ->andReturn($this->arrayData()['profile_image']);
+
+ return $entity;
+ }
+
+ private function mockRepository(UserEntity $entity): UserRepositoryInterface
+ {
+ $repository = Mockery::mock(UserRepositoryInterface::class);
+
+ $repository
+ ->shouldReceive('findByEmail')
+ ->with(
+ Mockery::on(
+ fn($arg) => $arg instanceof Email && $arg->getValue() === $this->arrayData()['email']
+ )
+ )
+ ->andReturn($entity);
+
+ $repository
+ ->shouldReceive('savePasswordResetToken')
+ ->with(
+ Mockery::on(fn($arg) => $arg instanceof UserId && $arg->getValue() === 1),
+ Mockery::type(PasswordResetToken::class)
+ )
+ ->andReturnNull();
+
+ return $repository;
+ }
+
+ private function mockPasswordResetNotification(
+ UserEntity $entity,
+ PasswordResetToken $token
+ ): PasswordResetNotificationServiceInterface
+ {
+ $service = Mockery::mock(PasswordResetNotificationServiceInterface::class);
+
+ $service
+ ->shouldReceive('sendResetLink')
+ ->with(
+ $entity,
+ $token->getValue()
+ )
+ ->andReturnNull();
+
+ return $service;
+ }
+
+ private function mockThrottleService(UserEntity $entity): ThrottlePasswordResetRequestServiceInterface
+ {
+ $service = Mockery::mock(ThrottlePasswordResetRequestServiceInterface::class);
+
+ $service
+ ->shouldReceive('checkThrottling')
+ ->with($entity)
+ ->andReturnTrue();
+
+ return $service;
+ }
+
+ private function mockGenerateTokenService(PasswordResetToken $token): PasswordResetGenerateTokenServiceInterface
+ {
+ $service = Mockery::mock(PasswordResetGenerateTokenServiceInterface::class);
+
+ $service
+ ->shouldReceive('generateToken')
+ ->andReturn($token);
+
+ return $service;
+ }
+
+ public function test_request_password_reset_success(): void
+ {
+ $userEntity = $this->mockUserEntity();
+ $token = new PasswordResetToken(random_bytes(32));
+
+ $useCase = new RequestUserPasswordResetUseCase(
+ $this->mockRepository($userEntity),
+ $this->mockGenerateTokenService($token),
+ $this->mockPasswordResetNotification($userEntity, $token),
+ $this->mockThrottleService($userEntity)
+ );
+
+ $useCase->handle($this->arrayData()['email']);
+
+ $this->assertTrue(true, 'Password reset request handled successfully');
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Application/UseCase/PasswordResetUseCase.php b/src/app/User/Application/UseCase/PasswordResetUseCase.php
new file mode 100644
index 0000000..2eb804e
--- /dev/null
+++ b/src/app/User/Application/UseCase/PasswordResetUseCase.php
@@ -0,0 +1,48 @@
+buildObjectToken($token);
+
+ if (!$this->tokenValidator->validate($objectToken->getValue())) {
+ throw new Exception('Invalid or expired token.');
+ }
+
+ $userId = $this->tokenValidator->getUserIdByToken($objectToken->getValue());
+ if (is_null($userId)) {
+ throw new Exception('User not found for the given token.');
+ }
+
+ $objectUserId = $this->buildObjectUserId($userId);
+
+ $this->repository->resetPassword($objectUserId, $objectToken, $newPassword);
+ }
+
+ private function buildObjectToken(string $token): PasswordResetToken
+ {
+ return new PasswordResetToken($token);
+ }
+
+ private function buildObjectUserId($userId): UserId
+ {
+ return new UserId($userId);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Application/UseCase/RequestUserPasswordResetUseCase.php b/src/app/User/Application/UseCase/RequestUserPasswordResetUseCase.php
new file mode 100644
index 0000000..e2f70ac
--- /dev/null
+++ b/src/app/User/Application/UseCase/RequestUserPasswordResetUseCase.php
@@ -0,0 +1,42 @@
+repository->findByEmail($this->buildObjectEmail($email));
+
+ if (!$user) {
+ throw new InvalidArgumentException('User not found');
+ }
+
+ $this->throttleService->checkThrottling($user);
+
+ $token = $this->tokenService->generateToken();
+
+ $this->repository->savePasswordResetToken($user->getUserId(), $token);
+
+ $this->notificationService->sendResetLink($user, $token->getValue());
+ }
+
+ private function buildObjectEmail(string $email): Email
+ {
+ return new Email($email);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Domain/DomainTest/PasswordResetRequestTest.php b/src/app/User/Domain/DomainTest/PasswordResetRequestTest.php
new file mode 100644
index 0000000..c02ccf7
--- /dev/null
+++ b/src/app/User/Domain/DomainTest/PasswordResetRequestTest.php
@@ -0,0 +1,51 @@
+ 1,
+ 'user_id' => 1,
+ 'token' => Str::random(33),
+ 'requested_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
+ 'expired_at' => (new DateTimeImmutable('+1 hour'))->format('Y-m-d H:i:s'),
+ ];
+ }
+
+ public function test_check_entity_type(): void
+ {
+ $result = PasswordRequestEntityFactory::build($this->arrayData());
+
+ $this->assertInstanceOf(PasswordRequestEntity::class, $result);
+ }
+
+ public function test_check_entity_properties(): void
+ {
+ $result = PasswordRequestEntityFactory::build($this->arrayData());
+
+ $this->assertEquals($this->arrayData()['id'], $result->getId());
+ $this->assertEquals($this->arrayData()['user_id'], $result->getUserId()->getValue());
+ $this->assertSame(33, strlen($result->getToken()->getValue()));
+ $this->assertEquals($this->arrayData()['requested_at'], $result->getRequestedAt()->format('Y-m-d H:i:s'));
+ $this->assertEquals($this->arrayData()['expired_at'], $result->getExpiredAt()->getValue()->format('Y-m-d H:i:s'));
+ }
+}
diff --git a/src/app/User/Domain/Entity/PasswordRequestEntity.php b/src/app/User/Domain/Entity/PasswordRequestEntity.php
new file mode 100644
index 0000000..3a91122
--- /dev/null
+++ b/src/app/User/Domain/Entity/PasswordRequestEntity.php
@@ -0,0 +1,51 @@
+id;
+ }
+
+ public function getUserId(): UserId
+ {
+ return $this->userId;
+ }
+
+ public function getToken(): PasswordResetToken
+ {
+ return $this->token;
+ }
+
+ public function getRequestedAt(): DateTimeImmutable
+ {
+ return $this->requestedAt;
+ }
+
+ public function getExpiredAt(): ExpiredAt
+ {
+ return $this->expiredAt;
+ }
+
+ public function isExpired(?DateTimeImmutable $now = null): bool
+ {
+ return $this->expiredAt->isExpired($now);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Domain/Factory/PasswordRequestEntityFactory.php b/src/app/User/Domain/Factory/PasswordRequestEntityFactory.php
new file mode 100644
index 0000000..6f4ca4f
--- /dev/null
+++ b/src/app/User/Domain/Factory/PasswordRequestEntityFactory.php
@@ -0,0 +1,23 @@
+value = $token;
+ }
+
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/InfrastructureTest/BcryptPasswordHasherTest.php b/src/app/User/Infrastructure/InfrastructureTest/Hasher/BcryptPasswordHasherTest.php
similarity index 100%
rename from src/app/User/Infrastructure/InfrastructureTest/BcryptPasswordHasherTest.php
rename to src/app/User/Infrastructure/InfrastructureTest/Hasher/BcryptPasswordHasherTest.php
diff --git a/src/app/User/Infrastructure/InfrastructureTest/GenerateTokenServiceTest.php b/src/app/User/Infrastructure/InfrastructureTest/Service/GenerateTokenServiceTest.php
similarity index 99%
rename from src/app/User/Infrastructure/InfrastructureTest/GenerateTokenServiceTest.php
rename to src/app/User/Infrastructure/InfrastructureTest/Service/GenerateTokenServiceTest.php
index 1356c73..fb7ed6a 100644
--- a/src/app/User/Infrastructure/InfrastructureTest/GenerateTokenServiceTest.php
+++ b/src/app/User/Infrastructure/InfrastructureTest/Service/GenerateTokenServiceTest.php
@@ -1,7 +1,8 @@
refresh();
+ $this->service = new PasswordResetGenerateTokenService();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+
+ public function test_check_service(): void
+ {
+ $result = $this->service->generateToken();
+
+ $this->assertInstanceOf(PasswordResetToken::class, $result);
+ }
+
+ public function test_check_token_length(): void
+ {
+ $result = $this->service->generateToken();
+
+ $this->assertEquals(32, strlen($result->getValue()));
+ }
+
+ public function test_check_token_too_much_length(): void
+ {
+ $result = $this->service->generateToken();
+
+ $this->assertLessThanOrEqual(33, strlen($result->getValue()));
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/InfrastructureTest/Service/PasswordResetNotificationServiceTest.php b/src/app/User/Infrastructure/InfrastructureTest/Service/PasswordResetNotificationServiceTest.php
new file mode 100644
index 0000000..611ac69
--- /dev/null
+++ b/src/app/User/Infrastructure/InfrastructureTest/Service/PasswordResetNotificationServiceTest.php
@@ -0,0 +1,117 @@
+refresh();
+ $this->user = $this->createUser();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUser(): User
+ {
+ return User::create([
+ 'first_name' => 'Sergio',
+ 'last_name' => 'Ramos',
+ 'email' => "real-madrid".rand(). "@test.com",
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ]);
+ }
+
+ private function mockEntity(): UserEntity
+ {
+ $factory = Mockery::mock(
+ 'alias' . UserEntityFactory::class
+ );
+
+ $entity = Mockery::mock(UserEntity::class);
+
+ $factory
+ ->shouldReceive('build')
+ ->andReturn($entity);
+
+ $entity
+ ->shouldReceive('getUserId')
+ ->andReturn(new UserId($this->user->id));
+
+ $entity
+ ->shouldReceive('getFirstName')
+ ->andReturn($this->createUser()['first_name']);
+
+ $entity
+ ->shouldReceive('getLastName')
+ ->andReturn($this->createUser()['last_name']);
+
+ $hashed = bcrypt($this->createUser()['password']);
+ $entity
+ ->shouldReceive('getPassword')
+ ->andReturn(Password::fromHashed($hashed));
+
+ $entity
+ ->shouldReceive('getEmail')
+ ->andReturn(new Email($this->createUser()['email']));
+
+ $entity
+ ->shouldReceive('getBio')
+ ->andReturn($this->createUser()['bio']);
+
+ return $entity;
+ }
+
+
+ public function test_sent_reset_link(): void
+ {
+ Mail::fake();
+
+ $user = $this->mockEntity();
+ $token = 'test-reset-token-123456';
+
+ $service = new PasswordResetNotificationService();
+ $service->sendResetLink($user, $token);
+
+ Mail::assertSent(PasswordResetNotification::class, function ($mail) use ($user, $token) {
+
+ return $mail->hasTo($user->getEmail()->getValue()) &&
+ $mail->token === $token &&
+ $mail->envelope()->subject === 'Reset Your Password' &&
+ $mail->content()->view === 'emails.password_reset' &&
+ $mail->content()->with['resetUrl'] === url("/password-reset/{$token}");
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/InfrastructureTest/Service/PasswordResetTokenValidatorServiceTest.php b/src/app/User/Infrastructure/InfrastructureTest/Service/PasswordResetTokenValidatorServiceTest.php
new file mode 100644
index 0000000..53228ea
--- /dev/null
+++ b/src/app/User/Infrastructure/InfrastructureTest/Service/PasswordResetTokenValidatorServiceTest.php
@@ -0,0 +1,75 @@
+refresh();
+ $this->service = new PasswordResetTokenValidatorService();
+ $this->user = $this->createUser();
+ $this->resetRequest = $this->createResetRequest();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ PasswordResetRequest::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUser(): User
+ {
+ return User::Create([
+ 'first_name' => 'Sergio',
+ 'last_name' => 'Ramos',
+ 'email' => "real-madrid".rand(). "@test.com",
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ]);
+ }
+
+ private function createResetRequest(): PasswordResetRequest
+ {
+ return PasswordResetRequest::create([
+ 'user_id' => $this->user->id,
+ 'token' => bin2hex(random_bytes(32)),
+ 'requested_at' => now(),
+ 'expired_at' => now()->addMinutes(30),
+ ]);
+ }
+
+ public function test_check_token_validate(): void
+ {
+ $this->expectNotToPerformAssertions();
+
+ $this->service->validate(
+ $this->user->id,
+ $this->resetRequest->token
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/InfrastructureTest/Service/ThrottlePasswordResetRequestServiceTest.php b/src/app/User/Infrastructure/InfrastructureTest/Service/ThrottlePasswordResetRequestServiceTest.php
new file mode 100644
index 0000000..e809388
--- /dev/null
+++ b/src/app/User/Infrastructure/InfrastructureTest/Service/ThrottlePasswordResetRequestServiceTest.php
@@ -0,0 +1,161 @@
+refresh();
+ $this->user = $this->createUser();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ PasswordResetRequest::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUser(): User
+ {
+ return User::create([
+ 'first_name' => 'Sergio',
+ 'last_name' => 'Ramos',
+ 'email' => "real-madrid".rand(). "@test.com",
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ]);
+ }
+
+ private function mockEntity(): UserEntity
+ {
+ $factory = Mockery::mock(
+ 'alias' . UserEntityFactory::class
+ );
+
+ $entity = Mockery::mock(UserEntity::class);
+
+ $factory
+ ->shouldReceive('build')
+ ->andReturn($entity);
+
+ $entity
+ ->shouldReceive('getUserId')
+ ->andReturn(new UserId($this->user->id));
+
+ $entity
+ ->shouldReceive('getFirstName')
+ ->andReturn($this->createUser()['first_name']);
+
+ $entity
+ ->shouldReceive('getLastName')
+ ->andReturn($this->createUser()['last_name']);
+
+ $hashed = bcrypt($this->createUser()['password']);
+ $entity
+ ->shouldReceive('getPassword')
+ ->andReturn(Password::fromHashed($hashed));
+
+ $entity
+ ->shouldReceive('getEmail')
+ ->andReturn(new Email($this->createUser()['email']));
+
+ $entity
+ ->shouldReceive('getBio')
+ ->andReturn($this->createUser()['bio']);
+
+ return $entity;
+ }
+
+
+ public function test_throttle_password_request_correct_request(): void
+ {
+ for ($i = 0; $i < 3; $i++) {
+ PasswordResetRequest::create([
+ 'user_id' => $this->user->id,
+ 'token' => 'token-' . $i,
+ 'requested_at' => now()->subMinutes(10),
+ 'expired_at' => now()->addHour(),
+ 'created_at' => now()->subMinutes(10),
+ 'updated_at' => now()->subMinutes(10),
+ ]);
+ }
+
+
+ $service = new ThrottlePasswordResetRequestService(new User());
+
+ $this->expectNotToPerformAssertions();
+ $service->checkThrottling($this->mockEntity());
+ }
+
+ public function test_throttle_password_request_incorrect_request(): void
+ {
+ for ($i = 0; $i < 6; $i++) {
+ PasswordResetRequest::create([
+ 'user_id' => $this->user->id,
+ 'token' => 'token-' . $i,
+ 'requested_at' => now()->subMinutes(10),
+ 'expired_at' => now()->addHour(),
+ 'created_at' => now()->subMinutes(10),
+ 'updated_at' => now()->subMinutes(10),
+ ]);
+ }
+
+ $this->expectException(TooManyRequestsHttpException::class);
+ $service = new ThrottlePasswordResetRequestService(new User());
+
+ $service->checkThrottling($this->mockEntity());
+ }
+
+ public function test_throttle_password_request_wrong_user(): void
+ {
+ for ($i = 0; $i < 3; $i++) {
+ PasswordResetRequest::create([
+ 'user_id' => $this->user->id,
+ 'token' => 'token-' . $i,
+ 'requested_at' => now()->subMinutes(10),
+ 'expired_at' => now()->addHour(),
+ 'created_at' => now()->subMinutes(10),
+ 'updated_at' => now()->subMinutes(10),
+ ]);
+ }
+
+ $wrongUser = $this->createUser();
+ $wrongUserEntity = UserFromModelEntityFactory::buildFromModel($wrongUser);
+
+ $service = new ThrottlePasswordResetRequestService(new User());
+
+ $this->expectNotToPerformAssertions();
+
+ $service->checkThrottling($wrongUserEntity);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/InfrastructureTest/UserRepository_resetPasswordTest.php b/src/app/User/Infrastructure/InfrastructureTest/UserRepository_resetPasswordTest.php
new file mode 100644
index 0000000..b2c3842
--- /dev/null
+++ b/src/app/User/Infrastructure/InfrastructureTest/UserRepository_resetPasswordTest.php
@@ -0,0 +1,96 @@
+refresh();
+ $this->user = $this->createUser();
+ $this->repository = new UserRepository(
+ new BcryptPasswordHasher(),
+ $this->user
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ PasswordResetRequest::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUser(): User
+ {
+ return User::create([
+ 'first_name' => 'Sergio',
+ 'last_name' => 'Ramos',
+ 'email' => 'real-madrid15@test.com',
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ]);
+ }
+
+ public function test_reset_password_ok(): void
+ {
+ $objectId = new UserId($this->user->id);
+ $token = new PasswordResetToken(bin2hex(random_bytes(32)));
+ $newPassword = 'test1234test1234test1234test1234';
+
+ $this->repository->savePasswordResetToken($objectId, $token);
+
+ $this->repository->resetPassword($objectId, $token, $newPassword);
+
+ $updatedUser = $this->user->find($this->user->id);
+
+ $this->assertTrue(
+ Hash::check($newPassword, $updatedUser->password)
+ );
+ }
+
+ public function test_reset_password_invalid_userId(): void
+ {
+ $this->expectException(Exception::class);
+
+ $objectId = new UserId(100);
+ $token = new PasswordResetToken(bin2hex(random_bytes(32)));
+ $newPassword = 'test1234test1234test1234test1234';
+
+ $this->repository->savePasswordResetToken($objectId, $token);
+
+ $this->repository->resetPassword($objectId, $token, $newPassword);
+
+ $updatedUser = $this->user->find($this->user->id);
+
+ $this->assertTrue(
+ Hash::check($newPassword, $updatedUser->password)
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/InfrastructureTest/UserRepository_savePasswordResetTokenTest.php b/src/app/User/Infrastructure/InfrastructureTest/UserRepository_savePasswordResetTokenTest.php
new file mode 100644
index 0000000..8e0726a
--- /dev/null
+++ b/src/app/User/Infrastructure/InfrastructureTest/UserRepository_savePasswordResetTokenTest.php
@@ -0,0 +1,94 @@
+refresh();
+ $this->user = new User();
+ $this->userRepository = new UserRepository(
+ Mockery::mock(PasswordHasherInterface::class),
+ $this->user
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ PasswordResetRequest::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function mockPasswordToken(): PasswordResetToken
+ {
+ return new PasswordResetToken(
+ 'test-token-' . bin2hex(random_bytes(16))
+ );
+ }
+
+ public function test_save_password_reset_token_check_type(): void
+ {
+ $userId = $this->user
+ ->create([
+ 'first_name' => 'Toni',
+ 'last_name' => 'Kroos',
+ 'email' => 'real-madrid6@test.com',
+ 'password' => 'el-capitán-1234',
+ 'bio' => 'Real Madrid player',
+ 'location' => 'Madrid',
+ 'skills' => ['Football', 'Leadership'],
+ 'profile_image' => 'https://example.com/sergio.jpg'
+ ])
+ ->id;
+
+ $resetRequest = PasswordResetRequest::create([
+ 'user_id' => $userId,
+ 'token' => 'initial-token',
+ 'requested_at' => Carbon::now()->subHours(2),
+ 'expired_at' => Carbon::now()->subHour(),
+ 'created_at' => Carbon::now()->subHours(2),
+ 'updated_at' => Carbon::now()->subHours(2),
+ ]);
+
+ $token = $this->mockPasswordToken();
+
+ $this->userRepository->savePasswordResetToken(
+ new UserId($userId),
+ $token
+ );
+
+ $this->assertDatabaseHas('password_reset_requests', [
+ 'user_id' => $userId,
+ 'token' => $token->getValue(),
+ 'requested_at' => Carbon::now()->toDateTimeString(),
+ 'expired_at' => Carbon::now()->addHour()->toDateTimeString(),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/Mail/PasswordResetNotification.php b/src/app/User/Infrastructure/Mail/PasswordResetNotification.php
new file mode 100644
index 0000000..38fc645
--- /dev/null
+++ b/src/app/User/Infrastructure/Mail/PasswordResetNotification.php
@@ -0,0 +1,39 @@
+ url("/password-reset/{$this->token}")
+ ]
+ );
+ }
+
+ public function attachments(): array
+ {
+ return [];
+ }
+}
diff --git a/src/app/User/Infrastructure/Repository/UserRepository.php b/src/app/User/Infrastructure/Repository/UserRepository.php
index dfac6b0..44042bd 100644
--- a/src/app/User/Infrastructure/Repository/UserRepository.php
+++ b/src/app/User/Infrastructure/Repository/UserRepository.php
@@ -9,6 +9,8 @@
use App\User\Domain\RepositoryInterface\UserRepositoryInterface;
use App\User\Domain\ValueObject\Email;
use App\Common\Domain\ValueObject\UserId;
+use App\User\Domain\ValueObject\PasswordResetToken;
+use Carbon\Carbon;
use Exception;
use LogicException;
@@ -91,4 +93,46 @@ public function update(
return UserFromModelEntityFactory::buildFromModel($this->user->find($entity->getUserId()->getValue()));
}
+
+ public function savePasswordResetToken(
+ UserId $userId,
+ PasswordResetToken $token
+ ): void {
+ $targetUser = $this->user
+ ->with(['passwordResetRequests'])
+ ->where('id', $userId->getValue())
+ ->first();
+
+ if ($targetUser === null) {
+ throw new Exception('User not found');
+ }
+
+ if (!empty($targetUser->passwordResetRequests())) {
+ $targetUser->passwordResetRequests()->updateOrCreate([
+ 'token' => $token->getValue(),
+ 'requested_at' => Carbon::now(),
+ 'expired_at' => Carbon::now()->addHour(),
+ 'created_at' => Carbon::now(),
+ 'updated_at' => Carbon::now()
+ ]);
+ }
+ }
+
+ public function resetPassword(
+ UserId $userId,
+ PasswordResetToken $token,
+ string $newPassword
+ ): void {
+ $userModel = $this->user->find($userId->getValue());
+
+ if ($userModel === null) {
+ throw new Exception('User not found');
+ }
+
+ $hashedPassword = $this->hasher->hash($newPassword);
+
+ $userModel->update([
+ 'password' => $hashedPassword
+ ]);
+ }
}
diff --git a/src/app/User/Infrastructure/Service/PasswordResetGenerateTokenService.php b/src/app/User/Infrastructure/Service/PasswordResetGenerateTokenService.php
new file mode 100644
index 0000000..d20a711
--- /dev/null
+++ b/src/app/User/Infrastructure/Service/PasswordResetGenerateTokenService.php
@@ -0,0 +1,18 @@
+getEmail()->getValue())->send(new PasswordResetNotification($token));
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/Service/PasswordResetTokenValidatorService.php b/src/app/User/Infrastructure/Service/PasswordResetTokenValidatorService.php
new file mode 100644
index 0000000..f649ea0
--- /dev/null
+++ b/src/app/User/Infrastructure/Service/PasswordResetTokenValidatorService.php
@@ -0,0 +1,38 @@
+where('token', $token)
+ ->where('requested_at', '>=', now()->subMinutes(60))
+ ->exists();
+ }
+
+ public function getUserIdByToken(string $token): ?int
+ {
+ if (empty($token)) {
+ return null;
+ }
+
+ $user = DB::table('password_reset_requests')
+ ->where('token', $token)
+ ->where('requested_at', '>=', now()->subMinutes(60))
+ ->first();
+
+ return $user?->user_id;
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Infrastructure/Service/ThrottlePasswordResetRequestService.php b/src/app/User/Infrastructure/Service/ThrottlePasswordResetRequestService.php
new file mode 100644
index 0000000..2977f94
--- /dev/null
+++ b/src/app/User/Infrastructure/Service/ThrottlePasswordResetRequestService.php
@@ -0,0 +1,39 @@
+userModel->find($entity->getUserId()?->getValue());
+
+ if (!$user->passwordResetRequests()) {
+ throw new InvalidArgumentException('User does not have password reset requests.');
+ }
+
+ $requests = $user
+ ->passwordResetRequests()
+ ->where('created_at', '>=', now()->subSeconds(self::TIME_FRAME))
+ ->where('user_id', $user->id)
+ ->count();
+
+ if ($requests >= self::MAX_REQUESTS) {
+ throw new TooManyRequestsHttpException('Too many password reset requests. Please try again later.');
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Presentation/Controller/UserController.php b/src/app/User/Presentation/Controller/UserController.php
index 5e800d0..31f6ad7 100644
--- a/src/app/User/Presentation/Controller/UserController.php
+++ b/src/app/User/Presentation/Controller/UserController.php
@@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\User\Application\Factory\RegisterUserCommandFactory;
use App\User\Application\UseCase\LoginUserUseCase;
+use App\User\Application\UseCase\PasswordResetUseCase;
use App\User\Application\UseCase\ShowUserUseCase;
use App\User\Presentation\ViewModel\Factory\RegisterUserViewModelFactory;
use App\User\Presentation\ViewModel\ShowUserViewModel;
@@ -14,13 +15,14 @@
use App\User\Application\UseCase\UpdateUseCase;
use App\User\Application\UseCommand\UpdateUserCommand;
use App\User\Presentation\ViewModel\UpdateUserViewModel;
-use Illuminate\Support\Facades\Auth;
+use App\User\Application\UseCase\RequestUserPasswordResetUseCase;
use Illuminate\Support\Facades\DB;
use App\User\Application\UseCase\LogoutUserUseCase;
use Exception;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Throwable;
+use function Symfony\Component\Translation\t;
class UserController extends Controller
{
@@ -166,4 +168,62 @@ public function logout(
], 401);
}
}
+
+ public function passwordResetRequest(
+ Request $request,
+ RequestUserPasswordResetUseCase $useCase
+ ): JsonResponse {
+ DB::beginTransaction();
+
+ try {
+ $email = $request->input('email');
+
+ if (empty($email)) {
+ return response()->json([
+ 'status' => 'error',
+ 'message' => 'Email is required',
+ ], 400);
+ }
+
+ $useCase->handle($email);
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => 'Password reset link sent to your email.',
+ ], 200);
+ } catch (Exception $e) {
+ DB::rollBack();
+
+ return response()->json([
+ 'status' => 'error',
+ 'message' => $e->getMessage(),
+ ], 500);
+ }
+ }
+
+ public function passwordReset(
+ Request $request,
+ PasswordResetUseCase $useCase
+ ): void {
+ DB::beginTransaction();
+
+ try {
+ $token = $request->input('token');
+ $newPassword = $request->input('new_password');
+
+ if (empty($token) || empty($newPassword)) {
+ throw new Exception('Token, and new password are required.');
+ }
+
+ $useCase->handle($token, $newPassword);
+
+ DB::commit();
+
+ } catch (Exception $e) {
+ DB::rollBack();
+ throw new Exception('Password reset failed: ' . $e->getMessage());
+ }
+ }
}
\ No newline at end of file
diff --git a/src/app/User/Presentation/PresentationTest/Controller/UserController_passwordResetRequestTest.php b/src/app/User/Presentation/PresentationTest/Controller/UserController_passwordResetRequestTest.php
new file mode 100644
index 0000000..e6a4b64
--- /dev/null
+++ b/src/app/User/Presentation/PresentationTest/Controller/UserController_passwordResetRequestTest.php
@@ -0,0 +1,71 @@
+controller = new UserController();
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockUseCase(): RequestUserPasswordResetUseCase
+ {
+ $useCase = Mockery::mock(RequestUserPasswordResetUseCase::class);
+
+ $useCase
+ ->shouldReceive('handle')
+ ->with(Mockery::type('string'))
+ ->andReturn(true);
+
+ return $useCase;
+ }
+
+ private function arrayData(): array
+ {
+ return [
+ 'first_name' => 'Andres',
+ 'last_name' => 'Iniesta',
+ 'bio' => 'Soccer player',
+ 'email' => 'barcelona8@test.com',
+ 'location' => 'Spain',
+ 'skills' => json_encode(['dribbling', 'passing']),
+ 'profile_image' => 'https://example.com/profile.jpg',
+ ];
+ }
+
+ private function mockRequest(): Request
+ {
+ $request = Mockery::mock(Request::class);
+ $request->shouldReceive('input')
+ ->with('email')
+ ->andReturn($this->arrayData()['email']);
+
+ return $request;
+ }
+
+ public function test_controller(): void
+ {
+ $result = $this->controller->passwordResetRequest(
+ $this->mockRequest(),
+ $this->mockUseCase()
+ );
+
+ $this->assertInstanceOf(JsonResponse::class, $result);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Presentation/PresentationTest/Controller/UserController_passwordResetTest.php b/src/app/User/Presentation/PresentationTest/Controller/UserController_passwordResetTest.php
new file mode 100644
index 0000000..9469048
--- /dev/null
+++ b/src/app/User/Presentation/PresentationTest/Controller/UserController_passwordResetTest.php
@@ -0,0 +1,73 @@
+controller = new UserController();
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ private function mockUseCase(): PasswordResetUseCase
+ {
+ $useCase = Mockery::mock(PasswordResetUseCase::class);
+
+ $useCase
+ ->shouldReceive('handle')
+ ->with(
+ Mockery::type('string'),
+ Mockery::type('string'),
+ Mockery::type('string')
+ )
+ ->andReturn(true);
+
+ return $useCase;
+ }
+
+ private function mockRequest(): Request
+ {
+ $request = Mockery::mock(Request::class);
+
+ $request
+ ->shouldReceive('input')
+ ->with('user_id')
+ ->andReturn(1);
+
+ $request
+ ->shouldReceive('input')
+ ->andReturn(bin2hex(random_bytes(32)));
+
+ $request
+ ->shouldReceive('input')
+ ->with('new_password')
+ ->andReturn('new-password-1234');
+
+ return $request;
+ }
+
+ public function test_password_reset_controller_unit(): void
+ {
+ $this->controller
+ ->passwordReset(
+ $this->mockRequest(),
+ $this->mockUseCase()
+ );
+
+ $this->assertTrue(true, 'Password reset controller executed successfully.');
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Tests/User_PasswordResetRequestTest.php b/src/app/User/Tests/User_PasswordResetRequestTest.php
new file mode 100644
index 0000000..7a2642a
--- /dev/null
+++ b/src/app/User/Tests/User_PasswordResetRequestTest.php
@@ -0,0 +1,75 @@
+refreshDatabase();
+ $this->user = $this->createUser();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refreshDatabase();
+ parent::tearDown();
+ }
+
+ private function refreshDatabase(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ PasswordResetRequest::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUser(): User
+ {
+ $user = User::create([
+ 'first_name' => 'Lionel',
+ 'last_name' => 'Messi',
+ 'email' => "barcelona_".rand(). "@test.com",
+ 'password' => 'test1234',
+ 'bio' => 'I am a football player',
+ 'location' => 'Barcelona',
+ 'skills' => ['Laravel', 'React'],
+ 'profile_image' => 'https://example.com/profile.jpg',
+ ]);
+
+ return $user;
+ }
+
+ public function test_password_reset_request_ok(): void
+ {
+ $input = $this->user->email;
+
+ $response = $this
+ ->postJson(
+ $this->endpoint,
+ [
+ 'email' => $input,
+ ]
+ );
+
+ $response->assertStatus(200);
+ $this->assertDatabaseHas(
+ 'password_reset_requests',
+ [
+ 'user_id' => $this->user->id,
+ ],
+ 'mysql'
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Tests/User_PasswordResetTest.php b/src/app/User/Tests/User_PasswordResetTest.php
new file mode 100644
index 0000000..2185b33
--- /dev/null
+++ b/src/app/User/Tests/User_PasswordResetTest.php
@@ -0,0 +1,83 @@
+refresh();
+ $this->user = $this->createUser();
+ $this->token = bin2hex(random_bytes(32));
+ $this->createRequest();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->refresh();
+ parent::tearDown();
+ }
+
+ private function refresh(): void
+ {
+ if (env('APP_ENV') === 'testing') {
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
+ User::truncate();
+ PasswordResetRequest::truncate();
+ DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
+ }
+ }
+
+ private function createUser(): User
+ {
+ return User::create([
+ 'first_name' => 'Lionel',
+ 'last_name' => 'Messi',
+ 'email' => "barcelona_".rand(). "@test.com",
+ 'password' => 'test1234',
+ 'bio' => 'I am a football player',
+ 'location' => 'Barcelona',
+ 'skills' => ['Laravel', 'React'],
+ 'profile_image' => 'https://example.com/profile.jpg',
+ ]);
+ }
+
+ private function createRequest(): void
+ {
+ PasswordResetRequest::create([
+ 'user_id' => $this->user->id,
+ 'token' => $this->token,
+ 'requested_at' => Carbon::now(),
+ 'created_at' => Carbon::now(),
+ 'updated_at' => Carbon::now(),
+ 'expired_at' => Carbon::now()->addMinutes(60),
+ ]);
+ }
+
+ public function test_password_reset_confirm_ok(): void
+ {
+ $input = [
+ 'token' => $this->token,
+ 'new_password' => 'new-password-1234',
+ ];
+
+ $response = $this
+ ->postJson(
+ $this->endpoint,
+ $input
+ );
+
+ $response
+ ->assertStatus(200);
+ }
+}
\ No newline at end of file
diff --git a/src/app/User/Tests/User_RegisterTest.php b/src/app/User/Tests/User_RegisterTest.php
index 7ed7737..3c5a78b 100644
--- a/src/app/User/Tests/User_RegisterTest.php
+++ b/src/app/User/Tests/User_RegisterTest.php
@@ -29,11 +29,7 @@ protected function refresh()
}
}
- /**
- * @test
- * @testdox User registration test successfully (some properties are null)
- */
- public function test1(): void
+ public function test_register_user_with_properties_nullable(): void
{
$request = [
'first_name' => 'Cristiano',
@@ -48,17 +44,14 @@ public function test1(): void
$response = $this
->postJson(
- 'api/user/register',
+ 'api/users/register',
$request
);
+
$this->assertEquals(201, $response->getStatusCode());
}
- /**
- * @test
- * @testdox User registration test with invalid data (all properties are requested)
- */
- public function test2(): void
+ public function test_failed_with_invalid_data(): void
{
$request = [
'first_name' => 'Lionel',
@@ -73,7 +66,7 @@ public function test2(): void
$response = $this
->postJson(
- 'api/user/register',
+ 'api/users/register',
$request
);
diff --git a/src/app/User/Tests/User_ShowTest.php b/src/app/User/Tests/User_ShowTest.php
index b6401fb..6ede33e 100644
--- a/src/app/User/Tests/User_ShowTest.php
+++ b/src/app/User/Tests/User_ShowTest.php
@@ -9,7 +9,7 @@
class User_ShowTest extends TestCase
{
- private User $user;
+ private $user;
protected function setUp(): void
{
@@ -33,11 +33,7 @@ protected function refresh()
}
}
- /**
- * @test
- * @testdox User registration test successfully (some properties are null)
- */
- public function test1(): void
+ public function test_success_with_nullable_properties(): void
{
$user = User::create([
'first_name' => 'Lionel',
@@ -52,15 +48,11 @@ public function test1(): void
$userId = $user->id;
- $response = $this->getJson("api/user/show/{$userId}");
+ $response = $this->getJson("api/users/show/{$userId}");
$this->assertInstanceOf(TestResponse::class, $response);
}
- /**
- * @test
- * @testdox User registration test with invalid data (all properties are requested)
- */
- public function test2(): void
+ public function test_failed_with_invalid_property(): void
{
$user = User::create([
'first_name' => 'Cristiano',
@@ -75,7 +67,7 @@ public function test2(): void
$userId = $user->id;
- $response = $this->getJson("api/user/show/{$userId}");
+ $response = $this->getJson("api/users/show/{$userId}");
$this->assertEquals(200, $response->getStatusCode());
diff --git a/src/database/migrations/2025_06_22_122938_create_password_reset_requests_table.php b/src/database/migrations/2025_06_22_122938_create_password_reset_requests_table.php
new file mode 100644
index 0000000..2ebe1ff
--- /dev/null
+++ b/src/database/migrations/2025_06_22_122938_create_password_reset_requests_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('user_id')
+ ->constrained()
+ ->onDelete('cascade');
+ $table->string('token')->unique();
+ $table->dateTime('requested_at');
+ $table->dateTime('expired_at');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('password_reset_requests');
+ }
+};
diff --git a/src/resources/views/emails/password_reset.blade.php b/src/resources/views/emails/password_reset.blade.php
new file mode 100644
index 0000000..93d4a60
--- /dev/null
+++ b/src/resources/views/emails/password_reset.blade.php
@@ -0,0 +1,7 @@
+You requested to reset your password.
+
+Click the link below to proceed:
+
+{{ $resetUrl }}
+
+This link will expire in 1 hour.
diff --git a/src/routes/api.php b/src/routes/api.php
index cc438dc..a45b237 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -12,6 +12,14 @@
Route::post('login', [UserController::class, 'login'])->name('login');
Route::get('/show/{id}', [UserController::class, 'showUser'])->name('show');
Route::put('{id}', [UserController::class, 'update'])->name('update');
+ Route::post('/password-reset', [UserController::class, 'passwordResetRequest'])->name('passwordResetRequest');
+ Route::post('/password-reset/confirm', [UserController::class, 'passwordReset'])->name('passwordReset');
- Route::post('{userId}/posts', [PostController::class, 'create'])->name('posts.create');
+ Route::prefix('{userId}/posts')->name('posts.')->group(function () {
+ Route::post('/', [PostController::class, 'create'])->name('create');
+ Route::get('/', [PostController::class, 'getAllPosts'])->name('getAllPosts');
+ Route::get('public', [PostController::class, 'getOthersPosts'])->name('getOthersPosts');
+ Route::get('{postId}', [PostController::class, 'getEachPost'])->name('getEachPost');
+ Route::put('{postId}', [PostController::class, 'edit'])->name('edit');
+ });
});
\ No newline at end of file