Files

522 lines
24 KiB
PHP

<?php
namespace Tests\Feature\Polls;
use App\Http\Livewire\MainPage\PollCardFull;
use App\Http\Livewire\Polls\Manage;
use App\Http\Livewire\Polls\PollForm;
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\PostTranslation;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* Where polls appear: publishing on open, the late-publication warning,
* the main-page card, the post pages and the closed state.
*/
class PollPagesTest 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', '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' => '<p>The background of this poll.</p>']);
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' => '<p><br></p>']);
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' => '<p>Rich <strong>text</strong></p><script>alert(1)</script>']);
$html = $this->pollPage($this->slugOf($poll));
$this->assertStringContainsString('Dedicated page poll', $html);
$this->assertStringContainsString('Intro of Dedicated page poll', $html);
$this->assertStringContainsString('Rich <strong>text</strong>', $html);
$this->assertStringNotContainsString('<script>alert(1)</script>', $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'));
}
}