'array']); } /** A poll with a yes/no/if question and an optional open text question. */ 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, 'position' => 1, 'type' => 'yes_no_conditional', 'required' => true, 'label' => ['en' => 'Shall we meet monthly?'], 'settings' => [], ]); PollQuestion::create([ 'poll_id' => $poll->id, 'position' => 2, 'type' => 'text', 'required' => false, 'label' => ['en' => 'Anything else?'], 'settings' => ['multiline' => true, 'max_length' => 200], ]); return $poll->fresh(); } private function actAs(Model $profile, User $user): void { $this->actingAs($user, 'web'); session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]); } private function questionIds(Poll $poll): array { return $poll->questions()->pluck('id')->all(); } public function test_targeted_profile_sees_the_form(): void { $poll = $this->makePoll(); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertSet('state', PollForm::STATE_FORM) ->assertSee('Shall we meet monthly?') ->assertSee('Anything else?') ->assertSee('Submit vote'); } public function test_untargeted_profile_and_guest_see_nothing(): void { $poll = $this->makePoll(['target_profile_types' => [Organization::class]]); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertSet('state', PollForm::STATE_HIDDEN) ->assertDontSee('Shall we meet monthly?'); auth('web')->logout(); session()->forget(['activeProfileType', 'activeProfileId']); Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_HIDDEN); } public function test_submit_stores_vote_ledger_and_shows_receipt_once(): void { $poll = $this->makePoll(); [$yesNo, $text] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); $component = Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'maybe_if') ->set("answers.$yesNo.text", 'on a weekday') ->call('submit') ->assertHasNoErrors() ->assertSet('state', PollForm::STATE_SUBMITTED) ->assertSee('Thank you, your vote has been recorded.'); $receipt = $component->get('receiptCode'); $this->assertNotNull($receipt); $component->assertSee($receipt); // Copy button: the code reaches Alpine (@js is not compiled inside component attributes) $component->assertSeeHtml('code: '.\Illuminate\Support\Js::from($receipt)) ->assertSeeHtml('x-on:click="copy()"') ->assertDontSeeHtml('@js('); // The dedicated page has the verify link at the bottom, not in the receipt block $component->assertDontSee('Verify this poll'); $response = PollResponse::where('poll_id', $poll->id)->sole(); $this->assertSame(User::class, $response->voter_type); $this->assertSame($user->id, $response->user_id); $this->assertNotNull($response->submitted_at); // The optional, empty text question is not stored $this->assertSame(1, $response->answers()->count()); $this->assertSame('on a weekday', $response->answers()->first()->text); // Ledger: one cast entry, linked by sequence, matching the receipt, no identity $entry = PollLedgerEntry::where('poll_id', $poll->id)->sole(); $this->assertSame(PollLedgerEntry::CAST, $entry->entry_type); $this->assertSame($entry->sequence, $response->ledger_sequence); $this->assertSame((new Ledger())->fingerprint($receipt), $entry->fingerprint); $this->assertStringNotContainsString((string) $user->email, json_encode($entry->getAttributes())); $this->assertSame('counted', (new Ledger())->lookupReceipt($poll, $receipt)['status']); $this->assertSame([ ['question_id' => $yesNo, 'value' => 'maybe_if', 'text_sha256' => hash('sha256', 'on a weekday')], ['question_id' => $text, 'value' => null, 'text_sha256' => null], ], $entry->payload['answers']); // The text itself is only in poll_answers, never in the public ledger $this->assertStringNotContainsString('on a weekday', json_encode($entry->getAttributes())); // Reloading: the post page shows the vote, without the receipt Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertSet('state', PollForm::STATE_VOTED) ->assertSet('receiptCode', null) ->assertSee('You voted on') ->assertDontSee($receipt) ->assertDontSee('Change vote'); } public function test_main_page_card_disappears_after_voting(): void { $poll = $this->makePoll(); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card']) ->set("answers.$yesNo.value", 'yes') ->call('submit') ->assertSet('state', PollForm::STATE_SUBMITTED) // The card has no verify link of its own, so the receipt block keeps it ->assertSee('Verify this poll'); Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card']) ->assertSet('state', PollForm::STATE_HIDDEN) ->assertDontSee('Shall we meet monthly?'); } public function test_validation_errors_name_the_question(): void { $poll = $this->makePoll(); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->call('submit') ->assertHasErrors(["answers.$yesNo.value" => 'required']) ->assertSee('Shall we meet monthly?') ->assertSet('state', PollForm::STATE_FORM); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'maybe_if') ->call('submit') ->assertHasErrors(["answers.$yesNo.text"]) ->assertSeeHtmlInOrder(['Please fill in the condition.']) ->assertDontSee('maybe_if field') ->tap(fn ($component) => $this->assertSame(1, substr_count($component->html(), 'Please fill in the condition.'))); $this->assertSame(0, PollResponse::where('poll_id', $poll->id)->count()); $this->assertSame(0, PollLedgerEntry::where('poll_id', $poll->id)->count()); } public function test_date_slot_errors_do_not_show_the_slot_keys(): void { $poll = $this->makePoll(); $question = PollQuestion::create([ 'poll_id' => $poll->id, 'position' => 3, 'type' => 'date_time_slots', 'required' => true, 'label' => ['en' => 'When can you come?'], 'settings' => ['allow_if_needed' => true, 'slots' => [ ['key' => 'k1slot', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null], ['key' => 'k2slot', 'starts_at' => '2026-11-03 10:00', 'ends_at' => null], ]], ]); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); // One date answered: only the other date gets a message, in words Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->set("answers.{$question->id}.value.k1slot", 'yes') ->call('submit') ->assertHasErrors(["answers.{$question->id}.value.k2slot"]) ->assertSee('Choose an answer for this date.') ->assertDontSee('k2slot field'); } public function test_second_manager_cannot_vote_again_for_the_organization(): void { $poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]); [$yesNo] = $this->questionIds($poll); $organization = Organization::factory()->create(); [$manager1, $manager2] = User::factory()->count(2)->create(); // Both managers open the form before either has voted $this->actAs($organization, $manager2); $late = Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_FORM); $this->actAs($organization, $manager1); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->call('submit') ->assertSet('state', PollForm::STATE_SUBMITTED); $this->actAs($organization, $manager2); $late->set("answers.$yesNo.value", 'no') ->call('submit') ->assertSet('state', PollForm::STATE_VOTED) ->assertSet('receiptCode', null); $response = PollResponse::where('poll_id', $poll->id)->sole(); $this->assertSame(Organization::class, $response->voter_type); $this->assertSame($manager1->id, $response->user_id); $this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count()); } public function test_per_user_poll_lets_each_manager_vote(): void { $poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]); [$yesNo] = $this->questionIds($poll); $organization = Organization::factory()->create(); foreach (User::factory()->count(2)->create() as $manager) { $this->actAs($organization, $manager); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->call('submit') ->assertSet('state', PollForm::STATE_SUBMITTED); } $this->assertSame(2, PollResponse::where('poll_id', $poll->id)->where('voter_type', Organization::class)->count()); } public function test_changing_a_vote(): void { $poll = $this->makePoll(['allow_vote_change' => true]); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); $first = Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->call('submit') ->get('receiptCode'); $component = Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertSet('state', PollForm::STATE_VOTED) ->assertSee('Change vote') ->call('changeVote') ->assertSet('state', PollForm::STATE_FORM) ->assertSet("answers.$yesNo.value", 'yes') // prefilled ->set("answers.$yesNo.value", 'no') ->call('submit') ->assertSet('state', PollForm::STATE_SUBMITTED); $second = $component->get('receiptCode'); $this->assertNotSame($first, $second); $response = PollResponse::where('poll_id', $poll->id)->sole(); $this->assertSame('no', $response->answers()->sole()->value); $ledger = new Ledger(); $change = PollLedgerEntry::where('poll_id', $poll->id)->where('sequence', 2)->sole(); $this->assertSame(PollLedgerEntry::CHANGE, $change->entry_type); $this->assertSame(1, $change->refers_to_sequence); $this->assertSame(2, $response->ledger_sequence); $this->assertSame('replaced', $ledger->lookupReceipt($poll, $first)['status']); $this->assertSame('counted', $ledger->lookupReceipt($poll, $second)['status']); $this->assertTrue($ledger->verifyChain($poll)['valid']); } public function test_change_vote_is_refused_when_not_allowed(): void { $poll = $this->makePoll(['allow_vote_change' => false]); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->call('submit'); // Calling the actions directly, as a tampered request would Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->call('changeVote') ->assertSet('state', PollForm::STATE_VOTED) ->set("answers.$yesNo.value", 'no') ->call('submit') ->assertSet('state', PollForm::STATE_VOTED); $this->assertSame('yes', PollResponse::where('poll_id', $poll->id)->sole()->answers()->sole()->value); $this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count()); } public function test_poll_closing_while_the_form_is_open_refuses_the_vote(): void { $poll = $this->makePoll(); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); $component = Livewire::test(PollForm::class, ['pollId' => $poll->id]); $poll->update(['status' => Poll::STATUS_CLOSED]); $component->set("answers.$yesNo.value", 'yes') ->call('submit') ->assertSet('state', PollForm::STATE_HIDDEN); $this->assertSame(0, PollResponse::where('poll_id', $poll->id)->count()); } public function test_no_receipt_when_verification_is_off(): void { $poll = $this->makePoll(['verification' => Poll::VERIFICATION_OFF]); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->call('submit') ->assertSet('state', PollForm::STATE_SUBMITTED) ->assertSet('receiptCode', null) ->assertDontSee('Your receipt code'); // The ledger is still written, so verification can be enabled later $this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count()); } public function test_results_shown_after_vote_when_configured(): void { $poll = $this->makePoll(['results_visibility' => Poll::RESULTS_AFTER_VOTE]); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertDontSee('Results so far') ->set("answers.$yesNo.value", 'yes') ->call('submit') ->assertSee('Results so far') ->assertSee('1 response'); $hidden = $this->makePoll(['results_visibility' => Poll::RESULTS_AFTER_CLOSE]); [$hiddenYesNo] = $this->questionIds($hidden); Livewire::test(PollForm::class, ['pollId' => $hidden->id]) ->set("answers.$hiddenYesNo.value", 'yes') ->call('submit') ->assertDontSee('Results so far'); } public function test_receipt_email_is_sent_immediately_not_queued(): void { Mail::fake(); $poll = $this->makePoll(); [$yesNo] = $this->questionIds($poll); $user = User::factory()->create(); $this->actAs($user, $user); $component = Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->set("answers.$yesNo.value", 'yes') ->call('submit') ->call('emailReceipt') ->assertSet('receiptEmailed', true) ->assertSee('Sent to your email address.'); $receipt = $component->get('receiptCode'); Mail::assertSent(PollReceiptMail::class, fn ($mail) => $mail->hasTo($user->email) && $mail->receiptCode === $receipt); Mail::assertNothingQueued(); // Only once per submit $component->call('emailReceipt'); Mail::assertSent(PollReceiptMail::class, 1); } public function test_receipt_mail_renders(): void { $poll = $this->makePoll(); $user = User::factory()->create(); $html = (new PollReceiptMail($poll, $user, 'ABCDE-FGHJK-MNPQR-STUVW'))->render(); $this->assertStringContainsString('ABCDE-FGHJK-MNPQR-STUVW', $html); } }