'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', '
Long text
') ->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('Long text
', $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()); } }