From 6c7c18f2f79b2a14a63a07175643dbefe17d3ae3 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:20:31 +0100 Subject: [PATCH 1/3] feat: mute and block from the API, and list who the user mutes and blocks --- PRODUCT.md | 2 +- README.md | 6 +- app/Exceptions/ApiException.php | 14 +- .../Controllers/Api/UserBlockController.php | 46 ++++++ .../Controllers/Api/UserMuteController.php | 42 +++++ .../Requests/CreateApiUserBlockRequest.php | 33 ++++ .../Requests/CreateApiUserMuteRequest.php | 33 ++++ .../Requests/DeleteApiUserBlockRequest.php | 23 +++ .../Requests/DeleteApiUserMuteRequest.php | 23 +++ .../Requests/ListApiUserCommitsRequest.php | 3 +- app/Http/Requests/ShowApiUserRequest.php | 3 +- routes/api.php | 12 ++ .../Api/UserBlockControllerTest.php | 147 ++++++++++++++++++ .../Controllers/Api/UserControllerTest.php | 14 +- .../Api/UserMuteControllerTest.php | 132 ++++++++++++++++ 15 files changed, 519 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/Api/UserBlockController.php create mode 100644 app/Http/Controllers/Api/UserMuteController.php create mode 100644 app/Http/Requests/CreateApiUserBlockRequest.php create mode 100644 app/Http/Requests/CreateApiUserMuteRequest.php create mode 100644 app/Http/Requests/DeleteApiUserBlockRequest.php create mode 100644 app/Http/Requests/DeleteApiUserMuteRequest.php create mode 100644 tests/Feature/Controllers/Api/UserBlockControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserMuteControllerTest.php diff --git a/PRODUCT.md b/PRODUCT.md index 273c1ab5e..eed60ebd7 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,7 +23,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Sign-in is GitHub OAuth only. Profile changes happen on GitHub ("Edit on GitHub"), then sync at next login or with "Sync from GitHub" (once a minute). - Everything except the landing page, the public read API, and profile feeds requires login. - Public read API: read-only JSON under `/api/v1`, no authentication, for public profiles (with counts), a user's commits or replies, and single commits. It returns exactly what a guest's link preview may show: approved, non-deleted commits only (with their images), approved reply counts, and a parent only while it is approved. Never emails or other hidden profile fields. 60 requests a minute per IP. -- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, and notifications, and commit, reply, delete, and follow, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. +- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, notifications, and the people the user mutes and blocks, and commit, reply, delete, follow, mute, and block, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. - Profile feeds: every profile has an Atom feed of its latest 50 approved top-level commits at `/@{username}/feed.atom`, their images as enclosures, advertised with `` on the profile's guest preview. - First sign-in leads to a short setup at `/onboarding`: confirm the mirrored profile, pick a color, follow a few suggested developers, learn the composer, `?` shortcuts and terminal commits, then land on the log (or the page the user was headed to). Skippable at any step (Skip setup or Esc); finishing or skipping ends it for good. Accounts that existed before it never see it. - Profile links (`/@{username}`) and links to approved commits (`/@{username}/commits/{id}`) unfurl with a link preview (the author and their bio or counts, or the commit body and reply count) for guests and crawlers; a guest opening one sees the landing page, and the profile or commit itself stays behind login. Pending and rejected commits are a 404 for guests. diff --git a/README.md b/README.md index 53718050e..6d8d52f77 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,14 @@ Every profile also has an Atom feed of its latest 50 top-level commits at `/@{us ## Signed requests -The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting and following only take signed requests. Signed requests are limited to 120 a minute per user. +The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting, following, muting and blocking only take signed requests. Signed requests are limited to 120 a minute per user. Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web. A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. +`POST` and `DELETE /api/v1/users/{username}/mute` mute and unmute someone, and `POST` and `DELETE /api/v1/users/{username}/block` block and unblock them, as the web does: nobody can mute or block themselves, muting is silent, and blocking ends the follows both ways, which unblocking doesn't bring back. Each answers with the user and the new `is_muting` or `is_blocking`. `GET /api/v1/me/muted` and `GET /api/v1/me/blocked` list the people the user mutes and blocks, last first, as Settings → Safety does, 20 a page. + `GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it says `is_ranked`. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, which is always 0 on Trending, since the web offers none there. An empty Following log comes with a few `suggestions`. `GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `none`, or a comma-separated list of `replies`, `quotes`, `stars`, `mentions`, `follows` and `moderation`. The response echoes it as `filters.type`. Listing them marks every notification as read, whatever the filter, and `read_at` says when, so the groups read at or after it are the new ones. `GET /api/v1/notifications/{id}/group` lists the notifications of one group, with the same filter, the way the web expands it. @@ -122,7 +124,7 @@ An error is JSON with a `message` to show the user. The errors the app has to te | `400` | `invalid_grant` | `POST /api/v1/devices` only: the code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. | | `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. | | `401` | `device_not_approved` | Nobody approved the key as a device, or it was revoked. | -| `403` | `user_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. | +| `403` | `user_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. The error comes with the `user` they blocked, as the web shows them. | | `403` | `user_unavailable` | The profile isn't available to the user, because it blocked them. | | `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | diff --git a/app/Exceptions/ApiException.php b/app/Exceptions/ApiException.php index 1b146089c..5bd364129 100644 --- a/app/Exceptions/ApiException.php +++ b/app/Exceptions/ApiException.php @@ -7,31 +7,34 @@ use App\Enums\ApiErrorCode; use App\Models\User; use Illuminate\Http\JsonResponse; +use JsonSerializable; use Symfony\Component\HttpKernel\Exception\HttpException; /** - * An error from the signed API. It answers with its message and its code, and the status that goes with the code. + * An error from the signed API. It answers with its message and its code, and the status that goes with the code, and + * anything else the app needs to show it. */ final class ApiException extends HttpException { /** * @param array $headers + * @param array $data */ - public function __construct(public readonly ApiErrorCode $errorCode, string $message, array $headers = []) + public function __construct(public readonly ApiErrorCode $errorCode, string $message, array $headers = [], public readonly array $data = []) { parent::__construct($errorCode->status(), $message, headers: $headers); } /** * A profile across a block is unavailable to a signed request, as on the web. Only the one who blocked the other - * is told why. + * is told why, with the user they blocked as the API shows users, for the blocked page the web shows them. */ - public static function acrossBlock(User $user, bool $blockedByViewer): self + public static function acrossBlock(User $user, bool $blockedByViewer, JsonSerializable $shown): self { return $blockedByViewer ? new self(ApiErrorCode::UserBlocked, __("You blocked :name. Neither of you can follow, reply to, or star the other, or see each other's commits.", [ 'name' => $user->name, - ])) + ]), data: ['user' => $shown]) : new self(ApiErrorCode::UserUnavailable, __("This profile isn't available.")); } @@ -48,6 +51,7 @@ public function render(): JsonResponse return response()->json([ 'message' => $this->getMessage(), 'code' => $this->errorCode->value, + ...$this->data, ], $this->getStatusCode(), $this->getHeaders()); } } diff --git a/app/Http/Controllers/Api/UserBlockController.php b/app/Http/Controllers/Api/UserBlockController.php new file mode 100644 index 000000000..b9b4ad712 --- /dev/null +++ b/app/Http/Controllers/Api/UserBlockController.php @@ -0,0 +1,46 @@ +blocking()->latest('blocks.created_at')->latest('users.id')->cursorPaginate(20)); + } + + /** + * Blocks the user, which ends the follows both ways, as on the web, and answers with them and that they are now + * blocked. + */ + public function store(CreateApiUserBlockRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserBlock $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_blocking' => true]); + } + + /** + * Unblocking doesn't bring back the follows, as on the web. + */ + public function destroy(DeleteApiUserBlockRequest $request, #[CurrentUser] User $currentUser, User $user, DeleteUserBlock $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_blocking' => false]); + } +} diff --git a/app/Http/Controllers/Api/UserMuteController.php b/app/Http/Controllers/Api/UserMuteController.php new file mode 100644 index 000000000..83c0ef267 --- /dev/null +++ b/app/Http/Controllers/Api/UserMuteController.php @@ -0,0 +1,42 @@ +muting()->latest('mutes.created_at')->latest('users.id')->cursorPaginate(20)); + } + + /** + * Mutes the user silently, as on the web, and answers with them and that they are now muted. + */ + public function store(CreateApiUserMuteRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserMute $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_muting' => true]); + } + + public function destroy(DeleteApiUserMuteRequest $request, #[CurrentUser] User $currentUser, User $user, DeleteUserMute $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_muting' => false]); + } +} diff --git a/app/Http/Requests/CreateApiUserBlockRequest.php b/app/Http/Requests/CreateApiUserBlockRequest.php new file mode 100644 index 000000000..a0b3b44e6 --- /dev/null +++ b/app/Http/Requests/CreateApiUserBlockRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + + $blocked = $this->route('user'); + assert($blocked instanceof User); + + return $user->isNot($blocked); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/CreateApiUserMuteRequest.php b/app/Http/Requests/CreateApiUserMuteRequest.php new file mode 100644 index 000000000..3ef11509f --- /dev/null +++ b/app/Http/Requests/CreateApiUserMuteRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + + $muted = $this->route('user'); + assert($muted instanceof User); + + return $user->isNot($muted); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeleteApiUserBlockRequest.php b/app/Http/Requests/DeleteApiUserBlockRequest.php new file mode 100644 index 000000000..55189b445 --- /dev/null +++ b/app/Http/Requests/DeleteApiUserBlockRequest.php @@ -0,0 +1,23 @@ +user(); $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; - throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id)); + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user))); return true; } diff --git a/app/Http/Requests/ShowApiUserRequest.php b/app/Http/Requests/ShowApiUserRequest.php index d5a92b2bf..99e94133d 100644 --- a/app/Http/Requests/ShowApiUserRequest.php +++ b/app/Http/Requests/ShowApiUserRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests; use App\Exceptions\ApiException; +use App\Http\Resources\UserResource; use App\Models\User; use Illuminate\Foundation\Http\FormRequest; @@ -22,7 +23,7 @@ public function authorize(): bool $viewer = $this->user(); $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; - throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id)); + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user))); return true; } diff --git a/routes/api.php b/routes/api.php index 4721e9d9e..b7b955a69 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,9 +9,11 @@ use App\Http\Controllers\Api\LogController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\NotificationGroupController; +use App\Http\Controllers\Api\UserBlockController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserFollowController; +use App\Http\Controllers\Api\UserMuteController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; use App\Http\Middleware\AuthenticateSignedRequest; @@ -71,6 +73,16 @@ ->middleware('throttle:follows') ->name('api-user-follow.destroy'); + // User Mute... + Route::get('me/muted', [UserMuteController::class, 'index'])->name('api-user-mute.index'); + Route::post('users/{user:username}/mute', [UserMuteController::class, 'store'])->name('api-user-mute.store'); + Route::delete('users/{user:username}/mute', [UserMuteController::class, 'destroy'])->name('api-user-mute.destroy'); + + // User Block... + Route::get('me/blocked', [UserBlockController::class, 'index'])->name('api-user-block.index'); + Route::post('users/{user:username}/block', [UserBlockController::class, 'store'])->name('api-user-block.store'); + Route::delete('users/{user:username}/block', [UserBlockController::class, 'destroy'])->name('api-user-block.destroy'); + // Notifications... Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index'); Route::get('notifications/{notification}/group', [NotificationGroupController::class, 'show'])->name('api-notification-group.show'); diff --git a/tests/Feature/Controllers/Api/UserBlockControllerTest.php b/tests/Feature/Controllers/Api/UserBlockControllerTest.php new file mode 100644 index 000000000..5e2b29e22 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserBlockControllerTest.php @@ -0,0 +1,147 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may block a user, which ends the follows both ways, and answers with them and that they are blocked', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + $this->user->following()->attach($user); + $user->following()->attach($this->user); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user)); + + $response->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.lane', $user->lane()) + ->assertJsonPath('is_blocking', true); + + expect($this->user->isBlocking($user))->toBeTrue() + ->and($this->user->isFollowing($user))->toBeFalse() + ->and($user->isFollowing($this->user))->toBeFalse() + ->and($user->notifications()->count())->toBe(0); +}); + +it('may block a user who blocks them, as on the web', function (): void { + $user = User::factory()->create(); + + $user->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user)) + ->assertOk() + ->assertJsonPath('is_blocking', true); + + expect($this->user->isBlocking($user))->toBeTrue() + ->and($user->isBlocking($this->user))->toBeTrue(); +}); + +it('keeps one block when a user is blocked again', function (): void { + $user = User::factory()->create(); + + $this->user->blocking()->attach($user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user)) + ->assertOk() + ->assertJsonPath('is_blocking', true); + + expect($this->user->blocking()->count())->toBe(1); +}); + +it('may not block themselves', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $this->user))->assertForbidden(); + + expect($this->user->blocking()->count())->toBe(0); +}); + +it('may unblock a user, which does not bring back the follows, and answers that they are not blocked', function (): void { + $user = User::factory()->create(); + + $this->user->following()->attach($user); + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user))->assertOk(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-block.destroy', $user)) + ->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('is_blocking', false); + + expect($this->user->isBlocking($user))->toBeFalse() + ->and($this->user->isFollowing($user))->toBeFalse(); +}); + +it('may unblock a user who was not blocked', function (): void { + $user = User::factory()->create(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-block.destroy', $user)) + ->assertOk() + ->assertJsonPath('is_blocking', false); +}); + +it('returns not found for unknown usernames', function (string $method): void { + signedApiRequest($this->keyPair, $method, 'api/v1/users/missing/block')->assertNotFound(); +})->with(['POST', 'DELETE']); + +it('lists the people the user blocks, last blocked first, as Settings → Safety does', function (): void { + $first = User::factory()->create(); + $second = User::factory()->create(); + $other = User::factory()->create(); + + Date::setTestNow(now()->subHour()); + $this->user->blocking()->attach($first); + Date::setTestNow(); + $this->user->blocking()->attach($second); + $other->blocking()->attach($first); + + signedApiRequest($this->keyPair, 'GET', route('api-user-block.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$second->id, $first->id]) + ->assertJsonPath('data.0.username', $second->username) + ->assertJsonPath('data.0.name', $second->name) + ->assertJsonPath('data.0.avatar_url', $second->avatar_url) + ->assertJsonPath('data.0.lane', $second->lane()) + ->assertJsonMissingPath('data.0.pivot') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('pages through the people the user blocks, 20 at a time, even when they were blocked at once', function (): void { + $blocked = User::factory()->count(25)->create(); + + $this->user->blocking()->attach($blocked->pluck('id')); + + $expected = $blocked->sortByDesc('id')->pluck('id')->values(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-user-block.index')) + ->assertOk() + ->assertJsonCount(20, 'data') + ->assertJsonPath('data.*.id', $expected->take(20)->all()); + + signedApiRequest($this->keyPair, 'GET', route('api-user-block.index', ['cursor' => $first->json('meta.next_cursor')])) + ->assertOk() + ->assertJsonPath('data.*.id', $expected->slice(20)->values()->all()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists no one when the user blocks no one', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-user-block.index')) + ->assertOk() + ->assertJsonPath('data', []); +}); + +it('rejects requests that are not signed', function (string $method, string $route): void { + $user = User::factory()->create(); + + $this->json($method, route($route, $route === 'api-user-block.index' ? [] : [$user]))->assertUnauthorized(); + + expect($user->blockers()->count())->toBe(0); +})->with([ + ['GET', 'api-user-block.index'], + ['POST', 'api-user-block.store'], + ['DELETE', 'api-user-block.destroy'], +]); diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index 738d6f76a..c75ed6a2c 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -254,10 +254,16 @@ signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) ->assertForbidden() - ->assertExactJson([ - 'message' => "You blocked The Octocat. Neither of you can follow, reply to, or star the other, or see each other's commits.", - 'code' => 'user_blocked', - ]); + ->assertJsonPath('message', "You blocked The Octocat. Neither of you can follow, reply to, or star the other, or see each other's commits.") + ->assertJsonPath('code', 'user_blocked') + // Who they blocked, as the web shows them on the blocked profile. + ->assertJsonPath('user.id', $user->id) + ->assertJsonPath('user.username', $user->username) + ->assertJsonPath('user.name', 'The Octocat') + ->assertJsonPath('user.avatar_url', $user->avatar_url) + ->assertJsonPath('user.lane', $user->lane()) + ->assertJsonMissingPath('user.followers_count') + ->assertJsonMissingPath('data'); $this->getJson(route('api-user.show', $user)) ->assertOk() diff --git a/tests/Feature/Controllers/Api/UserMuteControllerTest.php b/tests/Feature/Controllers/Api/UserMuteControllerTest.php new file mode 100644 index 000000000..d7d483a20 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserMuteControllerTest.php @@ -0,0 +1,132 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may mute a user, and answers with them and that they are muted', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + $this->user->following()->attach($user); + $user->following()->attach($this->user); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-mute.store', $user)); + + $response->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.lane', $user->lane()) + ->assertJsonPath('is_muting', true); + + expect($this->user->isMuting($user))->toBeTrue() + ->and($this->user->isFollowing($user))->toBeTrue() + ->and($user->isFollowing($this->user))->toBeTrue() + ->and($user->notifications()->count())->toBe(0); +}); + +it('keeps one mute when a user is muted again', function (): void { + $user = User::factory()->create(); + + $this->user->muting()->attach($user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-mute.store', $user)) + ->assertOk() + ->assertJsonPath('is_muting', true); + + expect($this->user->muting()->count())->toBe(1); +}); + +it('may not mute themselves', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-mute.store', $this->user))->assertForbidden(); + + expect($this->user->muting()->count())->toBe(0); +}); + +it('may unmute a user, and answers that they are not muted', function (): void { + $user = User::factory()->create(); + + $this->user->muting()->attach($user); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-mute.destroy', $user)) + ->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('is_muting', false); + + expect($this->user->isMuting($user))->toBeFalse(); +}); + +it('may unmute a user who was not muted', function (): void { + $user = User::factory()->create(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-mute.destroy', $user)) + ->assertOk() + ->assertJsonPath('is_muting', false); +}); + +it('returns not found for unknown usernames', function (string $method): void { + signedApiRequest($this->keyPair, $method, 'api/v1/users/missing/mute')->assertNotFound(); +})->with(['POST', 'DELETE']); + +it('lists the people the user mutes, last muted first, as Settings → Safety does', function (): void { + $first = User::factory()->create(); + $second = User::factory()->create(); + $other = User::factory()->create(); + + Date::setTestNow(now()->subHour()); + $this->user->muting()->attach($first); + Date::setTestNow(); + $this->user->muting()->attach($second); + $other->muting()->attach($first); + + signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$second->id, $first->id]) + ->assertJsonPath('data.0.username', $second->username) + ->assertJsonPath('data.0.name', $second->name) + ->assertJsonPath('data.0.avatar_url', $second->avatar_url) + ->assertJsonPath('data.0.lane', $second->lane()) + ->assertJsonMissingPath('data.0.pivot') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('pages through the people the user mutes, 20 at a time, even when they were muted at once', function (): void { + $muted = User::factory()->count(25)->create(); + + $this->user->muting()->attach($muted->pluck('id')); + + $expected = $muted->sortByDesc('id')->pluck('id')->values(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index')) + ->assertOk() + ->assertJsonCount(20, 'data') + ->assertJsonPath('data.*.id', $expected->take(20)->all()); + + signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index', ['cursor' => $first->json('meta.next_cursor')])) + ->assertOk() + ->assertJsonPath('data.*.id', $expected->slice(20)->values()->all()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists no one when the user mutes no one', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index')) + ->assertOk() + ->assertJsonPath('data', []); +}); + +it('rejects requests that are not signed', function (string $method, string $route): void { + $user = User::factory()->create(); + + $this->json($method, route($route, $route === 'api-user-mute.index' ? [] : [$user]))->assertUnauthorized(); + + expect($user->mutedBy()->count())->toBe(0); +})->with([ + ['GET', 'api-user-mute.index'], + ['POST', 'api-user-mute.store'], + ['DELETE', 'api-user-mute.destroy'], +]); From 05f949c5de27e5a96e56074a80e6044261625004 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 19 Oct 2026 17:44:55 +0100 Subject: [PATCH 2/3] Reorder use statements in CurrentUserController --- app/Http/Controllers/Api/CurrentUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/CurrentUserController.php b/app/Http/Controllers/Api/CurrentUserController.php index b6f81dee2..2d5bf7fe1 100644 --- a/app/Http/Controllers/Api/CurrentUserController.php +++ b/app/Http/Controllers/Api/CurrentUserController.php @@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api; use App\Actions\DeleteUser; -use App\Http\Resources\CurrentUserResource; use App\Http\Requests\DeleteApiCurrentUserRequest; +use App\Http\Resources\CurrentUserResource; use App\Models\User; use App\Queries\NotificationGroupsQuery; use Illuminate\Container\Attributes\CurrentUser; From 0bd262175c8f91d05469f61c8dd5752b7253911a Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 19 Oct 2026 17:46:07 +0100 Subject: [PATCH 3/3] Fix formatting in api.php routes --- routes/api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/api.php b/routes/api.php index 1d10bacbc..3c96792f5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -60,7 +60,7 @@ Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); Route::delete('me', [CurrentUserController::class, 'destroy'])->name('api-current-user.destroy'); - + // Pastéis... Route::put('me/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('api-user-pasteis-mode.update');