From 3f74eb309bcdc11911f5bb81eafc38fca3af63bb Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:07:37 +0100 Subject: [PATCH] feat: vote on polls from the API Answers with the commit as its page shows it, so the app gets the poll's results once the vote is in. Votes are final and authors can't vote, as on the web. --- KNOWLEDGE_BASE.md | 1 + README.md | 4 +- .../Api/CommitPollVoteController.php | 27 ++++ .../CreateApiCommitPollVoteRequest.php | 65 ++++++++ routes/api.php | 4 + .../Api/CommitPollVoteControllerTest.php | 141 ++++++++++++++++++ 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/CommitPollVoteController.php create mode 100644 app/Http/Requests/CreateApiCommitPollVoteRequest.php create mode 100644 tests/Feature/Controllers/Api/CommitPollVoteControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f6ebb42f9..f42e3e259 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -433,6 +433,7 @@ A signed request sees what the web shows you, such as your own pending commits. - commit, reply, and quote, 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; - switch Pastéis mode and give pastéis; - report commits; - sync from GitHub and save your lane color; diff --git a/README.md b/README.md index 94e195109..c500a2389 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,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, saved commits, committing, deleting, following, starring, saving, muting, blocking, reporting and pastéis 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, saved commits, committing, deleting, following, starring, saving, muting, blocking, reporting, voting 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. @@ -95,6 +95,8 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. +`POST /api/v1/commits/{id}/poll/votes` votes on the commit's poll with an `option` id, as the web's Vote button does, and answers with the commit, whose `poll` now shows the results. Votes are final, and authors see their results instead of voting, so a second vote, a vote on the user's own poll, and one on a poll that isn't open yet or has closed are a `422` that says why. A commit the user may not see is a `404`. + `GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. `DELETE /api/v1/me` deletes the signed-in user's account the way Delete account does on the web, with their commits and every device they approved. It answers `204`, and from then on the key gets `device_not_approved`. diff --git a/app/Http/Controllers/Api/CommitPollVoteController.php b/app/Http/Controllers/Api/CommitPollVoteController.php new file mode 100644 index 000000000..ed4e912a1 --- /dev/null +++ b/app/Http/Controllers/Api/CommitPollVoteController.php @@ -0,0 +1,27 @@ +handle($user, $request->option()); + + return CommitResource::make($page->get($commit, $user)); + } +} diff --git a/app/Http/Requests/CreateApiCommitPollVoteRequest.php b/app/Http/Requests/CreateApiCommitPollVoteRequest.php new file mode 100644 index 000000000..6ae6055f2 --- /dev/null +++ b/app/Http/Requests/CreateApiCommitPollVoteRequest.php @@ -0,0 +1,65 @@ +commit()->isVisibleTo($this->voter()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * Votes are final, and the author sees the results instead of voting, as on the web (see OpenPoll). + * + * @return array> + */ + public function rules(): array + { + return [ + 'option' => [ + 'required', + 'uuid', + Rule::exists(PollOption::class, 'id')->where('poll_id', $this->commit()->poll?->id), + new OpenPoll($this->commit(), $this->voter()), + ], + ]; + } + + public function option(): PollOption + { + return PollOption::query()->findOrFail($this->string('option')->value()); + } + + private function commit(): Commit + { + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return $commit; + } + + private function voter(): User + { + $user = $this->user(); + assert($user instanceof User); + + return $user; + } +} diff --git a/routes/api.php b/routes/api.php index 2f2af37d8..1023335bc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Exceptions\ApiException; use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; +use App\Http\Controllers\Api\CommitPollVoteController; use App\Http\Controllers\Api\CommitReplyController; use App\Http\Controllers\Api\CommitReportController; use App\Http\Controllers\Api\CommitStarController; @@ -105,6 +106,9 @@ ->middleware('throttle:60,1') ->name('api-commit-pastel.store'); + // Commit Poll Vote... + Route::post('commits/{commit}/poll/votes', [CommitPollVoteController::class, 'store'])->name('api-commit-poll-vote.store'); + // Private starred commits... Route::get('starred', [StarredCommitController::class, 'index'])->name('api-starred.index'); diff --git a/tests/Feature/Controllers/Api/CommitPollVoteControllerTest.php b/tests/Feature/Controllers/Api/CommitPollVoteControllerTest.php new file mode 100644 index 000000000..7a44d5f59 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPollVoteControllerTest.php @@ -0,0 +1,141 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('votes and answers with the commit, whose poll now shows the results', function (): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + PollVote::factory()->for($poll->options[0], 'option')->count(2)->create(); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[1]->id]); + + $response->assertOk() + ->assertJsonPath('data.id', $poll->commit_id) + ->assertJsonPath('data.poll.has_voted', true) + ->assertJsonPath('data.poll.total_votes', 3) + ->assertJsonPath('data.poll.options.0.votes_count', 2) + ->assertJsonPath('data.poll.options.0.voted', false) + ->assertJsonPath('data.poll.options.0.percentage', 67) + ->assertJsonPath('data.poll.options.1.votes_count', 1) + ->assertJsonPath('data.poll.options.1.voted', true) + ->assertJsonPath('data.poll.options.1.percentage', 33); + + expect($this->user->pollVotes()->sole()->poll_option_id)->toBe($poll->options[1]->id); +}); + +it('answers with the commit as the commit endpoint shows it', function (): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[0]->id]); + + $response->assertOk() + ->assertExactJson(['data' => signedApiRequest($this->keyPair, 'GET', route('api-commit.show', $poll->commit))->json('data')]); +}); + +it('requires an option of the poll', function (Closure $option, string $message): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $option()]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['option' => $message]); + + expect(PollVote::query()->exists())->toBeFalse(); +})->with([ + 'missing' => [fn (): null => null, 'The option field is required.'], + 'not a uuid' => [fn (): string => 'tabs', 'The option field must be a valid UUID.'], + 'of another poll' => [fn (): string => Poll::factory()->withOptions('Vim', 'Emacs')->create()->options[0]->id, 'The selected option is invalid.'], +]); + +it('may not vote on a commit without a poll', function (): void { + $commit = Commit::factory()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $commit), ['option' => Poll::factory()->withOptions('Vim', 'Emacs')->create()->options[0]->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['option' => 'The selected option is invalid.']); +}); + +it('explains why a vote is refused, as the web does', function (Closure $poll, string $message): void { + $poll = $poll($this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[0]->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['option' => $message]); + + expect(PollVote::query()->exists())->toBeFalse(); +})->with([ + 'own poll' => [fn (User $user): Poll => Poll::factory()->for(Commit::factory()->for($user))->withOptions('Tabs', 'Spaces')->create(), "You can't vote on your own poll."], + 'own pending poll' => [fn (User $user): Poll => Poll::factory()->for(Commit::factory()->for($user)->pending())->withOptions('Tabs', 'Spaces')->unopened()->create(), 'This poll is not open for votes.'], + 'unopened poll' => [fn (User $user): Poll => Poll::factory()->withOptions('Tabs', 'Spaces')->unopened()->create(), 'This poll is not open for votes.'], + 'closed poll' => [fn (User $user): Poll => Poll::factory()->withOptions('Tabs', 'Spaces')->closed()->create(), 'This poll has closed.'], +]); + +it('refuses a second vote, since votes are final', function (): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + PollVote::factory()->for($poll->options[0], 'option')->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[1]->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['option' => 'You have already voted on this poll.']); + + expect($this->user->pollVotes()->sole()->poll_option_id)->toBe($poll->options[0]->id); +}); + +it('may not vote on a poll the user may not see', function (string $state): void { + $poll = Poll::factory()->for(Commit::factory()->{$state}())->withOptions('Tabs', 'Spaces')->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[0]->id]) + ->assertNotFound() + ->assertDontSee($poll->commit->body); + + expect(PollVote::query()->exists())->toBeFalse(); +})->with(['pending', 'rejected']); + +it('may not vote across a block in either direction', function (bool $voterBlocks): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + $author = $poll->commit->user; + + $voterBlocks ? $this->user->blocking()->attach($author) : $author->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[0]->id]) + ->assertNotFound(); + + expect(PollVote::query()->exists())->toBeFalse(); +})->with([true, false]); + +it('may still vote across a mute, as on the web', function (): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + $this->user->muting()->attach($poll->commit->user); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[0]->id]) + ->assertOk() + ->assertJsonPath('data.poll.has_voted', true); +}); + +it('may not vote on a deleted or unknown commit', function (): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + $poll->commit->delete(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-poll-vote.store', $poll->commit_id), ['option' => $poll->options[0]->id]) + ->assertNotFound(); + + signedApiRequest($this->keyPair, 'POST', 'api/v1/commits/0199e3a4-0000-7000-8000-000000000000/poll/votes', ['option' => $poll->options[0]->id]) + ->assertNotFound(); +}); + +it('requires a signed request', function (): void { + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->create(); + + $this->postJson(route('api-commit-poll-vote.store', $poll->commit), ['option' => $poll->options[0]->id]) + ->assertUnauthorized(); + + expect(PollVote::query()->exists())->toBeFalse(); +});