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,905 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user