Files

162 lines
6.6 KiB
PHP

<?php
namespace Tests\Feature\Polls;
use App\Actions\Jetstream\DeleteUser;
use App\Models\Admin;
use App\Models\Organization;
use App\Models\Poll;
use App\Models\PollAnswer;
use App\Models\PollLedgerEntry;
use App\Models\PollQuestion;
use App\Models\PollResponse;
use App\Models\User;
use App\Polls\Ledger;
use App\Polls\VoteWithdrawal;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Votes of permanently deleted profiles: identity removed, ballot withdrawn in the ledger.
*/
class PollVoteWithdrawalTest extends TestCase
{
use DatabaseTransactions;
private Ledger $ledger;
protected function setUp(): void
{
parent::setUp();
// phpunit.xml configures Redis, which is the dev site's cache too
config(['cache.default' => 'array']);
$this->ledger = new Ledger();
}
private function makePoll(array $attributes = []): Poll
{
$postId = DB::table('posts')->insertGetId([
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
]);
$poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
PollQuestion::create([
'poll_id' => $poll->id, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => [],
]);
return $poll;
}
/** Vote the way PollForm does. Returns the receipt code. */
private function vote(Poll $poll, Model $profile, User $user, string $value): string
{
$question = $poll->questions()->first();
$receipt = $this->ledger->newReceiptCode();
DB::transaction(function () use ($poll, $profile, $user, $value, $question, $receipt) {
$entry = $this->ledger->append($poll, PollLedgerEntry::CAST,
$this->ledger->ballotPayload([$question->id => ['value' => $value, 'text' => null]]),
$this->ledger->fingerprint($receipt));
$response = PollResponse::create([
'poll_id' => $poll->id, 'voter_id' => $profile->getKey(), 'voter_type' => get_class($profile), 'user_id' => $user->id,
'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(),
]);
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $question->id, 'value' => $value]);
});
return $receipt;
}
public function test_a_users_votes_are_withdrawn_and_the_ledger_stays_consistent(): void
{
$poll = $this->makePoll();
$leaving = User::factory()->create();
$staying = User::factory()->create();
$receipt = $this->vote($poll, $leaving, $leaving, 'yes');
$this->vote($poll, $staying, $staying, 'no');
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($leaving));
$this->assertSame(0, PollResponse::where('voter_id', $leaving->id)->where('voter_type', User::class)->count());
$this->assertSame(1, $poll->responses()->count());
$withdrawal = PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::WITHDRAW)->sole();
$this->assertSame(1, $withdrawal->refers_to_sequence);
$this->assertSame('withdrawn', $this->ledger->lookupReceipt($poll, $receipt)['status']);
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
// The recount still matches the published result
$this->assertSame($this->ledger->publishedTally($poll), $this->ledger->ledgerTally($poll));
$this->assertSame(1, $poll->questionSummaries()[$poll->questions()->first()->id]['total']);
}
public function test_an_organizations_votes_are_withdrawn(): void
{
$poll = $this->makePoll();
$organization = Organization::factory()->create();
$manager = User::factory()->create();
$this->vote($poll, $organization, $manager, 'yes');
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($organization));
$this->assertSame(0, $poll->responses()->count());
}
public function test_a_users_own_vote_for_an_organization_goes_only_in_per_user_polls(): void
{
$perUser = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
$perProfile = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
$organization = Organization::factory()->create();
$manager = User::factory()->create();
$this->vote($perUser, $organization, $manager, 'yes');
$this->vote($perProfile, $organization, $manager, 'yes');
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($manager));
$this->assertSame(0, $perUser->responses()->count());
// The organization's vote in a per-profile poll stays
$this->assertSame(1, $perProfile->responses()->count());
}
public function test_withdrawal_after_the_seal_keeps_the_chain_valid(): void
{
$poll = $this->makePoll();
$user = User::factory()->create();
$this->vote($poll, $user, $user, 'yes');
$poll->update(['status' => Poll::STATUS_CLOSED]);
$this->ledger->seal($poll);
app(VoteWithdrawal::class)->withdrawFor($user);
$this->assertTrue($this->ledger->verifyChain($poll->fresh())['valid']);
$types = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->pluck('entry_type')->all();
$this->assertSame(['cast', 'seal', 'withdraw'], $types);
}
public function test_permanent_deletion_withdraws_votes(): void
{
$poll = $this->makePoll();
$user = User::factory()->create();
$this->vote($poll, $user, $user, 'yes');
$withdrawal = \Mockery::mock(VoteWithdrawal::class);
$withdrawal->shouldReceive('withdrawFor')->once()->with(\Mockery::on(fn ($profile) => $profile->is($user)))->andReturn(1);
$this->app->instance(VoteWithdrawal::class, $withdrawal);
(new DeleteUser())->permanentlyDelete($user);
}
public function test_a_failed_deletion_keeps_the_votes(): void
{
$poll = $this->makePoll();
$user = User::factory()->create();
$this->vote($poll, $user, $user, 'yes');
// The test database has no debit account, so the balance step fails and the transaction rolls back
$result = (new DeleteUser())->permanentlyDelete($user);
$this->assertSame('error', $result['status']);
$this->assertSame(1, $poll->responses()->count());
$this->assertSame(0, PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::WITHDRAW)->count());
}
}