diff --git a/.gitignore b/.gitignore index 9f11b755..c0b6dde2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea/ +src/.config/ diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 741315d8..af253d1f 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Laravel\Sanctum\HasApiTokens; @@ -33,4 +34,14 @@ protected function casts(): array 'email_verified_at' => 'datetime', ]; } + + public function favorites(): BelongsToMany + { + return $this->belongsToMany( + WorldHeritage::class, + 'user_favorite', + 'user_id', + 'world_heritage_site_id' + )->withTimestamps(); + } } \ No newline at end of file diff --git a/src/app/Packages/Domains/Favorite/FavoriteRepository.php b/src/app/Packages/Domains/Favorite/FavoriteRepository.php new file mode 100644 index 00000000..6a98f5fe --- /dev/null +++ b/src/app/Packages/Domains/Favorite/FavoriteRepository.php @@ -0,0 +1,50 @@ +userModel->find($userId); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->favorites()->attach($worldHeritageSiteId); + } + + public function removeFavorite(int $userId, int $worldHeritageSiteId): void + { + $user = $this->userModel->find($userId); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->favorites()->detach($worldHeritageSiteId); + } + + public function getFavoriteWorldHeritageIds(int $userId): array + { + $user = $this->userModel->find($userId); + + if ($user === null) { + throw new Exception('User not found.'); + } + + return $user->favorites() + ->orderBy('user_favorite.created_at', 'desc') + ->pluck('world_heritage_sites.id') + ->all(); + } +} diff --git a/src/app/Packages/Domains/Favorite/Interface/FavoriteRepositoryInterface.php b/src/app/Packages/Domains/Favorite/Interface/FavoriteRepositoryInterface.php new file mode 100644 index 00000000..1c507374 --- /dev/null +++ b/src/app/Packages/Domains/Favorite/Interface/FavoriteRepositoryInterface.php @@ -0,0 +1,12 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::connection('mysql')->table('user_favorite')->truncate(); + WorldHeritage::truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function repository(): FavoriteRepository + { + return new FavoriteRepository(new User()); + } + + private function seedUser(): User + { + $faker = FakerFactory::create(); + + return User::create([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + private function seedWorldHeritage(int $id): WorldHeritage + { + return WorldHeritage::create([ + 'id' => $id, + 'official_name' => 'Example Site', + 'name' => 'Example Site', + 'region' => 'Asia', + 'category' => 'Cultural', + 'criteria' => ['i'], + 'year_inscribed' => 2000, + ]); + } + + public function test_addFavorite_attaches_world_heritage_site_to_user(): void + { + $user = $this->seedUser(); + $worldHeritage = $this->seedWorldHeritage(1); + + $this->repository()->addFavorite($user->id, $worldHeritage->id); + + $this->assertDatabaseHas('user_favorite', [ + 'user_id' => $user->id, + 'world_heritage_site_id' => $worldHeritage->id, + ]); + } + + public function test_addFavorite_throws_exception_when_user_not_found(): void + { + $worldHeritage = $this->seedWorldHeritage(1); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->addFavorite(999999, $worldHeritage->id); + } + + public function test_removeFavorite_detaches_world_heritage_site_from_user(): void + { + $user = $this->seedUser(); + $worldHeritage = $this->seedWorldHeritage(1); + $user->favorites()->attach($worldHeritage->id); + + $this->repository()->removeFavorite($user->id, $worldHeritage->id); + + $this->assertDatabaseMissing('user_favorite', [ + 'user_id' => $user->id, + 'world_heritage_site_id' => $worldHeritage->id, + ]); + } + + #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function test_removeFavorite_does_not_throw_when_not_favorited(): void + { + $user = $this->seedUser(); + $worldHeritage = $this->seedWorldHeritage(1); + + $this->repository()->removeFavorite($user->id, $worldHeritage->id); + } + + public function test_removeFavorite_throws_exception_when_user_not_found(): void + { + $worldHeritage = $this->seedWorldHeritage(1); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->removeFavorite(999999, $worldHeritage->id); + } + + public function test_getFavoriteWorldHeritageIds_returns_ids_ordered_by_most_recently_favorited(): void + { + $user = $this->seedUser(); + $worldHeritage1 = $this->seedWorldHeritage(1); + $worldHeritage2 = $this->seedWorldHeritage(2); + + $user->favorites()->attach($worldHeritage1->id, ['created_at' => now()->subMinute()]); + $user->favorites()->attach($worldHeritage2->id, ['created_at' => now()]); + + $result = $this->repository()->getFavoriteWorldHeritageIds($user->id); + + $this->assertSame([$worldHeritage2->id, $worldHeritage1->id], $result); + } + + public function test_getFavoriteWorldHeritageIds_returns_empty_array_when_no_favorites(): void + { + $user = $this->seedUser(); + + $result = $this->repository()->getFavoriteWorldHeritageIds($user->id); + + $this->assertSame([], $result); + } + + public function test_getFavoriteWorldHeritageIds_throws_exception_when_user_not_found(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->getFavoriteWorldHeritageIds(999999); + } +} diff --git a/src/app/Packages/Domains/WorldHeritage/Tests/QueryService/WorldHeritageQueryService_getHeritagesByIdsTest.php b/src/app/Packages/Domains/WorldHeritage/Tests/QueryService/WorldHeritageQueryService_getHeritagesByIdsTest.php new file mode 100644 index 00000000..112d5d30 --- /dev/null +++ b/src/app/Packages/Domains/WorldHeritage/Tests/QueryService/WorldHeritageQueryService_getHeritagesByIdsTest.php @@ -0,0 +1,98 @@ +refresh(); + + $seeder = new DatabaseSeeder(); + $seeder->run(); + + $this->app->bind(WorldHeritageSearchPort::class, static function () { + return new class implements WorldHeritageSearchPort { + public function search($query, int $currentPage, int $perPage): HeritageSearchResult { + return new HeritageSearchResult(ids: [], total: 0, currentPage: 1, perPage: $perPage, lastPage: 0); + } + }; + }); + + $this->queryService = app(WorldHeritageQueryService::class); + } + + 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;'); + WorldHeritage::truncate(); + Country::truncate(); + DB::table('site_state_parties')->truncate(); + Image::truncate(); + WorldHeritageDescription::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + public function test_returns_world_heritage_dto_collection(): void + { + $ids = DB::table('world_heritage_sites')->orderBy('id')->limit(2)->pluck('id')->all(); + + $result = $this->queryService->getHeritagesByIds($ids); + + $this->assertInstanceOf(WorldHeritageDtoCollection::class, $result); + } + + public function test_preserves_order_of_given_ids(): void + { + $ids = DB::table('world_heritage_sites')->orderBy('id')->limit(3)->pluck('id')->all(); + $this->assertCount(3, $ids, 'Seeder must insert at least 3 world heritages.'); + + $requested = [$ids[2], $ids[0], $ids[1]]; + + $result = $this->queryService->getHeritagesByIds($requested); + + $resultIds = array_map(fn ($dto) => $dto->getId(), $result->getHeritages()); + $this->assertSame($requested, $resultIds); + } + + public function test_returns_empty_collection_when_no_ids_given(): void + { + $result = $this->queryService->getHeritagesByIds([]); + + $this->assertSame([], $result->getHeritages()); + } + + public function test_skips_missing_ids_without_failing(): void + { + $existingId = (int) DB::table('world_heritage_sites')->orderBy('id')->value('id'); + $this->assertNotNull($existingId, 'Seeder must insert at least 1 world heritage.'); + + $result = $this->queryService->getHeritagesByIds([$existingId, 999_999_999]); + + $resultIds = array_map(fn ($dto) => $dto->getId(), $result->getHeritages()); + $this->assertSame([$existingId], $resultIds); + } +} diff --git a/src/app/Packages/Domains/WorldHeritage/WorldHeritageQueryService.php b/src/app/Packages/Domains/WorldHeritage/WorldHeritageQueryService.php index ca7a96c9..f9dd0683 100644 --- a/src/app/Packages/Domains/WorldHeritage/WorldHeritageQueryService.php +++ b/src/app/Packages/Domains/WorldHeritage/WorldHeritageQueryService.php @@ -246,6 +246,17 @@ public function searchHeritages(AlgoliaSearchListQuery $query): PaginationDto ); } + public function getHeritagesByIds(array $ids): WorldHeritageDtoCollection + { + $models = $this->readQueryService->findByIdsPreserveOrder($ids); + + $payloads = $models + ->map(fn ($m) => $this->buildWorldHeritagePayload($m)) + ->all(); + + return $this->buildDtoFromCollection($payloads); + } + public function getEachRegionsHeritagesCount(): array { $counts = $this->model diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/Favorite/AddFavoriteUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/Favorite/AddFavoriteUseCase.php new file mode 100644 index 00000000..91789323 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/Favorite/AddFavoriteUseCase.php @@ -0,0 +1,17 @@ +favoriteRepository->addFavorite($userId, $worldHeritageSiteId); + } +} diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/Favorite/RemoveFavoriteUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/Favorite/RemoveFavoriteUseCase.php new file mode 100644 index 00000000..dee77098 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/Favorite/RemoveFavoriteUseCase.php @@ -0,0 +1,17 @@ +favoriteRepository->removeFavorite($userId, $worldHeritageSiteId); + } +} diff --git a/src/app/Packages/Features/Controller/FavoriteController.php b/src/app/Packages/Features/Controller/FavoriteController.php new file mode 100644 index 00000000..7a7ce9ed --- /dev/null +++ b/src/app/Packages/Features/Controller/FavoriteController.php @@ -0,0 +1,105 @@ +handle($request->user()->id); + + return response()->json([ + 'status' => 'success', + 'data' => $dtoCollection->toSummaryArray(), + ], 200); + } catch (Throwable $throwable) { + Log::error('Failed to get favorites', [ + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + + public function addFavorite( + Request $request, + AddFavoriteUseCase $useCase, + ): JsonResponse { + try { + $useCase->handle( + $request->user()->id, + (int) $request->input('world_heritage_id'), + ); + + return response()->json([ + 'status' => 'success', + ], 201); + } catch (Throwable $throwable) { + if ($throwable->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to add favorite', [ + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + + public function removeFavorite( + Request $request, + RemoveFavoriteUseCase $useCase, + ): JsonResponse { + try { + $useCase->handle( + $request->user()->id, + (int) $request->route('world_heritage_id'), + ); + + return response()->json([ + 'status' => 'success', + ], 200); + } catch (Throwable $throwable) { + if ($throwable->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to remove favorite', [ + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } +} diff --git a/src/app/Packages/Features/QueryUseCases/QueryServiceInterface/WorldHeritageQueryServiceInterface.php b/src/app/Packages/Features/QueryUseCases/QueryServiceInterface/WorldHeritageQueryServiceInterface.php index e7919e72..63539cb9 100644 --- a/src/app/Packages/Features/QueryUseCases/QueryServiceInterface/WorldHeritageQueryServiceInterface.php +++ b/src/app/Packages/Features/QueryUseCases/QueryServiceInterface/WorldHeritageQueryServiceInterface.php @@ -5,6 +5,7 @@ use App\Common\Pagination\PaginationDto; use App\Packages\Features\QueryUseCases\Dto\WorldHeritageDto; +use App\Packages\Features\QueryUseCases\Dto\WorldHeritageDtoCollection; use App\Packages\Features\QueryUseCases\ListQuery\AlgoliaSearchListQuery; @@ -25,5 +26,9 @@ public function searchHeritages( AlgoliaSearchListQuery $query ): PaginationDto; + public function getHeritagesByIds( + array $ids + ): WorldHeritageDtoCollection; + public function getEachRegionsHeritagesCount(): array; } \ No newline at end of file diff --git a/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetFavoriteHeritagesUseCaseTest.php b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetFavoriteHeritagesUseCaseTest.php new file mode 100644 index 00000000..d777492d --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetFavoriteHeritagesUseCaseTest.php @@ -0,0 +1,70 @@ +shouldReceive('getFavoriteWorldHeritageIds') + ->once() + ->with(1) + ->andReturn([3, 2]); + + $dtoCollection = new WorldHeritageDtoCollection(); + + /** @var WorldHeritageQueryServiceInterface|MockInterface $worldHeritageQueryService */ + $worldHeritageQueryService = Mockery::mock(WorldHeritageQueryServiceInterface::class); + $worldHeritageQueryService->shouldReceive('getHeritagesByIds') + ->once() + ->with([3, 2]) + ->andReturn($dtoCollection); + + $useCase = new GetFavoriteHeritagesUseCase($favoriteRepository, $worldHeritageQueryService); + + $result = $useCase->handle(1); + + $this->assertSame($dtoCollection, $result); + } + + public function test_handle_returns_empty_collection_when_user_has_no_favorites(): void + { + /** @var FavoriteRepositoryInterface|MockInterface $favoriteRepository */ + $favoriteRepository = Mockery::mock(FavoriteRepositoryInterface::class); + $favoriteRepository->shouldReceive('getFavoriteWorldHeritageIds') + ->once() + ->with(1) + ->andReturn([]); + + $dtoCollection = new WorldHeritageDtoCollection(); + + /** @var WorldHeritageQueryServiceInterface|MockInterface $worldHeritageQueryService */ + $worldHeritageQueryService = Mockery::mock(WorldHeritageQueryServiceInterface::class); + $worldHeritageQueryService->shouldReceive('getHeritagesByIds') + ->once() + ->with([]) + ->andReturn($dtoCollection); + + $useCase = new GetFavoriteHeritagesUseCase($favoriteRepository, $worldHeritageQueryService); + + $result = $useCase->handle(1); + + $this->assertSame([], $result->getHeritages()); + } +} diff --git a/src/app/Packages/Features/QueryUseCases/UseCase/Favorite/GetFavoriteHeritagesUseCase.php b/src/app/Packages/Features/QueryUseCases/UseCase/Favorite/GetFavoriteHeritagesUseCase.php new file mode 100644 index 00000000..857e68f2 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/UseCase/Favorite/GetFavoriteHeritagesUseCase.php @@ -0,0 +1,22 @@ +favoriteRepository->getFavoriteWorldHeritageIds($userId); + + return $this->worldHeritageQueryService->getHeritagesByIds($ids); + } +} diff --git a/src/app/Packages/Features/Tests/CommandUseCases/AddFavoriteUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/AddFavoriteUseCaseTest.php new file mode 100644 index 00000000..50426fca --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/AddFavoriteUseCaseTest.php @@ -0,0 +1,46 @@ +shouldReceive('addFavorite') + ->once() + ->with(1, 2); + + (new AddFavoriteUseCase($repository))->handle(1, 2); + } + + public function test_handle_propagates_exception_when_user_not_found(): void + { + /** @var FavoriteRepositoryInterface|MockInterface $repository */ + $repository = Mockery::mock(FavoriteRepositoryInterface::class); + $repository->shouldReceive('addFavorite') + ->once() + ->with(999999, 2) + ->andThrow(new Exception('User not found.')); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new AddFavoriteUseCase($repository))->handle(999999, 2); + } +} diff --git a/src/app/Packages/Features/Tests/CommandUseCases/RemoveFavoriteUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/RemoveFavoriteUseCaseTest.php new file mode 100644 index 00000000..037c4e27 --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/RemoveFavoriteUseCaseTest.php @@ -0,0 +1,46 @@ +shouldReceive('removeFavorite') + ->once() + ->with(1, 2); + + (new RemoveFavoriteUseCase($repository))->handle(1, 2); + } + + public function test_handle_propagates_exception_when_user_not_found(): void + { + /** @var FavoriteRepositoryInterface|MockInterface $repository */ + $repository = Mockery::mock(FavoriteRepositoryInterface::class); + $repository->shouldReceive('removeFavorite') + ->once() + ->with(999999, 2) + ->andThrow(new Exception('User not found.')); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new RemoveFavoriteUseCase($repository))->handle(999999, 2); + } +} diff --git a/src/app/Packages/Features/Tests/FavoriteControllerTest.php b/src/app/Packages/Features/Tests/FavoriteControllerTest.php new file mode 100644 index 00000000..1f4229d9 --- /dev/null +++ b/src/app/Packages/Features/Tests/FavoriteControllerTest.php @@ -0,0 +1,253 @@ +truncate(); + + $this->app->bind(WorldHeritageSearchPort::class, static function () { + return new class implements WorldHeritageSearchPort { + public function search($query, int $currentPage, int $perPage): HeritageSearchResult + { + return new HeritageSearchResult(ids: [], total: 0, currentPage: 1, perPage: $perPage, lastPage: 0); + } + }; + }); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::connection('mysql')->table('user_favorite')->truncate(); + DB::connection('mysql')->table('personal_access_tokens')->truncate(); + WorldHeritage::truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(): User + { + return User::create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret123'), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + private function seedWorldHeritage(int $id): WorldHeritage + { + return WorldHeritage::create([ + 'id' => $id, + 'official_name' => 'Example Site', + 'name' => 'Example Site', + 'region' => 'Asia', + 'category' => 'Cultural', + 'criteria' => ['i'], + 'year_inscribed' => 2000, + ]); + } + + public function test_addFavorite_returns_201_and_persists_favorite_when_authenticated(): void + { + $user = $this->seedUser(); + $worldHeritage = $this->seedWorldHeritage(1); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withToken($token) + ->postJson('/api/v1/favorites', ['world_heritage_id' => $worldHeritage->id]); + + $response->assertStatus(201) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseHas('user_favorite', [ + 'user_id' => $user->id, + 'world_heritage_site_id' => $worldHeritage->id, + ]); + } + + public function test_addFavorite_returns_401_when_unauthenticated(): void + { + $worldHeritage = $this->seedWorldHeritage(1); + + $response = $this->postJson('/api/v1/favorites', ['world_heritage_id' => $worldHeritage->id]); + + $response->assertStatus(401); + } + + public function test_addFavorite_returns_500_on_unexpected_error(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $repository = Mockery::mock(FavoriteRepositoryInterface::class); + $repository->shouldReceive('addFavorite') + ->andThrow(new RuntimeException('Unexpected error')); + + $this->app->instance(AddFavoriteUseCase::class, new AddFavoriteUseCase($repository)); + + $response = $this->withToken($token) + ->postJson('/api/v1/favorites', ['world_heritage_id' => 1]); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } + + public function test_removeFavorite_returns_200_and_removes_favorite_when_authenticated(): void + { + $user = $this->seedUser(); + $worldHeritage = $this->seedWorldHeritage(1); + $token = $user->createToken('auth-token')->plainTextToken; + $user->favorites()->attach($worldHeritage->id); + + $response = $this->withToken($token) + ->deleteJson("/api/v1/favorites/{$worldHeritage->id}"); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseMissing('user_favorite', [ + 'user_id' => $user->id, + 'world_heritage_site_id' => $worldHeritage->id, + ]); + } + + public function test_removeFavorite_returns_200_when_not_favorited(): void + { + $user = $this->seedUser(); + $worldHeritage = $this->seedWorldHeritage(1); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withToken($token) + ->deleteJson("/api/v1/favorites/{$worldHeritage->id}"); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + } + + public function test_removeFavorite_returns_401_when_unauthenticated(): void + { + $worldHeritage = $this->seedWorldHeritage(1); + + $response = $this->deleteJson("/api/v1/favorites/{$worldHeritage->id}"); + + $response->assertStatus(401); + } + + public function test_removeFavorite_returns_500_on_unexpected_error(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $repository = Mockery::mock(FavoriteRepositoryInterface::class); + $repository->shouldReceive('removeFavorite') + ->andThrow(new RuntimeException('Unexpected error')); + + $this->app->instance(RemoveFavoriteUseCase::class, new RemoveFavoriteUseCase($repository)); + + $response = $this->withToken($token) + ->deleteJson('/api/v1/favorites/1'); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } + + public function test_getFavorites_returns_200_with_favorite_heritages_when_authenticated(): void + { + $user = $this->seedUser(); + $worldHeritage1 = $this->seedWorldHeritage(1); + $worldHeritage2 = $this->seedWorldHeritage(2); + $token = $user->createToken('auth-token')->plainTextToken; + + $user->favorites()->attach($worldHeritage1->id); + $user->favorites()->attach($worldHeritage2->id); + + $response = $this->withToken($token)->getJson('/api/v1/favorites'); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']) + ->assertJsonCount(2, 'data'); + + $ids = array_column($response->json('data'), 'id'); + $this->assertEqualsCanonicalizing([$worldHeritage1->id, $worldHeritage2->id], $ids); + } + + public function test_getFavorites_returns_200_with_empty_array_when_user_has_no_favorites(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withToken($token)->getJson('/api/v1/favorites'); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']) + ->assertJsonCount(0, 'data'); + } + + public function test_getFavorites_returns_401_when_unauthenticated(): void + { + $response = $this->getJson('/api/v1/favorites'); + + $response->assertStatus(401); + } + + public function test_getFavorites_returns_500_on_unexpected_error(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $favoriteRepository = Mockery::mock(FavoriteRepositoryInterface::class); + $favoriteRepository->shouldReceive('getFavoriteWorldHeritageIds') + ->andThrow(new RuntimeException('Unexpected error')); + + $worldHeritageQueryService = Mockery::mock(WorldHeritageQueryServiceInterface::class); + + $this->app->instance( + GetFavoriteHeritagesUseCase::class, + new GetFavoriteHeritagesUseCase($favoriteRepository, $worldHeritageQueryService), + ); + + $response = $this->withToken($token)->getJson('/api/v1/favorites'); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } +} diff --git a/src/app/Providers/RepositoryProvider.php b/src/app/Providers/RepositoryProvider.php index cbba05bf..50348344 100644 --- a/src/app/Providers/RepositoryProvider.php +++ b/src/app/Providers/RepositoryProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Packages\Domains\Favorite\FavoriteRepository; +use App\Packages\Domains\Favorite\Interface\FavoriteRepositoryInterface; use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\UserRepository; use App\Packages\Domains\WorldHeritageRepository; @@ -21,6 +23,11 @@ public function register(): void UserRepositroyInterface::class, UserRepository::class ); + + $this->app->bind( + FavoriteRepositoryInterface::class, + FavoriteRepository::class + ); } public function boot(): void diff --git a/src/database/migrations/2026_08_01_000000_create_user_favorite_table.php b/src/database/migrations/2026_08_01_000000_create_user_favorite_table.php new file mode 100644 index 00000000..0376384c --- /dev/null +++ b/src/database/migrations/2026_08_01_000000_create_user_favorite_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedBigInteger('user_id'); + $table->unsignedInteger('world_heritage_site_id'); + $table->timestamps(); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + + $table->foreign('world_heritage_site_id') + ->references('id') + ->on('world_heritage_sites') + ->cascadeOnDelete(); + + $table->unique(['user_id', 'world_heritage_site_id'], 'user_favorite_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_favorite'); + } +}; \ No newline at end of file diff --git a/src/routes/api.php b/src/routes/api.php index 3d84d52c..83bd0a17 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,6 +1,7 @@ prefix('favorites')->middleware('auth:sanctum')->group(function (): void { + Route::get('/', 'getFavorites'); + Route::post('/', 'addFavorite'); + Route::delete('/{world_heritage_id}', 'removeFavorite'); + }); }); \ No newline at end of file