diff --git a/app/Api/RouteServiceProvider.php b/app/Api/RouteServiceProvider.php index af8c4124e3..1d6d9231c7 100755 --- a/app/Api/RouteServiceProvider.php +++ b/app/Api/RouteServiceProvider.php @@ -139,6 +139,7 @@ private function apiRoutes(): void ->readOnly() ->relationships(function ($relationships) { $relationships->hasMany('playerGames')->readOnly(); + $relationships->hasMany('playerAchievementSets')->readOnly(); }); }); }); diff --git a/app/Api/V2/Achievements/AchievementSchema.php b/app/Api/V2/Achievements/AchievementSchema.php index 55ad329a9f..433c9517f5 100644 --- a/app/Api/V2/Achievements/AchievementSchema.php +++ b/app/Api/V2/Achievements/AchievementSchema.php @@ -31,9 +31,7 @@ class AchievementSchema extends Schema /** * Relationships that should always be eager loaded. */ - protected array $with = [ - 'achievementSet.gameAchievementSets', - ]; + protected array $with = ['achievementSet.gameAchievementSets']; /** * Default pagination parameters when client doesn't provide any. @@ -104,7 +102,7 @@ public function filters(): array return [ WhereIdIn::make($this), Scope::make('state', 'withState'), - Scope::make('gameId', 'forGame'), + Scope::make('gameId', 'forGameId'), WhereIn::make('type')->delimiter(','), ]; } diff --git a/app/Api/V2/PlayerAchievementSets/PlayerAchievementSetResource.php b/app/Api/V2/PlayerAchievementSets/PlayerAchievementSetResource.php new file mode 100644 index 0000000000..8fff5ce4d8 --- /dev/null +++ b/app/Api/V2/PlayerAchievementSets/PlayerAchievementSetResource.php @@ -0,0 +1,97 @@ + $this->resource->achievements_unlocked, + 'achievementsUnlockedHardcore' => $this->resource->achievements_unlocked_hardcore, + + 'points' => $this->resource->points, + 'pointsHardcore' => $this->resource->points_hardcore, + 'pointsWeighted' => $this->resource->points_weighted, + + 'completionPercentage' => $this->resource->completion_percentage, + 'completionPercentageHardcore' => $this->resource->completion_percentage_hardcore, + + 'lastUnlockAt' => $this->resource->last_unlock_at, + 'lastUnlockHardcoreAt' => $this->resource->last_unlock_hardcore_at, + 'completedAt' => $this->resource->completed_at, + 'completedHardcoreAt' => $this->resource->completed_hardcore_at, + + 'timeTakenSeconds' => $this->resource->time_taken, + 'timeTakenHardcoreSeconds' => $this->resource->time_taken_hardcore, + + 'setContext' => $this->getSetContext(), + ]; + } + + /** + * Get the resource's relationships. + * + * @param Request|null $request + */ + public function relationships($request): iterable + { + return [ + 'achievementSet' => $this->relation('achievementSet')->withoutLinks(), + 'game' => $this->relation('game')->withoutLinks(), + ]; + } + + /** + * @param Request|null $request + */ + public function links($request): Links + { + // Player achievement sets have no dedicated web URL. + return new Links(); + } + + /** + * Get game/type pairs so callers know the game context + * without needing to include the full game resource. + * + * @return array + */ + private function getSetContext(): array + { + $gameAchievementSets = $this->resource->achievementSet->gameAchievementSets; + + $hasCoreAttachment = $gameAchievementSets->contains( + fn ($gas) => $gas->type === AchievementSetType::Core + ); + $hasNonCoreAttachment = $gameAchievementSets->contains( + fn ($gas) => $gas->type !== AchievementSetType::Core + ); + + $setsToInclude = ($hasCoreAttachment && $hasNonCoreAttachment) + ? $gameAchievementSets->filter(fn ($gas) => $gas->type !== AchievementSetType::Core) + : $gameAchievementSets; + + return $setsToInclude->map(fn ($gas) => [ + 'achievementSetId' => $gas->achievement_set_id, + 'gameId' => $gas->game_id, + 'type' => $gas->type instanceof AchievementSetType ? $gas->type->value : $gas->type, + ])->values()->all(); + } +} diff --git a/app/Api/V2/PlayerAchievementSets/PlayerAchievementSetSchema.php b/app/Api/V2/PlayerAchievementSets/PlayerAchievementSetSchema.php new file mode 100644 index 0000000000..3c18b11217 --- /dev/null +++ b/app/Api/V2/PlayerAchievementSets/PlayerAchievementSetSchema.php @@ -0,0 +1,106 @@ + 1]; + + /** + * Default sort order when client doesn't provide any. + * Shows sets with most recently unlocked achievements first. + */ + protected $defaultSort = '-lastUnlockAt'; + + /** + * Get the resource type. + */ + public static function type(): string + { + return 'player-achievement-sets'; + } + + /** + * Get the resource fields. + */ + public function fields(): array + { + return [ + ID::make(), + + Number::make('achievementsUnlocked', 'achievements_unlocked')->readOnly(), + Number::make('achievementsUnlockedHardcore', 'achievements_unlocked_hardcore')->readOnly(), + + Number::make('points', 'points')->sortable()->readOnly(), + Number::make('pointsHardcore', 'points_hardcore')->sortable()->readOnly(), + Number::make('pointsWeighted', 'points_weighted')->sortable()->readOnly(), + + Number::make('completionPercentage', 'completion_percentage')->sortable()->readOnly(), + Number::make('completionPercentageHardcore', 'completion_percentage_hardcore')->sortable()->readOnly(), + + DateTime::make('lastUnlockAt', 'last_unlock_at')->sortable()->readOnly(), + DateTime::make('lastUnlockHardcoreAt', 'last_unlock_hardcore_at')->sortable()->readOnly(), + DateTime::make('completedAt', 'completed_at')->sortable()->readOnly(), + DateTime::make('completedHardcoreAt', 'completed_hardcore_at')->sortable()->readOnly(), + + Number::make('timeTakenSeconds', 'time_taken')->sortable()->readOnly(), + Number::make('timeTakenHardcoreSeconds', 'time_taken_hardcore')->sortable()->readOnly(), + + ArrayList::make('setContext')->readOnly(), + + BelongsTo::make('achievementSet')->type('achievement-sets')->readOnly(), + HasOneThrough::make('game')->type('games'), // HasOneThrough is always implicitly ->readOnly() + ]; + } + + /** + * Get the resource filters. + */ + public function filters(): array + { + return [ + WhereIdIn::make($this), + Where::make('achievementSetId', 'achievement_set_id'), + Scope::make('gameId', 'forGameId'), + ]; + } + + /** + * Get the resource paginator. + */ + public function pagination(): ?Paginator + { + return PagePagination::make() + ->withDefaultPerPage(50); + } +} diff --git a/app/Api/V2/PlayerGames/PlayerGameResource.php b/app/Api/V2/PlayerGames/PlayerGameResource.php index 1ec5a69b7a..f6533e323a 100644 --- a/app/Api/V2/PlayerGames/PlayerGameResource.php +++ b/app/Api/V2/PlayerGames/PlayerGameResource.php @@ -33,9 +33,9 @@ public function attributes($request): iterable 'beatenHardcoreAt' => $this->resource->beaten_hardcore_at, // Time tracking. - 'playtimeTotal' => $this->resource->playtime_total, - 'timeToBeat' => $this->resource->time_to_beat, - 'timeToBeatHardcore' => $this->resource->time_to_beat_hardcore, + 'playtimeTotalSeconds' => $this->resource->playtime_total, + 'timeToBeatSeconds' => $this->resource->time_to_beat, + 'timeToBeatHardcoreSeconds' => $this->resource->time_to_beat_hardcore, ]; } @@ -49,6 +49,7 @@ public function relationships($request): iterable return [ 'achievementSets' => $this->relation('achievementSets')->withoutLinks(), 'game' => $this->relation('game')->withoutLinks(), + 'playerAchievementSets' => $this->relation('playerAchievementSets')->withoutLinks(), ]; } diff --git a/app/Api/V2/PlayerGames/PlayerGameSchema.php b/app/Api/V2/PlayerGames/PlayerGameSchema.php index 1896218d32..b85119c086 100644 --- a/app/Api/V2/PlayerGames/PlayerGameSchema.php +++ b/app/Api/V2/PlayerGames/PlayerGameSchema.php @@ -16,6 +16,7 @@ use LaravelJsonApi\Eloquent\Fields\Number; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo; use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany; +use LaravelJsonApi\Eloquent\Fields\Relations\HasManyThrough; use LaravelJsonApi\Eloquent\Filters\Where; use LaravelJsonApi\Eloquent\Filters\WhereIdIn; use LaravelJsonApi\Eloquent\Pagination\PagePagination; @@ -73,16 +74,14 @@ public function fields(): array DateTime::make('beatenHardcoreAt', 'beaten_hardcore_at')->readOnly(), // Time tracking. - Number::make('playtimeTotal', 'playtime_total')->readOnly(), - Number::make('timeToBeat', 'time_to_beat')->readOnly(), - Number::make('timeToBeatHardcore', 'time_to_beat_hardcore')->readOnly(), + Number::make('playtimeTotalSeconds', 'playtime_total')->readOnly(), + Number::make('timeToBeatSeconds', 'time_to_beat')->readOnly(), + Number::make('timeToBeatHardcoreSeconds', 'time_to_beat_hardcore')->readOnly(), // Relationships. BelongsToMany::make('achievementSets')->type('achievement-sets')->readOnly(), BelongsTo::make('game')->readOnly(), - - // TODO add relationships - // - playerAchievementSets (HasMany PlayerAchievementSet) - per-set player progress + HasManyThrough::make('playerAchievementSets')->type('player-achievement-sets'), ]; } diff --git a/app/Api/V2/Server.php b/app/Api/V2/Server.php index 21d30c35f7..c69c77b1a8 100644 --- a/app/Api/V2/Server.php +++ b/app/Api/V2/Server.php @@ -37,6 +37,7 @@ protected function allSchemas(): array Hubs\HubSchema::class, LeaderboardEntries\LeaderboardEntrySchema::class, Leaderboards\LeaderboardSchema::class, + PlayerAchievementSets\PlayerAchievementSetSchema::class, PlayerGames\PlayerGameSchema::class, Systems\SystemSchema::class, Users\UserSchema::class, diff --git a/app/Api/V2/Users/UserResource.php b/app/Api/V2/Users/UserResource.php index 68a25d7931..a56705fc32 100644 --- a/app/Api/V2/Users/UserResource.php +++ b/app/Api/V2/Users/UserResource.php @@ -67,11 +67,11 @@ public function attributes($request): iterable public function relationships($request): iterable { return [ + 'playerAchievementSets' => $this->relation('playerAchievementSets')->withoutLinks(), 'playerGames' => $this->relation('playerGames')->withoutLinks(), // TODO add relationships // 'lastGame' => $this->relation('lastGame'), - // 'playerAchievementSets' => $this->relation('playerAchievementSets'), // 'playerAchievements' => $this->relation('playerAchievements'), // 'awards' => $this->relation('playerBadges'), // 'following' => $this->relation('followedUsers'), diff --git a/app/Api/V2/Users/UserSchema.php b/app/Api/V2/Users/UserSchema.php index 9321f78e5f..9a36b9693c 100644 --- a/app/Api/V2/Users/UserSchema.php +++ b/app/Api/V2/Users/UserSchema.php @@ -92,11 +92,11 @@ public function fields(): array Str::make('visibleRole')->readOnly(), ArrayList::make('displayableRoles')->readOnly(), + HasMany::make('playerAchievementSets')->type('player-achievement-sets')->cannotEagerLoad()->readOnly(), HasMany::make('playerGames')->type('player-games')->cannotEagerLoad()->readOnly(), // TODO add relationships and relationship endpoints // - lastGame (BelongsTo Game) - // - playerAchievementSets (HasMany PlayerAchievementSet) // - playerAchievements (HasMany PlayerAchievement) // - awards (HasMany PlayerBadge) // - following (BelongsToMany User) - users this user follows diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php index d78a5f2cd9..d5d59ecdc9 100644 --- a/app/Models/Achievement.php +++ b/app/Models/Achievement.php @@ -691,7 +691,7 @@ public function scopeWithUnlocksByUser(Builder $query, User $user): Builder * @param Builder $query * @return Builder */ - public function scopeForGame(Builder $query, int $gameId): Builder + public function scopeForGameId(Builder $query, int $gameId): Builder { return $query->whereExists(function ($subQuery) use ($gameId) { $subQuery->select(DB::raw(1)) diff --git a/app/Models/PlayerAchievementSet.php b/app/Models/PlayerAchievementSet.php index 4c51b2e8b3..b8e88d5027 100644 --- a/app/Models/PlayerAchievementSet.php +++ b/app/Models/PlayerAchievementSet.php @@ -4,12 +4,20 @@ namespace App\Models; +use App\Platform\Enums\AchievementSetType; use App\Support\Database\Eloquent\BasePivot; +use Database\Factories\PlayerAchievementSetFactory; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; class PlayerAchievementSet extends BasePivot { + /** @use HasFactory */ + use HasFactory; + protected $table = 'player_achievement_sets'; protected $casts = [ @@ -21,6 +29,11 @@ class PlayerAchievementSet extends BasePivot 'last_unlock_hardcore_at' => 'datetime', ]; + protected static function newFactory(): PlayerAchievementSetFactory + { + return PlayerAchievementSetFactory::new(); + } + // == accessors // == mutators @@ -60,5 +73,32 @@ public function player(): BelongsTo return $this->user(); } + /** + * Prefer the non-core attachment so we return the + * parent game rather than the subset backing game. + * + * @return HasOneThrough + */ + public function game(): HasOneThrough + { + return $this->hasOneThrough( + Game::class, + GameAchievementSet::class, + 'achievement_set_id', // FK on game_achievement_sets + 'id', // FK on games (its primary key) + 'achievement_set_id', // Local key + 'game_id' // Local key + )->orderByRaw("CASE WHEN game_achievement_sets.type != ? THEN 0 ELSE 1 END", [AchievementSetType::Core->value]); + } + // == scopes + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForGameId(Builder $query, int $gameId): Builder + { + return $query->whereHas('game', fn (Builder $q) => $q->where('games.id', $gameId)); + } } diff --git a/app/Models/PlayerGame.php b/app/Models/PlayerGame.php index 478ade53aa..94b119dd06 100644 --- a/app/Models/PlayerGame.php +++ b/app/Models/PlayerGame.php @@ -6,6 +6,7 @@ use App\Community\Enums\AwardType; use App\Support\Database\Eloquent\BasePivot; +use App\Support\Database\Eloquent\Relations\UserScopedHasManyThrough; use Database\Factories\PlayerGameFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -104,6 +105,23 @@ public function player(): BelongsTo return $this->user(); } + /** + * @return UserScopedHasManyThrough + */ + public function playerAchievementSets(): UserScopedHasManyThrough + { + /** @var UserScopedHasManyThrough */ + return new UserScopedHasManyThrough( + (new PlayerAchievementSet())->newQuery(), + $this, + new GameAchievementSet(), + 'game_id', + 'achievement_set_id', + 'game_id', + 'achievement_set_id' + ); + } + // == scopes /** diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index d702c0dd2c..4cd882fb01 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -54,6 +54,11 @@ public function viewPlayerGames(?User $user, User $model): bool return true; } + public function viewPlayerAchievementSets(?User $user, User $model): bool + { + return true; + } + public function create(User $user): bool { // nobody creates users just like that. diff --git a/app/Support/Database/Eloquent/Relations/UserScopedHasManyThrough.php b/app/Support/Database/Eloquent/Relations/UserScopedHasManyThrough.php new file mode 100644 index 0000000000..c31e2e5fc1 --- /dev/null +++ b/app/Support/Database/Eloquent/Relations/UserScopedHasManyThrough.php @@ -0,0 +1,54 @@ +user_id` is always null. + * + * This class properly applies the user_id constraint in both the direct + * access and eager loading paths. + * + * @template TRelatedModel of Model + * @template TIntermediateModel of Model + * @template TDeclaringModel of Model + * + * @extends HasManyThrough + */ +class UserScopedHasManyThrough extends HasManyThrough +{ + public function addConstraints(): void + { + parent::addConstraints(); + + // For direct (non-eager) access, scope to the parent model's user. + if (static::$constraints) { + $userId = $this->farParent->user_id ?? null; + if ($userId !== null) { + $this->query->where($this->related->getTable() . '.user_id', $userId); + } + } + } + + /** + * @param array $models + */ + public function addEagerConstraints(array $models): void + { + parent::addEagerConstraints($models); + + // All parent models share the same user_id in a user-scoped request. + $userId = $models[0]->user_id ?? null; + if ($userId !== null) { + $this->query->where($this->related->getTable() . '.user_id', $userId); + } + } +} diff --git a/database/factories/PlayerAchievementSetFactory.php b/database/factories/PlayerAchievementSetFactory.php new file mode 100644 index 0000000000..72803d6229 --- /dev/null +++ b/database/factories/PlayerAchievementSetFactory.php @@ -0,0 +1,29 @@ + + */ +class PlayerAchievementSetFactory extends Factory +{ + protected $model = PlayerAchievementSet::class; + + public function definition(): array + { + $user = User::inRandomOrder()->first(); + $achievementSet = AchievementSet::inRandomOrder()->first(); + + return [ + 'user_id' => $user?->id ?? 1, + 'achievement_set_id' => $achievementSet?->id ?? 1, + ]; + } +} diff --git a/tests/Feature/Api/V2/PlayerAchievementSetsTest.php b/tests/Feature/Api/V2/PlayerAchievementSetsTest.php new file mode 100644 index 0000000000..fc321c4c35 --- /dev/null +++ b/tests/Feature/Api/V2/PlayerAchievementSetsTest.php @@ -0,0 +1,709 @@ +create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + $user = User::factory()->create(); + PlayerAchievementSet::factory()->create([ + 'user_id' => $user->id, + 'achievement_set_id' => $achievementSet->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->get("/api/v2/users/{$user->ulid}/player-achievement-sets"); + + // Assert + $response->assertUnauthorized(); + } + + public function testItFetchesPlayerAchievementSetsForUser(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + + $coreSet = AchievementSet::factory()->create(); + $bonusSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $coreSet->id, + 'type' => AchievementSetType::Core, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $bonusSet->id, + 'type' => AchievementSetType::Bonus, + ]); + + $player = User::factory()->create(); + $pas1 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $coreSet->id, + 'last_unlock_hardcore_at' => now()->subDay(), + ]); + $pas2 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $bonusSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertCount(2, $data); + + $ids = collect($data)->pluck('id')->toArray(); + $this->assertContains((string) $pas1->id, $ids); + $this->assertContains((string) $pas2->id, $ids); + } + + public function testItReturns404ForNonexistentUser(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get('/api/v2/users/nonexistent-user/player-achievement-sets'); + + // Assert + $response->assertNotFound(); + } + + public function testItSortsByLastUnlockAtByDefault(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $set1 = AchievementSet::factory()->create(); + $set2 = AchievementSet::factory()->create(); + $set3 = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $set1->id, + 'type' => AchievementSetType::Core, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $set2->id, + 'type' => AchievementSetType::Bonus, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $set3->id, + 'type' => AchievementSetType::Bonus, + ]); + + $pas1 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set1->id, + 'last_unlock_at' => now()->subDays(3), + ]); + $pas2 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set2->id, + 'last_unlock_at' => now()->subDay(), + ]); + $pas3 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set3->id, + 'last_unlock_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $data = $response->json('data'); + $ids = collect($data)->pluck('id')->toArray(); + + $this->assertEquals([ + (string) $pas3->id, + (string) $pas2->id, + (string) $pas1->id, + ], $ids); + } + + public function testItCanFilterByAchievementSetId(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $set1 = AchievementSet::factory()->create(); + $set2 = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $set1->id, + 'type' => AchievementSetType::Core, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $set2->id, + 'type' => AchievementSetType::Bonus, + ]); + + $pas1 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set1->id, + 'last_unlock_hardcore_at' => now(), + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set2->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets?filter[achievementSetId]={$set1->id}"); + + // Assert + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertCount(1, $data); + $this->assertEquals((string) $pas1->id, $data[0]['id']); + } + + public function testItCanFilterByGameId(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $player = User::factory()->create(); + + $game1 = Game::factory()->create(['system_id' => $system->id]); + $game2 = Game::factory()->create(['system_id' => $system->id]); + + $set1 = AchievementSet::factory()->create(); + $set2 = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game1->id, + 'achievement_set_id' => $set1->id, + 'type' => AchievementSetType::Core, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game2->id, + 'achievement_set_id' => $set2->id, + 'type' => AchievementSetType::Core, + ]); + + $pas1 = PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set1->id, + 'last_unlock_hardcore_at' => now(), + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $set2->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets?filter[gameId]={$game1->id}"); + + // Assert + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertCount(1, $data); + $this->assertEquals((string) $pas1->id, $data[0]['id']); + } + + public function testItCanIncludeAchievementSetRelationship(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSet = AchievementSet::factory()->create([ + 'achievements_published' => 25, + 'points_total' => 500, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets?include=achievementSet"); + + // Assert + $response->assertSuccessful(); + $included = $response->json('included'); + $this->assertNotEmpty($included); + $this->assertEquals('achievement-sets', $included[0]['type']); + $this->assertEquals(25, $included[0]['attributes']['achievementsPublished']); + } + + public function testItCanIncludeGameRelationship(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $player = User::factory()->create(); + + $game = Game::factory()->create([ + 'system_id' => $system->id, + 'title' => 'Sonic the Hedgehog', + ]); + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets?include=game"); + + // Assert + $response->assertSuccessful(); + $included = $response->json('included'); + $this->assertNotEmpty($included); + $this->assertEquals('games', $included[0]['type']); + $this->assertEquals('Sonic the Hedgehog', $included[0]['attributes']['title']); + } + + public function testItIncludesRealGameInsteadOfSubsetBackingGame(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $player = User::factory()->create(); + + $realGame = Game::factory()->create(['system_id' => $system->id, 'title' => 'Real Game']); + $backingGame = Game::factory()->create(['system_id' => $system->id, 'title' => 'Backing Game']); + + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $realGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $backingGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets?include=game"); + + // Assert + $response->assertSuccessful(); + $included = $response->json('included'); + $this->assertCount(1, $included); + $this->assertEquals('Real Game', $included[0]['attributes']['title']); + } + + public function testItPaginatesBy50ByDefault(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSets = AchievementSet::factory()->count(60)->create(); + foreach ($achievementSets as $index => $achievementSet) { + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now()->subMinutes($index), + ]); + } + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $this->assertCount(50, $response->json('data')); + $this->assertEquals(50, $response->json('meta.page.perPage')); + $this->assertEquals(60, $response->json('meta.page.total')); + } + + public function testItReturnsProgressAttributes(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'achievements_unlocked' => 15, + 'achievements_unlocked_hardcore' => 12, + 'points' => 300, + 'points_hardcore' => 240, + 'points_weighted' => 600, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $attributes = $response->json('data.0.attributes'); + + $this->assertEquals(15, $attributes['achievementsUnlocked']); + $this->assertEquals(12, $attributes['achievementsUnlockedHardcore']); + $this->assertEquals(300, $attributes['points']); + $this->assertEquals(240, $attributes['pointsHardcore']); + $this->assertEquals(600, $attributes['pointsWeighted']); + } + + public function testItReturnsCompletionTimestamps(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'completion_percentage' => 0.75, + 'completion_percentage_hardcore' => 0.60, + 'completed_at' => now()->subDays(2), + 'completed_hardcore_at' => now()->subDay(), + 'last_unlock_at' => now()->subHours(6), + 'last_unlock_hardcore_at' => now()->subHours(3), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $attributes = $response->json('data.0.attributes'); + + $this->assertNotNull($attributes['completedAt']); + $this->assertNotNull($attributes['completedHardcoreAt']); + $this->assertNotNull($attributes['lastUnlockAt']); + $this->assertNotNull($attributes['lastUnlockHardcoreAt']); + $this->assertEquals(0.75, $attributes['completionPercentage']); + $this->assertEquals(0.60, $attributes['completionPercentageHardcore']); + } + + public function testItReturnsTimeTrackingAttributes(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'time_taken' => 7200, + 'time_taken_hardcore' => 5400, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $attributes = $response->json('data.0.attributes'); + + $this->assertEquals(7200, $attributes['timeTakenSeconds']); + $this->assertEquals(5400, $attributes['timeTakenHardcoreSeconds']); + } + + public function testItReturnsSetContext(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $attributes = $response->json('data.0.attributes'); + + $this->assertNotEmpty($attributes['setContext']); + $this->assertEquals($achievementSet->id, $attributes['setContext'][0]['achievementSetId']); + $this->assertEquals($game->id, $attributes['setContext'][0]['gameId']); + $this->assertEquals('bonus', $attributes['setContext'][0]['type']); + } + + public function testItExcludesSubsetBackingGameFromSetContext(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $player = User::factory()->create(); + + // The achievement set is core on one game (the backing game) and bonus on another. + $backingGame = Game::factory()->create(['system_id' => $system->id]); + $realGame = Game::factory()->create(['system_id' => $system->id]); + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $backingGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $realGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $setContext = $response->json('data.0.attributes.setContext'); + + // The backing game (core) should be excluded, only the real game (bonus) remains. + $this->assertCount(1, $setContext); + $this->assertEquals($achievementSet->id, $setContext[0]['achievementSetId']); + $this->assertEquals($realGame->id, $setContext[0]['gameId']); + $this->assertEquals('bonus', $setContext[0]['type']); + } + + public function testItDoesNotIncludeSelfLinks(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $player = User::factory()->create(); + + $achievementSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $achievementSet->id, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-achievement-sets') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-achievement-sets"); + + // Assert + $response->assertSuccessful(); + $this->assertArrayNotHasKey('links', $response->json('data.0')); + } + + public function testItCanBeIncludedOnPlayerGames(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $system = System::factory()->create(); + $player = User::factory()->create(); + + $game = Game::factory()->create(['system_id' => $system->id]); + $coreSet = AchievementSet::factory()->create(); + $bonusSet = AchievementSet::factory()->create(); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $coreSet->id, + 'type' => AchievementSetType::Core, + ]); + GameAchievementSet::factory()->create([ + 'game_id' => $game->id, + 'achievement_set_id' => $bonusSet->id, + 'type' => AchievementSetType::Bonus, + ]); + + PlayerGame::factory()->create([ + 'user_id' => $player->id, + 'game_id' => $game->id, + 'last_played_at' => now(), + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $coreSet->id, + 'achievements_unlocked_hardcore' => 10, + 'last_unlock_hardcore_at' => now(), + ]); + PlayerAchievementSet::factory()->create([ + 'user_id' => $player->id, + 'achievement_set_id' => $bonusSet->id, + 'achievements_unlocked_hardcore' => 5, + 'last_unlock_hardcore_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('player-games') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$player->ulid}/player-games?include=playerAchievementSets"); + + // Assert + $response->assertSuccessful(); + $included = $response->json('included'); + $this->assertCount(2, $included); + + $types = collect($included)->pluck('type')->unique()->toArray(); + $this->assertEquals(['player-achievement-sets'], $types); + + // The setContext should make the records distinguishable as core vs bonus. + $setContextTypes = collect($included) + ->pluck('attributes.setContext.0.type') + ->sort() + ->values() + ->toArray(); + $this->assertEquals(['bonus', 'core'], $setContextTypes); + } +} diff --git a/tests/Feature/Api/V2/PlayerGamesTest.php b/tests/Feature/Api/V2/PlayerGamesTest.php index 34affa2ac1..9420dc9525 100644 --- a/tests/Feature/Api/V2/PlayerGamesTest.php +++ b/tests/Feature/Api/V2/PlayerGamesTest.php @@ -359,6 +359,9 @@ public function testItReturnsMilestoneTimestamps(): void 'game_id' => $game->id, 'beaten_at' => now()->subDays(5), 'beaten_hardcore_at' => now()->subDays(4), + 'playtime_total' => 3600, + 'time_to_beat' => 1800, + 'time_to_beat_hardcore' => 1500, 'last_played_at' => now(), ]); @@ -374,6 +377,9 @@ public function testItReturnsMilestoneTimestamps(): void $this->assertNotNull($attributes['beatenAt']); $this->assertNotNull($attributes['beatenHardcoreAt']); + $this->assertEquals(3600, $attributes['playtimeTotalSeconds']); + $this->assertEquals(1800, $attributes['timeToBeatSeconds']); + $this->assertEquals(1500, $attributes['timeToBeatHardcoreSeconds']); } public function testItDoesNotIncludeSelfLinks(): void