'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()); } }