diff --git a/.github/workflows/server_test.yml b/.github/workflows/server_test.yml index 2c2229ca..aa36c022 100644 --- a/.github/workflows/server_test.yml +++ b/.github/workflows/server_test.yml @@ -48,6 +48,7 @@ jobs: with: php-version: "8.2" tools: composer:v2 + coverage: pcov - name: Cache Composer dependencies uses: actions/cache@v4 @@ -102,5 +103,12 @@ jobs: APP_ENV: testing - name: Run tests - run: ./vendor/bin/phpunit --stop-on-failure --testdox - working-directory: ./src \ No newline at end of file + run: ./vendor/bin/phpunit --stop-on-failure --testdox --coverage-clover coverage/clover.xml + working-directory: ./src + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./src/coverage/clover.xml + fail_ci_if_error: false \ No newline at end of file diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 00000000..42a7049b --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,41 @@ +name: Static Analysis + +on: + pull_request: + types: [opened, synchronize] + paths: + - "src/**/*.php" + workflow_dispatch: {} + +jobs: + mago: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: ${{ runner.os }}-composer-${{ hashFiles('src/composer.lock') }} + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + working-directory: ./src + + - name: Setup Mago + uses: nhedger/setup-mago@v1 + + - name: Run Mago lint + run: ./vendor/bin/mago lint + working-directory: ./src \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9f11b755 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..40f0bed6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: 85% + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: false \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore index 59c942ca..453f061f 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,4 +1,5 @@ /.phpunit.cache +/.composer /node_modules /public/build /public/hot @@ -7,6 +8,7 @@ /storage/app/public /storage/app/private/unesco/world-heritage-sites.json /storage/app/private/unesco/normalized/world_heritage_sites_translation.json +/storage/certs/ /vendor .env diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 100644 index 00000000..c59dddcc --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +## Commit Rules + +- コミットは必ず細分化する(1つの論理的変更 = 1コミット) +- コミットメッセージに Claude の署名(`Co-Authored-By`, `Generated with Claude Code` など)を含めない +- コミットメッセージは英語で記述する(例: `feat: add deleteUser to UserRepositroyInterface`) + +## Branch Rules + +- 機能ごとに親ブランチを切り、層ごとに子ブランチを切る + - 例: `feat/user-delete`(親) + - 例: `feat/user-delete_infra-layer`(子) + - 例: `feat/user-delete_application-layer`(子) + - 例: `feat/user-delete_presentation-layer`(子) +- 子ブランチは親ブランチへ PR を出す +- 親ブランチはさらに上位ブランチへ PR を出す + +## PR Rules + +### Title +- 日英併記にする +- 例: `feat: implement Presentation layer for DELETE /api/v1/users/{id} / ユーザー削除APIのPresentation層実装` + +### Description +以下の構成で記述する: + +``` +## Motivation / 目的 + +(英語で動機・背景を説明。日本語でも補足する) + +## What I have done / 実施内容 + +(実装内容を箇条書きで記述) + +## Test Results / テスト結果 + +(実行したテストのメソッド名を列挙) + +Closes #xxx +``` + +- PR description に Claude の署名(`Generated with Claude Code` など)を含めない + +## Testing Rules + +- テストファイルはすべて `app/Packages/` 配下に置く(`tests/` ではない) + - phpunit.xml が `app/Packages` のみをスキャンするため +- Infra 層のテストは実 DB(Docker の `heritage-mysql-test`)を使ったインテグレーションテストにする(Mockery 不使用) +- Application 層・Domain 層のテストは Mockery を使ったユニットテストにする +- void を返すメソッドのテストでは `#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]` アトリビュートを使う + +## Architecture Rules (DDD / CQRS) + +- `QueryUseCases`: 読み取り専用(副作用なし) + - UseCase, Dto, Factory/Dto, ViewModel, Factory/ViewModel +- `CommandUseCases`: 書き込み(状態変更あり) + - UseCase, UseCommand +- `UserRepositroyInterface` の引数は `UserEntity`(Command オブジェクトを直接渡さない) +- パスワードのハッシュ化は Application 層(UseCase)で行う +- パスワード変更は独立した別 API とし、更新系 Command には含めない + +## Docker File Sync + +- macOS + Docker Desktop ではファイル同期に遅延が発生することがある +- テストが古い挙動を示す場合は `docker cp` でファイルを強制同期する + ```bash + docker cp heritage-app: + docker exec heritage-app php artisan route:clear + ``` diff --git a/src/app/Models/User.php b/src/app/Models/User.php new file mode 100644 index 00000000..741315d8 --- /dev/null +++ b/src/app/Models/User.php @@ -0,0 +1,36 @@ + 'hashed', + 'subscription_expires_at' => 'datetime', + 'email_verified_at' => 'datetime', + ]; + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/ImageEntity.php b/src/app/Packages/Domains/Image/ImageEntity.php similarity index 93% rename from src/app/Packages/Domains/ImageEntity.php rename to src/app/Packages/Domains/Image/ImageEntity.php index bfa8d34d..85983824 100644 --- a/src/app/Packages/Domains/ImageEntity.php +++ b/src/app/Packages/Domains/Image/ImageEntity.php @@ -1,6 +1,6 @@ $data['subscription_tier'], + 'expires_at' => $data['subscription_expires_at'] ?? null, + 'now' => $data['now'] ?? null, + ]); + + return new UserEntity( + id: isset($data['id']) ? (int) $data['id'] : null, + firstName: $data['first_name'], + lastName: $data['last_name'], + email: new Email($data['email']), + ageRange: AgeRange::from($data['age_range']), + subscription: $subscription, + passwordHash: $data['password_hash'] ?? null, + ); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php new file mode 100644 index 00000000..d9719a07 --- /dev/null +++ b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php @@ -0,0 +1,14 @@ +getId()); + + if ($user === null) { + throw new RuntimeException('User not found.'); + } + + return $user->createToken('auth-token')->plainTextToken; + } + + public function revokeAllTokens(UserEntity $entity): void + { + $user = User::find($entity->getId()); + + if ($user === null) { + throw new RuntimeException('User not found.'); + } + + $user->tokens()->delete(); + } + + // Logout lives here rather than in UserRepository because it operates on personal_access_tokens, + // not on user data. No user lookup is needed — the plain-text token alone identifies the session to end. + public function revokeCurrentToken(string $plainTextToken): void + { + $token = PersonalAccessToken::findToken($plainTextToken); + + if ($token !== null) { + $token->delete(); + } + } +} diff --git a/src/app/Packages/Domains/User/Subscription/Subscription.php b/src/app/Packages/Domains/User/Subscription/Subscription.php new file mode 100644 index 00000000..4e834d3d --- /dev/null +++ b/src/app/Packages/Domains/User/Subscription/Subscription.php @@ -0,0 +1,46 @@ +now = $now ?? new DateTimeImmutable(); + } + + public function isActive(): bool + { + return $this->tier === SubscriptionTier::Premium + && $this->expiresAt !== null + && $this->expiresAt > $this->now; + } + + public function isExpired(): bool + { + return $this->tier === SubscriptionTier::Premium + && ($this->expiresAt === null || $this->expiresAt <= $this->now); + } + + public function isFree(): bool + { + return $this->tier === SubscriptionTier::Free; + } + + public function getTier(): SubscriptionTier + { + return $this->tier; + } + + public function getExpiresAt(): ?DateTimeImmutable + { + return $this->expiresAt; + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Subscription/SubscriptionTier.php b/src/app/Packages/Domains/User/Subscription/SubscriptionTier.php new file mode 100644 index 00000000..3a06ceec --- /dev/null +++ b/src/app/Packages/Domains/User/Subscription/SubscriptionTier.php @@ -0,0 +1,9 @@ + 'free', + 'expires_at' => null, + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertTrue($subscription->isFree()); + } + + public function test_build_returns_premium_subscription_with_string_expires_at(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertFalse($subscription->isFree()); + $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); + } + + public function test_build_accepts_datetime_immutable_for_expires_at(): void + { + $expiresAt = new DateTimeImmutable('2027-06-01'); + + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => $expiresAt, + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); + } + + public function test_build_accepts_string_for_now(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2025-01-01 00:00:00', + 'now' => '2026-01-01 00:00:00', + ]); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_build_accepts_datetime_immutable_for_now(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2025-01-01 00:00:00', + 'now' => new DateTimeImmutable('2026-01-01'), + ]); + + $this->assertTrue($subscription->isExpired()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php new file mode 100644 index 00000000..c5649cf8 --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php @@ -0,0 +1,74 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]; + + $entity = UserEntityFactory::build($data); + + $this->assertInstanceOf(UserEntity::class, $entity); + $this->assertSame($data['id'], $entity->getId()); + $this->assertSame($data['first_name'], $entity->getFirstName()); + $this->assertSame($data['last_name'], $entity->getLastName()); + $this->assertSame($data['email'], $entity->getEmail()->value()); + $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); + $this->assertTrue($entity->getSubscription()->isFree()); + $this->assertNull($entity->getPasswordHash()); + } + + public function test_build_sets_password_hash_when_provided(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => null, + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => 'hashed_value', + ]); + + $this->assertNull($entity->getId()); + $this->assertSame('hashed_value', $entity->getPasswordHash()); + } + + public function test_build_passes_expires_at_to_subscription(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'premium', + 'subscription_expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertFalse($entity->getSubscription()->isFree()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php new file mode 100644 index 00000000..bd71646a --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php @@ -0,0 +1,158 @@ +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('personal_access_tokens')->truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function service(): SanctumTokenService + { + return new SanctumTokenService(); + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + '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, + ], $overrides)); + } + + private function buildEntityFromUser(User $user): UserEntity + { + return UserEntityFactory::build([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'age_range' => $user->age_range, + 'subscription_tier' => $user->subscription_tier, + 'subscription_expires_at' => $user->subscription_expires_at, + 'password_hash' => $user->password, + ]); + } + + public function test_createToken_returns_plain_text_token(): void + { + $user = $this->seedUser(); + $entity = $this->buildEntityFromUser($user); + + $token = $this->service()->createToken($entity); + + $this->assertIsString($token); + $this->assertNotEmpty($token); + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'tokenable_type' => User::class, + ]); + } + + public function test_createToken_throws_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User not found.'); + + $this->service()->createToken($entity); + } + + public function test_revokeAllTokens_deletes_tokens_for_user(): void + { + $user = $this->seedUser(); + $entity = $this->buildEntityFromUser($user); + + $user->createToken('token-1'); + $user->createToken('token-2'); + + $this->service()->revokeAllTokens($entity); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_revokeAllTokens_throws_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User not found.'); + + $this->service()->revokeAllTokens($entity); + } + + public function test_revokeCurrentToken_deletes_the_given_token(): void + { + $user = $this->seedUser(); + $plainText = $user->createToken('auth-token')->plainTextToken; + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + + $this->service()->revokeCurrentToken($plainText); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function test_revokeCurrentToken_does_nothing_when_token_not_found(): void + { + $this->service()->revokeCurrentToken('invalid-token-that-does-not-exist'); + } +} diff --git a/src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php b/src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php new file mode 100644 index 00000000..f603f9ea --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php @@ -0,0 +1,121 @@ +assertTrue($subscription->isActive()); + } + + public function test_isActive_returns_false_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isActive_returns_false_when_premium_and_expires_at_is_null(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isActive_returns_false_when_premium_and_expired(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('-1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isExpired_returns_true_when_premium_and_past_expiry(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('-1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_isExpired_returns_true_when_premium_and_expires_at_is_null(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_isExpired_returns_false_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isExpired()); + } + + public function test_isExpired_returns_false_when_premium_and_not_expired(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isExpired()); + } + + public function test_isFree_returns_true_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isFree()); + } + + public function test_isFree_returns_false_when_premium(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isFree()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/UserEntityTest.php b/src/app/Packages/Domains/User/Tests/UserEntityTest.php new file mode 100644 index 00000000..5211af0c --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/UserEntityTest.php @@ -0,0 +1,91 @@ +unique()->randomNumber(5), + firstName: $overrides['first_name'] ?? $faker->firstName(), + lastName: $overrides['last_name'] ?? $faker->lastName(), + email: isset($overrides['email']) ? new Email($overrides['email']) : new Email($faker->unique()->safeEmail()), + ageRange: $overrides['age_range'] ?? AgeRange::Twenties, + subscription: $overrides['subscription'] ?? $subscription, + passwordHash: $overrides['password_hash'] ?? null, + ); + } + + public function test_getId_returns_correct_value(): void + { + $entity = $this->buildEntity(['id' => 42]); + $this->assertSame(42, $entity->getId()); + } + + public function test_getId_returns_null_when_not_yet_persisted(): void + { + $entity = $this->buildEntity(['id' => null]); + $this->assertNull($entity->getId()); + } + + public function test_getFirstName_returns_correct_value(): void + { + $entity = $this->buildEntity(['first_name' => 'John']); + $this->assertSame('John', $entity->getFirstName()); + } + + public function test_getLastName_returns_correct_value(): void + { + $entity = $this->buildEntity(['last_name' => 'Doe']); + $this->assertSame('Doe', $entity->getLastName()); + } + + public function test_getFullName_concatenates_first_and_last_name(): void + { + $entity = $this->buildEntity(['first_name' => 'John', 'last_name' => 'Doe']); + $this->assertSame('John Doe', $entity->getFullName()); + } + + public function test_getEmail_returns_correct_value(): void + { + $entity = $this->buildEntity(['email' => 'john@example.com']); + $this->assertSame('john@example.com', $entity->getEmail()->value()); + } + + public function test_getAgeRange_returns_correct_enum(): void + { + $entity = $this->buildEntity(['age_range' => AgeRange::Thirties]); + $this->assertSame(AgeRange::Thirties, $entity->getAgeRange()); + } + + public function test_getSubscription_returns_subscription_instance(): void + { + $subscription = new Subscription(SubscriptionTier::Premium, null); + $entity = $this->buildEntity(['subscription' => $subscription]); + $this->assertSame($subscription, $entity->getSubscription()); + } + + public function test_getPasswordHash_returns_hashed_value(): void + { + $entity = $this->buildEntity(['password_hash' => 'hashed_secret']); + $this->assertSame('hashed_secret', $entity->getPasswordHash()); + } + + public function test_getPasswordHash_returns_null_when_not_set(): void + { + $entity = $this->buildEntity(['password_hash' => null]); + $this->assertNull($entity->getPasswordHash()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php new file mode 100644 index 00000000..839e1b5c --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php @@ -0,0 +1,176 @@ +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;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function repository(): UserRepository + { + return new UserRepository(new User()); + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + '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, + ], $overrides)); + } + + private function buildEntity(array $overrides = []): UserEntity + { + $faker = FakerFactory::create(); + + return UserEntityFactory::build(array_merge([ + 'id' => null, + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => bcrypt('password'), + ], $overrides)); + } + + public function test_createUser_persists_user_and_returns_dto(): void + { + $result = $this->repository()->createUser($this->buildEntity()); + + $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['email' => $result->email]); + } + + public function test_findById_returns_user_dto_when_user_exists(): void + { + $user = $this->seedUser(); + $result = $this->repository()->findById($user->id); + + $this->assertInstanceOf(UserDto::class, $result); + $this->assertSame($user->id, $result->id); + $this->assertSame($user->email, $result->email); + } + + public function test_findById_returns_null_when_user_not_found(): void + { + $result = $this->repository()->findById(999999); + + $this->assertNull($result); + } + + public function test_updateUser_persists_changes_and_returns_dto(): void + { + $user = $this->seedUser(['email' => 'before@example.com']); + $entity = $this->buildEntity([ + 'id' => $user->id, + 'email' => 'after@example.com', + ]); + + $result = $this->repository()->updateUser($entity); + + $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); + } + + public function test_updateUser_throws_exception_when_user_not_found(): void + { + $entity = $this->buildEntity(['id' => 999999]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->updateUser($entity); + } + + public function test_deleteUser_removes_user_when_exists(): void + { + $user = $this->seedUser(); + + $this->repository()->deleteUser($user->id); + + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + public function test_deleteUser_throws_exception_when_user_not_found(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->deleteUser(999999); + } + + public function test_findByEmail_returns_user_entity_when_user_exists(): void + { + $user = $this->seedUser(['email' => 'john@example.com']); + + $result = $this->repository()->findByEmail(new Email('john@example.com')); + + $this->assertInstanceOf(UserEntity::class, $result); + $this->assertSame($user->email, $result->getEmail()->value()); + $this->assertSame($user->first_name, $result->getFirstName()); + $this->assertSame($user->last_name, $result->getLastName()); + } + + public function test_findByEmail_returns_null_when_user_does_not_exist(): void + { + $result = $this->repository()->findByEmail(new Email('notfound@example.com')); + + $this->assertNull($result); + } + + public function test_findByEmail_returns_correct_password_hash(): void + { + $plainPassword = 'secret123'; + $this->seedUser([ + 'email' => 'hash@example.com', + 'password' => bcrypt($plainPassword), + ]); + + $result = $this->repository()->findByEmail(new Email('hash@example.com')); + + $this->assertNotNull($result->getPasswordHash()); + $this->assertTrue(password_verify($plainPassword, $result->getPasswordHash())); + } + +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php b/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php new file mode 100644 index 00000000..4861109d --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php @@ -0,0 +1,41 @@ +assertSame('user@example.com', $email->value()); + } + + public function test_constructor_throws_on_invalid_email(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email format'); + + new Email('not-an-email'); + } + + public function test_isEqual_returns_true_when_emails_match(): void + { + $email = new Email('user@example.com'); + $otherEmail = new Email('user@example.com'); + + $this->assertTrue($email->isEqual($otherEmail)); + } + + public function test_isEqual_returns_false_when_emails_differ(): void + { + $email = new Email('user@example.com'); + $otherEmail = new Email('other@example.com'); + + $this->assertFalse($email->isEqual($otherEmail)); + } +} diff --git a/src/app/Packages/Domains/User/UserEntity.php b/src/app/Packages/Domains/User/UserEntity.php new file mode 100644 index 00000000..7caa7784 --- /dev/null +++ b/src/app/Packages/Domains/User/UserEntity.php @@ -0,0 +1,59 @@ +id; + } + + public function getPasswordHash(): ?string + { + return $this->passwordHash; + } + + public function getFirstName(): string + { + return $this->firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function getFullName(): string + { + return $this->firstName . ' ' . $this->lastName; + } + + public function getEmail(): Email + { + return $this->email; + } + + public function getAgeRange(): AgeRange + { + return $this->ageRange; + } + + public function getSubscription(): Subscription + { + return $this->subscription; + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/UserRepository.php b/src/app/Packages/Domains/User/UserRepository.php new file mode 100644 index 00000000..43d16575 --- /dev/null +++ b/src/app/Packages/Domains/User/UserRepository.php @@ -0,0 +1,102 @@ +userModel->create([ + 'email' => $entity->getEmail()->value(), + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'password' => $entity->getPasswordHash(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), + ]); + + if (!$insertUser->wasRecentlyCreated) { + throw new Exception('Failed to create user.'); + } + + return UserDtoFactory::build($insertUser->toArray()); + } + + public function findById(int $id): ?UserDto + { + $user = $this->userModel->find($id); + + if ($user === null) { + return null; + } + + return UserDtoFactory::build($user->toArray()); + } + + public function updateUser(UserEntity $entity): UserDto + { + $user = $this->userModel->find($entity->getId()); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->update([ + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'email' => $entity->getEmail()->value(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), + ]); + + $user->refresh(); + + return UserDtoFactory::build($user->toArray()); + } + + public function deleteUser(int $id): void + { + $user = $this->userModel->find($id); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->delete(); + } + + public function findByEmail(Email $email): ?UserEntity + { + $user = $this->userModel->where('email', $email->value())->first(); + + if ($user === null) { + return null; + } + + return UserEntityFactory::build([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'age_range' => $user->age_range, + 'subscription_tier' => $user->subscription_tier, + 'subscription_expires_at' => $user->subscription_expires_at, + 'password_hash' => $user->password, + ]); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/ValueObject/Email.php b/src/app/Packages/Domains/User/ValueObject/Email.php new file mode 100644 index 00000000..4c8eecc3 --- /dev/null +++ b/src/app/Packages/Domains/User/ValueObject/Email.php @@ -0,0 +1,27 @@ +value; + } + + public function isEqual(self $otherEmail): bool + { + return $this->value === $otherEmail->value; + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/Adapter/AlgoliaWorldHeritageSearchAdapter.php b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php similarity index 97% rename from src/app/Packages/Domains/Adapter/AlgoliaWorldHeritageSearchAdapter.php rename to src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php index cd441604..97f72711 100644 --- a/src/app/Packages/Domains/Adapter/AlgoliaWorldHeritageSearchAdapter.php +++ b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php @@ -1,10 +1,10 @@ null, + 'first_name' => $command->firstName, + 'last_name' => $command->lastName, + 'email' => $command->email, + 'age_range' => $command->ageRange, + 'subscription_tier' => $command->subscriptionTier, + 'subscription_expires_at' => $command->subscriptionExpiresAt, + 'password_hash' => bcrypt($command->password), + ]); + + return $this->userRepository->createUser($entity); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php new file mode 100644 index 00000000..6c982bd2 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php @@ -0,0 +1,17 @@ +userRepository->deleteUser($id); + } +} diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php new file mode 100644 index 00000000..e6207e91 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php @@ -0,0 +1,34 @@ +userRepository->findByEmail(new Email($email)); + + if ($entity === null) { + throw new InvalidArgumentException('Invalid credentials.'); + } + + if (!password_verify($password, $entity->getPasswordHash())) { + throw new InvalidArgumentException('Invalid user data. Please try again.'); + } + + $token = $this->tokenService->createToken($entity); + + return new AuthTokenDto(token: $token); + } +} diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php new file mode 100644 index 00000000..432496b3 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php @@ -0,0 +1,17 @@ +tokenService->revokeCurrentToken($plainTextToken); + } +} diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php new file mode 100644 index 00000000..266d8b25 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php @@ -0,0 +1,30 @@ + $command->id, + 'first_name' => $command->firstName, + 'last_name' => $command->lastName, + 'email' => $command->email, + 'age_range' => $command->ageRange, + 'subscription_tier' => $command->subscriptionTier, + 'subscription_expires_at' => $command->subscriptionExpiresAt, + ]); + + return $this->userRepository->updateUser($entity); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php b/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php new file mode 100644 index 00000000..271cdd2b --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php @@ -0,0 +1,48 @@ +handle( + email: $request->input('email'), + password: $request->input('password'), + ); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'token' => $dto->token, + 'token_type' => $dto->tokenType, + ], + ], 200); + } catch (InvalidArgumentException $exception) { + return response()->json([ + 'status' => 'error', + 'message' => $exception->getMessage(), + ], 401); + } catch (Throwable $throw) { + Log::error('Failed to login', [ + 'message' => $throw->getMessage(), + 'trace' => $throw->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + + public function logout(Request $request, LogoutUseCase $useCase): JsonResponse + { + try { + $useCase->handle($request->bearerToken() ?? ''); + + return response()->json([ + 'status' => 'success', + ], 200); + } catch (Throwable $throw) { + Log::error('Failed to logout', [ + 'message' => $throw->getMessage(), + 'trace' => $throw->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } +} diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php new file mode 100644 index 00000000..49589f83 --- /dev/null +++ b/src/app/Packages/Features/Controller/UserController.php @@ -0,0 +1,139 @@ +handle($request->route('id')); + + return response()->json([ + 'status' => 'success', + 'data' => UserViewModelFactory::build($dto)->toArray(), + ], 200); + } catch (\Exception $exception) { + if ($exception->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to get user by id', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + + public function updateUser( + Request $request, + UpdateUserUseCase $useCase, + ): JsonResponse { + try { + $command = UpdateUserCommand::fromArray(array_merge( + $request->all(), + ['id' => $request->route('id')], + )); + $dto = $useCase->handle($command); + + return response()->json([ + 'status' => 'success', + 'data' => UserViewModelFactory::build($dto)->toArray(), + ], 200); + } catch (\Exception $exception) { + if ($exception->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to update user', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + + public function deleteUser( + Request $request, + DeleteUserUseCase $useCase, + ): JsonResponse { + try { + $useCase->handle((int) $request->route('id')); + + return response()->json([ + 'status' => 'success', + ], 200); + } catch (\Exception $exception) { + if ($exception->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to delete user', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + + public function createUser( + Request $request, + CreateUserUseCase $useCase, + ): JsonResponse { + try { + $command = CreateUserCommand::fromArray($request->all()); + $dto = $useCase->handle($command); + + return response()->json([ + 'status' => 'success', + 'data' => UserViewModelFactory::build($dto)->toArray(), + ], 201); + } catch (\Throwable $throwable) { + Log::error('Failed to create user', [ + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php b/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php new file mode 100644 index 00000000..0524ed31 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php @@ -0,0 +1,11 @@ +unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'teens', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_returns_user_dto_when_user_exists(): void + { + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with($dto->id) + ->andReturn($dto); + + $result = (new GetUserByIdUseCase($repository))->handle($dto->id); + + $this->assertSame($dto, $result); + } + + public function test_handle_throws_exception_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with(999) + ->andReturn(null); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new GetUserByIdUseCase($repository))->handle(999); + } +} diff --git a/src/app/Packages/Features/QueryUseCases/Tests/UseCase/SearchWorldHeritagesWithAlgoliaUseCaseTest.php b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/SearchWorldHeritagesWithAlgoliaUseCaseTest.php index 78643746..46ae6bad 100644 --- a/src/app/Packages/Features/QueryUseCases/Tests/UseCase/SearchWorldHeritagesWithAlgoliaUseCaseTest.php +++ b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/SearchWorldHeritagesWithAlgoliaUseCaseTest.php @@ -10,7 +10,7 @@ use App\Packages\Features\QueryUseCases\ListQuery\AlgoliaSearchListQuery; use App\Packages\Features\QueryUseCases\UseCase\SearchWorldHeritagesWithAlgoliaUseCase; use App\Packages\Features\QueryUseCases\QueryServiceInterface\WorldHeritageQueryServiceInterface; -use App\Packages\Domains\Infra\CountryResolver; +use App\Packages\Domains\WorldHeritage\Infra\CountryResolver; use App\Packages\Features\QueryUseCases\Dto\WorldHeritageDto; use App\Packages\Features\QueryUseCases\Dto\WorldHeritageDtoCollection; diff --git a/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelFactoryTest.php b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelFactoryTest.php new file mode 100644 index 00000000..13074c32 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelFactoryTest.php @@ -0,0 +1,31 @@ +unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'free', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + + $viewModel = UserViewModelFactory::build($dto); + + $this->assertInstanceOf(UserViewModel::class, $viewModel); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelTest.php b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelTest.php new file mode 100644 index 00000000..007cd581 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelTest.php @@ -0,0 +1,55 @@ +unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + + $result = (new UserViewModel($dto))->toArray(); + + $this->assertSame($dto->id, $result['id']); + $this->assertSame($dto->firstName, $result['first_name']); + $this->assertSame($dto->lastName, $result['last_name']); + $this->assertSame($dto->email, $result['email']); + $this->assertSame($dto->ageRange, $result['age_range']); + $this->assertSame($dto->subscriptionTier, $result['subscription_tier']); + $this->assertNull($result['subscription_expires_at']); + } + + public function test_toArray_includes_subscription_expires_at_when_set(): void + { + $faker = FakerFactory::create(); + + $dto = new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: '20s', + subscriptionTier: 'premium', + subscriptionExpiresAt: '2027-01-01 00:00:00', + ); + + $result = (new UserViewModel($dto))->toArray(); + + $this->assertSame('2027-01-01 00:00:00', $result['subscription_expires_at']); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/QueryUseCases/UseCase/SearchWorldHeritagesWithAlgoliaUseCase.php b/src/app/Packages/Features/QueryUseCases/UseCase/SearchWorldHeritagesWithAlgoliaUseCase.php index a45ab75a..2bc2c733 100644 --- a/src/app/Packages/Features/QueryUseCases/UseCase/SearchWorldHeritagesWithAlgoliaUseCase.php +++ b/src/app/Packages/Features/QueryUseCases/UseCase/SearchWorldHeritagesWithAlgoliaUseCase.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\QueryUseCases\UseCase; -use App\Packages\Domains\Infra\CountryResolver; +use App\Packages\Domains\WorldHeritage\Infra\CountryResolver; use App\Packages\Features\QueryUseCases\Factory\ListQuery\AlgoliaSearchListQueryFactory; use App\Packages\Features\QueryUseCases\QueryServiceInterface\WorldHeritageQueryServiceInterface; use App\Common\Pagination\PaginationDto; diff --git a/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php b/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php new file mode 100644 index 00000000..788aa02d --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php @@ -0,0 +1,25 @@ +userRepository->findById($id); + + if ($userDto === null) { + throw new Exception('User not found.'); + } + + return $userDto; + } +} diff --git a/src/app/Packages/Features/QueryUseCases/ViewModel/User/UserViewModel.php b/src/app/Packages/Features/QueryUseCases/ViewModel/User/UserViewModel.php new file mode 100644 index 00000000..17402a17 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/ViewModel/User/UserViewModel.php @@ -0,0 +1,25 @@ + $this->dto->id, + 'first_name' => $this->dto->firstName, + 'last_name' => $this->dto->lastName, + 'email' => $this->dto->email, + 'age_range' => $this->dto->ageRange, + 'subscription_tier' => $this->dto->subscriptionTier, + 'subscription_expires_at' => $this->dto->subscriptionExpiresAt, + ]; + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/CommandUseCases/CreateUserCommandTest.php b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserCommandTest.php new file mode 100644 index 00000000..8957489d --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserCommandTest.php @@ -0,0 +1,72 @@ + $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => $faker->randomElement(['free', 'premium']), + ], $overrides); + } + + public function test_fromArray_creates_command_with_valid_data(): void + { + $data = $this->validData(); + $command = CreateUserCommand::fromArray($data); + + $this->assertSame($data['first_name'], $command->firstName); + $this->assertSame($data['last_name'], $command->lastName); + $this->assertSame($data['email'], $command->email); + $this->assertSame($data['password'], $command->password); + $this->assertSame($data['age_range'], $command->ageRange); + $this->assertSame($data['subscription_tier'], $command->subscriptionTier); + $this->assertNull($command->subscriptionExpiresAt); + } + + public function test_fromArray_sets_subscription_expires_at_when_provided(): void + { + $command = CreateUserCommand::fromArray($this->validData([ + 'subscription_expires_at' => '2027-01-01 00:00:00', + ])); + + $this->assertSame('2027-01-01 00:00:00', $command->subscriptionExpiresAt); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('missingKeyProvider')] + public function test_fromArray_throws_when_required_key_is_missing(string $missingKey): void + { + $data = $this->validData(); + unset($data[$missingKey]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Missing required key: {$missingKey}"); + + CreateUserCommand::fromArray($data); + } + + public static function missingKeyProvider(): array + { + return [ + ['first_name'], + ['last_name'], + ['email'], + ['password'], + ['age_range'], + ['subscription_tier'], + ]; + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php new file mode 100644 index 00000000..81e16326 --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php @@ -0,0 +1,69 @@ + $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => 'free', + ]); + } + + private function buildDto(): UserDto + { + $faker = FakerFactory::create(); + + return new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'free', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_passes_entity_to_repository_and_returns_dto(): void + { + $command = $this->buildCommand(); + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('createUser') + ->once() + ->with(Mockery::type(UserEntity::class)) + ->andReturn($dto); + + $useCase = new CreateUserUseCase($repository); + $result = $useCase->handle($command); + + $this->assertSame($dto, $result); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php new file mode 100644 index 00000000..74f4650a --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php @@ -0,0 +1,45 @@ +shouldReceive('deleteUser') + ->once() + ->with(1); + + (new DeleteUserUseCase($repository))->handle(1); + } + + public function test_handle_propagates_exception_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('deleteUser') + ->once() + ->with(999999) + ->andThrow(new \Exception('User not found.')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new DeleteUserUseCase($repository))->handle(999999); + } +} diff --git a/src/app/Packages/Features/Tests/CommandUseCases/LoginUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/LoginUseCaseTest.php new file mode 100644 index 00000000..84cb59db --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/LoginUseCaseTest.php @@ -0,0 +1,99 @@ + 1, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => $email, + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => $passwordHash ?? password_hash('secret', PASSWORD_BCRYPT), + ]); + } + + public function test_handle_returns_token_dto_on_valid_credentials(): void + { + $entity = $this->buildEntity(); + + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn($entity); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldReceive('createToken') + ->once() + ->with($entity) + ->andReturn('plain-text-token'); + + $result = (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'secret'); + + $this->assertInstanceOf(AuthTokenDto::class, $result); + $this->assertSame('plain-text-token', $result->token); + $this->assertSame('Bearer', $result->tokenType); + } + + public function test_handle_throws_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn(null); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldNotReceive('createToken'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'secret'); + } + + public function test_handle_throws_on_wrong_password(): void + { + $entity = $this->buildEntity(); + + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn($entity); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldNotReceive('createToken'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid user data. Please try again.'); + + (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'wrong-password'); + } +} diff --git a/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php new file mode 100644 index 00000000..ad1b7d93 --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php @@ -0,0 +1,45 @@ +shouldReceive('revokeCurrentToken') + ->once() + ->with('plain-text-token'); + + (new LogoutUseCase($tokenService))->handle('plain-text-token'); + } + + public function test_handle_propagates_exception_when_revocation_fails(): void + { + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldReceive('revokeCurrentToken') + ->once() + ->andThrow(new RuntimeException('Revocation failed.')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Revocation failed.'); + + (new LogoutUseCase($tokenService))->handle('plain-text-token'); + } +} diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php new file mode 100644 index 00000000..0e4b4ade --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php @@ -0,0 +1,72 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => $faker->randomElement(['free', 'premium']), + ], $overrides); + } + + public function test_fromArray_creates_command_with_valid_data(): void + { + $data = $this->validData(); + $command = UpdateUserCommand::fromArray($data); + + $this->assertSame((int) $data['id'], $command->id); + $this->assertSame($data['first_name'], $command->firstName); + $this->assertSame($data['last_name'], $command->lastName); + $this->assertSame($data['email'], $command->email); + $this->assertSame($data['age_range'], $command->ageRange); + $this->assertSame($data['subscription_tier'], $command->subscriptionTier); + $this->assertNull($command->subscriptionExpiresAt); + } + + public function test_fromArray_sets_subscription_expires_at_when_provided(): void + { + $command = UpdateUserCommand::fromArray($this->validData([ + 'subscription_expires_at' => '2027-01-01 00:00:00', + ])); + + $this->assertSame('2027-01-01 00:00:00', $command->subscriptionExpiresAt); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('missingKeyProvider')] + public function test_fromArray_throws_when_required_key_is_missing(string $missingKey): void + { + $data = $this->validData(); + unset($data[$missingKey]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Missing required key: {$missingKey}"); + + UpdateUserCommand::fromArray($data); + } + + public static function missingKeyProvider(): array + { + return [ + ['id'], + ['first_name'], + ['last_name'], + ['email'], + ['age_range'], + ['subscription_tier'], + ]; + } +} diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php new file mode 100644 index 00000000..63f14d3a --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php @@ -0,0 +1,68 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => 'free', + ]); + } + + private function buildDto(): UserDto + { + $faker = FakerFactory::create(); + + return new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'teens', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_passes_entity_to_repository_and_returns_dto(): void + { + $command = $this->buildCommand(); + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('updateUser') + ->once() + ->with(Mockery::type(UserEntity::class)) + ->andReturn($dto); + + $result = (new UpdateUserUseCase($repository))->handle($command); + + $this->assertSame($dto, $result); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/CreateUserTest.php b/src/app/Packages/Features/Tests/CreateUserTest.php new file mode 100644 index 00000000..254fd2f0 --- /dev/null +++ b/src/app/Packages/Features/Tests/CreateUserTest.php @@ -0,0 +1,88 @@ +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;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function validPayload(array $overrides = []): array + { + $faker = FakerFactory::create(); + + return array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => $faker->randomElement(['free', 'premium']), + ], $overrides); + } + + public function test_create_user_returns_201_with_user_data(): void + { + $response = $this->postJson('/api/v1/user/create', $this->validPayload()); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'status', + 'data' => [ + 'id', + 'first_name', + 'last_name', + 'email', + 'age_range', + 'subscription_tier', + 'subscription_expires_at', + ], + ]) + ->assertJsonFragment(['status' => 'success']); + } + + public function test_create_user_returns_500_when_required_key_is_missing(): void + { + $payload = $this->validPayload(); + unset($payload['email']); + + $response = $this->postJson('/api/v1/user/create', $payload); + + $response->assertStatus(500) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_create_user_returns_500_on_duplicate_email(): void + { + $payload = $this->validPayload(); + + $this->postJson('/api/v1/user/create', $payload); + $response = $this->postJson('/api/v1/user/create', $payload); + + $response->assertStatus(500) + ->assertJsonFragment(['status' => 'error']); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/DeleteUserTest.php b/src/app/Packages/Features/Tests/DeleteUserTest.php new file mode 100644 index 00000000..7b78711a --- /dev/null +++ b/src/app/Packages/Features/Tests/DeleteUserTest.php @@ -0,0 +1,70 @@ +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;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + '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, + ], $overrides)); + } + + public function test_delete_user_returns_200_when_user_exists(): void + { + $user = $this->seedUser(); + + $response = $this->deleteJson("/api/v1/users/{$user->id}"); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + public function test_delete_user_returns_404_when_user_not_found(): void + { + $response = $this->deleteJson('/api/v1/users/999999'); + + $response->assertStatus(404) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'User not found.', + ]); + } +} diff --git a/src/app/Packages/Features/Tests/GetCountEachRegionTest.php b/src/app/Packages/Features/Tests/GetCountEachRegionTest.php index 295cdc4f..4792b209 100644 --- a/src/app/Packages/Features/Tests/GetCountEachRegionTest.php +++ b/src/app/Packages/Features/Tests/GetCountEachRegionTest.php @@ -5,8 +5,8 @@ use App\Models\Country; use App\Models\Image; use App\Models\WorldHeritage; -use App\Packages\Domains\Ports\Dto\HeritageSearchResult; -use App\Packages\Domains\Ports\WorldHeritageSearchPort; +use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult; +use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Database\Seeders\DatabaseSeeder; use Illuminate\Support\Facades\DB; use Tests\TestCase; diff --git a/src/app/Packages/Features/Tests/GetUserByIdTest.php b/src/app/Packages/Features/Tests/GetUserByIdTest.php new file mode 100644 index 00000000..8bae7766 --- /dev/null +++ b/src/app/Packages/Features/Tests/GetUserByIdTest.php @@ -0,0 +1,84 @@ +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;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + '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, + ], $overrides)); + } + + public function test_get_user_by_id_returns_200_with_user_data(): void + { + $user = $this->seedUser(); + + $response = $this->getJson("/api/v1/users/{$user->id}"); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => [ + 'id', + 'first_name', + 'last_name', + 'email', + 'age_range', + 'subscription_tier', + 'subscription_expires_at', + ], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'id' => $user->id, + 'email' => $user->email, + ]); + } + + public function test_get_user_by_id_returns_404_when_user_not_found(): void + { + $response = $this->getJson('/api/v1/users/999999'); + + $response->assertStatus(404) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'User not found.', + ]); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/GetWorldHeritageByIdTest.php b/src/app/Packages/Features/Tests/GetWorldHeritageByIdTest.php index 29d64268..e3d10d5b 100644 --- a/src/app/Packages/Features/Tests/GetWorldHeritageByIdTest.php +++ b/src/app/Packages/Features/Tests/GetWorldHeritageByIdTest.php @@ -5,8 +5,8 @@ use App\Models\Country; use App\Models\Image; use App\Models\WorldHeritage; -use App\Packages\Domains\Ports\Dto\HeritageSearchResult; -use App\Packages\Domains\Ports\WorldHeritageSearchPort; +use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult; +use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Database\Seeders\DatabaseSeeder; use Illuminate\Support\Facades\DB; use Tests\TestCase; diff --git a/src/app/Packages/Features/Tests/GetWorldHeritagesTest.php b/src/app/Packages/Features/Tests/GetWorldHeritagesTest.php index 41f7a96d..b8724653 100644 --- a/src/app/Packages/Features/Tests/GetWorldHeritagesTest.php +++ b/src/app/Packages/Features/Tests/GetWorldHeritagesTest.php @@ -4,8 +4,8 @@ use App\Models\Image; use App\Models\WorldHeritageDescription; -use App\Packages\Domains\Ports\Dto\HeritageSearchResult; -use App\Packages\Domains\Ports\WorldHeritageSearchPort; +use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult; +use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Database\Seeders\DatabaseSeeder; use Tests\TestCase; use Illuminate\Support\Facades\DB; diff --git a/src/app/Packages/Features/Tests/LoginTest.php b/src/app/Packages/Features/Tests/LoginTest.php new file mode 100644 index 00000000..55593757 --- /dev/null +++ b/src/app/Packages/Features/Tests/LoginTest.php @@ -0,0 +1,109 @@ +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('personal_access_tokens')->truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(string $email, string $password): User + { + return User::create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => $email, + 'password' => bcrypt($password), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + public function test_login_returns_200_with_token_on_valid_credentials(): void + { + $this->seedUser('john@example.com', 'secret123'); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => ['token', 'token_type'], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'token_type' => 'Bearer', + ]); + } + + public function test_login_returns_401_when_user_not_found(): void + { + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'notfound@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_login_returns_401_on_wrong_password(): void + { + $this->seedUser('john@example.com', 'secret123'); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_login_returns_500_on_unexpected_error(): void + { + $this->mock(LoginUseCase::class) + ->shouldReceive('handle') + ->andThrow(new RuntimeException('Unexpected error')); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } +} diff --git a/src/app/Packages/Features/Tests/LogoutTest.php b/src/app/Packages/Features/Tests/LogoutTest.php new file mode 100644 index 00000000..ac099390 --- /dev/null +++ b/src/app/Packages/Features/Tests/LogoutTest.php @@ -0,0 +1,89 @@ +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('personal_access_tokens')->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, + ]); + } + + public function test_logout_returns_200_when_authenticated(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withToken($token) + ->postJson('/api/v1/user/logout'); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_logout_returns_401_when_unauthenticated(): void + { + $response = $this->postJson('/api/v1/user/logout'); + + $response->assertStatus(401); + } + + public function test_logout_returns_500_on_unexpected_error(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $this->mock(LogoutUseCase::class) + ->shouldReceive('handle') + ->andThrow(new RuntimeException('Unexpected error')); + + $response = $this->withToken($token) + ->postJson('/api/v1/user/logout'); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } +} diff --git a/src/app/Packages/Features/Tests/QueryUseCases/UserDtoFactoryTest.php b/src/app/Packages/Features/Tests/QueryUseCases/UserDtoFactoryTest.php new file mode 100644 index 00000000..8c95913f --- /dev/null +++ b/src/app/Packages/Features/Tests/QueryUseCases/UserDtoFactoryTest.php @@ -0,0 +1,54 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]; + + $dto = UserDtoFactory::build($data); + + $this->assertInstanceOf(UserDto::class, $dto); + $this->assertSame((int)$data['id'], $dto->id); + $this->assertSame($data['first_name'], $dto->firstName); + $this->assertSame($data['last_name'], $dto->lastName); + $this->assertSame($data['email'], $dto->email); + $this->assertSame($data['age_range'], $dto->ageRange); + $this->assertSame($data['subscription_tier'], $dto->subscriptionTier); + $this->assertNull($dto->subscriptionExpiresAt); + } + + public function test_build_casts_id_to_int(): void + { + $faker = FakerFactory::create(); + + $dto = UserDtoFactory::build([ + 'id' => '42', + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->assertSame(42, $dto->id); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/SearchWorldHeritagesTest.php b/src/app/Packages/Features/Tests/SearchWorldHeritagesTest.php index abd9fc7d..09c50136 100644 --- a/src/app/Packages/Features/Tests/SearchWorldHeritagesTest.php +++ b/src/app/Packages/Features/Tests/SearchWorldHeritagesTest.php @@ -6,8 +6,8 @@ use App\Models\Image; use App\Models\WorldHeritage; use App\Models\WorldHeritageDescription; -use App\Packages\Domains\Ports\Dto\HeritageSearchResult; -use App\Packages\Domains\Ports\WorldHeritageSearchPort; +use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult; +use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Database\Seeders\DatabaseSeeder; use Illuminate\Support\Facades\DB; diff --git a/src/app/Packages/Features/Tests/UpdateUserTest.php b/src/app/Packages/Features/Tests/UpdateUserTest.php new file mode 100644 index 00000000..b375ab58 --- /dev/null +++ b/src/app/Packages/Features/Tests/UpdateUserTest.php @@ -0,0 +1,109 @@ +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;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + '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, + ], $overrides)); + } + + private function validPayload(array $overrides = []): array + { + $faker = FakerFactory::create(); + + return array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '30s', + 'subscription_tier' => 'premium', + ], $overrides); + } + + public function test_update_user_returns_200_with_updated_data(): void + { + $user = $this->seedUser(); + + $payload = $this->validPayload(['email' => 'updated@example.com']); + $response = $this->patchJson("/api/v1/users/{$user->id}", $payload); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => [ + 'id', + 'first_name', + 'last_name', + 'email', + 'age_range', + 'subscription_tier', + 'subscription_expires_at', + ], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'email' => 'updated@example.com', + ]); + } + + public function test_update_user_returns_404_when_user_not_found(): void + { + $response = $this->patchJson('/api/v1/users/999999', $this->validPayload()); + + $response->assertStatus(404) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'User not found.', + ]); + } + + public function test_update_user_returns_500_when_required_key_is_missing(): void + { + $user = $this->seedUser(); + $payload = $this->validPayload(); + unset($payload['email']); + + $response = $this->patchJson("/api/v1/users/{$user->id}", $payload); + + $response->assertStatus(500) + ->assertJsonFragment(['status' => 'error']); + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index c3847f2b..a12b9fe0 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -3,8 +3,10 @@ namespace App\Providers; use Algolia\AlgoliaSearch\Api\SearchClient; -use App\Packages\Domains\Adapter\AlgoliaWorldHeritageSearchAdapter; -use App\Packages\Domains\Ports\WorldHeritageSearchPort; +use App\Packages\Domains\User\Interface\TokenServiceInterface; +use App\Packages\Domains\User\Service\SanctumTokenService; +use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaWorldHeritageSearchAdapter; +use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Google\Cloud\Storage\StorageClient; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Storage; @@ -22,6 +24,8 @@ public function register(): void { // We bind via a factory closure (instead of a simple class binding) because: // - The adapter needs runtime configuration (Algolia app id / API key / index name) + $this->app->bind(TokenServiceInterface::class, SanctumTokenService::class); + $this->app->bind(WorldHeritageSearchPort::class, function () { $client = SearchClient::create(config('algolia.algolia_app_id'), config('algolia.algolia_search_api_key')); diff --git a/src/app/Providers/PortsServiceProvider.php b/src/app/Providers/PortsServiceProvider.php index 0162cb94..774c3295 100644 --- a/src/app/Providers/PortsServiceProvider.php +++ b/src/app/Providers/PortsServiceProvider.php @@ -5,13 +5,13 @@ use App\Packages\Domains\Adapter\GcsObjectStorageAdapter; use App\Packages\Domains\Adapter\GcsSignedUrlAdapter; use App\Packages\Domains\Infra\GcsImageObjectRemover; -use App\Packages\Domains\Ports\ObjectRemovePort; -use App\Packages\Domains\Ports\ObjectStoragePort; -use App\Packages\Domains\Ports\SignedUrlPort; +use App\Packages\Domains\WorldHeritage\Ports\ObjectRemovePort; +use App\Packages\Domains\WorldHeritage\Ports\ObjectStoragePort; +use App\Packages\Domains\WorldHeritage\Ports\SignedUrlPort; use Illuminate\Support\ServiceProvider; use Google\Cloud\Storage\StorageClient; -use App\Packages\Domains\Ports\WorldHeritageSearchPort; -use App\Packages\Domains\Adapter\AlgoliaWorldHeritageSearchAdapter; +use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; +use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaWorldHeritageSearchAdapter; class PortsServiceProvider extends ServiceProvider { diff --git a/src/app/Providers/QueryServiceProvider.php b/src/app/Providers/QueryServiceProvider.php index ee95209f..35a53e60 100644 --- a/src/app/Providers/QueryServiceProvider.php +++ b/src/app/Providers/QueryServiceProvider.php @@ -4,9 +4,9 @@ use Illuminate\Support\ServiceProvider; use App\Packages\Features\QueryUseCases\QueryServiceInterface\WorldHeritageQueryServiceInterface; -use App\Packages\Domains\WorldHeritageQueryService; +use App\Packages\Domains\WorldHeritage\WorldHeritageQueryService; use App\Packages\Features\QueryUseCases\QueryServiceInterface\WorldHeritageReadQueryServiceInterface; -use App\Packages\Domains\WorldHeritageReadQueryService; +use App\Packages\Domains\WorldHeritage\WorldHeritageReadQueryService; class QueryServiceProvider extends ServiceProvider { diff --git a/src/app/Providers/RepositoryProvider.php b/src/app/Providers/RepositoryProvider.php index eb3d6f29..cbba05bf 100644 --- a/src/app/Providers/RepositoryProvider.php +++ b/src/app/Providers/RepositoryProvider.php @@ -2,26 +2,27 @@ namespace App\Providers; -use Illuminate\Support\ServiceProvider; -use App\Packages\Domains\WorldHeritageRepositoryInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\UserRepository; use App\Packages\Domains\WorldHeritageRepository; +use App\Packages\Domains\WorldHeritageRepositoryInterface; +use Illuminate\Support\ServiceProvider; class RepositoryProvider extends ServiceProvider { - /** - * Register services. - */ public function register(): void { $this->app->bind( WorldHeritageRepositoryInterface::class, WorldHeritageRepository::class ); + + $this->app->bind( + UserRepositroyInterface::class, + UserRepository::class + ); } - /** - * Bootstrap services. - */ public function boot(): void { // diff --git a/src/app/Support/StudyRegionResolver.php b/src/app/Support/StudyRegionResolver.php index a99188bb..b7967bc6 100644 --- a/src/app/Support/StudyRegionResolver.php +++ b/src/app/Support/StudyRegionResolver.php @@ -2,10 +2,10 @@ namespace App\Support; -use App\Packages\Domains\StudyRegion\CountryAliases; -use App\Packages\Domains\StudyRegion\ExceptionalStudyRegions; -use App\Packages\Domains\StudyRegion\Iso3ToStudyRegionMap; -use App\Packages\Domains\StudyRegion\CountryToStudyRegionMap; +use App\Packages\Domains\WorldHeritage\StudyRegion\CountryAliases; +use App\Packages\Domains\WorldHeritage\StudyRegion\ExceptionalStudyRegions; +use App\Packages\Domains\WorldHeritage\StudyRegion\Iso3ToStudyRegionMap; +use App\Packages\Domains\WorldHeritage\StudyRegion\CountryToStudyRegionMap; use App\Enums\StudyRegion; class StudyRegionResolver diff --git a/src/composer.json b/src/composer.json index 49f6da77..c82bc295 100644 --- a/src/composer.json +++ b/src/composer.json @@ -12,6 +12,7 @@ "algolia/algoliasearch-client-php": "^4.0", "google/cloud-storage": "^1", "laravel/framework": "^11.31", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.9", "league/flysystem-google-cloud-storage": "^3", "symfony/intl": "^7.4" diff --git a/src/composer.lock b/src/composer.lock index 1aabe4ab..68421a89 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "68110e920a7d7e2ac88297daac555d38", + "content-hash": "3720ffca039e682d60884862ba8486cd", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -1935,6 +1935,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", diff --git a/src/config/sanctum.php b/src/config/sanctum.php new file mode 100644 index 00000000..ff9ca035 --- /dev/null +++ b/src/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => 1440, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/src/database/migrations/2026_06_27_000000_create_users_table.php b/src/database/migrations/2026_06_27_000000_create_users_table.php new file mode 100644 index 00000000..d58b8e48 --- /dev/null +++ b/src/database/migrations/2026_06_27_000000_create_users_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('email')->unique(); + $table->string('password'); + $table->enum('age_range', ['teens', '20s', '30s', '40s', '50s', '60plus']); + $table->enum('subscription_tier', ['free', 'premium'])->default('free'); + $table->timestamp('subscription_expires_at')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; \ No newline at end of file diff --git a/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php b/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/src/mago.toml b/src/mago.toml new file mode 100644 index 00000000..19143253 --- /dev/null +++ b/src/mago.toml @@ -0,0 +1,186 @@ +[source] +paths = ["app/Packages"] + +[linter] +excludes = ["database/migrations"] + +# --- 無効化: スタイル・冗長性系(厳しすぎるため外す) --- +[linter.rules.strict-types] +enabled = false + +[linter.rules.no-else-clause] +enabled = false + +[linter.rules.no-redundant-write-visibility] +enabled = false + +[linter.rules.no-redundant-method-override] +enabled = false + +[linter.rules.no-redundant-parentheses] +enabled = false + +[linter.rules.no-redundant-string-concat] +enabled = false + +[linter.rules.no-redundant-isset] +enabled = false + +[linter.rules.no-redundant-final] +enabled = false + +[linter.rules.no-redundant-readonly] +enabled = false + +[linter.rules.no-redundant-block] +enabled = false + +[linter.rules.no-redundant-continue] +enabled = false + +[linter.rules.no-redundant-use] +enabled = false + +[linter.rules.no-redundant-nullsafe] +enabled = false + +[linter.rules.no-redundant-label] +enabled = false + +[linter.rules.no-redundant-literal-return] +enabled = false + +[linter.rules.prefer-arrow-function] +enabled = false + +[linter.rules.prefer-early-continue] +enabled = false + +[linter.rules.prefer-static-closure] +enabled = false + +[linter.rules.inline-variable-return] +enabled = false + +[linter.rules.no-boolean-flag-parameter] +enabled = false + +[linter.rules.no-isset] +enabled = false + +[linter.rules.halstead] +enabled = false + +[linter.rules.readable-literal] +enabled = false + +[linter.rules.literal-named-argument] +enabled = false + +[linter.rules.no-shorthand-ternary] +enabled = false + +[linter.rules.kan-defect] +enabled = false + +[linter.rules.sensitive-parameter] +enabled = false + +[linter.rules.explicit-nullable-param] +enabled = false + +[linter.rules.tagged-todo] +enabled = false + +[linter.rules.tagged-fixme] +enabled = false + +[linter.rules.valid-docblock] +enabled = false + +[linter.rules.no-trailing-space] +enabled = false + +[linter.rules.no-closing-tag] +enabled = false + +[linter.rules.no-php-tag-terminator] +enabled = false + +[linter.rules.prefer-first-class-callable] +enabled = false + +[linter.rules.no-nested-ternary] +enabled = false + +[linter.rules.no-empty] +enabled = false + +[linter.rules.block-statement] +enabled = false + +[linter.rules.class-name] +enabled = false + +# --- 有効化: バグ・セキュリティ系 --- +[linter.rules.no-eval] +enabled = true +level = "Error" + +[linter.rules.no-global] +enabled = true +level = "Error" + +[linter.rules.no-error-control-operator] +enabled = true +level = "Error" + +[linter.rules.no-empty-catch-clause] +enabled = true +level = "Error" + +[linter.rules.no-insecure-comparison] +enabled = true +level = "Error" + +[linter.rules.no-literal-password] +enabled = false + +[linter.rules.no-shell-execute-string] +enabled = true +level = "Error" + +[linter.rules.no-debug-symbols] +enabled = true +level = "Warning" + +[linter.rules.loop-does-not-iterate] +enabled = true +level = "Warning" + +[linter.rules.no-self-assignment] +enabled = true +level = "Warning" + +[linter.rules.no-assign-in-condition] +enabled = true +level = "Warning" + +[linter.rules.identity-comparison] +enabled = true +level = "Warning" + +[linter.rules.cyclomatic-complexity] +enabled = true +level = "Error" +threshold = 60 + +[linter.rules.excessive-parameter-list] +enabled = true +level = "Warning" +threshold = 25 + +[linter.rules.too-many-methods] +enabled = true +level = "Warning" +threshold = 30 diff --git a/src/phpunit.xml b/src/phpunit.xml index f2373c92..c39fe1f7 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -14,6 +14,12 @@ app + + + + + + diff --git a/src/routes/api.php b/src/routes/api.php index 68ce170e..3b734ccb 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,11 +1,28 @@ group(function (): void { - Route::get('/heritages', [WorldHeritageController::class, 'getWorldHeritages']); - Route::get('/heritages/search', [WorldHeritageController::class, 'searchWorldHeritages']); - Route::get('heritages/region-count', [WorldHeritageController::class, 'getWorldHeritagesCountByRegion']); - Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); + Route::controller(WorldHeritageController::class)->prefix('heritages')->group(function (): void { + Route::get('/', 'getWorldHeritages'); + Route::get('/search', 'searchWorldHeritages'); + Route::get('/region-count', 'getWorldHeritagesCountByRegion'); + Route::get('/{id}', 'getWorldHeritageById'); + }); + + Route::post('/user/create', [UserController::class, 'createUser']); + + Route::controller(AuthController::class)->prefix('user')->group(function (): void { + Route::post('/login', 'login'); + Route::post('/logout', 'logout')->middleware('auth:sanctum'); + }); + + Route::controller(UserController::class)->prefix('users')->group(function (): void { + Route::get('/{id}', 'getUserById'); + Route::patch('/{id}', 'updateUser'); + Route::delete('/{id}', 'deleteUser'); + }); }); \ No newline at end of file diff --git a/src/storage/app/private/unesco/normalized/world_heritage_sites.json b/src/storage/app/private/unesco/normalized/world_heritage_sites.json index 0b2f406e..e6303f03 100644 --- a/src/storage/app/private/unesco/normalized/world_heritage_sites.json +++ b/src/storage/app/private/unesco/normalized/world_heritage_sites.json @@ -2,7 +2,7 @@ "meta": { "schema": "world_heritage_sites.import.v1", "source_raw": "unesco/world-heritage-sites.json", - "generated_at": "2026-05-04T20:29:46+09:00", + "generated_at": "2026-06-26T08:46:51+09:00", "rows_scanned": 1248, "sites": 1248, "target_table": "world_heritage_sites" @@ -2087,7 +2087,7 @@ "longitude": 29.25, "short_description": "The park's immense savannahs, grasslands and woodlands, interspersed with gallery forests along the river banks and the swampy depressions, are home to four large mammals: the elephant, giraffe, hippopotamus and above all the white rhinoceros. Though much larger than the black rhino, it is harmless; only some 30 individuals remain.", "unesco_site_url": null, - "main_image_url": "https://whc.unesco.org/document/224889" + "main_image_url": "https://whc.unesco.org/document/225843" }, { "id": 137, @@ -5521,7 +5521,7 @@ "buffer_zone_hectares": null, "latitude": 31.63139, "longitude": -7.98667, - "short_description": "Founded in 1070–72 by the Almoravids, Marrakesh remained a political, economic and cultural centre for a long period. Its influence was felt throughout the western Muslim world, from North Africa to Andalusia. It has several impressive monuments dating from that period: the Koutoubiya Mosque, the Kasbah, the battlements, monumental doors, gardens, etc. Later architectural jewels include the Bandiâ Palace, the Ben Youssef Madrasa, the Saadian Tombs, several great residences and Place Jamaâ El Fna, a veritable open-air theatre.", + "short_description": "Founded in 1070–72 by the Almoravids, Marrakesh remained a political, economic and cultural centre for a long period. Its influence was felt throughout the western Muslim world, from North Africa to Andalusia. It has several impressive monuments dating from that period: the Koutoubiya Mosque, the Kasbah, the battlements, monumental doors, gardens, etc. Later architectural jewels include the Badiâ Palace, the Ben Youssef Madrasa, the Saadian Tombs, several great residences and Place Jamaâ El Fna, a veritable open-air theatre.", "unesco_site_url": null, "main_image_url": "https://whc.unesco.org/document/110419" }, @@ -21172,7 +21172,7 @@ "longitude": 13.45, "short_description": "Berlin Modernism Housing Estates. The property consists of six housing estates that testify to innovative housing policies from 1910 to 1933, especially during the Weimar Republic, when the city of Berlin was particularly progressive socially, politically and culturally. The property is an outstanding example of the building reform movement that contributed to improving housing and living conditions for people with low incomes through novel approaches to town planning, architecture and garden design. The estates also provide exceptional examples of new urban and architectural typologies, featuring fresh design solutions, as well as technical and aesthetic innovations. Bruno Taut, Martin Wagner and Walter Gropius were among the leading architects of these projects which exercised considerable influence on the development of housing around the world.", "unesco_site_url": null, - "main_image_url": "https://whc.unesco.org/document/195415" + "main_image_url": "https://whc.unesco.org/document/220337" }, { "id": 1240, @@ -27408,7 +27408,7 @@ "longitude": 126.1044444444, "short_description": "Situated in the eastern Yellow Sea on the southwestern and southern coast of the Republic of Korea, the site comprises four component parts: Seocheon Getbol, Gochang Getbol, Shinan Getbol and Boseong-Suncheon Getbol. The site exhibits a complex combination of geological, oceanographic and climatologic conditions that have led to the development of coastal diverse sedimentary systems. Each component represents one of four tidal flat subtypes (estuarine type, open embayed type, archipelago type and semi-enclosed type). The site hosts high levels of biodiversity, with reports of 2,150 species of flora and fauna, including 22 globally threatened or near-threatened species. It is home to 47 endemic and five endangered marine invertebrate species besides a total of 118 migratory bird species for which the site provides critical habitats. Endemic fauna includes Mud Octopuses (Octopus minor), and deposit feeders like Japanese Mud Crabs (Macrophthalmus japonica), Fiddler Crabs (Uca lactea), and Polychaetes (bristle worms), Stimpson’s Ghost Crabs (Ocypode stimpsoni), Yellow Sea Sand Snails (Umbonium thomasi), , as well as various suspension feeders like clams. The site demonstrates the link between geodiversity and biodiversity, and demonstrates the dependence of cultural diversity and human activity on the natural environment.", "unesco_site_url": null, - "main_image_url": "https://whc.unesco.org/document/172487" + "main_image_url": "https://whc.unesco.org/document/225482" }, { "id": 1592, diff --git a/src/storage/app/private/unesco/world_heritage_sites_translation.json b/src/storage/app/private/unesco/world_heritage_sites_translation.json index b401ac3d..343efb6f 100644 --- a/src/storage/app/private/unesco/world_heritage_sites_translation.json +++ b/src/storage/app/private/unesco/world_heritage_sites_translation.json @@ -4,7 +4,7 @@ "scope": "ALL", "fetched": 1248, "total_count": 1248, - "dumped_at": "2026-06-22T08:46:36+09:00", + "dumped_at": "2026-06-26T08:14:48+09:00", "source": "https:\/\/data.unesco.org\/api\/explore\/v2.1\/catalog\/datasets\/whc001\/records", "order_by": "id_no asc" },