diff --git a/app/Api/RouteServiceProvider.php b/app/Api/RouteServiceProvider.php index 5480e72e47..f97f05f47f 100755 --- a/app/Api/RouteServiceProvider.php +++ b/app/Api/RouteServiceProvider.php @@ -113,6 +113,7 @@ private function apiRoutes(): void ->only('index', 'show') ->readOnly() ->relationships(function ($relationships) { + $relationships->hasMany('comments')->readOnly(); $relationships->hasMany('playerAchievements')->readOnly(); }); @@ -128,6 +129,7 @@ private function apiRoutes(): void ->only('index', 'show') ->readOnly() ->relationships(function ($relationships) { + $relationships->hasMany('comments')->readOnly(); $relationships->hasMany('hashes')->readOnly(); }); @@ -158,6 +160,7 @@ private function apiRoutes(): void $relationships->hasMany('playerAchievements')->readOnly(); $relationships->hasMany('playerAchievementSets')->readOnly(); $relationships->hasMany('playerGames')->readOnly(); + $relationships->hasMany('wallComments')->readOnly(); }); }); }); diff --git a/app/Api/V2/Achievements/AchievementSchema.php b/app/Api/V2/Achievements/AchievementSchema.php index a3f27d8918..bdbe9d5211 100644 --- a/app/Api/V2/Achievements/AchievementSchema.php +++ b/app/Api/V2/Achievements/AchievementSchema.php @@ -81,6 +81,7 @@ public function fields(): array BelongsTo::make('developer')->type('users')->readOnly(), HasOne::make('achievementSet')->type('achievement-sets')->readOnly(), + HasMany::make('comments', 'visibleComments')->type('comments')->cannotEagerLoad()->readOnly(), HasMany::make('games')->type('games')->readOnly(), HasMany::make('playerAchievements')->type('player-achievements')->cannotEagerLoad()->readOnly(), diff --git a/app/Api/V2/Comments/CommentResource.php b/app/Api/V2/Comments/CommentResource.php new file mode 100644 index 0000000000..1a4f56781e --- /dev/null +++ b/app/Api/V2/Comments/CommentResource.php @@ -0,0 +1,65 @@ + $this->authorAvatarUrl(), + 'authorDisplayName' => $this->resource->getAttribute('author_display_name') ?? $this->resource->user->display_name, + 'authorId' => $this->resource->getAttribute('author_ulid') ?? $this->resource->user->ulid, + 'body' => $this->resource->body, + 'permalink' => $this->resource->url, + 'submittedAt' => $this->resource->created_at, + ]; + } + + /** + * Get the resource's relationships. + * + * @param Request|null $request + */ + public function relationships($request): iterable + { + return [ + 'author' => $this->relation('author', 'user')->withoutLinks()->showDataIfLoaded(), + ]; + } + + /** + * @param Request|null $request + */ + public function links($request): Links + { + return new Links(); + } + + private function authorAvatarUrl(): string + { + $authorUsername = $this->resource->getAttribute('author_username'); + + if ($authorUsername) { + return (new User(['username' => $authorUsername]))->avatar_url; + } + + return $this->resource->user->avatar_url; + } +} diff --git a/app/Api/V2/Comments/CommentSchema.php b/app/Api/V2/Comments/CommentSchema.php new file mode 100644 index 0000000000..e56ef8cef2 --- /dev/null +++ b/app/Api/V2/Comments/CommentSchema.php @@ -0,0 +1,97 @@ + 1]; + + /** + * Default sort order when client doesn't provide any. + */ + protected $defaultSort = 'submittedAt'; + + /** + * Get the resource type. + */ + public static function type(): string + { + return 'comments'; + } + + /** + * Get the resource fields. + */ + public function fields(): array + { + return [ + ID::make(), + + Str::make('body')->readOnly(), + Str::make('authorAvatarUrl')->readOnly(), + Str::make('authorDisplayName')->readOnly(), + Str::make('authorId')->readOnly(), + Str::make('permalink')->readOnly(), + DateTime::make('submittedAt', 'created_at')->sortable()->readOnly(), + + BelongsTo::make('author', 'user')->type('users')->readOnly(), + ]; + } + + /** + * Get the resource filters. + */ + public function filters(): array + { + return [ + WhereIdIn::make($this), + ]; + } + + /** + * Get the resource paginator. + */ + public function pagination(): ?Paginator + { + return PagePagination::make() + ->withDefaultPerPage(50); + } + + /** + * @param Relation $query + * @return Relation + */ + public function relatableQuery(?Request $request, Relation $query): Relation + { + return $query + ->notAutomated() + ->whereHas('user') + ->withAggregate('user as author_display_name', 'display_name') + ->withAggregate('user as author_username', 'username') + ->withAggregate('user as author_ulid', 'ulid'); + } +} diff --git a/app/Api/V2/Controllers/UserController.php b/app/Api/V2/Controllers/UserController.php index 96c62a2df7..3ff6f81cfa 100644 --- a/app/Api/V2/Controllers/UserController.php +++ b/app/Api/V2/Controllers/UserController.php @@ -7,6 +7,8 @@ use App\Api\V2\UserAwards\UserAwardKind; use App\Models\PlayerBadge; use App\Models\User; +use App\Policies\UserCommentPolicy; +use LaravelJsonApi\Core\Exceptions\JsonApiException; use LaravelJsonApi\Core\Pagination\Page; use LaravelJsonApi\Core\Responses\RelatedResponse; use LaravelJsonApi\Laravel\Http\Controllers\Actions; @@ -57,4 +59,32 @@ protected function readRelatedAwards( ->withMeta($meta) ->withQueryParameters($request); } + + protected function readingWallComments( + User $user, + ResourceQuery $request, + ): void { + $this->abortIfWallCommentsAreHidden($user, $request); + } + + protected function readingRelatedWallComments( + User $user, + ResourceQuery $request, + ): void { + $this->abortIfWallCommentsAreHidden($user, $request); + } + + private function abortIfWallCommentsAreHidden(User $user, ResourceQuery $request): void + { + if ((new UserCommentPolicy())->viewAny($request->user(), $user)) { + return; + } + + throw JsonApiException::error([ + 'status' => '404', + 'code' => 'not_found', + 'title' => 'Not Found', + 'detail' => "No comments found for user {$user->display_name}.", + ]); + } } diff --git a/app/Api/V2/Games/GameSchema.php b/app/Api/V2/Games/GameSchema.php index c402ac662a..1b8cc32ed9 100644 --- a/app/Api/V2/Games/GameSchema.php +++ b/app/Api/V2/Games/GameSchema.php @@ -77,6 +77,7 @@ public function fields(): array BelongsTo::make('system')->readOnly(), BelongsToMany::make('achievementSets')->readOnly(), + HasMany::make('comments', 'visibleComments')->type('comments')->cannotEagerLoad()->readOnly(), HasMany::make('hashes')->type('game-hashes')->readOnly(), // TODO implement relationship endpoints to enable links diff --git a/app/Api/V2/Server.php b/app/Api/V2/Server.php index 01bb47d937..28c37227bc 100644 --- a/app/Api/V2/Server.php +++ b/app/Api/V2/Server.php @@ -33,6 +33,7 @@ protected function allSchemas(): array return [ Achievements\AchievementSchema::class, AchievementSets\AchievementSetSchema::class, + Comments\CommentSchema::class, EventAwards\EventAwardSchema::class, Events\EventSchema::class, GameHashes\GameHashSchema::class, diff --git a/app/Api/V2/Users/UserSchema.php b/app/Api/V2/Users/UserSchema.php index 6e4e559a34..6092935288 100644 --- a/app/Api/V2/Users/UserSchema.php +++ b/app/Api/V2/Users/UserSchema.php @@ -97,6 +97,7 @@ public function fields(): array HasMany::make('playerAchievements')->type('player-achievements')->cannotEagerLoad()->readOnly(), HasMany::make('playerAchievementSets')->type('player-achievement-sets')->cannotEagerLoad()->readOnly(), HasMany::make('playerGames')->type('player-games')->cannotEagerLoad()->readOnly(), + HasMany::make('wallComments', 'visibleComments')->type('comments')->cannotEagerLoad()->readOnly(), HasMany::make('awards', 'playerBadges') ->type('user-awards') ->cannotEagerLoad() @@ -116,7 +117,6 @@ public function fields(): array // - followers (BelongsToMany User) - users following this user // - authoredAchievements (HasMany Achievement) // - claims (HasMany AchievementSetClaim) - // - wall comments (HasMany Comment, commentable_type=user.comment, commentable_id=self) ]; } diff --git a/app/Policies/AchievementPolicy.php b/app/Policies/AchievementPolicy.php index 5bd637cde7..9b3b3f8b71 100644 --- a/app/Policies/AchievementPolicy.php +++ b/app/Policies/AchievementPolicy.php @@ -60,6 +60,11 @@ public function view(?User $user, Achievement $achievement): bool return true; } + public function viewComments(?User $user, Achievement $achievement): bool + { + return true; + } + public function viewPlayerAchievements(?User $user, Achievement $achievement): bool { return true; diff --git a/app/Policies/GamePolicy.php b/app/Policies/GamePolicy.php index c69c2e2603..f8d4254b0a 100644 --- a/app/Policies/GamePolicy.php +++ b/app/Policies/GamePolicy.php @@ -115,6 +115,11 @@ public function view(?User $user, Game $game): bool return true; } + public function viewComments(?User $user, Game $game): bool + { + return true; + } + public function viewHashes(?User $user, Game $game): bool { return true; diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 780b6a33b2..8b01724e10 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -69,6 +69,14 @@ public function viewPlayerGames(?User $user, User $model): bool return true; } + public function viewWallComments(?User $user, User $model): bool + { + // This is only to facilitate the V2 Web API. + // Disabled or banned walls are hidden by the V2 controller hooks so both + // related-resource and relationship-linkage routes return JSON:API 404s. + return true; + } + public function create(User $user): bool { // nobody creates users just like that. diff --git a/tests/Feature/Api/V2/CommentsTest.php b/tests/Feature/Api/V2/CommentsTest.php new file mode 100644 index 0000000000..04b61b40f9 --- /dev/null +++ b/tests/Feature/Api/V2/CommentsTest.php @@ -0,0 +1,495 @@ +createGame(); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->get("/api/v2/games/{$game->id}/comments"); + + // Assert + $response->assertUnauthorized(); + } + + public function testItFetchesGameComments(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + $author = User::factory()->create([ + 'display_name' => 'CommentAuthor', + 'username' => 'CommentAuthor', + ]); + + $comment1 = Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + 'body' => 'This game is excellent.', + 'created_at' => '2024-01-18 15:01:04', + ]); + $comment2 = Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + 'body' => 'Still excellent.', + 'created_at' => '2024-01-19 15:01:04', + ]); + Comment::factory()->create([ + 'commentable_id' => $game->id + 1, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments"); + + // Assert + $response->assertSuccessful(); + + $this->assertEquals([ + (string) $comment1->id, + (string) $comment2->id, + ], collect($response->json('data'))->pluck('id')->all()); + + $this->assertEquals('This game is excellent.', $response->json('data.0.attributes.body')); + $this->assertEquals($author->avatarUrl, $response->json('data.0.attributes.authorAvatarUrl')); + $this->assertEquals('CommentAuthor', $response->json('data.0.attributes.authorDisplayName')); + $this->assertEquals($author->ulid, $response->json('data.0.attributes.authorId')); + $this->assertEquals(route('comment.show', ['comment' => $comment1->id]), $response->json('data.0.attributes.permalink')); + $this->assertArrayHasKey('submittedAt', $response->json('data.0.attributes')); + $this->assertArrayNotHasKey('links', $response->json('data.0')); + } + + public function testItFetchesAchievementComments(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $achievement = $this->createAchievement(); + $author = User::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $achievement->id, + 'commentable_type' => CommentableType::Achievement, + 'user_id' => $author->id, + 'body' => 'This achievement is fair.', + ]); + Comment::factory()->create([ + 'commentable_id' => $achievement->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/achievements/{$achievement->id}/comments"); + + // Assert + $response->assertSuccessful(); + $response->assertFetchedMany([ + ['type' => 'comments', 'id' => (string) $comment->id], + ]); + $this->assertCount(1, $response->json('data')); + } + + public function testItFetchesUserWallComments(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $wallOwner = User::factory()->create(['is_user_wall_active' => true]); + $author = User::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $wallOwner->id, + 'commentable_type' => CommentableType::User, + 'user_id' => $author->id, + 'body' => 'Thanks for the help.', + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$wallOwner->ulid}/wall-comments"); + + // Assert + $response->assertSuccessful(); + $response->assertFetchedMany([ + ['type' => 'comments', 'id' => (string) $comment->id], + ]); + } + + public function testItFetchesUserWallCommentRelationshipLinkage(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $wallOwner = User::factory()->create(['is_user_wall_active' => true]); + $author = User::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $wallOwner->id, + 'commentable_type' => CommentableType::User, + 'user_id' => $author->id, + 'body' => 'Thanks for the help.', + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$wallOwner->ulid}/relationships/wall-comments"); + + // Assert + $response->assertSuccessful(); + $this->assertEquals([ + ['type' => 'comments', 'id' => (string) $comment->id], + ], $response->json('data')); + } + + public function testItIncludesAuthorWhenRequested(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + $author = User::factory()->create([ + 'display_name' => 'CommentAuthor', + 'motto' => 'Do not include this', + ]); + + Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments?include=author&fields[users]=displayName,avatarUrl"); + + // Assert + $response->assertSuccessful(); + + $this->assertEquals('users', $response->json('data.0.relationships.author.data.type')); + $this->assertEquals($author->ulid, $response->json('data.0.relationships.author.data.id')); + + $includedAuthor = collect($response->json('included'))->firstWhere('type', 'users'); + $this->assertEquals('CommentAuthor', $includedAuthor['attributes']['displayName']); + $this->assertArrayHasKey('avatarUrl', $includedAuthor['attributes']); + $this->assertArrayNotHasKey('motto', $includedAuthor['attributes']); + } + + public function testItDoesNotIncludeAuthorRelationshipByDefault(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + $author = User::factory()->create(); + + Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments"); + + // Assert + $response->assertSuccessful(); + $this->assertArrayNotHasKey('relationships', $response->json('data.0')); + $this->assertArrayNotHasKey('included', $response->json()); + } + + public function testItSortsCommentsDescendingWhenRequested(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + $author = User::factory()->create(); + + $olderComment = Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + 'created_at' => '2024-01-18 15:01:04', + ]); + $newerComment = Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + 'created_at' => '2024-01-19 15:01:04', + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments?sort=-submittedAt"); + + // Assert + $response->assertSuccessful(); + + $this->assertEquals([ + (string) $newerComment->id, + (string) $olderComment->id, + ], collect($response->json('data'))->pluck('id')->all()); + } + + public function testItPaginatesBy50ByDefault(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + $author = User::factory()->create(); + + Comment::factory()->count(75)->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments"); + + // Assert + $response->assertSuccessful(); + $this->assertCount(50, $response->json('data')); + $this->assertEquals(50, $response->json('meta.page.perPage')); + $this->assertEquals(75, $response->json('meta.page.total')); + } + + public function testItExcludesDeletedCommentsAndCommentsFromBannedUsers(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + $author = User::factory()->create(); + $bannedAuthor = User::factory()->create(['banned_at' => now()]); + $deletedAuthor = User::factory()->create(['deleted_at' => now()]); + + $visibleComment = Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + ]); + Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $bannedAuthor->id, + ]); + Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $deletedAuthor->id, + ]); + Comment::factory()->create([ + 'commentable_id' => $game->id, + 'commentable_type' => CommentableType::Game, + 'user_id' => $author->id, + 'deleted_at' => now(), + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments"); + + // Assert + $response->assertSuccessful(); + $response->assertFetchedMany([ + ['type' => 'comments', 'id' => (string) $visibleComment->id], + ]); + $this->assertCount(1, $response->json('data')); + } + + public function testItExcludesAutomatedSystemComments(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + User::factory()->create(['id' => Comment::SYSTEM_USER_ID]); + $achievement = $this->createAchievement(); + $author = User::factory()->create(); + + $humanComment = Comment::factory()->create([ + 'commentable_id' => $achievement->id, + 'commentable_type' => CommentableType::Achievement, + 'user_id' => $author->id, + ]); + Comment::factory()->create([ + 'commentable_id' => $achievement->id, + 'commentable_type' => CommentableType::Achievement, + 'user_id' => Comment::SYSTEM_USER_ID, // !! + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/achievements/{$achievement->id}/comments"); + + // Assert + $response->assertSuccessful(); + $response->assertFetchedMany([ + ['type' => 'comments', 'id' => (string) $humanComment->id], + ]); + $this->assertCount(1, $response->json('data')); + } + + public function testItReturns404ForDisabledUserWall(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $wallOwner = User::factory()->create(['is_user_wall_active' => false]); + $author = User::factory()->create(); + + Comment::factory()->create([ + 'commentable_id' => $wallOwner->id, + 'commentable_type' => CommentableType::User, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$wallOwner->ulid}/wall-comments"); + + // Assert + $response->assertNotFound(); + } + + public function testItReturns404ForDisabledUserWallCommentRelationshipLinkage(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $wallOwner = User::factory()->create(['is_user_wall_active' => false]); + $author = User::factory()->create(); + + Comment::factory()->create([ + 'commentable_id' => $wallOwner->id, + 'commentable_type' => CommentableType::User, + 'user_id' => $author->id, + ]); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/users/{$wallOwner->ulid}/relationships/wall-comments"); + + // Assert + $response->assertNotFound(); + } + + public function testItReturns404ForMissingParent(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get('/api/v2/games/999999/comments'); + + // Assert + $response->assertNotFound(); + } + + public function testItReturns400ForInvalidSort(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments?sort=body"); + + // Assert + $response->assertStatus(400); + } + + public function testItReturns400ForUnsupportedInclude(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + $game = $this->createGame(); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get("/api/v2/games/{$game->id}/comments?include=game"); + + // Assert + $response->assertStatus(400); + } + + public function testItDoesNotExposeStandaloneCommentsIndex(): void + { + // Arrange + User::factory()->create(['web_api_key' => 'test-key']); + + // Act + $response = $this->jsonApi('v2') + ->expects('comments') + ->withHeader('X-API-Key', 'test-key') + ->get('/api/v2/comments'); + + // Assert + $response->assertNotFound(); + } + + private function createGame(): Game + { + $system = System::factory()->create(); + + return Game::factory()->create(['system_id' => $system->id]); + } + + private function createAchievement(): Achievement + { + $game = $this->createGame(); + + return Achievement::factory()->create(['game_id' => $game->id]); + } +}