Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+420
View File
@@ -0,0 +1,420 @@
<?php
namespace Tests\Feature\Polls;
use App\Http\Livewire\Polls\PollForm;
use App\Mail\PollReceiptMail;
use App\Models\Admin;
use App\Models\Organization;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use App\Models\PollQuestion;
use App\Models\PollResponse;
use App\Models\User;
use App\Polls\Ledger;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Tests\TestCase;
/**
* The voting form, rendered as a real Livewire component.
*/
class PollFormTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
// phpunit.xml configures Redis, which is the dev site's cache too
config(['cache.default' => '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);
}
}