Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user