diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d68c799fa..163ffe25d 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -439,7 +439,7 @@ A signed request sees what the web shows you, such as your own pending commits. - commit, reply, and quote, with images, and delete your commits; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; - star or unstar, and save or unsave; -- vote on polls; +- create polls on commits that are neither replies nor quotes, and vote on polls; - change Remotes settings, read conversations and mark them pulled, react to messages, find people, and start orgs; - stash or reopen conversations and orgs, merge or drop message requests, and send typing indicators; - switch Pastéis mode and give pastéis; @@ -447,7 +447,7 @@ A signed request sees what the web shows you, such as your own pending commits. - sync from GitHub and save your lane color; - delete your account. -Signed request bodies are JSON, or multipart when they carry images, so app commits can have images but no polls or link-card choice. Sending messages in Remotes remains web-only, as do pinning, scheduling, search, and translations. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. +Signed request bodies are JSON, or multipart when they carry images, so app commits can have images or a poll but no link-card choice. Sending messages in Remotes remains web-only, as do pinning, scheduling, search, and translations. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. Sources: [signed requests](app/Services/SignedRequests.php), [sign-in page](app/Http/Controllers/MobileAuthorizationController.php), [devices settings](resources/js/pages/device/index.tsx), [API routes](routes/api.php). diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 97ea97aa4..d66f9deb1 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -40,12 +40,12 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren } /** - * Answers with the new commit as its page shows it, with the commit it quotes, pending review unless the user is an - * admin. + * Answers with the new commit as its page shows it, with the commit it quotes and its poll, pending review unless + * the user is an admin. */ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll()); return CommitResource::make($page->get($commit, $user)) ->response() diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php index c0a9fbf74..c60dc0deb 100644 --- a/app/Http/Requests/CreateApiCommitRequest.php +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests; +use App\Dto\NewPoll; +use App\Enums\PollDuration; use App\Models\Commit; use App\Models\User; use App\Rules\VisibleCommit; @@ -16,7 +18,7 @@ /** * A commit from the mobile app, sent as JSON, or with images as multipart (see SignedRequests). It may reply to a commit - * or quote one, as on the web, but not both. The images are checked as the web checks them. It has no poll yet. + * or quote one, as on the web, but not both. The images are checked as the web checks them. */ final class CreateApiCommitRequest extends FormRequest { @@ -46,6 +48,10 @@ public function rules(): array 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], 'quoted_commit_id' => ['bail', 'nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, __('That commit is no longer available to quote.'))], + 'poll' => [...$attachmentRules['poll'], 'prohibits:quoted_commit_id'], + 'poll.options' => $attachmentRules['poll.options'], + 'poll.options.*' => $attachmentRules['poll.options.*'], + 'poll.duration' => $attachmentRules['poll.duration'], 'images' => $attachmentRules['images'], 'images.*' => $attachmentRules['images.*'], 'image_descriptions' => $attachmentRules['image_descriptions'], @@ -86,6 +92,18 @@ public function quotedCommit(): ?Commit : null; } + public function poll(): ?NewPoll + { + if (! $this->filled('poll')) { + return null; + } + + /** @var list $options */ + $options = $this->array('poll.options'); + + return new NewPoll($options, PollDuration::from($this->integer('poll.duration'))); + } + /** * @return list */ diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index ffeeae9a6..48cf1f4be 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\CommitStatus; +use App\Enums\PollDuration; use App\Http\Requests\CreateApiCommitRequest; use App\Jobs\ModerateCommit; use App\Models\Commit; @@ -658,6 +659,82 @@ expect($quoted->quotes()->count())->toBe(0); })->with(['blocking' => true, 'blocked' => false]); +it('commits a poll from a signed request, which waits for the commit to be approved', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Tabs or spaces?', + 'poll' => ['options' => ['Tabs', ' Spaces ', 'Both :tada:'], 'duration' => PollDuration::SevenDays->value], + ]); + + $poll = $user->commits()->sole()->poll; + + $response->assertCreated() + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.poll.id', $poll->id) + ->assertJsonPath('data.poll.closes_at', null) + ->assertJsonPath('data.poll.options.*.label', ['Tabs', 'Spaces', 'Both 🎉']); + + expect($poll->duration)->toBe(PollDuration::SevenDays) + ->and($poll->opened_at)->toBeNull(); +}); + +it('opens the poll of an admin right away', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + config(['app.admins' => [$user->username]]); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Tabs or spaces?', + 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => PollDuration::OneHour->value], + ]) + ->assertCreated() + ->assertJsonPath('data.status', 'approved') + ->assertJsonPath('data.poll.closes_at', now()->startOfSecond()->addHour()->toJSON()) + ->assertJsonPath('data.poll.is_closed', false); +}); + +it('rejects a poll on a quote from a signed request', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $quoted = Commit::factory()->create(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), [ + 'body' => 'Which is it?', + 'quoted_commit_id' => $quoted->id, + 'poll' => ['options' => ['DNS', 'Not DNS'], 'duration' => PollDuration::OneDay->value], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['poll' => 'The poll field prohibits quoted commit id from being present.']); + + expect($user->commits()->exists())->toBeFalse() + ->and(Poll::query()->exists())->toBeFalse(); +}); + +it('validates the poll of a commit from a signed request as the web does', function (Closure $data, string $field, string $message): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'Tabs or spaces?', ...$data()]) + ->assertUnprocessable() + ->assertJsonValidationErrors([$field => $message]); + + expect($user->commits()->exists())->toBeFalse() + ->and(Poll::query()->exists())->toBeFalse(); +})->with([ + 'one option' => [fn (): array => ['poll' => ['options' => ['Tabs'], 'duration' => 1440]], 'poll.options', 'The poll.options field must have at least 2 items.'], + 'five options' => [fn (): array => ['poll' => ['options' => ['A', 'B', 'C', 'D', 'E'], 'duration' => 1440]], 'poll.options', 'The poll.options field must not have more than 4 items.'], + 'empty option' => [fn (): array => ['poll' => ['options' => ['Tabs', ' '], 'duration' => 1440]], 'poll.options.1', 'The poll.options.1 field is required.'], + 'long option' => [fn (): array => ['poll' => ['options' => ['Tabs', str_repeat('a', 51)], 'duration' => 1440]], 'poll.options.1', 'The poll.options.1 field must not be greater than 50 characters.'], + 'duplicate options' => [fn (): array => ['poll' => ['options' => ['Tabs', 'tabs'], 'duration' => 1440]], 'poll.options.1', 'The poll.options.1 field has a duplicate value.'], + 'unknown duration' => [fn (): array => ['poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 30]], 'poll.duration', 'The selected poll.duration is invalid.'], + 'no duration' => [fn (): array => ['poll' => ['options' => ['Tabs', 'Spaces']]], 'poll.duration', 'The poll.duration field is required when poll is present.'], + 'no options' => [fn (): array => ['poll' => ['duration' => 1440]], 'poll.options', 'The poll.options field is required when poll is present.'], + 'a reply' => [fn (): array => ['parent_id' => Commit::factory()->create()->id, 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440]], 'poll', 'The poll field prohibits parent id / images from being present.'], +]); + it('commits images from a signed request, in order, with their descriptions', function (): void { Storage::fake();