Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,15 +439,15 @@ 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;
- report 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).

Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/Api/CommitController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 19 additions & 1 deletion app/Http/Requests/CreateApiCommitRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -86,6 +92,18 @@ public function quotedCommit(): ?Commit
: null;
}

public function poll(): ?NewPoll
{
if (! $this->filled('poll')) {
return null;
}

/** @var list<string> $options */
$options = $this->array('poll.options');

return new NewPoll($options, PollDuration::from($this->integer('poll.duration')));
}

/**
* @return list<UploadedFile>
*/
Expand Down
77 changes: 77 additions & 0 deletions tests/Feature/Controllers/Api/CommitControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
Loading