'array', 'scout.driver' => null]); app(PermissionRegistrar::class)->forgetCachedPermissions(); app()->setLocale('en'); } /** A poll with one yes/no question; $from is the post's publication date (null = unpublished). */ private function makePoll(string $title, array $attributes = [], ?string $from = '2026-01-01 00:00:00'): Poll { $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-pages-test-' . $category->id] ); $postId = DB::table('posts')->insertGetId([ 'postable_id' => 1, 'postable_type' => Admin::class, 'category_id' => $category->id, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('post_translations')->insert([ 'post_id' => $postId, 'locale' => 'en', 'slug' => 'poll-pages-' . $postId, 'title' => $title, 'excerpt' => "Intro of $title", 'from' => $from, 'created_at' => now(), 'updated_at' => now(), ]); $poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes)); PollQuestion::create([ 'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'required' => true, 'label' => ['en' => "Question of $title"], 'settings' => [], ]); return $poll; } private function actAs(Model $profile, User $user): void { $this->actingAs($user, 'web'); session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]); } private function actAsAdmin(): void { $user = User::factory()->create(); $admin = Admin::factory()->create(); $admin->users()->attach($user); $role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\poll-pages-role", 'guard_name' => 'web']); $role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage polls', 'guard_name' => 'web'])); $user->assignRole($role); $this->actingAs($user, 'web'); $this->actingAs($admin, 'admin'); session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]); } private function vote(Poll $poll, User $user): void { PollResponse::create([ 'poll_id' => $poll->id, 'voter_id' => $user->id, 'voter_type' => User::class, 'user_id' => $user->id, 'dedupe_key' => $poll->dedupeKey($user, $user->id), 'submitted_at' => now(), ]); } // ----------------------------------------------------------------- // Publishing // ----------------------------------------------------------------- public function test_opening_a_poll_publishes_its_unpublished_post(): void { $this->actAsAdmin(); $poll = $this->makePoll('Unpublished poll', ['status' => Poll::STATUS_DRAFT], null); $translation = PostTranslation::where('post_id', $poll->post_id)->sole(); // Saving as draft leaves the post unpublished Livewire::test(Manage::class)->call('openEditModal', $poll->id)->call('savePoll'); $this->assertNull($translation->fresh()->from); Livewire::test(Manage::class) ->call('openEditModal', $poll->id) ->set('form.status', Poll::STATUS_OPEN) ->set('form.opens_at', '2026-11-01 09:00') ->call('savePoll') ->assertHasNoErrors(); $this->assertSame('2026-11-01 09:00', \Illuminate\Support\Carbon::parse($translation->fresh()->from)->format('Y-m-d H:i')); } public function test_an_existing_publication_date_is_kept_and_a_later_one_warned_about(): void { $this->actAsAdmin(); $poll = $this->makePoll('Late post', ['status' => Poll::STATUS_DRAFT], '2026-12-24 08:00:00'); $component = Livewire::test(Manage::class) ->call('openEditModal', $poll->id) ->set('tab', 'settings') ->assertDontSee('is published later than the poll opens') ->set('form.status', Poll::STATUS_OPEN) ->set('form.opens_at', '2026-11-01 09:00') ->assertSee('is published later than the poll opens') ->assertSee('EN:'); $this->assertArrayHasKey('en', $component->instance()->latePublications()); $component->call('savePoll')->assertHasNoErrors(); $this->assertSame('2026-12-24 08:00', \Illuminate\Support\Carbon::parse(PostTranslation::where('post_id', $poll->post_id)->sole()->from)->format('Y-m-d H:i')); // Opening on or after the publication date: no warning Livewire::test(Manage::class) ->call('openEditModal', $poll->id) ->set('form.opens_at', '2026-12-24 08:00') ->set('tab', 'settings') ->assertDontSee('is published later than the poll opens'); } // ----------------------------------------------------------------- // Main-page card // ----------------------------------------------------------------- public function test_card_shows_the_soonest_closing_unanswered_poll(): void { $user = User::factory()->create(); $this->actAs($user, $user); // Other open polls in the test database would compete for the card; close them for this test Poll::query()->update(['status' => Poll::STATUS_CLOSED]); $later = $this->makePoll('Closes later', ['closes_at' => now()->addDays(10)]); $sooner = $this->makePoll('Closes sooner', ['closes_at' => now()->addDays(2)]); $this->makePoll('Not for users', ['target_profile_types' => [Organization::class]]); $this->makePoll('Unpublished', [], null); Livewire::test(PollCardFull::class) ->assertSet('pollId', $sooner->id) ->assertSee('Closes sooner') ->assertSee('Intro of Closes sooner') ->assertSee('Question of Closes sooner'); // Answered: the next one takes its place $this->vote($sooner, $user); Livewire::test(PollCardFull::class)->assertSet('pollId', $later->id); $this->vote($later, $user); Livewire::test(PollCardFull::class)->assertSet('pollId', null)->assertDontSee('Question of'); } public function test_card_links_to_the_poll_page_for_several_questions(): void { $user = User::factory()->create(); $this->actAs($user, $user); Poll::query()->update(['status' => Poll::STATUS_CLOSED]); $poll = $this->makePoll('Long poll'); PollQuestion::create([ 'poll_id' => $poll->id, 'position' => 2, 'type' => 'yes_no', 'required' => true, 'label' => ['en' => 'Second question of Long poll'], 'settings' => [], ]); Livewire::test(PollCardFull::class) ->assertSet('pollId', $poll->id) ->assertSee('Long poll') ->assertSee('Intro of Long poll') ->assertSee('Take part') ->assertSee(route('poll.show', ['slug' => $this->slugOf($poll)]), false) ->assertDontSee('Question of Long poll') ->assertDontSee('Read more'); } public function test_card_shows_a_single_question_with_read_more_when_there_is_content(): void { $user = User::factory()->create(); $this->actAs($user, $user); Poll::query()->update(['status' => Poll::STATUS_CLOSED]); $poll = $this->makePoll('Short poll'); // No content: just the question Livewire::test(PollCardFull::class) ->assertSee('Question of Short poll') ->assertDontSee('Read more') ->assertDontSee('Take part'); // With content: "Read more..." to the poll page, above the question PostTranslation::where('post_id', $poll->post_id)->update(['content' => '

The background of this poll.

']); Livewire::test(PollCardFull::class) ->assertSeeInOrder(['Read more...', 'Question of Short poll']) ->assertSee(route('poll.show', ['slug' => $this->slugOf($poll)]), false) ->assertDontSee('The background of this poll.'); // Empty markup from the editor counts as no content PostTranslation::where('post_id', $poll->post_id)->update(['content' => '


']); Livewire::test(PollCardFull::class)->assertDontSee('Read more'); } public function test_card_is_empty_for_guests(): void { Livewire::test(PollCardFull::class)->assertSet('pollId', null); } // ----------------------------------------------------------------- // Closed polls on the post page // ----------------------------------------------------------------- public function test_closed_poll_shows_results_on_the_post_page_only(): void { $user = User::factory()->create(); $this->actAs($user, $user); $poll = $this->makePoll('Finished poll', [ 'status' => Poll::STATUS_CLOSED, 'results_visibility' => Poll::RESULTS_AFTER_CLOSE, ]); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertSet('state', PollForm::STATE_CLOSED) ->assertSee('This poll is closed.') ->assertSee('Results') ->assertDontSee('Submit vote'); Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card']) ->assertSet('state', PollForm::STATE_HIDDEN); // Results hidden when the poll never shows them $poll->update(['results_visibility' => Poll::RESULTS_NEVER]); Livewire::test(PollForm::class, ['pollId' => $poll->id]) ->assertSee('This poll is closed.') ->assertDontSee('Question of Finished poll'); // Profiles the poll was not for see nothing $poll->update(['target_profile_types' => [Organization::class]]); Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_HIDDEN); } // ----------------------------------------------------------------- // Post pages // ----------------------------------------------------------------- /** * Render the poll page through PollController::show. Called directly: localized * routes are registered without a locale prefix in tests, so an HTTP request would 404. */ private function pollPage(string $slug): string { $view = app(\App\Http\Controllers\PollController::class)->show($slug); return $view->render(); } private function slugOf(Poll $poll): string { return PostTranslation::where('post_id', $poll->post_id)->value('slug'); } public function test_poll_page_is_only_for_logged_in_profiles(): void { // Same protection as the other member pages: login, verified email, completed // registration and accepted principles $middleware = app('router')->getRoutes()->getByName('poll.show')->gatherMiddleware(); foreach (['auth:web', 'verified', 'registration-complete', 'principles-accepted'] as $required) { $this->assertContains($required, $middleware); } // Safety net in the controller: a guest is sent to the login and the poll is remembered $poll = $this->makePoll('Guest poll'); $slug = $this->slugOf($poll); $response = $this->pollResponse($slug); $this->assertTrue($response->isRedirect()); $this->assertStringContainsString('/login', $response->getTargetUrl()); $this->assertStringEndsWith('/' . $slug, session('url.intended')); } public function test_explicit_indexing_never_indexes_a_poll(): void { // A spy engine records what Scout sends; synchronous so nothing is queued $engine = new class extends \Laravel\Scout\Engines\NullEngine { public array $updated = []; public array $deleted = []; public function update($models) { $this->updated = array_merge($this->updated, $models->modelKeys()); } public function delete($models) { $this->deleted = array_merge($this->deleted, $models->modelKeys()); } }; app(\Laravel\Scout\EngineManager::class)->extend('spy', fn () => $engine); config(['scout.driver' => 'spy', 'scout.queue' => false]); $poll = $this->makePoll('Never indexed'); $pollPost = \App\Models\Post::find($poll->post_id); $plain = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class])); // $post->searchable(), as Polls/Manage, Posts/Manage and the observers call it $pollPost->searchable(); $this->assertNotContains($pollPost->id, $engine->updated); $this->assertContains($pollPost->id, $engine->deleted); // A collection with a poll and a normal post: only the normal post is indexed $engine->updated = $engine->deleted = []; \App\Models\Post::whereKey([$pollPost->id, $plain->id])->get()->searchable(); $this->assertSame([$plain->id], $engine->updated); $this->assertSame([$pollPost->id], $engine->deleted); } public function test_poll_posts_stay_out_of_search_and_guest_pdfs(): void { $poll = $this->makePoll('Hidden poll'); $post = \App\Models\Post::find($poll->post_id); $this->assertFalse($post->shouldBeSearchable()); $plain = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class])); $this->assertTrue($plain->shouldBeSearchable()); // A poll attached to a post of another category counts too $other = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class])); Poll::create(['post_id' => $other->id, 'status' => Poll::STATUS_OPEN]); $this->assertFalse($other->fresh()->shouldBeSearchable()); try { app(\App\Http\Controllers\PostController::class)->postPdf($poll->post_id); $this->fail('A guest got the PDF of a poll post'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(404, $e->getStatusCode()); } } /** Render or redirect the poll page as the current session, with optional query. */ private function pollResponse(string $slug, string $query = '') { $request = \Illuminate\Http\Request::create(url('/en/poll/' . $slug . $query)); $request->setLaravelSession($this->app['session.store']); $this->app->instance('request', $request); $this->app['url']->setRequest($request); return app(\App\Http\Controllers\PollController::class)->show($slug); } public function test_after_login_the_only_eligible_profile_is_switched_to(): void { $user = User::factory()->create(); $organization = Organization::factory()->create(['name' => 'Switch Target Org']); $user->organizations()->attach($organization->id); $this->actAs($user, $user); $poll = $this->makePoll('Organizations only', ['target_profile_types' => [Organization::class]]); $slug = $this->slugOf($poll); $direct = route('organization.direct-login', ['organizationId' => $organization->id]) . '?intended=' . urlencode(url('/en/poll/' . $slug)); // A link with ?switch=1 alone does not switch (security audit R2) $this->assertFalse($this->pollResponse($slug, '?switch=1') instanceof \Illuminate\Http\RedirectResponse); // Back from the login (one-time marker): straight to the organization's own login link, which returns here session(['poll_switch_after_login' => '/en/poll/' . $slug]); $response = $this->pollResponse($slug); $this->assertTrue($response->isRedirect()); $this->assertSame($direct, $response->getTargetUrl()); $this->assertNull(session('poll_switch_after_login')); // used once // A normal visit offers it instead of redirecting $html = $this->pollResponse($slug)->render(); $this->assertStringContainsString('Take part as Switch Target Org', $html); $this->assertStringContainsString(e($direct), $html); // As the organization: no switch offered, the form is there $this->actAs($organization, $user); session(['poll_switch_after_login' => '/en/poll/' . $slug]); $html = $this->pollResponse($slug)->render(); $this->assertStringNotContainsString('Take part as', $html); } public function test_several_eligible_profiles_are_offered_not_switched_to(): void { $user = User::factory()->create(); $organization = Organization::factory()->create(['name' => 'Choice Org']); $bank = \App\Models\Bank::factory()->create(['name' => 'Choice Bank']); $user->organizations()->attach($organization->id); $user->banksManaged()->attach($bank->id); $this->actAs($user, $user); $poll = $this->makePoll('Orgs and banks', ['target_profile_types' => [Organization::class, \App\Models\Bank::class]]); session(['poll_switch_after_login' => '/en/poll/' . $this->slugOf($poll)]); $response = $this->pollResponse($this->slugOf($poll)); $html = $response->render(); $this->assertStringContainsString('Take part as Choice Org', $html); $this->assertStringContainsString('Take part as Choice Bank', $html); $this->assertStringContainsString(e(route('bank.direct-login', ['bankId' => $bank->id])), $html); } public function test_no_switch_without_an_eligible_profile(): void { $user = User::factory()->create(); $this->actAs($user, $user); $poll = $this->makePoll('Banks only', ['target_profile_types' => [\App\Models\Bank::class]]); session(['poll_switch_after_login' => '/en/poll/' . $this->slugOf($poll)]); $html = $this->pollResponse($this->slugOf($poll))->render(); $this->assertStringNotContainsString('Take part as', $html); } public function test_login_response_returns_to_a_poll_page(): void { $user = User::factory()->create(); $this->actingAs($user, 'web'); foreach (['/en/poll/some-poll', '/nl/peiling/een-peiling', '/de/umfrage/x', '/es/encuesta/x', '/fr/sondage/x'] as $path) { session(['url.intended' => url($path)]); $response = app(\App\Http\Responses\LoginResponse::class)->toResponse(request()); $this->assertSame(url($path), $response->getTargetUrl(), $path); $this->assertSame($path, session()->pull('poll_switch_after_login'), $path); } // Other pages still go to the main page session(['url.intended' => url('/en/polls/1/results.csv')]); $response = app(\App\Http\Responses\LoginResponse::class)->toResponse(request()); $this->assertNotSame(url('/en/polls/1/results.csv'), $response->getTargetUrl()); } public function test_poll_page_shows_post_and_form(): void { $user = User::factory()->create(); $this->actAs($user, $user); $poll = $this->makePoll('Dedicated page poll'); PostTranslation::where('post_id', $poll->post_id)->update(['content' => '

Rich text

']); $html = $this->pollPage($this->slugOf($poll)); $this->assertStringContainsString('Dedicated page poll', $html); $this->assertStringContainsString('Intro of Dedicated page poll', $html); $this->assertStringContainsString('Rich text', $html); $this->assertStringNotContainsString('', $html); $this->assertStringContainsString('Question of Dedicated page poll', $html); $this->assertStringContainsString('Submit vote', $html); } public function test_unpublished_poll_page_is_only_for_post_admins(): void { $poll = $this->makePoll('Hidden poll', [], null); $slug = $this->slugOf($poll); $user = User::factory()->create(); $this->actAs($user, $user); $this->assertStringNotContainsString('Hidden poll', $this->pollPage($slug)); $this->actAsAdmin(); $this->assertStringContainsString('Hidden poll', $this->pollPage($slug)); } public function test_draft_poll_is_not_found_even_with_a_published_post(): void { // Published post, poll still a draft (security audit R3) $poll = $this->makePoll('Draft with published post', ['status' => Poll::STATUS_DRAFT]); $slug = $this->slugOf($poll); $user = User::factory()->create(); $this->actAs($user, $user); $this->assertStringNotContainsString('Draft with published post', $this->pollPage($slug)); $this->actAsAdmin(); $this->assertStringContainsString('Draft with published post', $this->pollPage($slug)); } public function test_only_admin_profiles_preview_unpublished_polls(): void { // An organization whose role somehow has poll rights still gets no preview (security audit R4) $poll = $this->makePoll('Unpublished for orgs', [], null); $user = User::factory()->create(); $organization = Organization::factory()->create(); $user->organizations()->attach($organization->id); $role = Role::firstOrCreate(['name' => "Organization\\{$organization->id}\\preview-role", 'guard_name' => 'web']); $role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage polls', 'guard_name' => 'web'])); $user->assignRole($role); app(PermissionRegistrar::class)->forgetCachedPermissions(); $this->actAs($organization, $user); $this->assertTrue(\Illuminate\Support\Facades\Gate::allows('access polls')); // the permission is there... $this->assertStringNotContainsString('Unpublished for orgs', $this->pollPage($this->slugOf($poll))); // ...but no preview } public function test_post_pages_of_a_poll_redirect_to_the_poll_page(): void { $user = User::factory()->create(); $this->actAs($user, $user); $poll = $this->makePoll('Redirected poll'); $slug = $this->slugOf($poll); $posts = app(\App\Http\Controllers\PostController::class); $bySlug = $posts->showBySlug($slug); $this->assertInstanceOf(\Illuminate\Http\RedirectResponse::class, $bySlug); $this->assertSame(route('poll.show', ['slug' => $slug]), $bySlug->getTargetUrl()); $byId = $posts->showById($poll->post_id); $this->assertInstanceOf(\Illuminate\Http\RedirectResponse::class, $byId); $this->assertSame(route('poll.show', ['slug' => $slug]), $byId->getTargetUrl()); // Not a poll: not found on the poll route $this->assertStringNotContainsString('Redirected poll', $this->pollPage('no-such-slug')); } }