diff --git a/app/Actions/BlockUser.php b/app/Actions/BlockUser.php new file mode 100644 index 0000000..293a837 --- /dev/null +++ b/app/Actions/BlockUser.php @@ -0,0 +1,19 @@ +firstOrCreate([ + 'user_id' => $user->id, + 'blocked_user_id' => $toBlock->id, + ]); + } +} diff --git a/app/Actions/UnBlockUser.php b/app/Actions/UnBlockUser.php new file mode 100644 index 0000000..d529e9d --- /dev/null +++ b/app/Actions/UnBlockUser.php @@ -0,0 +1,22 @@ +where([ + 'user_id' => $user->id, + 'blocked_user_id' => $toBlock->id, + ]) + ->delete(); + } +} diff --git a/app/Http/Controllers/BlockController.php b/app/Http/Controllers/BlockController.php new file mode 100644 index 0000000..efe5d18 --- /dev/null +++ b/app/Http/Controllers/BlockController.php @@ -0,0 +1,36 @@ +handle($loggedInUser, $user); + + return response(status: 201); + + } + + public function destroy( + #[CurrentUser] User $loggedInUser, + User $user, + UnBlockUser $action + ): Response { + $action->handle($loggedInUser, $user); + + return response(status: 204); + + } +} diff --git a/app/Http/Controllers/ForYouFeedController.php b/app/Http/Controllers/ForYouFeedController.php index b745bb0..d244c8c 100644 --- a/app/Http/Controllers/ForYouFeedController.php +++ b/app/Http/Controllers/ForYouFeedController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers; +use App\Models\User; use App\Queries\ForYouFeedQuery; +use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -13,9 +15,9 @@ final class ForYouFeedController /** * Get the for-you feed. */ - public function __invoke(Request $request): JsonResponse + public function __invoke(Request $request, #[CurrentUser] ?User $user): JsonResponse { - $forYouFeed = new ForYouFeedQuery(); + $forYouFeed = new ForYouFeedQuery($user); $posts = $forYouFeed->builder() ->paginate( diff --git a/app/Models/BlockedUser.php b/app/Models/BlockedUser.php new file mode 100644 index 0000000..cc494a0 --- /dev/null +++ b/app/Models/BlockedUser.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function blockedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'blocked_user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index bbe1e51..e762401 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -79,4 +79,20 @@ public function following(): BelongsToMany { return $this->belongsToMany(self::class, 'followers', 'follower_id', 'user_id'); } + + /** + * @return HasMany + */ + public function blockedUsers(): HasMany + { + return $this->hasMany(BlockedUser::class, 'user_id'); + } + + /** + * @return HasMany + */ + public function blockedByUsers(): HasMany + { + return $this->hasMany(BlockedUser::class, 'blocked_user_id'); + } } diff --git a/app/Queries/FollowingFeedQuery.php b/app/Queries/FollowingFeedQuery.php index dca0028..fd36a9d 100644 --- a/app/Queries/FollowingFeedQuery.php +++ b/app/Queries/FollowingFeedQuery.php @@ -7,6 +7,7 @@ use App\Models\Post; use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; final readonly class FollowingFeedQuery { @@ -23,7 +24,11 @@ public function builder(): Builder return Post::query() ->whereIn('user_id', $followingUsersQuery) + ->whereNotIn('user_id', fn (QueryBuilder $query): QueryBuilder => $query->select('blocked_user_id') + ->from('blocked_users') + ->where('user_id', $this->user->id)) ->with(['user', 'likes']) + ->latest('updated_at'); } } diff --git a/app/Queries/ForYouFeedQuery.php b/app/Queries/ForYouFeedQuery.php index 20d9c91..98f3615 100644 --- a/app/Queries/ForYouFeedQuery.php +++ b/app/Queries/ForYouFeedQuery.php @@ -5,10 +5,16 @@ namespace App\Queries; use App\Models\Post; +use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; final readonly class ForYouFeedQuery { + public function __construct( + private ?User $user + ) {} + /** * @return Builder */ @@ -16,6 +22,10 @@ public function builder(): Builder { return Post::query() ->with(['user', 'likes']) + ->when($this->user, fn (Builder $query): Builder => $query->whereNotIn('user_id', fn (QueryBuilder $query): QueryBuilder => $query->select('blocked_user_id') + ->from('blocked_users') + ->where('user_id', $this->user?->id))) + ->latest('updated_at'); } } diff --git a/database/factories/BlockedUserFactory.php b/database/factories/BlockedUserFactory.php new file mode 100644 index 0000000..e52b29c --- /dev/null +++ b/database/factories/BlockedUserFactory.php @@ -0,0 +1,28 @@ + + */ +final class BlockedUserFactory extends Factory +{ + protected $model = BlockedUser::class; + + public function definition(): array + { + return [ + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'user_id' => User::factory(), + 'blocked_user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2025_10_03_144125_create_blocked_users_table.php b/database/migrations/2025_10_03_144125_create_blocked_users_table.php new file mode 100644 index 0000000..37151d6 --- /dev/null +++ b/database/migrations/2025_10_03_144125_create_blocked_users_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained(); + $table->foreignId('blocked_user_id')->constrained('users'); + $table->unique(['user_id', 'blocked_user_id']); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('blocked_users'); + } +}; diff --git a/routes/api.php b/routes/api.php index 8b25efa..14543e6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,6 +43,10 @@ Route::post('/follows/{user}', [FollowController::class, 'store'])->name('follows.store'); Route::delete('/follows/{user}', [FollowController::class, 'destroy'])->name('follows.destroy'); + // Blocks + Route::post('/blocks/{user}', [App\Http\Controllers\BlockController::class, 'store'])->name('blocks.store'); + Route::delete('/blocks/{user}', [App\Http\Controllers\BlockController::class, 'destroy'])->name('blocks.destroy'); + // Likes... Route::post('/likes/{post}', [LikeController::class, 'store'])->name('likes.store'); Route::delete('/likes/{post}', [LikeController::class, 'destroy'])->name('likes.destroy'); diff --git a/tests/Feature/Http/BlockControllerTest.php b/tests/Feature/Http/BlockControllerTest.php new file mode 100644 index 0000000..6aa304e --- /dev/null +++ b/tests/Feature/Http/BlockControllerTest.php @@ -0,0 +1,55 @@ +create(); + $targetUser = User::factory()->create(); + + Sanctum::actingAs($user, ['*']); + + $response = $this->postJson(route('blocks.store', $targetUser)); + + $response->assertStatus(201); + + expect($user->blockedUsers()->count())->toBe(1); +}); + +it('may unblock a user', function () { + $user = User::factory()->create(); + $targetUser = User::factory()->create(); + + App\Models\BlockedUser::factory()->create([ + 'user_id' => $user->id, + 'blocked_user_id' => $targetUser->id, + ]); + expect($user->blockedUsers()->count())->toBe(1); + + Sanctum::actingAs($user, ['*']); + + $response = $this->deleteJson(route('blocks.destroy', $targetUser)); + + $response->assertStatus(204); + + expect($user->refresh()->blockedUsers()->count())->toBe(0); +}); + +it('cannot block a user twice', function () { + $user = User::factory()->create(); + $targetUser = User::factory()->create(); + + App\Models\BlockedUser::factory()->create([ + 'user_id' => $user->id, + 'blocked_user_id' => $targetUser->id, + ]); + Sanctum::actingAs($user, ['*']); + + $response = $this->postJson(route('blocks.store', $targetUser)); + + $response->assertStatus(201); + + expect($user->blockedUsers()->count())->toBe(1); +}); diff --git a/tests/Unit/Actions/BlockUserTest.php b/tests/Unit/Actions/BlockUserTest.php new file mode 100644 index 0000000..0c510d0 --- /dev/null +++ b/tests/Unit/Actions/BlockUserTest.php @@ -0,0 +1,42 @@ +create(); + $targetUser = User::factory()->create(); + $action = app(App\Actions\BlockUser::class); + + $action->handle($user, $targetUser); + + expect($user->blockedUsers()->count())->toBe(1) + ->and($targetUser->blockedByUsers()->count())->toBe(1); +}); + +test('re-block a user does not duplicate the block', function (): void { + $user = User::factory()->create(); + $targetUser = User::factory()->create(); + $action = app(App\Actions\BlockUser::class); + + $action->handle($user, $targetUser); + + expect($user->blockedUsers()->count())->toBe(1) + ->and($targetUser->blockedByUsers()->count())->toBe(1); + + $action->handle($user, $targetUser); + + expect($user->blockedUsers()->count())->toBe(1) + ->and($targetUser->blockedByUsers()->count())->toBe(1); +}); + +test('block users models returns correct data', function (): void { + $user = User::factory()->create(); + $targetUser = User::factory()->create(); + $action = app(App\Actions\BlockUser::class); + $action->handle($user, $targetUser); + $blockedUser = App\Models\BlockedUser::first(); + expect($blockedUser->user->id)->toEqual($user->id); + expect($blockedUser->blockedUser->id)->toEqual($targetUser->id); +}); diff --git a/tests/Unit/Actions/UnBlockUserTest.php b/tests/Unit/Actions/UnBlockUserTest.php new file mode 100644 index 0000000..f0be0f2 --- /dev/null +++ b/tests/Unit/Actions/UnBlockUserTest.php @@ -0,0 +1,32 @@ +create(); + $targetUser = User::factory()->create(); + app(App\Actions\BlockUser::class)->handle($user, $targetUser); + $action = app(App\Actions\UnBlockUser::class); + + expect($user->blockedUsers()->count())->toBe(1) + ->and($targetUser->blockedByUsers()->count())->toBe(1); + $action->handle($user, $targetUser); + + expect($user->blockedUsers()->count())->toBe(0) + ->and($targetUser->blockedByUsers()->count())->toBe(0); + +}); + +test('re-unblock does nothing', function (): void { + $user = User::factory()->create(); + $targetUser = User::factory()->create(); + $action = app(App\Actions\UnBlockUser::class); + + expect($user->blockedUsers()->count())->toBe(0); + + $action->handle($user, $targetUser); + + expect($user->blockedUsers()->count())->toBe(0); +}); diff --git a/tests/Unit/Queries/FollowingFeedQueryTest.php b/tests/Unit/Queries/FollowingFeedQueryTest.php index d3beea2..9f7d0a6 100644 --- a/tests/Unit/Queries/FollowingFeedQueryTest.php +++ b/tests/Unit/Queries/FollowingFeedQueryTest.php @@ -51,3 +51,28 @@ expect($posts)->toHaveCount(0); }); + +it('may not return posts from users blocked by the user', function (): void { + $user = User::factory()->create(); + $blockedUser = User::factory()->create(); + $followedUser = User::factory()->create(); + + app(App\Actions\FollowUser::class)->handle($user, $followedUser); + app(App\Actions\FollowUser::class)->handle($user, $blockedUser); + app(App\Actions\BlockUser::class)->handle($user, $blockedUser); + + $followedPost = Post::factory()->create([ + 'user_id' => $followedUser->id, + ]); + $blockedPost = Post::factory()->create([ + 'user_id' => $blockedUser->id, + ]); + + $followingFeed = new FollowingFeedQuery($user); + $posts = $followingFeed->builder()->get(); + expect($posts)->toHaveCount(1); + $firstPost = $posts->first(); + expect($firstPost->id)->toBe($followedPost->id); + expect($firstPost->id)->not()->toBe($blockedPost->id); + +}); diff --git a/tests/Unit/Queries/ForYouFeedQueryTest.php b/tests/Unit/Queries/ForYouFeedQueryTest.php index 3b72711..795af1b 100644 --- a/tests/Unit/Queries/ForYouFeedQueryTest.php +++ b/tests/Unit/Queries/ForYouFeedQueryTest.php @@ -8,7 +8,8 @@ use Illuminate\Database\Eloquent\Builder; it('may return a query builder', function (): void { - $forYouFeed = new ForYouFeedQuery(); + $user = User::factory()->create(); + $forYouFeed = new ForYouFeedQuery($user); $builder = $forYouFeed->builder(); @@ -22,7 +23,7 @@ $olderPost = Post::factory()->create(['user_id' => $user->id, 'updated_at' => now()->subHour()]); $newerPost = Post::factory()->create(['user_id' => $otherUser->id, 'updated_at' => now()]); - $forYouFeed = new ForYouFeedQuery(); + $forYouFeed = new ForYouFeedQuery($user); $posts = $forYouFeed->builder()->get(); expect($posts)->toHaveCount(2) @@ -30,8 +31,29 @@ }); it('may return empty collection when no posts exist', function (): void { - $forYouFeed = new ForYouFeedQuery(); + $user = User::factory()->create(); + $forYouFeed = new ForYouFeedQuery($user); $posts = $forYouFeed->builder()->get(); expect($posts)->toHaveCount(0); }); + +it('may not return posts from users blocked by the user', function (): void { + $user = User::factory()->create(); + $blockedUser = User::factory()->create(); + $otherUser = User::factory()->create(); + + app(App\Actions\BlockUser::class)->handle($user, $blockedUser); + $post = Post::factory()->create([ + 'user_id' => $otherUser->id, + ]); + $blockedPost = Post::factory()->create([ + 'user_id' => $blockedUser->id, + ]); + $forYouFeed = new ForYouFeedQuery($user); + $posts = $forYouFeed->builder()->get(); + expect($posts)->toHaveCount(1); + $firstPost = $posts->first(); + expect($firstPost->id)->toBe($post->id); + expect($firstPost->id)->not()->toBe($blockedPost->id); +});