From 131fa66c86b962b86aac2983aacdf33fa5d28f01 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:30:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20switch=20past=C3=A9is=20mode=20and=20gi?= =?UTF-8?q?ve=20past=C3=A9is=20from=20the=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile app gets the web's pastéis mode, so it needs the same two writes and the counts. PUT /api/v1/me/pasteis-mode runs UpdateUserPasteisMode, like the switch in the web's settings, and answers like GET /api/v1/me. That answer, and the one after signing in, now carry pasteis_mode, pasteis_remaining, pasteis_resets_at and pasteis_received, which only the signed-in user gets. POST /api/v1/commits/{commit}/pasteis runs CreateCommitPastel with the web's rules: the mode is on, the commit is someone else's and the user may see it, ten a day that come back at 00:00 UTC, and 60 a minute. It answers with the commit as its page shows it, plus pasteis_remaining, pasteis_resets_at and author_pasteis_received. With none left it is a 422 with the new pasteis_exhausted code and the web's message. Signed commits now always carry pasteis_count and signed profiles pasteis_received. The web only shows them in pastéis mode and so does the app, but sending them either way keeps the app's kept answers right when the mode changes. --- PRODUCT.md | 2 +- README.md | 7 +- app/Enums/ApiErrorCode.php | 2 + app/Exceptions/ApiException.php | 8 ++ .../Api/CommitPastelController.php | 38 +++++ .../Controllers/Api/CurrentUserController.php | 6 +- app/Http/Controllers/Api/DeviceController.php | 6 +- app/Http/Controllers/Api/UserController.php | 7 +- .../Api/UserPasteisModeController.php | 27 ++++ .../Requests/CreateApiCommitPastelRequest.php | 41 ++++++ .../UpdateApiUserPasteisModeRequest.php | 25 ++++ app/Http/Resources/CommitResource.php | 15 +- app/Http/Resources/CurrentUserResource.php | 33 +++++ routes/api.php | 8 ++ .../Controllers/Api/CommitControllerTest.php | 3 + .../Api/CommitPastelControllerTest.php | 136 ++++++++++++++++++ .../Api/CurrentUserControllerTest.php | 26 +++- .../Controllers/Api/DeviceControllerTest.php | 1 + .../Controllers/Api/LogControllerTest.php | 3 +- .../Api/NotificationControllerTest.php | 2 + .../Controllers/Api/UserControllerTest.php | 2 + .../Api/UserPasteisModeControllerTest.php | 45 ++++++ 22 files changed, 422 insertions(+), 21 deletions(-) create mode 100644 app/Http/Controllers/Api/CommitPastelController.php create mode 100644 app/Http/Controllers/Api/UserPasteisModeController.php create mode 100644 app/Http/Requests/CreateApiCommitPastelRequest.php create mode 100644 app/Http/Requests/UpdateApiUserPasteisModeRequest.php create mode 100644 app/Http/Resources/CurrentUserResource.php create mode 100644 tests/Feature/Controllers/Api/CommitPastelControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserPasteisModeControllerTest.php diff --git a/PRODUCT.md b/PRODUCT.md index 273c1ab5e..06239d735 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, and notifications, and commit, reply, delete, follow, switch pastéis mode, and give pastéis, 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..12baa226e 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ 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 and pastéis 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. @@ -87,6 +87,8 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `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. +`GET /api/v1/me` says whether the user has pastéis mode on (`pasteis_mode`), how many of their 10 pastéis a day they may still give (`pasteis_remaining`), when that goes back to 10 (`pasteis_resets_at`, the next 00:00 UTC), and how many they were given (`pasteis_received`). `PUT /api/v1/me/pasteis-mode` with `{"enabled": true}` or `false` switches the mode, as the web's settings do, and answers like `GET /api/v1/me`. With it on, `POST /api/v1/commits/{id}/pasteis` gives someone else's commit one pastel. It answers with the commit, `pasteis_remaining`, `pasteis_resets_at` and `author_pasteis_received`, the author's new total. Signed commits always carry `pasteis_count` and signed profiles `pasteis_received`, and the app shows them only in pastéis mode, as the web does. + Each request sends three headers: | Header | Value | @@ -124,7 +126,8 @@ An error is JSON with a `message` to show the user. The errors the app has to te | `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_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. | +| `422` | `pasteis_exhausted` | `POST /api/v1/commits/{id}/pasteis` only: the user gave all 10 of today's pastéis. They come back at 00:00 UTC. | +| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, 60 pastéis a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for one that doesn't exist. Unsigned requests to the public endpoints get the same errors as before, without codes. diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php index 05650da54..de2fbc4d7 100644 --- a/app/Enums/ApiErrorCode.php +++ b/app/Enums/ApiErrorCode.php @@ -15,6 +15,7 @@ enum ApiErrorCode: string case InvalidGrant = 'invalid_grant'; case UserBlocked = 'user_blocked'; case UserUnavailable = 'user_unavailable'; + case PasteisExhausted = 'pasteis_exhausted'; case RateLimited = 'rate_limited'; public function status(): int @@ -23,6 +24,7 @@ public function status(): int self::InvalidGrant => 400, self::InvalidSignature, self::DeviceNotApproved => 401, self::UserBlocked, self::UserUnavailable => 403, + self::PasteisExhausted => 422, self::RateLimited => 429, }; } diff --git a/app/Exceptions/ApiException.php b/app/Exceptions/ApiException.php index 1b146089c..1fd47f190 100644 --- a/app/Exceptions/ApiException.php +++ b/app/Exceptions/ApiException.php @@ -43,6 +43,14 @@ public static function invalidGrant(): self return new self(ApiErrorCode::InvalidGrant, __('This sign-in has expired or was already used. Sign in again in the app.')); } + /** + * The user gave every pastel they get today. The web's own words, which say when they come back. + */ + public static function pasteisExhausted(): self + { + return new self(ApiErrorCode::PasteisExhausted, __('you ran out of pastéis. wait until tomorrow.')); + } + public function render(): JsonResponse { return response()->json([ diff --git a/app/Http/Controllers/Api/CommitPastelController.php b/app/Http/Controllers/Api/CommitPastelController.php new file mode 100644 index 000000000..0066d6178 --- /dev/null +++ b/app/Http/Controllers/Api/CommitPastelController.php @@ -0,0 +1,38 @@ +handle($user, $commit)) { + return ApiException::pasteisExhausted()->render(); + } + + $commit = $page->get($commit, $user); + + return CommitResource::make($commit)->additional([ + 'pasteis_remaining' => $user->refresh()->pasteisRemaining(), + 'pasteis_resets_at' => today()->addDay()->toIso8601ZuluString(), + 'author_pasteis_received' => $commit->user?->pasteis_received, + ]); + } +} diff --git a/app/Http/Controllers/Api/CurrentUserController.php b/app/Http/Controllers/Api/CurrentUserController.php index beaae6724..442077293 100644 --- a/app/Http/Controllers/Api/CurrentUserController.php +++ b/app/Http/Controllers/Api/CurrentUserController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api; -use App\Http\Resources\UserResource; +use App\Http\Resources\CurrentUserResource; use App\Models\User; use App\Queries\NotificationGroupsQuery; use Illuminate\Container\Attributes\CurrentUser; @@ -14,9 +14,9 @@ /** * The signed-in user, with the unread count the web shows on Notifications. */ - public function show(#[CurrentUser] User $user, NotificationGroupsQuery $notifications): UserResource + public function show(#[CurrentUser] User $user, NotificationGroupsQuery $notifications): CurrentUserResource { - return UserResource::make($user->loadCounts())->additional([ + return CurrentUserResource::make($user->loadCounts())->additional([ 'unread_notifications_count' => $notifications->unreadCount($user), ]); } diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php index 05d5d80db..b3f786582 100644 --- a/app/Http/Controllers/Api/DeviceController.php +++ b/app/Http/Controllers/Api/DeviceController.php @@ -7,7 +7,7 @@ use App\Actions\CreateDevice; use App\Exceptions\ApiException; use App\Http\Requests\CreateApiDeviceRequest; -use App\Http\Resources\UserResource; +use App\Http\Resources\CurrentUserResource; use App\Models\Device; use App\Queries\NotificationGroupsQuery; use Illuminate\Http\JsonResponse; @@ -19,7 +19,7 @@ * Approves the key that signed the request as a device, for the user who approved the app on the web, and answers * as GET /api/v1/me does. A code redeemed by another request in the meantime gets the same answer as a wrong one. */ - public function store(CreateApiDeviceRequest $request, CreateDevice $action, NotificationGroupsQuery $notifications): JsonResponse|UserResource + public function store(CreateApiDeviceRequest $request, CreateDevice $action, NotificationGroupsQuery $notifications): CurrentUserResource|JsonResponse { $device = $action->handle($request->authorizationCode()); @@ -29,7 +29,7 @@ public function store(CreateApiDeviceRequest $request, CreateDevice $action, Not Auth::setUser($device->user); - return UserResource::make($device->user->loadCounts())->additional([ + return CurrentUserResource::make($device->user->loadCounts())->additional([ 'unread_notifications_count' => $notifications->unreadCount($device->user), ]); } diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 67ebbd8ab..ab0b46dd1 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -19,9 +19,9 @@ final readonly class UserController { /** - * A signed request gets the profile as the web shows it to that user, with whether they mute it, and its - * achievements and Unix rank. The pinned commit is left out of the commits list, so it comes with the profile, as - * on the web. + * A signed request gets the profile as the web shows it to that user, with whether they mute it, its achievements + * and Unix rank, and how many pastéis it was given, which the app only shows in pastéis mode, as the web does. The + * pinned commit is left out of the commits list, so it comes with the profile, as on the web. */ public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PinnedCommitQuery $pinned, ReplyPreviewsQuery $replyPreviews): UserResource { @@ -36,6 +36,7 @@ public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentU 'is_following' => $currentUser->isFollowing($user), 'follows_you' => $user->isFollowing($currentUser), 'is_muting' => $currentUser->isMuting($user), + 'pasteis_received' => $user->pasteis_received, 'activity' => $activity->get($user, $currentUser), 'streaks' => $userStreaks, 'achievements' => $achievements->get($user, $currentUser, $userStreaks), diff --git a/app/Http/Controllers/Api/UserPasteisModeController.php b/app/Http/Controllers/Api/UserPasteisModeController.php new file mode 100644 index 000000000..d6df458dc --- /dev/null +++ b/app/Http/Controllers/Api/UserPasteisModeController.php @@ -0,0 +1,27 @@ +handle($user, $request->boolean('enabled')); + + return CurrentUserResource::make($user->loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($user), + ]); + } +} diff --git a/app/Http/Requests/CreateApiCommitPastelRequest.php b/app/Http/Requests/CreateApiCommitPastelRequest.php new file mode 100644 index 000000000..5b0c63965 --- /dev/null +++ b/app/Http/Requests/CreateApiCommitPastelRequest.php @@ -0,0 +1,41 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return match (true) { + ! $user->pasteis_mode => Response::deny(__('Turn on pastéis mode to give pastéis.')), + $commit->user_id === $user->id => Response::deny(__('You cannot award your own commit.')), + $commit->user_id === null, ! $commit->isVisibleTo($user) => Response::denyAsNotFound(), + default => Response::allow(), + }; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/UpdateApiUserPasteisModeRequest.php b/app/Http/Requests/UpdateApiUserPasteisModeRequest.php new file mode 100644 index 000000000..d2c7490a2 --- /dev/null +++ b/app/Http/Requests/UpdateApiUserPasteisModeRequest.php @@ -0,0 +1,25 @@ +> + */ + public function rules(): array + { + return [ + 'enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index d5b0dfa1f..ca0c79bb9 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -18,13 +18,13 @@ * * A signed request gets the commit as the web shows it to that user instead: parent_id is the parent the commit * replies to, whoever may see it, and on top come the body split into prose, inline code and highlighted code - * blocks, where it mentions someone, its status, whether the author pinned it, and whether the user starred or saved - * it. Like the web's cards, it also has preview_segments, the excerpt a list shows of a long commit, the poll, with - * what the user may see of its votes, and the commit it quotes, or null once the user may no longer see that one, - * while quoted_commit_id still says it quoted something. In a list, reply_preview is the newest reply or quote the - * web shows under it. As on the web, the counts, link_preview, images, starred, is_saved, parent, poll, - * quoted_commit and reply_preview are only there when they were loaded, so ancestors and the commits of - * notifications come without some of them. + * blocks, where it mentions someone, its status, whether the author pinned it, whether the user starred or saved it, + * and how many pastéis it was given, which the app only shows in pastéis mode, as the web does. Like the web's cards, + * it also has preview_segments, the excerpt a list shows of a long commit, the poll, with what the user may see of + * its votes, and the commit it quotes, or null once the user may no longer see that one, while quoted_commit_id still + * says it quoted something. In a list, reply_preview is the newest reply or quote the web shows under it. As on the + * web, the counts, link_preview, images, starred, is_saved, parent, poll, quoted_commit and reply_preview are only + * there when they were loaded, so ancestors and the commits of notifications come without some of them. * * A deleted commit that still has replies stays in its thread as a tombstone: its id, parent_id, deleted_at and * replies_count, but never its author or body. Only signed requests are ever sent one. @@ -75,6 +75,7 @@ public function toArray(Request $request): array 'is_pinned' => $this->user?->pinned_commit_id === $this->id, 'starred' => $this->whenHas('starred'), 'is_saved' => $this->whenHas('is_saved'), + 'pasteis_count' => $this->pasteis_count, 'deleted_at' => null, 'parent' => $this->whenLoaded('parent', fn (Commit $parent): self => self::make($parent)), 'quoted_commit_id' => $this->quoted_commit_id, diff --git a/app/Http/Resources/CurrentUserResource.php b/app/Http/Resources/CurrentUserResource.php new file mode 100644 index 000000000..4cb0e90e0 --- /dev/null +++ b/app/Http/Resources/CurrentUserResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + ...UserResource::make($this->resource)->toArray($request), + 'pasteis_mode' => $this->pasteis_mode, + 'pasteis_remaining' => $this->pasteisRemaining(), + 'pasteis_resets_at' => today()->addDay()->toIso8601ZuluString(), + 'pasteis_received' => $this->pasteis_received, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 4721e9d9e..9e85a255c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Controllers\Api\CommitController as ApiCommitController; +use App\Http\Controllers\Api\CommitPastelController; use App\Http\Controllers\Api\CommitReplyController; use App\Http\Controllers\Api\CurrentUserController; use App\Http\Controllers\Api\DeviceController; @@ -12,6 +13,7 @@ use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserFollowController; +use App\Http\Controllers\Api\UserPasteisModeController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; use App\Http\Middleware\AuthenticateSignedRequest; @@ -48,6 +50,7 @@ // Signed by the mobile app with an SSH key that the user approved as a device... Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); + Route::put('me/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('api-user-pasteis-mode.update'); // Log... Route::get('log/{log}', [LogController::class, 'show'])->name('api-log.show'); @@ -63,6 +66,11 @@ ->withTrashed() ->name('api-commit-reply.index'); + // Commit Pastel... + Route::post('commits/{commit}/pasteis', [CommitPastelController::class, 'store']) + ->middleware('throttle:60,1') + ->name('api-commit-pastel.store'); + // User Follow... Route::post('users/{user:username}/follow', [UserFollowController::class, 'store']) ->middleware('throttle:follows') diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 0733a035a..c7b05a422 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -206,6 +206,7 @@ $commit = Commit::factory()->for($user)->for($root, 'parent')->create([ 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', 'body' => 'Ask @nunomaduro about `@Override`', + 'pasteis_count' => 4, ]); $image = CommitImage::factory()->for($commit)->create(); Commit::factory()->for($commit, 'parent')->create(); @@ -257,6 +258,7 @@ 'is_pinned' => false, 'starred' => true, 'is_saved' => true, + 'pasteis_count' => 4, 'deleted_at' => null, 'quoted_commit_id' => null, 'quoted_commit' => null, @@ -280,6 +282,7 @@ 'is_pinned' => false, 'starred' => false, 'is_saved' => false, + 'pasteis_count' => 0, 'deleted_at' => null, 'quoted_commit_id' => null, 'quoted_commit' => null, diff --git a/tests/Feature/Controllers/Api/CommitPastelControllerTest.php b/tests/Feature/Controllers/Api/CommitPastelControllerTest.php new file mode 100644 index 000000000..f9eab8d60 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPastelControllerTest.php @@ -0,0 +1,136 @@ +user = User::factory()->create(['pasteis_mode' => true]); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('gives a commit a pastel and answers with its count, what is left today and what its author has been given', function (): void { + $this->travelTo(now()->setDate(2026, 10, 17)->setTime(15, 30)); + $author = User::factory()->create(['pasteis_mode' => false, 'pasteis_received' => 4]); + $commit = Commit::factory()->for($author)->create(['pasteis_count' => 2]); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.pasteis_count', 3) + ->assertJsonPath('pasteis_remaining', 9) + ->assertJsonPath('pasteis_resets_at', '2026-10-18T00:00:00Z') + ->assertJsonPath('author_pasteis_received', 5); + + expect($commit->refresh()->pasteis_count)->toBe(3) + ->and($author->refresh()->pasteis_received)->toBe(5) + ->and($this->user->refresh()->pasteisRemaining())->toBe(9); +}); + +it('answers with the commit as the commit endpoint shows it', function (): void { + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit)); + + $response->assertOk() + ->assertJsonPath('data', signedApiRequest($this->keyPair, 'GET', route('api-commit.show', $commit))->json('data')); +}); + +it('gives ten a day across commits, then says the user ran out until midnight UTC', function (): void { + $this->travelTo(now()->setDate(2026, 10, 16)->setTime(23, 59, 59)); + $commit = Commit::factory()->create(); + $otherCommit = Commit::factory()->create(); + + for ($given = 1; $given <= User::DAILY_PASTEIS; $given++) { + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit)) + ->assertOk() + ->assertJsonPath('data.pasteis_count', $given) + ->assertJsonPath('pasteis_remaining', User::DAILY_PASTEIS - $given); + } + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $otherCommit)) + ->assertUnprocessable() + ->assertExactJson(['message' => 'you ran out of pastéis. wait until tomorrow.', 'code' => 'pasteis_exhausted']); + + expect($otherCommit->refresh()->pasteis_count)->toBe(0); + + $this->travel(1)->seconds(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $otherCommit)) + ->assertOk() + ->assertJsonPath('data.pasteis_count', 1) + ->assertJsonPath('pasteis_remaining', 9) + ->assertJsonPath('pasteis_resets_at', '2026-10-18T00:00:00Z'); +}); + +it('may not give a pastel with pastéis mode off', function (): void { + $this->user->update(['pasteis_mode' => false]); + $commit = Commit::factory()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit)) + ->assertForbidden() + ->assertJsonPath('message', 'Turn on pastéis mode to give pastéis.'); + + expect($commit->refresh()->pasteis_count)->toBe(0) + ->and($this->user->refresh()->pasteisRemaining())->toBe(User::DAILY_PASTEIS); +}); + +it('may not give their own commit a pastel, as on the web', function (Closure $commit): void { + $commit = $commit($this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit)) + ->assertForbidden() + ->assertJsonPath('message', 'You cannot award your own commit.'); + + expect($commit->refresh()->pasteis_count)->toBe(0) + ->and($this->user->refresh()->pasteis_received)->toBe(0); +})->with([ + 'approved' => fn (User $user): Commit => Commit::factory()->for($user)->create(), + 'pending' => fn (User $user): Commit => Commit::factory()->for($user)->pending()->create(), +]); + +it('may not give a pastel to a commit the user may not see, without spending one', function (string $state): void { + $commit = Commit::factory()->{$state}()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit))->assertNotFound(); + + expect($commit->refresh()->pasteis_count)->toBe(0) + ->and($this->user->refresh()->pasteisRemaining())->toBe(User::DAILY_PASTEIS); +})->with(['pending', 'rejected', 'trashed']); + +it('may not give a pastel across a block', function (Closure $block): void { + $commit = Commit::factory()->create(); + + $block($this->user, $commit->user); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit))->assertNotFound(); + + expect($commit->refresh()->pasteis_count)->toBe(0); +})->with([ + 'blocking' => fn (User $user, User $author) => $user->blocking()->attach($author), + 'blocked' => fn (User $user, User $author) => $author->blocking()->attach($user), +]); + +it('shares the web limit of 60 a minute', function (): void { + $commit = Commit::factory()->create(); + + RateLimiter::increment(sha1((string) $this->user->id), amount: 60); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-pastel.store', $commit)) + ->assertTooManyRequests() + ->assertJsonPath('code', 'rate_limited'); + + expect($commit->refresh()->pasteis_count)->toBe(0); +}); + +it('rejects requests that are not signed', function (): void { + $commit = Commit::factory()->create(); + + $this->postJson(route('api-commit-pastel.store', $commit))->assertUnauthorized(); + + expect($commit->refresh()->pasteis_count)->toBe(0); +}); diff --git a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php index 094cf8f1a..e8d3563d3 100644 --- a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -6,7 +6,9 @@ use App\Models\Notification; use App\Models\User; -it('returns the signed-in user with their unread notifications', function (): void { +it('returns the signed-in user with their unread notifications and their pastéis', function (): void { + $this->travelTo(now()->setDate(2026, 10, 17)->setTime(9, 15)); + $user = User::factory()->create([ 'username' => 'octocat', 'name' => 'The Octocat', @@ -15,6 +17,10 @@ 'company' => '@github', 'location' => 'San Francisco', 'website' => 'https://github.blog', + 'pasteis_mode' => true, + 'pasteis_received' => 12, + 'pasteis_given_today' => 3, + 'pasteis_given_on' => today(), ]); $keyPair = apiKeyPairFor($user); @@ -45,6 +51,10 @@ 'followers_count' => 3, 'following_count' => 2, 'lane' => $user->lane(), + 'pasteis_mode' => true, + 'pasteis_remaining' => 7, + 'pasteis_resets_at' => '2026-10-18T00:00:00Z', + 'pasteis_received' => 12, ], 'unread_notifications_count' => 1, ]); @@ -66,3 +76,17 @@ it('rejects requests that are not signed', function (): void { $this->getJson(route('api-current-user.show'))->assertUnauthorized(); }); + +it('gives the user all their pastéis again once a new day starts in UTC, and what they were given with the mode off', function (): void { + $this->travelTo(now()->setDate(2026, 10, 17)->setTime(0, 0, 1)); + + $user = User::factory()->create(['pasteis_mode' => false, 'pasteis_received' => 5, 'pasteis_given_today' => 10, 'pasteis_given_on' => '2026-10-16']); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.pasteis_mode', false) + ->assertJsonPath('data.pasteis_remaining', User::DAILY_PASTEIS) + ->assertJsonPath('data.pasteis_resets_at', '2026-10-18T00:00:00Z') + ->assertJsonPath('data.pasteis_received', 5); +}); diff --git a/tests/Feature/Controllers/Api/DeviceControllerTest.php b/tests/Feature/Controllers/Api/DeviceControllerTest.php index fac0e58a9..a7739336e 100644 --- a/tests/Feature/Controllers/Api/DeviceControllerTest.php +++ b/tests/Feature/Controllers/Api/DeviceControllerTest.php @@ -34,6 +34,7 @@ ->assertJsonPath('data.username', 'octocat') ->assertJsonPath('data.lane', $this->user->lane()) ->assertJsonPath('data.commits_count', 0) + ->assertJsonPath('data.pasteis_mode', false) ->assertJsonPath('unread_notifications_count', 0); $device = Device::query()->sole(); diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index c66f43226..6f471ec29 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -24,7 +24,7 @@ 'name' => 'The Octocat', 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', ]); - $older = Commit::factory()->for($author)->create(['body' => 'It was DNS.', 'created_at' => now()->subMinute()]); + $older = Commit::factory()->for($author)->create(['body' => 'It was DNS.', 'created_at' => now()->subMinute(), 'pasteis_count' => 2]); $newer = Commit::factory()->for($this->user)->pending()->create(['body' => 'Mine, pending']); Commit::factory()->for($older, 'parent')->create(['created_at' => now()->subSeconds(30)]); $reply = Commit::factory()->for($older, 'parent')->create(['created_at' => now()->subSeconds(20)]); @@ -78,6 +78,7 @@ 'is_pinned' => false, 'starred' => false, 'is_saved' => false, + 'pasteis_count' => 2, 'deleted_at' => null, 'quoted_commit_id' => null, 'quoted_commit' => null, diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php index 228dfeb58..f1b015867 100644 --- a/tests/Feature/Controllers/Api/NotificationControllerTest.php +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -69,6 +69,7 @@ 'mentions' => [], 'status' => 'approved', 'is_pinned' => false, + 'pasteis_count' => 0, 'deleted_at' => null, 'parent' => [ 'id' => $reply->parent->id, @@ -83,6 +84,7 @@ 'mentions' => [], 'status' => 'approved', 'is_pinned' => false, + 'pasteis_count' => 0, 'deleted_at' => null, 'quoted_commit_id' => null, ], diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index 738d6f76a..0f25061d1 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -85,6 +85,7 @@ 'company' => '@github', 'location' => 'San Francisco', 'website' => 'https://github.blog', + 'pasteis_received' => 7, ]); Commit::factory()->for($user)->count(2)->create(); @@ -119,6 +120,7 @@ 'is_following' => true, 'follows_you' => false, 'is_muting' => false, + 'pasteis_received' => 7, 'activity' => resolve(UserActivityQuery::class)->get($user, $viewer), 'streaks' => ['longest' => 1, 'current' => 1], 'achievements' => resolve(UserAchievementsQuery::class)->get($user, $viewer), diff --git a/tests/Feature/Controllers/Api/UserPasteisModeControllerTest.php b/tests/Feature/Controllers/Api/UserPasteisModeControllerTest.php new file mode 100644 index 000000000..ff23628aa --- /dev/null +++ b/tests/Feature/Controllers/Api/UserPasteisModeControllerTest.php @@ -0,0 +1,45 @@ +create(['pasteis_mode' => ! $enabled]); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'PUT', route('api-user-pasteis-mode.update'), ['enabled' => $enabled]); + + $response->assertOk() + ->assertJsonPath('data.pasteis_mode', $enabled) + ->assertExactJson(signedApiRequest($keyPair, 'GET', route('api-current-user.show'))->json()); + + expect($user->refresh()->pasteis_mode)->toBe($enabled); +})->with([true, false]); + +it('keeps what the user gave and was given today when the mode goes off and on again', function (): void { + $user = User::factory()->create(['pasteis_mode' => true, 'pasteis_received' => 3, 'pasteis_given_today' => 4, 'pasteis_given_on' => today()]); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'PUT', route('api-user-pasteis-mode.update'), ['enabled' => false])->assertOk(); + + signedApiRequest($keyPair, 'PUT', route('api-user-pasteis-mode.update'), ['enabled' => true]) + ->assertOk() + ->assertJsonPath('data.pasteis_remaining', 6) + ->assertJsonPath('data.pasteis_received', 3); +}); + +it('needs to be told on or off', function (mixed $enabled): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'PUT', route('api-user-pasteis-mode.update'), $enabled === null ? [] : ['enabled' => $enabled]) + ->assertUnprocessable() + ->assertJsonValidationErrors('enabled'); + + expect($user->refresh()->pasteis_mode)->toBeFalse(); +})->with([null, 'sometimes']); + +it('rejects requests that are not signed', function (): void { + $this->putJson(route('api-user-pasteis-mode.update'), ['enabled' => true])->assertUnauthorized(); +});