Files
timebank-cc-public/tests/Feature/Polls/PollManageTest.php
T

906 lines
38 KiB
PHP

<?php
namespace Tests\Feature\Polls;
use App\Http\Livewire\Polls\Manage;
use App\Models\Admin;
use App\Models\Category;
use App\Models\Organization;
use App\Models\Poll;
use App\Models\PollQuestion;
use App\Models\PollResponse;
use App\Models\Post;
use App\Models\PostTranslation;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* The Polls admin page and its Edit Poll modal, rendered as a real Livewire component.
*/
class PollManageTest 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']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
/** An Admin profile, active in the session, whose web user holds $permission for it. */
private function actAsAdmin(?string $permission = 'manage polls'): Admin
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$admin->users()->attach($user);
if ($permission) {
Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']);
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\poll-test-role", 'guard_name' => 'web']);
$role->givePermissionTo($permission);
$user->assignRole($role);
}
$this->actingAs($user, 'web');
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
return $admin;
}
private function makePoll(string $title = 'Meeting frequency', array $attributes = []): Poll
{
$postId = DB::table('posts')->insertGetId([
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
]);
DB::table('post_translations')->insert([
'post_id' => $postId, 'locale' => 'en', 'slug' => 'poll-test-' . $postId, 'title' => $title,
'created_at' => now(), 'updated_at' => now(),
]);
return Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_DRAFT], $attributes));
}
public function test_page_requires_admin_with_posts_permission(): void
{
$this->actAsAdmin(null);
Livewire::test(Manage::class)->assertStatus(403);
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
Livewire::test(Manage::class)->assertStatus(403);
}
public function test_list_search_and_status_filter(): void
{
$this->actAsAdmin();
$this->makePoll('Meeting frequency');
$this->makePoll('Garden day', ['status' => Poll::STATUS_OPEN]);
Livewire::test(Manage::class)
->assertSee('Meeting frequency')
->assertSee('Garden day')
->set('search', 'Garden')
->assertSee('Garden day')
->assertDontSee('Meeting frequency')
->set('search', '')
->set('statusFilter', 'draft')
->assertSee('Meeting frequency')
->assertDontSee('Garden day');
}
public function test_view_only_admin_can_open_but_not_save(): void
{
$this->actAsAdmin('view polls');
$poll = $this->makePoll();
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('showEditModal', true)
->call('savePoll')
->assertStatus(403);
}
public function test_build_a_poll_with_every_question_type(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
$component = Livewire::test(Manage::class)->call('openEditModal', $poll->id);
foreach (['yes_no_conditional', 'single_choice', 'multi_choice', 'date_time_slots', 'scale', 'text', 'ranking', 'yes_no'] as $i => $type) {
$component->set('newQuestionType', $type)->call('addQuestion')
->set("questions.$i.label.en", "Question $type");
}
// Fill in the blanks the types start with
foreach ([1, 2, 6] as $i) {
$component->set("questions.$i.settings.options.0.label.en", 'First')
->set("questions.$i.settings.options.1.label.en", 'Second');
}
$component->set('questions.3.settings.slots.0.starts_at', '2026-11-02 10:00')
->set('questions.2.settings.max_selections', '2') // number inputs arrive as strings
->set('questions.6.settings.top_n', '')
->set('form.status', Poll::STATUS_OPEN)
->call('savePoll')
->assertHasNoErrors()
->assertSet('showEditModal', false);
$questions = $poll->questions()->get();
$this->assertSame(
['yes_no_conditional', 'single_choice', 'multi_choice', 'date_time_slots', 'scale', 'text', 'ranking', 'yes_no'],
$questions->pluck('type')->all()
);
$this->assertSame([1, 2, 3, 4, 5, 6, 7, 8], $questions->pluck('position')->all());
$this->assertSame([], $questions[7]->settings);
$this->assertSame('Question ranking', $questions[6]->labelFor('en'));
$this->assertSame(2, $questions[2]->settings['max_selections']);
$this->assertNull($questions[6]->settings['top_n']);
$this->assertSame(Poll::STATUS_OPEN, $poll->fresh()->status);
}
public function test_option_and_date_errors_name_their_position(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('newQuestionType', 'single_choice')->call('addQuestion')
->set('newQuestionType', 'date_time_slots')->call('addQuestion')
->set('questions.0.label.en', 'Which day?')
->set('questions.1.label.en', 'When?')
->call('savePoll')
->assertHasErrors(['questions.0.settings.options.0.label', 'questions.1.settings.slots.0.starts_at'])
->assertSee('Question 1, option 1')
->assertSee('Question 2, date 1')
->assertDontSee('The questions.');
}
public function test_date_fields_use_the_flatpickr_inputs(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Dates poll', ['opens_at' => '2026-11-01 09:30:00']);
$component = Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('form.opens_at', '2026-11-01 09:30')
->set('newQuestionType', 'date_time_slots')
->call('addQuestion');
// opens/closes, 4 targeting dates, start/end of the new slot
$this->assertSame(8, substr_count($component->html(), 'data-selector-id='));
$component->assertDontSeeHtml('type="datetime-local"')
->assertDontSeeHtml('type="date"');
}
public function test_post_tab_edits_texts_and_adds_a_language(): void
{
config(['scout.driver' => null]);
$this->actAsAdmin();
$poll = $this->makePoll('Original title');
PostTranslation::where('post_id', $poll->post_id)->update(['from' => '2026-10-01 00:00:00']);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('postFields.en.title', 'Original title')
->set('postFields.en.title', 'Better title')
->set('postFields.en.excerpt', 'Short intro')
->set('postFields.en.content', '<p>Long <em>text</em></p>')
->set('postFields.nl.title', 'Betere titel')
->call('savePoll')
->assertHasNoErrors();
$translations = PostTranslation::where('post_id', $poll->post_id)->get()->keyBy('locale');
$this->assertSame('Better title', $translations['en']->title);
$this->assertSame('Short intro', $translations['en']->excerpt);
$this->assertSame('<p>Long <em>text</em></p>', $translations['en']->content);
$this->assertSame('poll-test-' . $poll->post_id, $translations['en']->slug); // links keep working
$this->assertSame('Betere titel', $translations['nl']->title);
$this->assertSame('betere-titel', $translations['nl']->slug);
$this->assertSame('2026-10-01', substr((string) $translations['nl']->from, 0, 10)); // published like the rest
$this->assertFalse($translations->has('de'));
}
public function test_post_tab_title_rules(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Title rules');
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('postFields.en.title', '')
->set('postFields.de.excerpt', 'Einleitung ohne Titel')
->call('savePoll')
->assertHasErrors(['postFields.en.title', 'postFields.de.title'])
->assertHasNoErrors('postFields.fr.title')
->assertSee('This tab has errors');
$this->assertSame('Title rules', PostTranslation::where('post_id', $poll->post_id)->sole()->title);
}
public function test_post_tab_image_owner_and_captions(): void
{
config(['scout.driver' => null]);
Storage::fake('media');
$this->actAsAdmin();
$poll = $this->makePoll('Image poll');
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('image', UploadedFile::fake()->image('garden.jpg', 800, 600))
->assertHasNoErrors('image')
->set('mediaOwner', 'Photo: A. Gardener')
->set('mediaCaptions.en', 'The community garden')
->set('mediaCaptions.nl', 'De buurttuin')
->call('savePoll')
->assertHasNoErrors();
$media = $poll->post->fresh()->getFirstMedia('posts');
$this->assertNotNull($media);
$this->assertSame('Photo: A. Gardener', $media->getCustomProperty('owner'));
$this->assertSame('De buurttuin', $media->getCustomProperty('caption-nl'));
// Caption change without a new upload keeps the image
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('mediaCaptions.en', 'The community garden')
->set('mediaCaptions.en', 'Our garden')
->call('savePoll');
$this->assertSame('Our garden', $poll->post->fresh()->getFirstMedia('posts')->getCustomProperty('caption-en'));
// Removing only happens on save
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->call('markImageForRemoval')
->call('closeModal');
$this->assertNotNull($poll->post->fresh()->getFirstMedia('posts'));
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->call('markImageForRemoval')
->call('savePoll');
$this->assertNull($poll->post->fresh()->getFirstMedia('posts'));
}
public function test_post_tab_rejects_non_images(): void
{
Storage::fake('media');
$this->actAsAdmin();
$poll = $this->makePoll('Upload check');
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('image', UploadedFile::fake()->create('notes.pdf', 10, 'application/pdf'))
->assertHasErrors('image');
}
public function test_content_is_read_only_once_the_poll_is_open(): void
{
config(['scout.driver' => null]);
Storage::fake('media');
$this->actAsAdmin();
$poll = $this->makePoll('Open poll', ['status' => Poll::STATUS_OPEN]);
$question = PollQuestion::create([
'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'required' => true,
'label' => ['en' => 'Original question'], 'settings' => [],
]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('contentLocked', true)
->assertSee('This poll is read-only.')
->assertSee('set the status back to Draft')
// Tampered requests: none of these may change the content
->set('postFields.en.title', 'Changed title')
->set('questions.0.label.en', 'Changed question')
->call('addQuestion')
->set('image', UploadedFile::fake()->image('new.jpg'))
->set('form.closes_at', '2030-01-01 12:00')
->call('savePoll')
->assertHasNoErrors();
$this->assertSame('Open poll', PostTranslation::where('post_id', $poll->post_id)->sole()->title);
$this->assertSame('Original question', $question->fresh()->labelFor('en'));
$this->assertSame(1, $poll->questions()->count());
$this->assertNull($poll->post->fresh()->getFirstMedia('posts'));
// Settings still change
$this->assertSame('2030-01-01 12:00', $poll->fresh()->closes_at->format('Y-m-d H:i'));
}
public function test_opening_a_draft_saves_the_last_edits_then_locks(): void
{
config(['scout.driver' => null]);
$this->actAsAdmin();
$poll = $this->makePoll('Draft poll');
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('contentLocked', false)
->set('tab', 'settings')
->assertDontSee('When you save, the poll opens')
->set('form.status', Poll::STATUS_OPEN)
->assertSee('When you save, the poll opens')
->set('postFields.en.title', 'Final title')
->set('newQuestionType', 'yes_no')
->call('addQuestion')
->set('questions.0.label.en', 'Final question')
->call('savePoll')
->assertHasNoErrors();
$this->assertSame('Final title', PostTranslation::where('post_id', $poll->post_id)->sole()->title);
$this->assertSame(1, $poll->questions()->count());
Livewire::test(Manage::class)->call('openEditModal', $poll->id)->assertSet('contentLocked', true);
}
public function test_back_to_draft_unlocks_only_without_votes(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Reopened poll', ['status' => Poll::STATUS_OPEN]);
PollQuestion::create([
'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'required' => true,
'label' => ['en' => 'Question'], 'settings' => [],
]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('form.status', Poll::STATUS_DRAFT)
->call('savePoll')
->assertHasNoErrors();
Livewire::test(Manage::class)->call('openEditModal', $poll->id)->assertSet('contentLocked', false);
// With a vote it stays open and locked
$poll->refresh()->update(['status' => Poll::STATUS_OPEN]);
$voter = User::factory()->create();
PollResponse::create([
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSee('People have already voted')
->set('form.status', Poll::STATUS_DRAFT)
->call('savePoll')
->assertHasErrors('form.status');
$this->assertSame(Poll::STATUS_OPEN, $poll->fresh()->status);
}
/** An open poll with one question and one vote. */
private function votedPoll(array $attributes = []): Poll
{
$poll = $this->makePoll('Voted poll', array_merge(['status' => Poll::STATUS_OPEN], $attributes));
\App\Models\PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => []]);
$voter = User::factory()->create();
PollResponse::create([
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
]);
return $poll;
}
public function test_anonymity_cannot_be_switched_off_after_voting(): void
{
$this->actAsAdmin();
$poll = $this->votedPoll(['anonymous' => true]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('form.anonymous', false)
->call('savePoll')
->assertHasErrors('form.anonymous');
$this->assertTrue($poll->fresh()->anonymous);
// Switching it on stays possible
$open = $this->votedPoll(['anonymous' => false]);
Livewire::test(Manage::class)
->call('openEditModal', $open->id)
->set('form.anonymous', true)
->call('savePoll')
->assertHasNoErrors();
$this->assertTrue($open->fresh()->anonymous);
}
public function test_vote_scope_is_fixed_after_voting(): void
{
$this->actAsAdmin();
$poll = $this->votedPoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('form.vote_scope', Poll::VOTE_SCOPE_PER_USER)
->call('savePoll')
->assertHasErrors('form.vote_scope');
$this->assertSame(Poll::VOTE_SCOPE_PER_PROFILE, $poll->fresh()->vote_scope);
// Without votes it can still change
$fresh = $this->makePoll('No votes yet');
Livewire::test(Manage::class)
->call('openEditModal', $fresh->id)
->set('form.vote_scope', Poll::VOTE_SCOPE_PER_USER)
->call('savePoll')
->assertHasNoErrors();
$this->assertSame(Poll::VOTE_SCOPE_PER_USER, $fresh->fresh()->vote_scope);
}
public function test_sealed_poll_keeps_its_status_and_dates(): void
{
$this->actAsAdmin();
$poll = $this->votedPoll(['closes_at' => '2026-10-01 12:00:00']);
$poll->update(['status' => Poll::STATUS_CLOSED]);
app(\App\Polls\Ledger::class)->seal($poll);
$this->assertNotNull($poll->fresh()->sealed_at);
Livewire::test(Manage::class)->call('openEditModal', $poll->id)
->set('form.status', Poll::STATUS_OPEN)->call('savePoll')->assertHasErrors('form.status');
Livewire::test(Manage::class)->call('openEditModal', $poll->id)
->set('form.closes_at', '2027-01-01 12:00')->call('savePoll')->assertHasErrors('form.status');
$this->assertSame(Poll::STATUS_CLOSED, $poll->fresh()->status);
$this->assertSame('2026-10-01 12:00', $poll->fresh()->closes_at->format('Y-m-d H:i'));
// Other settings can still be saved
Livewire::test(Manage::class)->call('openEditModal', $poll->id)
->set('form.results_visibility', Poll::RESULTS_AFTER_CLOSE)->call('savePoll')->assertHasNoErrors();
$this->assertSame(Poll::RESULTS_AFTER_CLOSE, $poll->fresh()->results_visibility);
}
public function test_unknown_question_settings_are_not_stored(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('newQuestionType', 'single_choice')->call('addQuestion')
->set('questions.0.label.en', 'Which day?')
->set('questions.0.settings.options.0.label.en', 'Saturday')
->set('questions.0.settings.options.1.label.en', 'Sunday')
// Tampered client state: extra keys at both levels
->set('questions.0.settings.injected', ['anything' => true])
->set('questions.0.settings.options.0.extra', 'x')
->call('savePoll')
->assertHasNoErrors();
$settings = $poll->questions()->first()->settings;
$this->assertSame(['options', 'allow_other'], array_keys($settings));
$this->assertSame(['key', 'label'], array_keys($settings['options'][0]));
$this->assertSame('Saturday', $settings['options'][0]['label']['en']);
}
public function test_delete_a_poll_without_votes(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Unwanted poll');
Livewire::test(Manage::class)
->call('confirmDelete', $poll->id)
->assertSet('showDeleteModal', true)
->assertSee('Unwanted poll')
->call('deletePoll')
->assertSet('showDeleteModal', false);
$this->assertSoftDeleted('polls', ['id' => $poll->id]);
$this->assertSoftDeleted('posts', ['id' => $poll->post_id]);
}
public function test_only_drafts_without_ledger_entries_are_deleted(): void
{
$this->actAsAdmin();
$voted = $this->votedPoll();
Livewire::test(Manage::class)->call('confirmDelete', $voted->id)->call('deletePoll');
$this->assertNotSoftDeleted('polls', ['id' => $voted->id]);
// Opened once (closed now), nobody voted: archive instead of delete
$closed = $this->makePoll('Closed without votes', ['status' => Poll::STATUS_CLOSED]);
Livewire::test(Manage::class)->call('confirmDelete', $closed->id)->call('deletePoll');
$this->assertNotSoftDeleted('polls', ['id' => $closed->id]);
// A withdrawn vote leaves no response but its ballot stays in the ledger
$withdrawn = $this->makePoll('Withdrawn vote');
DB::transaction(fn () => app(\App\Polls\Ledger::class)->append($withdrawn, \App\Models\PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'x')));
Livewire::test(Manage::class)->call('confirmDelete', $withdrawn->id)->call('deletePoll');
$this->assertNotSoftDeleted('polls', ['id' => $withdrawn->id]);
}
public function test_deleting_needs_the_delete_permission(): void
{
$this->actAsAdmin('view polls'); // may open the page, not delete
$poll = $this->makePoll();
Livewire::test(Manage::class)->call('confirmDelete', $poll->id)->assertStatus(403);
Livewire::test(Manage::class)->call('deletePoll')->assertStatus(403);
$this->assertNotSoftDeleted('polls', ['id' => $poll->id]);
}
public function test_archived_polls_only_show_under_the_archived_filter(): void
{
$this->actAsAdmin();
$kept = $this->makePoll('Kept poll', ['status' => Poll::STATUS_CLOSED]);
$old = $this->votedPoll(['status' => Poll::STATUS_CLOSED]);
$old->post->translations()->update(['title' => 'Old voted poll']);
$component = Livewire::test(Manage::class)
->call('archivePoll', $old->id)
->assertSee('Kept poll')
->assertDontSee('Old voted poll');
$this->assertNotNull($old->fresh()->archived_at);
// Not under the status filters either
$component->set('statusFilter', Poll::STATUS_CLOSED)->assertSee('Kept poll')->assertDontSee('Old voted poll');
// Only under "Archived"
$component->set('statusFilter', 'archived')->assertSee('Old voted poll')->assertDontSee('Kept poll');
// And back
$component->call('restoreFromArchive', $old->id)->set('statusFilter', '')->assertSee('Old voted poll');
$this->assertNull($old->fresh()->archived_at);
}
public function test_an_open_poll_cannot_be_archived(): void
{
$this->actAsAdmin();
$open = $this->votedPoll();
Livewire::test(Manage::class)->call('archivePoll', $open->id);
$this->assertNull($open->fresh()->archived_at);
}
public function test_archiving_needs_the_update_permission(): void
{
$this->actAsAdmin('view polls');
$poll = $this->makePoll('Closed', ['status' => Poll::STATUS_CLOSED]);
Livewire::test(Manage::class)->call('archivePoll', $poll->id)->assertStatus(403);
$this->assertNull($poll->fresh()->archived_at);
}
public function test_posts_page_cannot_edit_the_post_of_an_open_poll(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Guarded poll', ['status' => Poll::STATUS_OPEN]);
$translationId = PostTranslation::where('post_id', $poll->post_id)->value('id');
// The posts editor shows the category name
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
\App\Models\CategoryTranslation::firstOrCreate(
['category_id' => $category->id, 'locale' => 'en'],
['name' => 'Poll', 'slug' => 'poll-manage-test-' . $category->id]
);
DB::table('posts')->where('id', $poll->post_id)->update(['category_id' => $category->id]);
if (!DB::table('languages')->where('lang_code', 'en')->exists()) {
DB::table('languages')->insert(['name' => 'English', 'lang_code' => 'en', 'flag' => '']);
}
Livewire::test(\App\Http\Livewire\Posts\Manage::class)
->call('edit', $translationId)
->assertSet('postId', null);
$poll->refresh()->update(['status' => Poll::STATUS_DRAFT]);
Livewire::test(\App\Http\Livewire\Posts\Manage::class)
->call('edit', $translationId)
->assertSet('postId', $poll->post_id);
}
public function test_invalid_questions_are_not_saved(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('newQuestionType', 'single_choice')
->call('addQuestion')
->call('savePoll')
->assertHasErrors(['questions.0.label', 'questions.0.settings.options.0.label'])
->assertSet('showEditModal', true)
->assertSee('This tab has errors');
$this->assertSame(0, $poll->questions()->count());
}
public function test_cannot_open_a_poll_without_questions(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('form.status', Poll::STATUS_OPEN)
->call('savePoll')
->assertHasErrors('form.status')
->assertSet('tab', 'settings');
$this->assertSame(Poll::STATUS_DRAFT, $poll->fresh()->status);
}
public function test_changing_type_resets_settings_and_list_items_can_be_edited(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
$component = Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('newQuestionType', 'scale')
->call('addQuestion')
->assertSet('questions.0.settings.max', 5)
->set('questions.0.type', 'ranking')
->assertSet('questions.0.settings.top_n', null)
->call('addListItem', 0, 'options')
->set('questions.0.settings.options.0.label.en', 'A')
->set('questions.0.settings.options.2.label.en', 'C')
->call('moveListItem', 0, 'options', 2, -1)
->call('removeListItem', 0, 'options', 2)
->call('addListItem', 0, 'not-a-list');
$options = $component->get('questions.0.settings.options');
$this->assertCount(2, $options);
$this->assertSame(['A', 'C'], array_map(fn ($option) => $option['label']['en'] ?? null, $options));
}
public function test_questions_are_locked_once_someone_voted(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Locked poll', ['status' => Poll::STATUS_OPEN]);
$question = PollQuestion::create([
'poll_id' => $poll->id, 'position' => 1, 'type' => 'text', 'required' => true,
'label' => ['en' => 'Original'], 'settings' => ['multiline' => true, 'max_length' => 100],
]);
$voter = User::factory()->create();
PollResponse::create([
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->assertSet('contentLocked', true)
->assertSee('This poll is read-only.')
->call('addQuestion')
->call('removeQuestion', 0)
->assertCount('questions', 1)
->set('questions.0.label.en', 'Tampered')
->set('form.closes_at', '2030-01-01 12:00')
->call('savePoll')
->assertHasNoErrors();
$this->assertSame('Original', $question->fresh()->labelFor('en'));
$this->assertSame('2030-01-01 12:00', $poll->fresh()->closes_at->format('Y-m-d H:i'));
}
public function test_tampered_question_id_cannot_touch_another_poll(): void
{
$this->actAsAdmin();
$other = $this->makePoll('Other poll');
$foreign = PollQuestion::create([
'poll_id' => $other->id, 'type' => 'text', 'label' => ['en' => 'Foreign'],
'settings' => ['multiline' => true, 'max_length' => 100],
]);
$poll = $this->makePoll();
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('newQuestionType', 'text')
->call('addQuestion')
->set('questions.0.id', $foreign->id)
->set('questions.0.label.en', 'Hijacked')
->call('savePoll');
$this->assertSame('Foreign', $foreign->fresh()->labelFor('en'));
$this->assertSame($other->id, $foreign->fresh()->poll_id);
}
public function test_targeting_is_saved_and_counted(): void
{
$this->actAsAdmin();
$poll = $this->makePoll();
User::factory()->create(['last_login_at' => '2026-09-15 10:00:00']);
Organization::factory()->create(['last_login_at' => '2026-09-15 10:00:00']);
$component = Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('tab', 'targeting')
->set('targeting.target_profile_types', [User::class, Organization::class])
->set('targeting.last_login_from', '2026-09-01')
->set('targeting.last_login_until', '2026-09-30')
->call('countMatchingProfiles');
$counts = $component->get('matchingCounts');
$this->assertSame(['User', 'Organization'], array_keys($counts));
$this->assertSame(
User::whereDate('last_login_at', '>=', '2026-09-01')->whereDate('last_login_at', '<=', '2026-09-30')->count(),
$counts['User']
);
$component->call('savePoll')->assertHasNoErrors();
$poll->refresh();
$this->assertSame([User::class, Organization::class], $poll->target_profile_types);
$this->assertSame('2026-09-30', $poll->last_login_until->toDateString());
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('targeting.last_login_until', '2026-08-01')
->call('savePoll')
->assertHasErrors('targeting.last_login_until');
}
public function test_results_tab_hides_voters_of_anonymous_polls(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Results poll', ['status' => Poll::STATUS_OPEN]);
$voter = User::factory()->create(['name' => 'Visible Voter Name']);
PollResponse::create([
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('tab', 'results')
->assertSee('Visible Voter Name')
->assertSee('Ballot ledger');
$poll->update(['anonymous' => true]);
Livewire::test(Manage::class)
->call('openEditModal', $poll->id)
->set('tab', 'results')
->assertDontSee('Visible Voter Name')
->assertSee('This poll is anonymous');
}
public function test_edit_query_parameter_opens_the_modal(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Deep link poll');
Livewire::withQueryParams(['edit' => $poll->id])
->test(Manage::class)
->assertSet('showEditModal', true)
->assertSet('editingPollId', $poll->id);
}
public function test_opened_from_the_posts_table_saving_returns_there_with_the_message(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Back to posts');
Livewire::withQueryParams(['edit' => $poll->id, 'from' => 'posts'])
->test(Manage::class)
->assertSet('returnToPosts', true)
->call('savePoll')
->assertHasNoErrors()
->assertRedirect(route('posts.manage'));
$this->assertSame('success', session('posts.manage.notification')['icon']);
// The posts table shows it
Livewire::test(\App\Http\Livewire\Posts\Manage::class)
->assertDispatched('wireui:notification');
}
public function test_opened_from_the_posts_table_closing_returns_there(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Close to posts');
Livewire::withQueryParams(['edit' => $poll->id, 'from' => 'posts'])
->test(Manage::class)
->call('closeModal')
->assertRedirect(route('posts.manage'));
// Close button and Escape set the modal property directly
Livewire::withQueryParams(['edit' => $poll->id, 'from' => 'posts'])
->test(Manage::class)
->set('showEditModal', false)
->assertRedirect(route('posts.manage'));
}
public function test_opened_in_the_poll_manager_saving_stays_there(): void
{
$this->actAsAdmin();
$poll = $this->makePoll('Stay here');
Livewire::withQueryParams(['edit' => $poll->id])
->test(Manage::class)
->assertSet('returnToPosts', false)
->call('savePoll')
->assertHasNoErrors()
->assertNoRedirect()
->assertSet('showEditModal', false)
->assertDispatched('wireui:notification');
}
public function test_new_poll_creates_post_and_opens_the_editor(): void
{
config(['scout.driver' => null]); // no Elasticsearch in tests
$admin = $this->actAsAdmin();
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
$component = Livewire::test(Manage::class)
->call('openCreateModal')
->assertSet('showCreateModal', true)
->set('newPoll.locale', 'nl')
->set('newPoll.title', 'Hoe vaak willen we samenkomen?')
->set('newPoll.excerpt', 'Geef je mening.')
->call('createPoll')
->assertHasNoErrors()
->assertSet('showCreateModal', false)
->assertSet('showEditModal', true)
->assertSet('tab', 'questions');
$poll = Poll::findOrFail($component->get('editingPollId'));
$this->assertSame(Poll::STATUS_DRAFT, $poll->status);
$this->assertTrue($poll->anonymous); // new polls start anonymous
$this->assertTrue($poll->pollable->is($admin));
$this->assertSame($category->id, $poll->post->category_id);
$translation = PostTranslation::where('post_id', $poll->post_id)->sole();
$this->assertSame('nl', $translation->locale);
$this->assertSame('Hoe vaak willen we samenkomen?', $translation->title);
$this->assertSame('Geef je mening.', $translation->excerpt);
$this->assertSame('hoe-vaak-willen-we-samenkomen', $translation->slug);
}
public function test_new_poll_validates_and_needs_create_permission(): void
{
config(['scout.driver' => null]);
$this->actAsAdmin();
Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
$before = Poll::count();
Livewire::test(Manage::class)
->call('openCreateModal')
->set('newPoll.title', 'ab')
->call('createPoll')
->assertHasErrors('newPoll.title')
->set('newPoll.title', 'Valid title')
->set('newPoll.locale', 'xx')
->call('createPoll')
->assertHasErrors('newPoll.locale');
$this->assertSame($before, Poll::count());
$this->actAsAdmin('view polls');
Livewire::test(Manage::class)->call('openCreateModal')->assertStatus(403);
Livewire::test(Manage::class)
->set('newPoll', ['locale' => 'en', 'title' => 'Sneaky poll', 'excerpt' => ''])
->call('createPoll')
->assertStatus(403);
$this->assertSame($before, Poll::count());
}
public function test_saving_a_post_in_a_poll_category_creates_a_draft_poll(): void
{
$admin = $this->actAsAdmin();
$pollCategory = Category::create(['type' => Poll::class]);
$newsCategory = Category::create(['type' => 'App\Models\News']);
$method = new \ReflectionMethod(\App\Http\Livewire\Posts\Manage::class, 'ensureDraftPoll');
$method->setAccessible(true);
$posts = new \App\Http\Livewire\Posts\Manage();
$pollPost = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => $admin->id, 'postable_type' => Admin::class, 'category_id' => $pollCategory->id]));
$newsPost = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => $admin->id, 'postable_type' => Admin::class, 'category_id' => $newsCategory->id]));
$method->invoke($posts, $pollPost);
$method->invoke($posts, $pollPost); // saving again keeps the one poll
$method->invoke($posts, $newsPost);
$this->assertSame(1, Poll::where('post_id', $pollPost->id)->count());
$poll = Poll::where('post_id', $pollPost->id)->sole();
$this->assertSame(Poll::STATUS_DRAFT, $poll->status);
$this->assertTrue($poll->anonymous);
$this->assertTrue($poll->pollable->is($admin));
$this->assertSame(0, Poll::where('post_id', $newsPost->id)->count());
}
}