Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ee398da
feat: add user_favorite migration for favorite heritages join table
zigzagdev Aug 1, 2026
66cd289
chore: ignore src/.config/ (psysh cache)
zigzagdev Aug 2, 2026
8792bd7
Merge pull request #560 from zigzagdev/feat/user-favorite_domain-layer
zigzagdev Aug 3, 2026
241471b
feat: add favorites relation to User model
zigzagdev Aug 6, 2026
f2b497d
feat: add FavoriteRepositoryInterface and FavoriteRepository
zigzagdev Aug 6, 2026
d813398
feat: bind FavoriteRepositoryInterface in RepositoryProvider
zigzagdev Aug 6, 2026
7719b6a
test: cover FavoriteRepository addFavorite in integration test
zigzagdev Aug 6, 2026
4c6e5a0
Merge pull request #561 from zigzagdev/feat/user-favorite_infra-layer
zigzagdev Aug 6, 2026
86e46e6
feat: add AddFavoriteUseCase
zigzagdev Aug 6, 2026
8755795
test: cover AddFavoriteUseCase
zigzagdev Aug 6, 2026
cb94ab5
Merge pull request #562 from zigzagdev/feat/user-favorite-application
zigzagdev Aug 6, 2026
b0b354a
feat: add FavoriteController and route
zigzagdev Aug 7, 2026
2f70034
test: cover FavoriteController integration
zigzagdev Aug 7, 2026
c293a43
Merge pull request #563 from zigzagdev/feat/user-favorite-presentation
zigzagdev Aug 7, 2026
ad058bf
feat: add getFavoriteWorldHeritageIds to FavoriteRepository
zigzagdev Aug 8, 2026
b4acd34
test: cover getFavoriteWorldHeritageIds in FavoriteRepositoryTest
zigzagdev Aug 8, 2026
5e2a643
Merge pull request #564 from zigzagdev/feat/user-favorite-index-list-…
zigzagdev Aug 8, 2026
b62ec92
feat: add getHeritagesByIds to WorldHeritageQueryService
zigzagdev Aug 8, 2026
38553e5
test: cover getHeritagesByIds in WorldHeritageQueryService
zigzagdev Aug 8, 2026
8315955
feat: add GetFavoriteHeritagesUseCase for favorite heritages list
zigzagdev Aug 8, 2026
74a2857
test: cover GetFavoriteHeritagesUseCase
zigzagdev Aug 8, 2026
04ecf9e
Merge pull request #570 from zigzagdev/feat/user-favorite-index-list-…
zigzagdev Aug 8, 2026
0dd7eba
feat: add getFavorites to FavoriteController and route
zigzagdev Aug 8, 2026
206baef
test: cover getFavorites in FavoriteControllerTest
zigzagdev Aug 8, 2026
0c4e887
test: stub WorldHeritageSearchPort in FavoriteControllerTest to avoid…
zigzagdev Aug 8, 2026
298153d
Merge pull request #571 from zigzagdev/feat/user-favorite-index-list-…
zigzagdev Aug 8, 2026
eb154e0
feat: add removeFavorite to FavoriteRepository
zigzagdev Aug 16, 2026
63bdf49
test: cover removeFavorite in FavoriteRepositoryTest
zigzagdev Aug 16, 2026
f2c9a2a
Merge pull request #572 from zigzagdev/feat/user-favorite-remove-infra
zigzagdev Aug 16, 2026
7839d41
feat: add RemoveFavoriteUseCase
zigzagdev Aug 16, 2026
7f1caa6
test: cover RemoveFavoriteUseCase
zigzagdev Aug 16, 2026
292614f
Merge pull request #575 from zigzagdev/feat/user-favorite-remove-appl…
zigzagdev Aug 16, 2026
76bbd1a
feat: add removeFavorite to FavoriteController and route
zigzagdev Aug 16, 2026
4527c4e
test: cover removeFavorite in FavoriteControllerTest
zigzagdev Aug 16, 2026
cf39eb3
Merge pull request #576 from zigzagdev/feat/user-favorite-remove-pres…
zigzagdev Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.idea/
src/.config/
11 changes: 11 additions & 0 deletions src/app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;

Expand Down Expand Up @@ -33,4 +34,14 @@ protected function casts(): array
'email_verified_at' => 'datetime',
];
}

public function favorites(): BelongsToMany
{
return $this->belongsToMany(
WorldHeritage::class,
'user_favorite',
'user_id',
'world_heritage_site_id'
)->withTimestamps();
}
}
50 changes: 50 additions & 0 deletions src/app/Packages/Domains/Favorite/FavoriteRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Packages\Domains\Favorite;

use App\Models\User;
use App\Packages\Domains\Favorite\Interface\FavoriteRepositoryInterface;
use Exception;

class FavoriteRepository implements FavoriteRepositoryInterface
{
public function __construct(
private User $userModel
) {}

public function addFavorite(int $userId, int $worldHeritageSiteId): void
{
$user = $this->userModel->find($userId);

if ($user === null) {
throw new Exception('User not found.');
}

$user->favorites()->attach($worldHeritageSiteId);
}

public function removeFavorite(int $userId, int $worldHeritageSiteId): void
{
$user = $this->userModel->find($userId);

if ($user === null) {
throw new Exception('User not found.');
}

$user->favorites()->detach($worldHeritageSiteId);
}

public function getFavoriteWorldHeritageIds(int $userId): array
{
$user = $this->userModel->find($userId);

if ($user === null) {
throw new Exception('User not found.');
}

return $user->favorites()
->orderBy('user_favorite.created_at', 'desc')
->pluck('world_heritage_sites.id')
->all();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Packages\Domains\Favorite\Interface;

interface FavoriteRepositoryInterface
{
public function addFavorite(int $userId, int $worldHeritageSiteId): void;

public function removeFavorite(int $userId, int $worldHeritageSiteId): void;

public function getFavoriteWorldHeritageIds(int $userId): array;
}
157 changes: 157 additions & 0 deletions src/app/Packages/Domains/Favorite/Tests/FavoriteRepositoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

namespace App\Packages\Domains\Favorite\Tests;

use App\Models\User;
use App\Models\WorldHeritage;
use App\Packages\Domains\Favorite\FavoriteRepository;
use Exception;
use Faker\Factory as FakerFactory;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class FavoriteRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->truncate();
}

protected function tearDown(): void
{
$this->truncate();
parent::tearDown();
}

private function truncate(): void
{
if (env('APP_ENV') === 'testing') {
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
DB::connection('mysql')->table('user_favorite')->truncate();
WorldHeritage::truncate();
User::truncate();
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
}
}

private function repository(): FavoriteRepository
{
return new FavoriteRepository(new User());
}

private function seedUser(): User
{
$faker = FakerFactory::create();

return User::create([
'first_name' => $faker->firstName(),
'last_name' => $faker->lastName(),
'email' => $faker->unique()->safeEmail(),
'password' => bcrypt('password'),
'age_range' => 'teens',
'subscription_tier' => 'free',
'subscription_expires_at' => null,
]);
}

private function seedWorldHeritage(int $id): WorldHeritage
{
return WorldHeritage::create([
'id' => $id,
'official_name' => 'Example Site',
'name' => 'Example Site',
'region' => 'Asia',
'category' => 'Cultural',
'criteria' => ['i'],
'year_inscribed' => 2000,
]);
}

public function test_addFavorite_attaches_world_heritage_site_to_user(): void
{
$user = $this->seedUser();
$worldHeritage = $this->seedWorldHeritage(1);

$this->repository()->addFavorite($user->id, $worldHeritage->id);

$this->assertDatabaseHas('user_favorite', [
'user_id' => $user->id,
'world_heritage_site_id' => $worldHeritage->id,
]);
}

public function test_addFavorite_throws_exception_when_user_not_found(): void
{
$worldHeritage = $this->seedWorldHeritage(1);

$this->expectException(Exception::class);
$this->expectExceptionMessage('User not found.');

$this->repository()->addFavorite(999999, $worldHeritage->id);
}

public function test_removeFavorite_detaches_world_heritage_site_from_user(): void
{
$user = $this->seedUser();
$worldHeritage = $this->seedWorldHeritage(1);
$user->favorites()->attach($worldHeritage->id);

$this->repository()->removeFavorite($user->id, $worldHeritage->id);

$this->assertDatabaseMissing('user_favorite', [
'user_id' => $user->id,
'world_heritage_site_id' => $worldHeritage->id,
]);
}

#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]
public function test_removeFavorite_does_not_throw_when_not_favorited(): void
{
$user = $this->seedUser();
$worldHeritage = $this->seedWorldHeritage(1);

$this->repository()->removeFavorite($user->id, $worldHeritage->id);
}

public function test_removeFavorite_throws_exception_when_user_not_found(): void
{
$worldHeritage = $this->seedWorldHeritage(1);

$this->expectException(Exception::class);
$this->expectExceptionMessage('User not found.');

$this->repository()->removeFavorite(999999, $worldHeritage->id);
}

public function test_getFavoriteWorldHeritageIds_returns_ids_ordered_by_most_recently_favorited(): void
{
$user = $this->seedUser();
$worldHeritage1 = $this->seedWorldHeritage(1);
$worldHeritage2 = $this->seedWorldHeritage(2);

$user->favorites()->attach($worldHeritage1->id, ['created_at' => now()->subMinute()]);
$user->favorites()->attach($worldHeritage2->id, ['created_at' => now()]);

$result = $this->repository()->getFavoriteWorldHeritageIds($user->id);

$this->assertSame([$worldHeritage2->id, $worldHeritage1->id], $result);
}

public function test_getFavoriteWorldHeritageIds_returns_empty_array_when_no_favorites(): void
{
$user = $this->seedUser();

$result = $this->repository()->getFavoriteWorldHeritageIds($user->id);

$this->assertSame([], $result);
}

public function test_getFavoriteWorldHeritageIds_throws_exception_when_user_not_found(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('User not found.');

$this->repository()->getFavoriteWorldHeritageIds(999999);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace App\Packages\Domains\WorldHeritage\Tests\QueryService;

use App\Models\Country;
use App\Models\Image;
use App\Models\WorldHeritage;
use App\Models\WorldHeritageDescription;
use App\Packages\Domains\WorldHeritage\WorldHeritageQueryService;
use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort;
use App\Packages\Domains\WorldHeritage\Ports\Dto\HeritageSearchResult;
use App\Packages\Features\QueryUseCases\Dto\WorldHeritageDtoCollection;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class WorldHeritageQueryService_getHeritagesByIdsTest extends TestCase
{
private WorldHeritageQueryService $queryService;

protected function setUp(): void
{
parent::setUp();
$this->refresh();

$seeder = new DatabaseSeeder();
$seeder->run();

$this->app->bind(WorldHeritageSearchPort::class, static function () {
return new class implements WorldHeritageSearchPort {
public function search($query, int $currentPage, int $perPage): HeritageSearchResult {
return new HeritageSearchResult(ids: [], total: 0, currentPage: 1, perPage: $perPage, lastPage: 0);
}
};
});

$this->queryService = app(WorldHeritageQueryService::class);
}

protected function tearDown(): void
{
$this->refresh();
parent::tearDown();
}

private function refresh(): void
{
if (env('APP_ENV') === 'testing') {
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;');
WorldHeritage::truncate();
Country::truncate();
DB::table('site_state_parties')->truncate();
Image::truncate();
WorldHeritageDescription::truncate();
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
}
}

public function test_returns_world_heritage_dto_collection(): void
{
$ids = DB::table('world_heritage_sites')->orderBy('id')->limit(2)->pluck('id')->all();

$result = $this->queryService->getHeritagesByIds($ids);

$this->assertInstanceOf(WorldHeritageDtoCollection::class, $result);
}

public function test_preserves_order_of_given_ids(): void
{
$ids = DB::table('world_heritage_sites')->orderBy('id')->limit(3)->pluck('id')->all();
$this->assertCount(3, $ids, 'Seeder must insert at least 3 world heritages.');

$requested = [$ids[2], $ids[0], $ids[1]];

$result = $this->queryService->getHeritagesByIds($requested);

$resultIds = array_map(fn ($dto) => $dto->getId(), $result->getHeritages());
$this->assertSame($requested, $resultIds);
}

public function test_returns_empty_collection_when_no_ids_given(): void
{
$result = $this->queryService->getHeritagesByIds([]);

$this->assertSame([], $result->getHeritages());
}

public function test_skips_missing_ids_without_failing(): void
{
$existingId = (int) DB::table('world_heritage_sites')->orderBy('id')->value('id');
$this->assertNotNull($existingId, 'Seeder must insert at least 1 world heritage.');

$result = $this->queryService->getHeritagesByIds([$existingId, 999_999_999]);

$resultIds = array_map(fn ($dto) => $dto->getId(), $result->getHeritages());
$this->assertSame([$existingId], $resultIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,17 @@ public function searchHeritages(AlgoliaSearchListQuery $query): PaginationDto
);
}

public function getHeritagesByIds(array $ids): WorldHeritageDtoCollection
{
$models = $this->readQueryService->findByIdsPreserveOrder($ids);

$payloads = $models
->map(fn ($m) => $this->buildWorldHeritagePayload($m))
->all();

return $this->buildDtoFromCollection($payloads);
}

public function getEachRegionsHeritagesCount(): array
{
$counts = $this->model
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Packages\Features\CommandUseCases\UseCase\Favorite;

use App\Packages\Domains\Favorite\Interface\FavoriteRepositoryInterface;

final class AddFavoriteUseCase
{
public function __construct(
private readonly FavoriteRepositoryInterface $favoriteRepository,
) {}

public function handle(int $userId, int $worldHeritageSiteId): void
{
$this->favoriteRepository->addFavorite($userId, $worldHeritageSiteId);
}
}
Loading
Loading