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,146 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryTranslation;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Polls\Ledger;
|
||||
use App\Polls\PollBackup;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Poll definitions in post backups: exported and restored as drafts, and an existing
|
||||
* poll with votes is never overwritten (that would cascade to its votes and ledger).
|
||||
*/
|
||||
class PollBackupTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private string $file;
|
||||
|
||||
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]);
|
||||
$this->file = storage_path('framework/testing/poll-backup-' . uniqid() . '.json');
|
||||
File::ensureDirectoryExists(dirname($this->file));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::delete($this->file);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makePollPost(string $slug, Organization $owner): Poll
|
||||
{
|
||||
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
CategoryTranslation::firstOrCreate(['category_id' => $category->id, 'locale' => 'en'], ['name' => 'Poll', 'slug' => 'poll-backup-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' => $slug, 'title' => 'Backed up poll', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create([
|
||||
'post_id' => $postId, 'pollable_id' => $owner->id, 'pollable_type' => Organization::class,
|
||||
'status' => Poll::STATUS_OPEN, 'closes_at' => '2026-12-01 18:00:00', 'vote_scope' => Poll::VOTE_SCOPE_PER_USER,
|
||||
'anonymous' => true, 'verification' => Poll::VERIFICATION_RECEIPTS,
|
||||
'filter_by_location' => true, 'location_city_ids' => [99999],
|
||||
'target_profile_types' => [Organization::class], 'registered_until' => '2025-12-31',
|
||||
]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'First?'], 'settings' => []]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 2, 'type' => 'scale', 'required' => false,
|
||||
'label' => ['en' => 'Second?', 'nl' => 'Tweede?'], 'settings' => ['min' => 1, 'max' => 5]]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
public function test_round_trip_restores_the_definition_as_a_draft(): void
|
||||
{
|
||||
$owner = Organization::factory()->create(['name' => 'Backup Owner Org']);
|
||||
$poll = $this->makePollPost('poll-backup-round-trip', $owner);
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->artisan('posts:backup', ['--post-ids' => (string) $poll->post_id, '--exclude-media' => true, '--output' => $this->file])
|
||||
->assertSuccessful();
|
||||
|
||||
$backup = json_decode(File::get($this->file), true);
|
||||
$this->assertSame(1, $backup['meta']['counts']['polls']);
|
||||
$this->assertCount(2, $backup['posts'][0]['poll']['questions']);
|
||||
$this->assertArrayNotHasKey('responses', $backup['posts'][0]['poll']);
|
||||
|
||||
// Gone from this database...
|
||||
DB::table('post_translations')->where('post_id', $poll->post_id)->delete();
|
||||
DB::table('posts')->where('id', $poll->post_id)->delete();
|
||||
|
||||
// ...and restored
|
||||
$this->artisan('posts:restore', ['file' => $this->file, '--profile-id' => $admin->id, '--profile-type' => 'Admin'])
|
||||
->expectsConfirmation('Do you want to proceed with the restore?', 'yes')
|
||||
->assertSuccessful();
|
||||
|
||||
$postId = PostTranslation::where('slug', 'poll-backup-round-trip')->value('post_id');
|
||||
$restored = Poll::where('post_id', $postId)->sole();
|
||||
$this->assertSame(Poll::STATUS_DRAFT, $restored->status);
|
||||
$this->assertTrue($restored->pollable->is($owner));
|
||||
$this->assertSame('2026-12-01 18:00', $restored->closes_at->format('Y-m-d H:i'));
|
||||
$this->assertSame(Poll::VOTE_SCOPE_PER_USER, $restored->vote_scope);
|
||||
$this->assertTrue($restored->anonymous);
|
||||
$this->assertSame(Poll::VERIFICATION_RECEIPTS, $restored->verification);
|
||||
$this->assertSame([Organization::class], $restored->target_profile_types);
|
||||
$this->assertFalse($restored->filter_by_location); // location ids differ between databases
|
||||
$this->assertEmpty($restored->location_city_ids);
|
||||
$this->assertSame(['yes_no', 'scale'], $restored->questions()->pluck('type')->all());
|
||||
$this->assertSame('Tweede?', $restored->questions()->get()[1]->labelFor('nl'));
|
||||
$this->assertNull($restored->sealed_at);
|
||||
}
|
||||
|
||||
public function test_import_rejects_unknown_owner_types(): void
|
||||
{
|
||||
$post = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
|
||||
$poll = PollBackup::import($post, ['owner_type' => 'Illuminate\\Support\\Facades\\File', 'owner_name' => 'x', 'vote_scope' => 'nonsense', 'questions' => [['type' => 'yes_no']]]);
|
||||
|
||||
$this->assertNull($poll->pollable_type);
|
||||
$this->assertSame(Poll::VOTE_SCOPE_PER_PROFILE, $poll->vote_scope);
|
||||
$this->assertSame(1, $poll->questions()->count());
|
||||
}
|
||||
|
||||
public function test_overwrite_never_deletes_a_poll_with_votes(): void
|
||||
{
|
||||
$poll = $this->makePollPost('poll-backup-protected', Organization::factory()->create());
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->artisan('posts:backup', ['--post-ids' => (string) $poll->post_id, '--exclude-media' => true, '--output' => $this->file])
|
||||
->assertSuccessful();
|
||||
|
||||
// Someone votes after the backup was made
|
||||
$ledger = new Ledger();
|
||||
DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'r')));
|
||||
$this->assertTrue(PollBackup::protectsSlugs(['poll-backup-protected']));
|
||||
|
||||
$this->artisan('posts:restore', ['file' => $this->file, '--profile-id' => $admin->id, '--profile-type' => 'Admin'])
|
||||
->expectsConfirmation('Do you want to proceed with the restore?', 'yes')
|
||||
->expectsChoice('What would you like to do?', 'overwrite', [
|
||||
'skip', 'overwrite', 'skip_all', 'overwrite_all',
|
||||
'Skip this post', 'Overwrite existing post(s)', 'Skip all duplicates', 'Overwrite all duplicates',
|
||||
])
|
||||
->expectsOutputToContain('Not overwritten: the existing post has a poll with votes.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertNotNull(Poll::find($poll->id));
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
$this->assertSame(1, PostTranslation::where('slug', 'poll-backup-protected')->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollResultsExportController;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\ResultsCsv;
|
||||
use App\Polls\Types\DateTimeSlotsType;
|
||||
use App\Polls\Types\MultiChoiceType;
|
||||
use App\Polls\Types\RankingType;
|
||||
use App\Polls\Types\ScaleType;
|
||||
use App\Polls\Types\SingleChoiceType;
|
||||
use App\Polls\Types\TextType;
|
||||
use App\Polls\Types\YesNoConditionalType;
|
||||
use App\Polls\Types\YesNoType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CSV export of poll results.
|
||||
*/
|
||||
class PollExportTest 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();
|
||||
app()->setLocale('en');
|
||||
}
|
||||
|
||||
private function opts(array $labels): array
|
||||
{
|
||||
return array_map(fn ($key, $label) => ['key' => $key, 'label' => ['en' => $label]], array_keys($labels), $labels);
|
||||
}
|
||||
|
||||
public function test_answers_as_text_per_type(): void
|
||||
{
|
||||
$this->assertSame('Yes', (new YesNoType())->answerToText('yes', null, []));
|
||||
$this->assertSame('Maybe, if...: after 18:00', (new YesNoConditionalType())->answerToText('maybe_if', 'after 18:00', []));
|
||||
|
||||
$options = ['options' => $this->opts(['a' => 'Garden', 'b' => 'Repair café']), 'allow_other' => true];
|
||||
$this->assertSame('Repair café', (new SingleChoiceType())->answerToText('b', null, $options));
|
||||
$this->assertSame('Other: Bike tour', (new SingleChoiceType())->answerToText('__other', 'Bike tour', $options));
|
||||
$this->assertSame('Garden; Other: Bike tour', (new MultiChoiceType())->answerToText(['a', '__other'], 'Bike tour', $options));
|
||||
$this->assertSame('1. Repair café; 2. Garden', (new RankingType())->answerToText(['b', 'a'], null, $options));
|
||||
|
||||
$slots = ['allow_if_needed' => true, 'slots' => [['key' => 's1', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null]]];
|
||||
$this->assertSame('Monday 2 November 2026, 10:00: If needed', (new DateTimeSlotsType())->answerToText(['s1' => 'if_needed'], null, $slots, 'en'));
|
||||
|
||||
$this->assertSame('4', (new ScaleType())->answerToText(4, null, ['min' => 1, 'max' => 5]));
|
||||
$this->assertSame('Free text', (new TextType())->answerToText(null, 'Free text', []));
|
||||
}
|
||||
|
||||
public function test_formula_injection_is_neutralised(): void
|
||||
{
|
||||
$this->assertSame("'=HYPERLINK(\"x\")", ResultsCsv::safeCell('=HYPERLINK("x")'));
|
||||
$this->assertSame("'+1", ResultsCsv::safeCell('+1'));
|
||||
$this->assertSame("'@SUM(A1)", ResultsCsv::safeCell('@SUM(A1)'));
|
||||
$this->assertSame('Normal answer', ResultsCsv::safeCell('Normal answer'));
|
||||
$this->assertSame('', ResultsCsv::safeCell(''));
|
||||
}
|
||||
|
||||
/** A poll with a single choice and a text question, and two votes. */
|
||||
private function pollWithVotes(bool $anonymous): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN, 'anonymous' => $anonymous]);
|
||||
$choice = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'single_choice', 'label' => ['en' => 'Favourite'],
|
||||
'settings' => ['options' => $this->opts(['a' => 'Garden', 'b' => 'Repair café'])],
|
||||
]);
|
||||
$text = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 2, 'type' => 'text', 'required' => false, 'label' => ['en' => 'Remarks'],
|
||||
'settings' => ['multiline' => true, 'max_length' => 100],
|
||||
]);
|
||||
|
||||
$alice = User::factory()->create(['name' => 'Alice Voter']);
|
||||
$this->vote($poll, $alice, $alice, [$choice->id => ['value' => 'a', 'text' => null], $text->id => ['value' => null, 'text' => '=cmd()']], '2026-10-01 10:00:00');
|
||||
$organization = Organization::factory()->create(['name' => 'Garden Club']);
|
||||
$bob = User::factory()->create(['name' => 'Bob Manager']);
|
||||
$this->vote($poll, $organization, $bob, [$choice->id => ['value' => 'b', 'text' => null]], '2026-10-02 11:00:00');
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, Model $profile, User $user, array $answers, string $at): void
|
||||
{
|
||||
$response = PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $profile->id, 'voter_type' => get_class($profile), 'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'submitted_at' => $at,
|
||||
]);
|
||||
$response->forceFill(['created_at' => $at, 'updated_at' => $at])->saveQuietly();
|
||||
foreach ($answers as $questionId => $answer) {
|
||||
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $questionId] + $answer);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_named_export_has_voters_and_answers(): void
|
||||
{
|
||||
$csv = new ResultsCsv($this->pollWithVotes(false), 'en');
|
||||
|
||||
$this->assertSame(['Voted', 'Changed', 'Profile type', 'Profile', 'Submitted by', 'Favourite', 'Remarks'], $csv->header());
|
||||
$this->assertSame([
|
||||
['2026-10-01 10:00', '', 'User', 'Alice Voter', 'Alice Voter', 'Garden', "'=cmd()"],
|
||||
['2026-10-02 11:00', '', 'Organization', 'Garden Club', 'Bob Manager', 'Repair café', ''],
|
||||
], iterator_to_array($csv->rows(), false));
|
||||
}
|
||||
|
||||
public function test_anonymous_export_leaves_out_who_and_when(): void
|
||||
{
|
||||
$csv = new ResultsCsv($this->pollWithVotes(true), 'en');
|
||||
|
||||
$this->assertSame(['Favourite', 'Remarks'], $csv->header());
|
||||
$rows = iterator_to_array($csv->rows(), false);
|
||||
$this->assertCount(2, $rows);
|
||||
sort($rows);
|
||||
$this->assertSame([['Garden', "'=cmd()"], ['Repair café', '']], $rows);
|
||||
$this->assertStringNotContainsString('Alice', json_encode($rows));
|
||||
}
|
||||
|
||||
public function test_download_requires_admin_with_poll_export_permission(): void
|
||||
{
|
||||
$poll = $this->pollWithVotes(false);
|
||||
|
||||
// A User profile
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->fail('A user profile could download the results');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
|
||||
// An admin who may only view polls (or manage posts) may not see who voted
|
||||
$webUser = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($webUser);
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\export-role", 'guard_name' => 'web']);
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'view polls', 'guard_name' => 'web']));
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage posts', 'guard_name' => 'web']));
|
||||
$webUser->assignRole($role);
|
||||
$this->actingAs($webUser, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->fail('An admin with only view polls could download the results');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
|
||||
// With the export permission the file comes
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'export polls', 'guard_name' => 'web']));
|
||||
app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$webUser->load('roles');
|
||||
|
||||
$response = (new PollResultsExportController())($poll);
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$body = ob_get_clean();
|
||||
|
||||
$this->assertStringStartsWith("\xEF\xBB\xBF", $body);
|
||||
$this->assertStringContainsString('Garden Club', $body);
|
||||
$this->assertStringContainsString('attachment; filename=poll-' . $poll->id . '-results-', $response->headers->get('Content-Disposition'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Polls\PollForm;
|
||||
use App\Mail\PollReceiptMail;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The voting form, rendered as a real Livewire component.
|
||||
*/
|
||||
class PollFormTest 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']);
|
||||
}
|
||||
|
||||
/** A poll with a yes/no/if question and an optional open text question. */
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => Admin::class,
|
||||
'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_conditional', 'required' => true,
|
||||
'label' => ['en' => 'Shall we meet monthly?'], 'settings' => [],
|
||||
]);
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 2, 'type' => 'text', 'required' => false,
|
||||
'label' => ['en' => 'Anything else?'], 'settings' => ['multiline' => true, 'max_length' => 200],
|
||||
]);
|
||||
|
||||
return $poll->fresh();
|
||||
}
|
||||
|
||||
private function actAs(Model $profile, User $user): void
|
||||
{
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]);
|
||||
}
|
||||
|
||||
private function questionIds(Poll $poll): array
|
||||
{
|
||||
return $poll->questions()->pluck('id')->all();
|
||||
}
|
||||
|
||||
public function test_targeted_profile_sees_the_form(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_FORM)
|
||||
->assertSee('Shall we meet monthly?')
|
||||
->assertSee('Anything else?')
|
||||
->assertSee('Submit vote');
|
||||
}
|
||||
|
||||
public function test_untargeted_profile_and_guest_see_nothing(): void
|
||||
{
|
||||
$poll = $this->makePoll(['target_profile_types' => [Organization::class]]);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_HIDDEN)
|
||||
->assertDontSee('Shall we meet monthly?');
|
||||
|
||||
auth('web')->logout();
|
||||
session()->forget(['activeProfileType', 'activeProfileId']);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_HIDDEN);
|
||||
}
|
||||
|
||||
public function test_submit_stores_vote_ledger_and_shows_receipt_once(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo, $text] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'maybe_if')
|
||||
->set("answers.$yesNo.text", 'on a weekday')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED)
|
||||
->assertSee('Thank you, your vote has been recorded.');
|
||||
|
||||
$receipt = $component->get('receiptCode');
|
||||
$this->assertNotNull($receipt);
|
||||
$component->assertSee($receipt);
|
||||
// Copy button: the code reaches Alpine (@js is not compiled inside component attributes)
|
||||
$component->assertSeeHtml('code: '.\Illuminate\Support\Js::from($receipt))
|
||||
->assertSeeHtml('x-on:click="copy()"')
|
||||
->assertDontSeeHtml('@js(');
|
||||
// The dedicated page has the verify link at the bottom, not in the receipt block
|
||||
$component->assertDontSee('Verify this poll');
|
||||
|
||||
$response = PollResponse::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame(User::class, $response->voter_type);
|
||||
$this->assertSame($user->id, $response->user_id);
|
||||
$this->assertNotNull($response->submitted_at);
|
||||
|
||||
// The optional, empty text question is not stored
|
||||
$this->assertSame(1, $response->answers()->count());
|
||||
$this->assertSame('on a weekday', $response->answers()->first()->text);
|
||||
|
||||
// Ledger: one cast entry, linked by sequence, matching the receipt, no identity
|
||||
$entry = PollLedgerEntry::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame(PollLedgerEntry::CAST, $entry->entry_type);
|
||||
$this->assertSame($entry->sequence, $response->ledger_sequence);
|
||||
$this->assertSame((new Ledger())->fingerprint($receipt), $entry->fingerprint);
|
||||
$this->assertStringNotContainsString((string) $user->email, json_encode($entry->getAttributes()));
|
||||
$this->assertSame('counted', (new Ledger())->lookupReceipt($poll, $receipt)['status']);
|
||||
$this->assertSame([
|
||||
['question_id' => $yesNo, 'value' => 'maybe_if', 'text_sha256' => hash('sha256', 'on a weekday')],
|
||||
['question_id' => $text, 'value' => null, 'text_sha256' => null],
|
||||
], $entry->payload['answers']);
|
||||
// The text itself is only in poll_answers, never in the public ledger
|
||||
$this->assertStringNotContainsString('on a weekday', json_encode($entry->getAttributes()));
|
||||
|
||||
// Reloading: the post page shows the vote, without the receipt
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->assertSet('receiptCode', null)
|
||||
->assertSee('You voted on')
|
||||
->assertDontSee($receipt)
|
||||
->assertDontSee('Change vote');
|
||||
}
|
||||
|
||||
public function test_main_page_card_disappears_after_voting(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card'])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED)
|
||||
// The card has no verify link of its own, so the receipt block keeps it
|
||||
->assertSee('Verify this poll');
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card'])
|
||||
->assertSet('state', PollForm::STATE_HIDDEN)
|
||||
->assertDontSee('Shall we meet monthly?');
|
||||
}
|
||||
|
||||
public function test_validation_errors_name_the_question(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->call('submit')
|
||||
->assertHasErrors(["answers.$yesNo.value" => 'required'])
|
||||
->assertSee('Shall we meet monthly?')
|
||||
->assertSet('state', PollForm::STATE_FORM);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'maybe_if')
|
||||
->call('submit')
|
||||
->assertHasErrors(["answers.$yesNo.text"])
|
||||
->assertSeeHtmlInOrder(['Please fill in the condition.'])
|
||||
->assertDontSee('maybe_if field')
|
||||
->tap(fn ($component) => $this->assertSame(1, substr_count($component->html(), 'Please fill in the condition.')));
|
||||
|
||||
$this->assertSame(0, PollResponse::where('poll_id', $poll->id)->count());
|
||||
$this->assertSame(0, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_date_slot_errors_do_not_show_the_slot_keys(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$question = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 3, 'type' => 'date_time_slots', 'required' => true,
|
||||
'label' => ['en' => 'When can you come?'],
|
||||
'settings' => ['allow_if_needed' => true, 'slots' => [
|
||||
['key' => 'k1slot', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null],
|
||||
['key' => 'k2slot', 'starts_at' => '2026-11-03 10:00', 'ends_at' => null],
|
||||
]],
|
||||
]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
// One date answered: only the other date gets a message, in words
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->set("answers.{$question->id}.value.k1slot", 'yes')
|
||||
->call('submit')
|
||||
->assertHasErrors(["answers.{$question->id}.value.k2slot"])
|
||||
->assertSee('Choose an answer for this date.')
|
||||
->assertDontSee('k2slot field');
|
||||
}
|
||||
|
||||
public function test_second_manager_cannot_vote_again_for_the_organization(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
// Both managers open the form before either has voted
|
||||
$this->actAs($organization, $manager2);
|
||||
$late = Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_FORM);
|
||||
|
||||
$this->actAs($organization, $manager1);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED);
|
||||
|
||||
$this->actAs($organization, $manager2);
|
||||
$late->set("answers.$yesNo.value", 'no')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->assertSet('receiptCode', null);
|
||||
|
||||
$response = PollResponse::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame(Organization::class, $response->voter_type);
|
||||
$this->assertSame($manager1->id, $response->user_id);
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_per_user_poll_lets_each_manager_vote(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
foreach (User::factory()->count(2)->create() as $manager) {
|
||||
$this->actAs($organization, $manager);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED);
|
||||
}
|
||||
|
||||
$this->assertSame(2, PollResponse::where('poll_id', $poll->id)->where('voter_type', Organization::class)->count());
|
||||
}
|
||||
|
||||
public function test_changing_a_vote(): void
|
||||
{
|
||||
$poll = $this->makePoll(['allow_vote_change' => true]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$first = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->get('receiptCode');
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->assertSee('Change vote')
|
||||
->call('changeVote')
|
||||
->assertSet('state', PollForm::STATE_FORM)
|
||||
->assertSet("answers.$yesNo.value", 'yes') // prefilled
|
||||
->set("answers.$yesNo.value", 'no')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED);
|
||||
$second = $component->get('receiptCode');
|
||||
|
||||
$this->assertNotSame($first, $second);
|
||||
$response = PollResponse::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame('no', $response->answers()->sole()->value);
|
||||
|
||||
$ledger = new Ledger();
|
||||
$change = PollLedgerEntry::where('poll_id', $poll->id)->where('sequence', 2)->sole();
|
||||
$this->assertSame(PollLedgerEntry::CHANGE, $change->entry_type);
|
||||
$this->assertSame(1, $change->refers_to_sequence);
|
||||
$this->assertSame(2, $response->ledger_sequence);
|
||||
$this->assertSame('replaced', $ledger->lookupReceipt($poll, $first)['status']);
|
||||
$this->assertSame('counted', $ledger->lookupReceipt($poll, $second)['status']);
|
||||
$this->assertTrue($ledger->verifyChain($poll)['valid']);
|
||||
}
|
||||
|
||||
public function test_change_vote_is_refused_when_not_allowed(): void
|
||||
{
|
||||
$poll = $this->makePoll(['allow_vote_change' => false]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit');
|
||||
|
||||
// Calling the actions directly, as a tampered request would
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->call('changeVote')
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->set("answers.$yesNo.value", 'no')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_VOTED);
|
||||
|
||||
$this->assertSame('yes', PollResponse::where('poll_id', $poll->id)->sole()->answers()->sole()->value);
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_poll_closing_while_the_form_is_open_refuses_the_vote(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id]);
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
|
||||
$component->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_HIDDEN);
|
||||
$this->assertSame(0, PollResponse::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_no_receipt_when_verification_is_off(): void
|
||||
{
|
||||
$poll = $this->makePoll(['verification' => Poll::VERIFICATION_OFF]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED)
|
||||
->assertSet('receiptCode', null)
|
||||
->assertDontSee('Your receipt code');
|
||||
|
||||
// The ledger is still written, so verification can be enabled later
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_results_shown_after_vote_when_configured(): void
|
||||
{
|
||||
$poll = $this->makePoll(['results_visibility' => Poll::RESULTS_AFTER_VOTE]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertDontSee('Results so far')
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSee('Results so far')
|
||||
->assertSee('1 response');
|
||||
|
||||
$hidden = $this->makePoll(['results_visibility' => Poll::RESULTS_AFTER_CLOSE]);
|
||||
[$hiddenYesNo] = $this->questionIds($hidden);
|
||||
Livewire::test(PollForm::class, ['pollId' => $hidden->id])
|
||||
->set("answers.$hiddenYesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertDontSee('Results so far');
|
||||
}
|
||||
|
||||
public function test_receipt_email_is_sent_immediately_not_queued(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->call('emailReceipt')
|
||||
->assertSet('receiptEmailed', true)
|
||||
->assertSee('Sent to your email address.');
|
||||
|
||||
$receipt = $component->get('receiptCode');
|
||||
Mail::assertSent(PollReceiptMail::class, fn ($mail) => $mail->hasTo($user->email) && $mail->receiptCode === $receipt);
|
||||
Mail::assertNothingQueued();
|
||||
|
||||
// Only once per submit
|
||||
$component->call('emailReceipt');
|
||||
Mail::assertSent(PollReceiptMail::class, 1);
|
||||
}
|
||||
|
||||
public function test_receipt_mail_renders(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$html = (new PollReceiptMail($poll, $user, 'ABCDE-FGHJK-MNPQR-STUVW'))->render();
|
||||
|
||||
$this->assertStringContainsString('ABCDE-FGHJK-MNPQR-STUVW', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The append-only ballot ledger: hash chain, tamper detection, changes,
|
||||
* withdrawals and receipts.
|
||||
*/
|
||||
class PollLedgerTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private Ledger $ledger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
$this->ledger = new Ledger();
|
||||
}
|
||||
|
||||
private function makePoll(): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => Admin::class,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN]);
|
||||
}
|
||||
|
||||
private function cast(Poll $poll, array $normalized, ?string $receipt = null, string $type = PollLedgerEntry::CAST, ?int $refersTo = null): PollLedgerEntry
|
||||
{
|
||||
return DB::transaction(fn () => $this->ledger->append(
|
||||
$poll,
|
||||
$type,
|
||||
$this->ledger->ballotPayload($normalized),
|
||||
$this->ledger->fingerprint($receipt ?? $this->ledger->newReceiptCode()),
|
||||
$refersTo
|
||||
));
|
||||
}
|
||||
|
||||
public function test_canonical_format_is_stable(): void
|
||||
{
|
||||
// Golden value: the browser-side verification must produce exactly this hash.
|
||||
// Changing it breaks every existing chain, see Ledger::FORMAT_VERSION.
|
||||
$entry = [
|
||||
'poll_id' => 7,
|
||||
'sequence' => 1,
|
||||
'entry_type' => 'cast',
|
||||
'fingerprint' => str_repeat('a', 64),
|
||||
'refers_to_sequence' => null,
|
||||
'payload' => ['answers' => [
|
||||
['question_id' => 3, 'value' => ['zz' => 'yes', 'ab' => 'no'], 'text' => null],
|
||||
['question_id' => 5, 'value' => null, 'text' => 'Café / "quoted" ü'],
|
||||
]],
|
||||
'recorded_at' => '2026-10-10T17:00:00Z',
|
||||
'previous_hash' => Ledger::GENESIS_HASH,
|
||||
];
|
||||
|
||||
$expectedCanonical = '{"entry_type":"cast","fingerprint":"' . str_repeat('a', 64) . '","payload":{"answers":['
|
||||
. '{"question_id":3,"text":null,"value":{"ab":"no","zz":"yes"}},'
|
||||
. '{"question_id":5,"text":"Café / \"quoted\" ü","value":null}]},'
|
||||
. '"poll_id":7,"previous_hash":"' . Ledger::GENESIS_HASH . '","recorded_at":"2026-10-10T17:00:00Z",'
|
||||
. '"refers_to_sequence":null,"sequence":1,"v":1}';
|
||||
|
||||
$this->assertSame(hash('sha256', $expectedCanonical), $this->ledger->hash($entry));
|
||||
}
|
||||
|
||||
public function test_ballot_payload_orders_answers_by_question(): void
|
||||
{
|
||||
$payload = $this->ledger->ballotPayload([
|
||||
9 => ['value' => 'b', 'text' => null],
|
||||
2 => ['value' => ['x', 'y'], 'text' => null],
|
||||
]);
|
||||
|
||||
$this->assertSame([2, 9], array_column($payload['answers'], 'question_id'));
|
||||
// Free text is committed to by its hash only
|
||||
$payload = $this->ledger->ballotPayload([3 => ['value' => null, 'text' => 'Ask the neighbours']]);
|
||||
$this->assertSame(['question_id' => 3, 'value' => null, 'text_sha256' => hash('sha256', 'Ask the neighbours')], $payload['answers'][0]);
|
||||
$this->assertStringNotContainsString('neighbours', json_encode($payload));
|
||||
}
|
||||
|
||||
public function test_chain_links_entries_and_verifies(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$first = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$second = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
$this->assertSame(1, $first->sequence);
|
||||
$this->assertSame(Ledger::GENESIS_HASH, $first->previous_hash);
|
||||
$this->assertSame($first->hash, $second->previous_hash);
|
||||
$this->assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/', $second->recorded_at);
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertSame(2, $result['entries']);
|
||||
$this->assertSame($second->hash, $result['head']);
|
||||
}
|
||||
|
||||
public function test_entries_are_recorded_with_the_day_only(): void
|
||||
{
|
||||
config(['app.timezone' => 'Europe/Paris']);
|
||||
\Illuminate\Support\Carbon::setTestNow(\Illuminate\Support\Carbon::parse('2026-10-11 14:37:12', 'Europe/Paris'));
|
||||
$ledger = new Ledger();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
$first = DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'a')));
|
||||
\Illuminate\Support\Carbon::setTestNow(\Illuminate\Support\Carbon::parse('2026-10-11 23:59:59', 'Europe/Paris'));
|
||||
$second = DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'b')));
|
||||
\Illuminate\Support\Carbon::setTestNow();
|
||||
|
||||
// Start of the local day, stored in UTC: no hour, minute or second of the vote
|
||||
$this->assertSame('2026-10-10T22:00:00Z', $first->recorded_at);
|
||||
$this->assertSame($first->recorded_at, $second->recorded_at);
|
||||
$this->assertTrue($ledger->verifyChain($poll)['valid']);
|
||||
}
|
||||
|
||||
public function test_chains_are_per_poll(): void
|
||||
{
|
||||
$pollA = $this->makePoll();
|
||||
$pollB = $this->makePoll();
|
||||
$this->cast($pollA, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
|
||||
$this->assertSame(1, $this->cast($pollB, [1 => ['value' => 'yes', 'text' => null]])->sequence);
|
||||
}
|
||||
|
||||
public function test_tampering_with_a_payload_breaks_the_chain(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$target = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
// Bypasses the model, as a direct DB edit would (the test DB user still has UPDATE)
|
||||
DB::table('poll_ledger')->where('id', $target->id)
|
||||
->update(['payload' => json_encode(['answers' => [['question_id' => 1, 'value' => 'yes', 'text' => null]]])]);
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame(2, $result['broken_at']);
|
||||
}
|
||||
|
||||
public function test_removing_an_entry_breaks_the_chain(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$middle = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
DB::table('poll_ledger')->where('id', $middle->id)->delete();
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame(3, $result['broken_at']);
|
||||
}
|
||||
|
||||
public function test_rehashing_a_tampered_entry_still_breaks_the_next_link(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$target = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
// An attacker who also recomputes the edited entry's own hash
|
||||
$attributes = $target->getAttributes();
|
||||
$attributes['payload'] = ['answers' => [['question_id' => 1, 'value' => 'no', 'text' => null]]];
|
||||
DB::table('poll_ledger')->where('id', $target->id)->update([
|
||||
'payload' => json_encode($attributes['payload']),
|
||||
'hash' => $this->ledger->hash($attributes),
|
||||
]);
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame(2, $result['broken_at']);
|
||||
}
|
||||
|
||||
public function test_entries_cannot_be_changed_or_deleted_through_the_model(): void
|
||||
{
|
||||
$entry = $this->cast($this->makePoll(), [1 => ['value' => 'yes', 'text' => null]]);
|
||||
|
||||
try {
|
||||
$entry->update(['payload' => null]);
|
||||
$this->fail('Update was allowed');
|
||||
} catch (LogicException $e) {
|
||||
$this->assertStringContainsString('cannot be changed', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$entry->delete();
|
||||
}
|
||||
|
||||
public function test_append_requires_a_transaction(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
// DatabaseTransactions wraps the test in one; leave it to simulate a bare call
|
||||
$level = DB::transactionLevel();
|
||||
for ($i = 0; $i < $level; $i++) {
|
||||
DB::rollBack();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->expectException(LogicException::class);
|
||||
$this->ledger->append($poll, PollLedgerEntry::CAST);
|
||||
} finally {
|
||||
for ($i = 0; $i < $level; $i++) {
|
||||
DB::beginTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_changes_and_withdrawals_decide_which_ballots_count(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$a = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]], 'AAAAA-AAAAA-AAAAA-AAAAA');
|
||||
$b = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]], 'BBBBB-BBBBB-BBBBB-BBBBB');
|
||||
$c = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]], 'CCCCC-CCCCC-CCCCC-CCCCC');
|
||||
|
||||
// Voter A changes their vote, voter C's profile is deleted
|
||||
$a2 = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]], 'DDDDD-DDDDD-DDDDD-DDDDD', PollLedgerEntry::CHANGE, $a->sequence);
|
||||
DB::transaction(fn () => $this->ledger->append($poll, PollLedgerEntry::WITHDRAW, ['reason' => 'profile_deleted'], null, $c->sequence));
|
||||
|
||||
$this->assertSame([$b->sequence, $a2->sequence], $this->ledger->activeBallots($poll)->pluck('sequence')->all());
|
||||
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
|
||||
|
||||
$this->assertSame('replaced', $this->ledger->lookupReceipt($poll, 'AAAAA-AAAAA-AAAAA-AAAAA')['status']);
|
||||
$this->assertSame('counted', $this->ledger->lookupReceipt($poll, 'bbbbb bbbbb bbbbb bbbbb')['status']);
|
||||
$this->assertSame('withdrawn', $this->ledger->lookupReceipt($poll, 'CCCCCCCCCCCCCCCCCCCC')['status']);
|
||||
$this->assertSame('counted', $this->ledger->lookupReceipt($poll, 'DDDDD-DDDDD-DDDDD-DDDDD')['status']);
|
||||
$this->assertNull($this->ledger->lookupReceipt($poll, 'EEEEE-EEEEE-EEEEE-EEEEE'));
|
||||
// A receipt only works for its own poll
|
||||
$this->assertNull($this->ledger->lookupReceipt($this->makePoll(), 'BBBBB-BBBBB-BBBBB-BBBBB'));
|
||||
}
|
||||
|
||||
public function test_receipt_codes(): void
|
||||
{
|
||||
$code = $this->ledger->newReceiptCode();
|
||||
|
||||
$this->assertMatchesRegularExpression('/^[2-9A-HJKMNP-Z]{5}(-[2-9A-HJKMNP-Z]{5}){3}$/', $code);
|
||||
$this->assertNotSame($code, $this->ledger->newReceiptCode());
|
||||
$this->assertSame($this->ledger->fingerprint($code), $this->ledger->fingerprint(strtolower(str_replace('-', ' ', $code))));
|
||||
}
|
||||
|
||||
public function test_payload_survives_the_json_column_round_trip(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->cast($poll, [
|
||||
4 => ['value' => ['k2' => 'yes', 'a1' => 'if_needed'], 'text' => null],
|
||||
2 => ['value' => ['b', 'a', 'c'], 'text' => null],
|
||||
3 => ['value' => 4, 'text' => null],
|
||||
8 => ['value' => null, 'text' => "Two\nlines – with ünicode \u{2028} and / slash"],
|
||||
9 => ['value' => [], 'text' => null],
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Mail\TestNewsletterMail;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Mailing;
|
||||
use App\Models\Poll;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Poll posts as mailing content blocks: own block template, link to the poll page,
|
||||
* and the poll's state at the time the email is built.
|
||||
*/
|
||||
class PollMailingTest 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']);
|
||||
}
|
||||
|
||||
private function makePollPost(array $attributes = []): Post
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
foreach (['en' => 'mailing-poll-' . $postId, 'nl' => 'mailing-peiling-' . $postId] as $locale => $slug) {
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $postId, 'locale' => $locale, 'slug' => $slug, 'title' => "Poll title {$locale}",
|
||||
'excerpt' => "Poll excerpt {$locale}", 'status' => 1, 'from' => now()->subDay(),
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
|
||||
return Post::with(['translations', 'poll'])->find($postId);
|
||||
}
|
||||
|
||||
public function test_poll_posts_get_the_poll_block(): void
|
||||
{
|
||||
$post = $this->makePollPost();
|
||||
|
||||
// No category needed: the poll relation decides
|
||||
$this->assertSame('poll', Mailing::determinePostType($post));
|
||||
$this->assertSame('emails.newsletter.blocks.poll', Mailing::blockTemplate('poll'));
|
||||
|
||||
// Without the config key (before config:merge) the default view is used
|
||||
config(['timebank_cc.mailing.templates.poll_block' => null, 'timebank-default.mailing.templates.poll_block' => null]);
|
||||
$this->assertSame('emails.newsletter.blocks.poll', Mailing::blockTemplate('poll'));
|
||||
}
|
||||
|
||||
public function test_block_data_links_to_the_poll_page_and_shows_the_state(): void
|
||||
{
|
||||
$open = $this->makePollPost(['closes_at' => now()->addWeek()]);
|
||||
$data = Mailing::pollBlockData($open, 'nl');
|
||||
$this->assertStringContainsString('/nl/peiling/mailing-peiling-' . $open->id, $data['url']);
|
||||
$this->assertFalse($data['poll_closed']);
|
||||
$this->assertNull($data['poll_opens']);
|
||||
$this->assertNotNull($data['poll_closes']);
|
||||
|
||||
$later = $this->makePollPost(['opens_at' => now()->addDay()]);
|
||||
$this->assertNotNull(Mailing::pollBlockData($later, 'en')['poll_opens']);
|
||||
|
||||
$closed = $this->makePollPost(['status' => Poll::STATUS_CLOSED]);
|
||||
$this->assertTrue(Mailing::pollBlockData($closed, 'en')['poll_closed']);
|
||||
|
||||
// Not a poll: nothing added
|
||||
$plain = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
$this->assertSame([], Mailing::pollBlockData($plain->load('translations'), 'en'));
|
||||
}
|
||||
|
||||
public function test_polls_for_part_of_the_members_are_recognised(): void
|
||||
{
|
||||
$everyone = $this->makePollPost()->poll;
|
||||
$this->assertFalse($everyone->hasLimitedAudience());
|
||||
|
||||
// All four profile types ticked is still everyone
|
||||
$everyone->update(['target_profile_types' => Poll::PROFILE_TYPES]);
|
||||
$this->assertFalse($everyone->fresh()->hasLimitedAudience());
|
||||
|
||||
foreach ([
|
||||
['target_profile_types' => [\App\Models\Organization::class]],
|
||||
['filter_by_location' => true],
|
||||
['last_login_from' => '2026-01-01'],
|
||||
['registered_until' => '2025-12-31'],
|
||||
['member_scope' => Poll::MEMBER_SCOPE_MANAGERS_COORDINATORS],
|
||||
] as $targeting) {
|
||||
$this->assertTrue($this->makePollPost($targeting)->poll->hasLimitedAudience(), json_encode($targeting));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_test_mail_renders_the_poll_block_in_dutch(): void
|
||||
{
|
||||
$open = $this->makePollPost(['closes_at' => now()->addWeek()]);
|
||||
$closed = $this->makePollPost(['status' => Poll::STATUS_CLOSED]);
|
||||
$mailing = Mailing::create([
|
||||
'title' => 'Poll mailing', 'type' => 'local_newsletter', 'subject' => ['nl' => 'Onderwerp'],
|
||||
'content_blocks' => [['post_id' => $open->id, 'order' => 1], ['post_id' => $closed->id, 'order' => 2]],
|
||||
]);
|
||||
|
||||
$html = (new TestNewsletterMail($mailing, 'nl'))->render();
|
||||
|
||||
$this->assertStringContainsString('Poll title nl', $html);
|
||||
$this->assertStringContainsString('/nl/peiling/mailing-peiling-' . $open->id, $html);
|
||||
$this->assertStringContainsString('Doe mee', $html);
|
||||
$this->assertStringContainsString('Open tot', $html);
|
||||
$this->assertStringContainsString('Bekijk de peiling', $html);
|
||||
$this->assertStringNotContainsString('/nl/post', $html);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Relations, cascades and the one-vote unique index of the poll tables.
|
||||
*/
|
||||
class PollModelTest 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']);
|
||||
}
|
||||
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
// Inserted directly: Post is Searchable and would sync to Elasticsearch
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => 'App\Models\Admin',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, $profile, User $user): PollResponse
|
||||
{
|
||||
return PollResponse::create([
|
||||
'poll_id' => $poll->id,
|
||||
'voter_id' => $profile->id,
|
||||
'voter_type' => get_class($profile),
|
||||
'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id),
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_relations_and_casts(): void
|
||||
{
|
||||
$poll = $this->makePoll(['target_profile_types' => [User::class]]);
|
||||
$question = PollQuestion::create([
|
||||
'poll_id' => $poll->id,
|
||||
'type' => 'single_choice',
|
||||
'label' => ['en' => 'Which day?', 'nl' => 'Welke dag?'],
|
||||
'settings' => ['options' => ['mon' => 'Monday']],
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
$response = $this->vote($poll, $user, $user);
|
||||
PollAnswer::create([
|
||||
'poll_response_id' => $response->id,
|
||||
'poll_question_id' => $question->id,
|
||||
'value' => ['mon'],
|
||||
]);
|
||||
|
||||
$post = Post::find($poll->post_id);
|
||||
$this->assertTrue($post->poll->is($poll));
|
||||
$this->assertSame([User::class], $poll->fresh()->target_profile_types);
|
||||
$this->assertSame('Welke dag?', $question->labelFor('nl'));
|
||||
$this->assertSame('Which day?', $question->labelFor('de')); // fallback locale
|
||||
$this->assertTrue($response->voter->is($user));
|
||||
$this->assertSame(['mon'], $poll->responses->first()->answers->first()->value);
|
||||
$this->assertTrue($poll->hasResponses());
|
||||
}
|
||||
|
||||
public function test_is_open_respects_status_and_window(): void
|
||||
{
|
||||
$this->assertTrue($this->makePoll()->isOpen());
|
||||
$this->assertFalse($this->makePoll(['status' => Poll::STATUS_DRAFT])->isOpen());
|
||||
$this->assertFalse($this->makePoll(['opens_at' => now()->addDay()])->isOpen());
|
||||
$this->assertFalse($this->makePoll(['closes_at' => now()->subMinute()])->isOpen());
|
||||
}
|
||||
|
||||
public function test_per_profile_poll_allows_one_vote_per_organization(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
$this->vote($poll, $organization, $manager1);
|
||||
|
||||
$this->expectException(QueryException::class);
|
||||
$this->vote($poll, $organization, $manager2);
|
||||
}
|
||||
|
||||
public function test_per_user_poll_allows_one_vote_per_user_behind_organization(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
$this->vote($poll, $organization, $manager1);
|
||||
$this->vote($poll, $organization, $manager2);
|
||||
$this->assertSame(2, $poll->responses()->count());
|
||||
|
||||
$this->expectException(QueryException::class);
|
||||
$this->vote($poll, $organization, $manager1);
|
||||
}
|
||||
|
||||
public function test_deleting_user_or_post_removes_votes(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user);
|
||||
|
||||
DB::table('users')->where('id', $user->id)->delete();
|
||||
$this->assertSame(0, $poll->responses()->count());
|
||||
|
||||
$this->vote($poll, $other = User::factory()->create(), $other);
|
||||
DB::table('posts')->where('id', $poll->post_id)->delete();
|
||||
$this->assertSame(0, PollResponse::where('user_id', $other->id)->count());
|
||||
$this->assertNull(Poll::withTrashed()->find($poll->id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollResultsExportController;
|
||||
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\User;
|
||||
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 Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Every poll action against every poll permission (security audit, round 2).
|
||||
* 'manage polls' implies all verbs; 'access polls' (page entry) comes from view or manage.
|
||||
*/
|
||||
class PollPermissionMatrixTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private const ALL = ['view polls', 'create polls', 'update polls', 'export polls', 'delete polls', 'manage polls'];
|
||||
|
||||
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]);
|
||||
foreach (self::ALL as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
private function actAsAdminWith(array $permissions): Admin
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
if ($permissions) {
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\matrix-role", 'guard_name' => 'web']);
|
||||
$role->syncPermissions($permissions);
|
||||
$user->assignRole($role);
|
||||
}
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private function makePoll(string $status, bool $withVote = false): 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' => 'matrix-' . $postId, 'title' => 'Matrix poll ' . $postId, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => $status, 'anonymous' => false]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => []]);
|
||||
if ($withVote) {
|
||||
$voter = User::factory()->create(['name' => 'matrixvoter' . $poll->id]);
|
||||
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;
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: array, 1: array<string, bool>}> permissions => expected outcome per action */
|
||||
public static function matrix(): array
|
||||
{
|
||||
// page create update export delete archive
|
||||
$row = fn ($p, $c, $u, $e, $d, $a) => ['page' => $p, 'create' => $c, 'update' => $u, 'export' => $e, 'delete' => $d, 'archive' => $a];
|
||||
|
||||
return [
|
||||
'no poll permission' => [[], $row(false, false, false, false, false, false)],
|
||||
'view polls' => [['view polls'], $row(true, false, false, false, false, false)],
|
||||
'view + create' => [['view polls', 'create polls'], $row(true, true, false, false, false, false)],
|
||||
'view + update' => [['view polls', 'update polls'], $row(true, false, true, false, false, true)],
|
||||
'view + export' => [['view polls', 'export polls'], $row(true, false, false, true, false, false)],
|
||||
'view + delete' => [['view polls', 'delete polls'], $row(true, false, false, false, true, false)],
|
||||
'manage polls' => [['manage polls'], $row(true, true, true, true, true, true)],
|
||||
// Posts rights give no access to polls
|
||||
'manage posts only' => [['manage posts'], $row(false, false, false, false, false, false)],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider matrix */
|
||||
public function test_permission_matrix(array $permissions, array $expected): void
|
||||
{
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$this->actAsAdminWith($permissions);
|
||||
|
||||
// Page entry (mount)
|
||||
Livewire::test(Manage::class)->assertStatus($expected['page'] ? 200 : 403);
|
||||
if (!$expected['page']) {
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create
|
||||
$before = Poll::count();
|
||||
$create = Livewire::test(Manage::class)->call('openCreateModal');
|
||||
if ($expected['create']) {
|
||||
$create->set('newPoll.title', 'Matrix created poll')->call('createPoll')->assertHasNoErrors();
|
||||
$this->assertSame($before + 1, Poll::count(), 'create');
|
||||
} else {
|
||||
$create->assertStatus(403);
|
||||
$this->assertSame($before, Poll::count(), 'create');
|
||||
}
|
||||
|
||||
// Update
|
||||
$poll = $this->makePoll(Poll::STATUS_DRAFT);
|
||||
$update = Livewire::test(Manage::class)->call('openEditModal', $poll->id)->set('form.results_visibility', Poll::RESULTS_AFTER_CLOSE)->call('savePoll');
|
||||
$this->assertSame($expected['update'] ? Poll::RESULTS_AFTER_CLOSE : Poll::RESULTS_NEVER, $poll->fresh()->results_visibility, 'update');
|
||||
|
||||
// Who voted (Results tab) and the CSV export
|
||||
$voted = $this->makePoll(Poll::STATUS_CLOSED, true);
|
||||
$results = Livewire::test(Manage::class)->call('openEditModal', $voted->id)->set('tab', 'results');
|
||||
$expected['export'] ? $results->assertSee('matrixvoter' . $voted->id) : $results->assertDontSee('matrixvoter' . $voted->id);
|
||||
$this->assertExportAllowed($expected['export'], $voted);
|
||||
|
||||
// Delete (a draft without votes)
|
||||
$draft = $this->makePoll(Poll::STATUS_DRAFT);
|
||||
$delete = Livewire::test(Manage::class)->call('confirmDelete', $draft->id);
|
||||
if ($expected['delete']) {
|
||||
$delete->call('deletePoll');
|
||||
$this->assertSoftDeleted('polls', ['id' => $draft->id]);
|
||||
} else {
|
||||
$delete->assertStatus(403); // refused at the confirmation already
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $draft->id]);
|
||||
}
|
||||
|
||||
// Archive (a closed poll)
|
||||
$closed = $this->makePoll(Poll::STATUS_CLOSED, true);
|
||||
Livewire::test(Manage::class)->call('archivePoll', $closed->id);
|
||||
$this->assertSame($expected['archive'], $closed->fresh()->archived_at !== null, 'archive');
|
||||
}
|
||||
|
||||
private function assertExportAllowed(bool $allowed, Poll $poll): void
|
||||
{
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->assertTrue($allowed, 'export should have been refused');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertFalse($allowed, 'export should have been allowed');
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_admin_profiles_get_nothing_even_with_poll_permissions(): void
|
||||
{
|
||||
// An organization whose role somehow carries 'manage polls'
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
$role = Role::firstOrCreate(['name' => "Organization\\{$organization->id}\\matrix-role", 'guard_name' => 'web']);
|
||||
$role->syncPermissions(['manage polls']);
|
||||
$user->assignRole($role);
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
|
||||
// A plain user
|
||||
$plain = User::factory()->create();
|
||||
$this->actingAs($plain, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $plain->id]);
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
}
|
||||
|
||||
public function test_routes_require_poll_access(): void
|
||||
{
|
||||
$routes = app('router')->getRoutes();
|
||||
$this->assertContains('user.can:access polls', $routes->getByName('polls.manage')->gatherMiddleware());
|
||||
$this->assertContains('user.can:access polls', $routes->getByName('polls.export')->gatherMiddleware());
|
||||
}
|
||||
|
||||
public function test_database_update_gives_manage_polls_to_every_admin_role(): void
|
||||
{
|
||||
foreach (['view posts', 'manage posts'] as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$definition = \App\Models\AdminRole::firstOrCreate(['name' => 'matrix-type'], ['label' => 'Matrix', 'permissions' => ['view posts']]);
|
||||
$admin = Admin::factory()->create();
|
||||
$typed = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\matrix-type", 'guard_name' => 'web']);
|
||||
$typed->syncPermissions(['view posts']);
|
||||
$legacy = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\admin", 'guard_name' => 'web']);
|
||||
$legacy->syncPermissions(['manage posts']);
|
||||
$organizationRole = Role::firstOrCreate(['name' => 'Organization\\999999\\organization-manager', 'guard_name' => 'web']);
|
||||
|
||||
$command = app(\App\Console\Commands\DatabaseUpdate::class);
|
||||
$command->setOutput(new \Illuminate\Console\OutputStyle(new \Symfony\Component\Console\Input\ArrayInput([]), new \Symfony\Component\Console\Output\NullOutput()));
|
||||
$method = new \ReflectionMethod($command, 'addPollPermissions');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($command);
|
||||
$method->invoke($command); // safe to run again
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$this->assertContains('manage polls', $definition->fresh()->permissions);
|
||||
$this->assertContains('view posts', $definition->fresh()->permissions); // others kept
|
||||
$this->assertSame(1, collect($definition->fresh()->permissions)->filter(fn ($p) => $p === 'manage polls')->count());
|
||||
$this->assertTrue($typed->fresh()->hasPermissionTo('manage polls'));
|
||||
$this->assertTrue($legacy->fresh()->hasPermissionTo('manage polls'));
|
||||
$this->assertTrue($legacy->fresh()->hasPermissionTo('manage posts')); // added, not re-synced
|
||||
$this->assertFalse($organizationRole->fresh()->hasPermissionTo('manage polls')); // admin roles only
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Posts\Manage as PostsManage;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The Posts page cannot delete or (un)publish the post of an opened poll (security audit R1).
|
||||
*/
|
||||
class PollPostsPageTest 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]);
|
||||
}
|
||||
|
||||
private function actAsPostsAdmin(array $extra = []): void
|
||||
{
|
||||
$permissions = array_merge(['view posts', 'delete posts', 'publish posts', 'unpublish posts'], $extra);
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\posts-only", 'guard_name' => 'web']);
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$role->syncPermissions($permissions);
|
||||
$user->assignRole($role);
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
}
|
||||
|
||||
/** @return array{0: ?Poll, 1: int} the poll (if any) and the translation id */
|
||||
private function makePost(?string $pollStatus, bool $withVote = false): array
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId(['postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$translationId = DB::table('post_translations')->insertGetId([
|
||||
'post_id' => $postId, 'locale' => 'en', 'slug' => 'posts-page-' . $postId, 'title' => 'Post ' . $postId,
|
||||
'from' => now()->subDay(), 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = $pollStatus ? Poll::create(['post_id' => $postId, 'status' => $pollStatus]) : null;
|
||||
if ($poll && $withVote) {
|
||||
$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, $translationId];
|
||||
}
|
||||
|
||||
public function test_an_open_poll_post_cannot_be_unpublished_or_deleted(): void
|
||||
{
|
||||
$this->actAsPostsAdmin(['manage polls']);
|
||||
[$poll, $translationId] = $this->makePost(Poll::STATUS_OPEN, true);
|
||||
|
||||
Livewire::test(PostsManage::class)->call('stopPublication', $translationId);
|
||||
$this->assertNull(PostTranslation::find($translationId)->till);
|
||||
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNotNull(PostTranslation::find($translationId));
|
||||
$this->assertNotNull(Post::find($poll->post_id));
|
||||
}
|
||||
|
||||
public function test_a_closed_poll_post_cannot_be_republished(): void
|
||||
{
|
||||
$this->actAsPostsAdmin();
|
||||
[, $translationId] = $this->makePost(Poll::STATUS_CLOSED, true);
|
||||
PostTranslation::whereKey($translationId)->update(['till' => now()->subHour()]);
|
||||
$till = PostTranslation::find($translationId)->till;
|
||||
|
||||
Livewire::test(PostsManage::class)->call('startPublication', $translationId);
|
||||
$this->assertEquals($till, PostTranslation::find($translationId)->till);
|
||||
}
|
||||
|
||||
public function test_a_draft_poll_post_needs_delete_polls_to_be_deleted(): void
|
||||
{
|
||||
$this->actAsPostsAdmin();
|
||||
[$draft, $translationId] = $this->makePost(Poll::STATUS_DRAFT);
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNotNull(PostTranslation::find($translationId));
|
||||
|
||||
$this->actAsPostsAdmin(['delete polls']);
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNull(PostTranslation::find($translationId));
|
||||
$this->assertNull(Post::find($draft->post_id));
|
||||
}
|
||||
|
||||
public function test_other_posts_are_unaffected(): void
|
||||
{
|
||||
$this->actAsPostsAdmin();
|
||||
[, $translationId] = $this->makePost(null);
|
||||
|
||||
Livewire::test(PostsManage::class)->call('stopPublication', $translationId);
|
||||
$this->assertNotNull(PostTranslation::find($translationId)->till);
|
||||
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNull(PostTranslation::find($translationId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Who a poll is shown to: open window, profile type, location, last login,
|
||||
* registration date, member scope, and whether the voter already voted.
|
||||
*/
|
||||
class PollTargetingTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private int $country;
|
||||
private int $otherCountry;
|
||||
private int $division;
|
||||
private int $city;
|
||||
private int $otherCity;
|
||||
|
||||
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();
|
||||
|
||||
// A small location hierarchy: country > division > two cities, plus another country
|
||||
$this->country = DB::table('countries')->insertGetId(['code' => 'XA', 'flag' => '', 'phonecode' => '9901']);
|
||||
$this->otherCountry = DB::table('countries')->insertGetId(['code' => 'XB', 'flag' => '', 'phonecode' => '9902']);
|
||||
$this->division = DB::table('divisions')->insertGetId(['country_id' => $this->country]);
|
||||
$this->city = DB::table('cities')->insertGetId(['country_id' => $this->country, 'division_id' => $this->division]);
|
||||
$this->otherCity = DB::table('cities')->insertGetId(['country_id' => $this->country, 'division_id' => $this->division]);
|
||||
}
|
||||
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => Admin::class,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
}
|
||||
|
||||
private function locate(Model $profile, int $country, ?int $division = null, ?int $city = null): Model
|
||||
{
|
||||
$profile->locations()->save(new Location([
|
||||
'country_id' => $country,
|
||||
'division_id' => $division,
|
||||
'city_id' => $city,
|
||||
]));
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
private function giveRole(User $user, Model $profile, string $role): void
|
||||
{
|
||||
$name = class_basename($profile) . '\\' . $profile->id . '\\' . $role;
|
||||
$user->assignRole(Role::firstOrCreate(['name' => $name, 'guard_name' => 'web']));
|
||||
}
|
||||
|
||||
public function test_untargeted_open_poll_is_shown_to_everyone(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($user, $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Bank::factory()->create(), $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Admin::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_closed_draft_or_out_of_window_poll_is_hidden(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($this->makePoll(['status' => Poll::STATUS_DRAFT])->isTargetedAt($user, $user));
|
||||
$this->assertFalse($this->makePoll(['status' => Poll::STATUS_CLOSED])->isTargetedAt($user, $user));
|
||||
$this->assertFalse($this->makePoll(['opens_at' => now()->addHour()])->isTargetedAt($user, $user));
|
||||
$this->assertFalse($this->makePoll(['closes_at' => now()->subHour()])->isTargetedAt($user, $user));
|
||||
}
|
||||
|
||||
public function test_open_now_scope_matches_is_open(): void
|
||||
{
|
||||
$open = $this->makePoll(['opens_at' => now()->subHour(), 'closes_at' => now()->addHour()]);
|
||||
$this->makePoll(['status' => Poll::STATUS_DRAFT]);
|
||||
$this->makePoll(['closes_at' => now()->subMinute()]);
|
||||
$this->makePoll(['opens_at' => now()->addMinute()]);
|
||||
|
||||
$ids = Poll::openNow()->pluck('id');
|
||||
$this->assertTrue($ids->contains($open->id));
|
||||
$this->assertCount(1, $ids->intersect(Poll::latest('id')->take(4)->pluck('id')));
|
||||
}
|
||||
|
||||
public function test_profile_type_targeting(): void
|
||||
{
|
||||
$poll = $this->makePoll(['target_profile_types' => [Organization::class]]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($poll->isTargetedAt($user, $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
$this->assertFalse($poll->isTargetedAt(Bank::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_location_targeting_uses_deepest_selected_level(): void
|
||||
{
|
||||
$inCity = $this->locate(User::factory()->create(), $this->country, $this->division, $this->city);
|
||||
$inOtherCity = $this->locate(User::factory()->create(), $this->country, $this->division, $this->otherCity);
|
||||
$abroad = $this->locate(User::factory()->create(), $this->otherCountry);
|
||||
$nowhere = User::factory()->create();
|
||||
|
||||
$byCountry = $this->makePoll(['filter_by_location' => true, 'location_country_ids' => [$this->country]]);
|
||||
$this->assertTrue($byCountry->isTargetedAt($inCity, $inCity));
|
||||
$this->assertTrue($byCountry->isTargetedAt($inOtherCity, $inOtherCity));
|
||||
$this->assertFalse($byCountry->isTargetedAt($abroad, $abroad));
|
||||
$this->assertFalse($byCountry->isTargetedAt($nowhere, $nowhere));
|
||||
|
||||
// Country and city selected: the city wins
|
||||
$byCity = $this->makePoll([
|
||||
'filter_by_location' => true,
|
||||
'location_country_ids' => [$this->country],
|
||||
'location_city_ids' => [$this->city],
|
||||
]);
|
||||
$this->assertTrue($byCity->isTargetedAt($inCity, $inCity));
|
||||
$this->assertFalse($byCity->isTargetedAt($inOtherCity, $inOtherCity));
|
||||
|
||||
// Location targeting switched off ignores the stored ids
|
||||
$off = $this->makePoll(['filter_by_location' => false, 'location_city_ids' => [$this->city]]);
|
||||
$this->assertTrue($off->isTargetedAt($abroad, $abroad));
|
||||
}
|
||||
|
||||
public function test_location_targeting_works_for_organizations(): void
|
||||
{
|
||||
$organization = $this->locate(Organization::factory()->create(), $this->country, $this->division, $this->city);
|
||||
$user = User::factory()->create();
|
||||
$poll = $this->makePoll(['filter_by_location' => true, 'location_division_ids' => [$this->division]]);
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($organization, $user));
|
||||
$this->assertFalse($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_last_login_range_is_inclusive_and_excludes_never_logged_in(): void
|
||||
{
|
||||
$poll = $this->makePoll(['last_login_from' => '2026-09-01', 'last_login_until' => '2026-09-30']);
|
||||
|
||||
$first = User::factory()->create(['last_login_at' => '2026-09-01 00:05:00']);
|
||||
$last = User::factory()->create(['last_login_at' => '2026-09-30 23:55:00']);
|
||||
$before = User::factory()->create(['last_login_at' => '2026-08-31 23:59:00']);
|
||||
$never = User::factory()->create(['last_login_at' => null]);
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($first, $first));
|
||||
$this->assertTrue($poll->isTargetedAt($last, $last));
|
||||
$this->assertFalse($poll->isTargetedAt($before, $before));
|
||||
$this->assertFalse($poll->isTargetedAt($never, $never));
|
||||
}
|
||||
|
||||
public function test_registration_range(): void
|
||||
{
|
||||
$poll = $this->makePoll(['registered_until' => '2025-12-31']);
|
||||
|
||||
$old = User::factory()->create();
|
||||
$old->forceFill(['created_at' => '2025-06-01 12:00:00'])->saveQuietly();
|
||||
$new = User::factory()->create();
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($old, $old));
|
||||
$this->assertFalse($poll->isTargetedAt($new, $new));
|
||||
|
||||
$organization = Organization::factory()->create();
|
||||
$this->assertFalse($poll->isTargetedAt($organization, $old));
|
||||
}
|
||||
|
||||
public function test_member_scope_managers_and_coordinators(): void
|
||||
{
|
||||
$poll = $this->makePoll(['member_scope' => Poll::MEMBER_SCOPE_MANAGERS_COORDINATORS]);
|
||||
$organization = Organization::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
[$manager, $coordinator, $plain] = User::factory()->count(3)->create();
|
||||
|
||||
$this->giveRole($manager, $organization, 'organization-manager');
|
||||
$this->giveRole($coordinator, $organization, 'organization-coordinator');
|
||||
$this->giveRole($manager, $bank, 'bank-manager');
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($organization, $manager));
|
||||
$this->assertTrue($poll->isTargetedAt($organization, $coordinator));
|
||||
$this->assertFalse($poll->isTargetedAt($organization, $plain));
|
||||
$this->assertTrue($poll->isTargetedAt($bank, $manager));
|
||||
$this->assertFalse($poll->isTargetedAt($bank, $coordinator)); // role on another profile
|
||||
|
||||
// A role on a different organization does not count
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$this->assertFalse($poll->isTargetedAt($otherOrganization, $manager));
|
||||
|
||||
// Admin profiles have no manager/coordinator roles: all linked users may vote
|
||||
$this->assertTrue($poll->isTargetedAt(Admin::factory()->create(), $plain));
|
||||
|
||||
// A User profile always votes for itself
|
||||
$this->assertTrue($poll->isTargetedAt($plain, $plain));
|
||||
}
|
||||
|
||||
public function test_member_scope_all_users(): void
|
||||
{
|
||||
$poll = $this->makePoll(['member_scope' => Poll::MEMBER_SCOPE_ALL_USERS]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
// Organization / Bank / Admin profiles always need the user behind them
|
||||
$this->assertFalse($poll->isTargetedAt(Organization::factory()->create(), null));
|
||||
// A user cannot vote as another User profile
|
||||
$this->assertFalse($poll->isTargetedAt(User::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_response_lookup_and_vote_change(): void
|
||||
{
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
$perProfile = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
$this->vote($perProfile, $organization, $manager1);
|
||||
// Any manager now sees the organization's vote
|
||||
$this->assertNotNull($perProfile->responseFor($organization, $manager2->id));
|
||||
$this->assertFalse($perProfile->canSubmit($organization, $manager2->id));
|
||||
|
||||
$perUser = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
$this->vote($perUser, $organization, $manager1);
|
||||
$this->assertFalse($perUser->canSubmit($organization, $manager1->id));
|
||||
$this->assertTrue($perUser->canSubmit($organization, $manager2->id));
|
||||
|
||||
$changeable = $this->makePoll(['allow_vote_change' => true]);
|
||||
$this->vote($changeable, $organization, $manager1);
|
||||
$this->assertTrue($changeable->canSubmit($organization, $manager1->id));
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, Model $profile, User $user): PollResponse
|
||||
{
|
||||
return PollResponse::create([
|
||||
'poll_id' => $poll->id,
|
||||
'voter_id' => $profile->id,
|
||||
'voter_type' => get_class($profile),
|
||||
'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id),
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Polls\PollForm;
|
||||
use App\Mail\PollReceiptMail;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Every string the poll code translates exists in all language files.
|
||||
*/
|
||||
class PollTranslationsTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private const LOCALES = ['en', 'nl', 'de', 'es', 'fr'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function pollStrings(): array
|
||||
{
|
||||
$finder = (new Finder())->files()->in([
|
||||
app_path('Polls'),
|
||||
app_path('Http/Livewire/Polls'),
|
||||
resource_path('views/polls'),
|
||||
resource_path('views/livewire/polls'),
|
||||
resource_path('views/emails/polls'),
|
||||
])->append([app_path('Mail/PollReceiptMail.php'), app_path('Http/Controllers/PollVerificationController.php'), resource_path('views/emails/newsletter/blocks/poll.blade.php')]);
|
||||
|
||||
$strings = [];
|
||||
foreach ($finder as $file) {
|
||||
preg_match_all('/(?:__|trans_choice)\(\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', file_get_contents($file->getPathname()), $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $match) {
|
||||
$string = stripslashes($match[2]);
|
||||
if (!str_starts_with($string, 'validation.')) {
|
||||
$strings[$string] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Used outside the poll folders: navigation menu and the posts table
|
||||
$strings['Polls'] = true;
|
||||
$strings['Edit poll'] = true;
|
||||
$strings['Editorial'] = true;
|
||||
$strings['Administration'] = true;
|
||||
$strings['Log in to take part in this poll.'] = true;
|
||||
$strings['Log in'] = true;
|
||||
$strings['Poll'] = true;
|
||||
$strings['Read-only'] = true;
|
||||
$strings['Polls created (as draft)'] = true;
|
||||
$strings['Not overwritten: poll has votes'] = true;
|
||||
// Ballot list labels, picked from an array in polls/verify.blade.php
|
||||
foreach (['Vote', 'Changed vote', 'Withdrawal', 'Seal'] as $label) {
|
||||
$strings[$label] = true;
|
||||
}
|
||||
$strings['This post belongs to a poll that is no longer a draft, so its content cannot be changed. Settings of the poll can be changed on the Polls page.'] = true;
|
||||
$strings['Read-only: this poll is no longer a draft'] = true;
|
||||
|
||||
return array_keys($strings);
|
||||
}
|
||||
|
||||
public function test_all_poll_strings_are_translated(): void
|
||||
{
|
||||
$strings = $this->pollStrings();
|
||||
$this->assertGreaterThan(50, count($strings), 'String extraction found too little');
|
||||
|
||||
foreach (self::LOCALES as $locale) {
|
||||
$translations = json_decode(file_get_contents(lang_path("$locale.json")), true, 512, JSON_THROW_ON_ERROR);
|
||||
$missing = array_values(array_diff($strings, array_keys($translations)));
|
||||
$this->assertSame([], $missing, "Missing in $locale.json");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_and_receipt_mail_render_in_dutch(): void
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN]);
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'type' => 'yes_no_conditional', 'required' => true,
|
||||
'label' => ['en' => 'Monthly?', 'nl' => 'Maandelijks?'], 'settings' => [],
|
||||
]);
|
||||
$user = User::factory()->create(['lang_preference' => 'nl']);
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
app()->setLocale('nl');
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSee('Maandelijks?')
|
||||
->assertSee('Misschien, als...')
|
||||
->assertDontSee('Ja, als...')
|
||||
->assertSee('Stem uitbrengen');
|
||||
|
||||
$html = (new PollReceiptMail($poll, $user, 'ABCDE-FGHJK-MNPQR-STUVW'))->render();
|
||||
$this->assertStringContainsString('Dit is je persoonlijke ontvangstcode', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollVerificationController;
|
||||
use App\Http\Livewire\Polls\ReceiptCheck;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The verification page, the public ledger JSON, sealing, the receipt check,
|
||||
* and the browser verifier (run in Node against ledgers built here).
|
||||
*/
|
||||
class PollVerificationTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private Ledger $ledger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
app()->setLocale('en');
|
||||
$this->ledger = new Ledger();
|
||||
}
|
||||
|
||||
/** An open poll with a slot question, a scale 0-2, a ranking and a free text question. */
|
||||
private function makePoll(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' => 'verify-' . $postId, 'title' => 'Verified poll',
|
||||
'from' => '2026-01-01', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN, 'results_visibility' => Poll::RESULTS_AFTER_VOTE], $attributes));
|
||||
|
||||
foreach ([
|
||||
['type' => 'date_time_slots', 'settings' => ['allow_if_needed' => true, 'slots' => [['key' => 'k9', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null], ['key' => 'a1', 'starts_at' => '2026-11-03 10:00', 'ends_at' => null]]]],
|
||||
['type' => 'scale', 'settings' => ['min' => 0, 'max' => 2]],
|
||||
['type' => 'ranking', 'settings' => ['options' => [['key' => 'x', 'label' => ['en' => 'X']], ['key' => 'y', 'label' => ['en' => 'Y']]], 'top_n' => null]],
|
||||
['type' => 'text', 'settings' => ['multiline' => true, 'max_length' => 200]],
|
||||
] as $position => $question) {
|
||||
PollQuestion::create($question + ['poll_id' => $poll->id, 'position' => $position + 1, 'required' => false, 'label' => ['en' => 'Q' . ($position + 1)]]);
|
||||
}
|
||||
|
||||
return $poll->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a vote the way PollForm does: response, answers and ledger entry together.
|
||||
*
|
||||
* @param array<int, array{value: mixed, text: ?string}> $answersByPosition keyed by question position (0-based)
|
||||
*/
|
||||
private function vote(Poll $poll, array $answersByPosition, ?string $receipt = null): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$questions = $poll->questions()->get()->values();
|
||||
$normalized = [];
|
||||
foreach ($answersByPosition as $position => $answer) {
|
||||
$normalized[$questions[$position]->id] = $answer;
|
||||
}
|
||||
$receipt = $receipt ?? $this->ledger->newReceiptCode();
|
||||
|
||||
$response = DB::transaction(function () use ($poll, $user, $normalized, $receipt) {
|
||||
$entry = $this->ledger->append($poll, PollLedgerEntry::CAST, $this->ledger->ballotPayload($normalized), $this->ledger->fingerprint($receipt));
|
||||
$response = 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), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(),
|
||||
]);
|
||||
foreach ($normalized as $questionId => $answer) {
|
||||
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $questionId] + $answer);
|
||||
}
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
return [$response, $receipt];
|
||||
}
|
||||
|
||||
private function populated(): Poll
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->vote($poll, [0 => ['value' => ['k9' => 'yes', 'a1' => 'if_needed'], 'text' => null], 1 => ['value' => 0, 'text' => null], 2 => ['value' => ['y', 'x'], 'text' => null], 3 => ['value' => null, 'text' => "Café \u{2028} \"quotes\" / ünïcode"]]);
|
||||
$this->vote($poll, [0 => ['value' => ['a1' => 'yes', 'k9' => 'no'], 'text' => null], 1 => ['value' => 2, 'text' => null], 2 => ['value' => ['x', 'y'], 'text' => null]]);
|
||||
$this->vote($poll, [1 => ['value' => 10 - 9, 'text' => null], 3 => ['value' => null, 'text' => 'short']]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/** Log in as one of the poll's voters (results are visible to them after voting). */
|
||||
private function actAsVoter(Poll $poll): User
|
||||
{
|
||||
$user = User::findOrFail(PollResponse::where('poll_id', $poll->id)->value('user_id'));
|
||||
$this->actAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function actAs(\Illuminate\Database\Eloquent\Model $profile, ?User $user = null): void
|
||||
{
|
||||
$this->actingAs($user ?? $profile, 'web');
|
||||
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]);
|
||||
}
|
||||
|
||||
private function ledgerJson(Poll $poll): array
|
||||
{
|
||||
if (!auth('web')->check()) {
|
||||
$this->actAsVoter($poll);
|
||||
}
|
||||
$response = app(PollVerificationController::class)->ledger($poll, $this->ledger);
|
||||
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
/** Run resources/js/poll-ledger-verifier.js in Node on the given ledger JSON. */
|
||||
private function browserVerify(array $data): array
|
||||
{
|
||||
$node = trim((string) shell_exec('command -v node'));
|
||||
if ($node === '') {
|
||||
$this->markTestSkipped('Node is not installed');
|
||||
}
|
||||
|
||||
$script = 'const fs = require("fs");'
|
||||
. 'eval(fs.readFileSync(process.argv[1], "utf8"));'
|
||||
. 'globalThis.PollLedgerVerifier.verify(JSON.parse(fs.readFileSync(0, "utf8")))'
|
||||
. '.then((r) => process.stdout.write(JSON.stringify(r)));';
|
||||
$process = new Process([$node, '-e', $script, resource_path('js/poll-ledger-verifier.js')]);
|
||||
$process->setInput(json_encode($data));
|
||||
$process->mustRun();
|
||||
|
||||
return json_decode($process->getOutput(), true);
|
||||
}
|
||||
|
||||
private function checks(array $result): array
|
||||
{
|
||||
return array_column($result['checks'], 'ok', 'key');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function test_browser_verifier_accepts_an_untouched_ledger(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
|
||||
$result = $this->browserVerify($this->ledgerJson($poll));
|
||||
|
||||
$this->assertTrue($result['valid'], json_encode($result));
|
||||
$this->assertSame(['chain' => true, 'recount' => true], $this->checks($result));
|
||||
}
|
||||
|
||||
public function test_browser_verifier_detects_tampering(): void
|
||||
{
|
||||
$data = $this->ledgerJson($this->populated());
|
||||
|
||||
$tampered = $data;
|
||||
$tampered['entries'][1]['payload']['answers'][1]['value'] = 1;
|
||||
$this->assertFalse($this->checks($this->browserVerify($tampered))['chain']);
|
||||
|
||||
$removed = $data;
|
||||
array_splice($removed['entries'], 1, 1);
|
||||
$this->assertFalse($this->checks($this->browserVerify($removed))['chain']);
|
||||
|
||||
// A published result that differs from the ballots
|
||||
$inflated = $data;
|
||||
$inflated['published_tally'][0]['count']++;
|
||||
$result = $this->checks($this->browserVerify($inflated));
|
||||
$this->assertTrue($result['chain']);
|
||||
$this->assertFalse($result['recount']);
|
||||
}
|
||||
|
||||
public function test_seal_and_withdrawal_after_it(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
|
||||
Artisan::call('polls:seal');
|
||||
$poll->refresh();
|
||||
$this->assertNotNull($poll->sealed_at);
|
||||
$seal = PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::SEAL)->sole();
|
||||
$this->assertSame($seal->hash, $poll->seal_hash);
|
||||
$this->assertSame(3, $seal->payload['active_ballots']);
|
||||
|
||||
// Idempotent
|
||||
Artisan::call('polls:seal');
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::SEAL)->count());
|
||||
|
||||
// A profile deleted after closing: its ballot is withdrawn after the seal, the seal still verifies
|
||||
DB::transaction(fn () => $this->ledger->append($poll, PollLedgerEntry::WITHDRAW, ['reason' => 'profile_deleted'], null, 1));
|
||||
PollResponse::where('poll_id', $poll->id)->where('ledger_sequence', 1)->delete();
|
||||
|
||||
$result = $this->browserVerify($this->ledgerJson($poll));
|
||||
$this->assertTrue($result['valid'], json_encode($result));
|
||||
$this->assertSame(['chain' => true, 'recount' => true, 'seal' => true], $this->checks($result));
|
||||
|
||||
// A different seal hash is caught
|
||||
$data = $this->ledgerJson($poll);
|
||||
$data['seal_hash'] = str_repeat('f', 64);
|
||||
$this->assertFalse($this->checks($this->browserVerify($data))['seal']);
|
||||
}
|
||||
|
||||
public function test_polls_past_closing_time_are_sealed_and_open_ones_not(): void
|
||||
{
|
||||
$ended = $this->makePoll(['closes_at' => now()->subMinute()]);
|
||||
$open = $this->makePoll(['closes_at' => now()->addDay()]);
|
||||
|
||||
Artisan::call('polls:seal');
|
||||
|
||||
$this->assertNotNull($ended->fresh()->sealed_at);
|
||||
$this->assertNull($open->fresh()->sealed_at);
|
||||
}
|
||||
|
||||
public function test_page_shows_state_recount_and_anonymous_ballots(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$this->actAsVoter($poll);
|
||||
|
||||
$html = app(PollVerificationController::class)->show($poll, $this->ledger)->render();
|
||||
|
||||
$this->assertStringContainsString('Verified poll', $html);
|
||||
$this->assertStringContainsString('intact', $html);
|
||||
$this->assertStringContainsString('The recount of the ballots matches the published result.', $html);
|
||||
$this->assertStringContainsString('[text]', $html);
|
||||
$this->assertStringNotContainsString('ünïcode', $html); // free text stays hidden
|
||||
$this->assertStringContainsString('root.PollLedgerVerifier = {', $html); // the verifier code is on the page
|
||||
}
|
||||
|
||||
public function test_receipts_level_hides_the_ballot_list_and_json(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['verification' => Poll::VERIFICATION_RECEIPTS]);
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$html = app(PollVerificationController::class)->show($poll, $this->ledger)->render();
|
||||
$this->assertStringContainsString('Check your vote', $html);
|
||||
$this->assertStringNotContainsString('All ballots', $html);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
app(PollVerificationController::class)->ledger($poll, $this->ledger);
|
||||
}
|
||||
|
||||
public function test_verification_off_or_draft_is_not_found(): void
|
||||
{
|
||||
$off = $this->makePoll(['verification' => Poll::VERIFICATION_OFF]);
|
||||
$this->assertSame('posts.not_found', app(PollVerificationController::class)->show($off, $this->ledger)->name());
|
||||
|
||||
$draft = $this->makePoll(['status' => Poll::STATUS_DRAFT]);
|
||||
$this->assertSame('posts.not_found', app(PollVerificationController::class)->show($draft, $this->ledger)->name());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Who may see what (security audit F1)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function showHtml(Poll $poll): ?string
|
||||
{
|
||||
$view = app(PollVerificationController::class)->show($poll, $this->ledger);
|
||||
|
||||
return $view->name() === 'posts.not_found' ? null : $view->render();
|
||||
}
|
||||
|
||||
private function ledgerAllowed(Poll $poll): bool
|
||||
{
|
||||
try {
|
||||
app(PollVerificationController::class)->ledger($poll, $this->ledger);
|
||||
return true;
|
||||
} catch (NotFoundHttpException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function test_profiles_the_poll_is_not_for_get_not_found(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['target_profile_types' => [\App\Models\Organization::class]]);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user);
|
||||
|
||||
$this->assertNull($this->showHtml($poll));
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
|
||||
// Its organization may
|
||||
$organization = \App\Models\Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
$this->actAs($organization, $user);
|
||||
$this->assertNotNull($this->showHtml($poll));
|
||||
}
|
||||
|
||||
public function test_unpublished_poll_is_not_found(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
DB::table('post_translations')->where('post_id', $poll->post_id)->update(['from' => now()->addWeek()]);
|
||||
$this->actAsVoter($poll);
|
||||
|
||||
$this->assertNull($this->showHtml($poll));
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
}
|
||||
|
||||
public function test_results_never_hides_the_ballots_from_voters(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['results_visibility' => Poll::RESULTS_NEVER]);
|
||||
$this->actAsVoter($poll);
|
||||
|
||||
$html = $this->showHtml($poll);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringContainsString('Check your vote', $html); // the receipt check stays
|
||||
$this->assertStringNotContainsString('All ballots', $html);
|
||||
$this->assertStringNotContainsString('root.PollLedgerVerifier', $html);
|
||||
$this->assertStringContainsString('when the results of this poll are visible to you', $html);
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
}
|
||||
|
||||
public function test_results_after_vote_only_for_those_who_voted(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$nonVoter = User::factory()->create();
|
||||
$this->actAs($nonVoter);
|
||||
$this->assertStringNotContainsString('All ballots', $this->showHtml($poll));
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
|
||||
$this->actAsVoter($poll);
|
||||
$this->assertStringContainsString('All ballots', $this->showHtml($poll));
|
||||
$this->assertTrue($this->ledgerAllowed($poll));
|
||||
}
|
||||
|
||||
public function test_results_after_close_only_once_closed(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['results_visibility' => Poll::RESULTS_AFTER_CLOSE]);
|
||||
$this->actAsVoter($poll);
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
$this->assertTrue($this->ledgerAllowed($poll->fresh()));
|
||||
}
|
||||
|
||||
public function test_receipt_checks_are_rate_limited(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user);
|
||||
|
||||
$component = Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id]);
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$component->set('code', 'AAAAA-BBBBB-CCCCC-DDDD' . chr(65 + $i))->call('check')->assertHasNoErrors();
|
||||
}
|
||||
$component->set('code', 'AAAAA-BBBBB-CCCCC-DDDDZ')->call('check')
|
||||
->assertHasErrors('code')
|
||||
->assertSee('Too many checks');
|
||||
}
|
||||
|
||||
public function test_receipt_check_shows_the_ballot_and_its_own_text(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[, $receipt] = $this->vote($poll, [1 => ['value' => 2, 'text' => null], 3 => ['value' => null, 'text' => 'Bring cake']]);
|
||||
|
||||
Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id])
|
||||
->set('code', strtolower($receipt))
|
||||
->call('check')
|
||||
->assertSet('notFound', false)
|
||||
->assertSee('Ballot #1')
|
||||
->assertSee('Counted in the result.')
|
||||
->assertSee('Bring cake')
|
||||
->assertSee('text matches its fingerprint');
|
||||
|
||||
Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id])
|
||||
->set('code', 'AAAAA-BBBBB-CCCCC-DDDDD')
|
||||
->call('check')
|
||||
->assertSet('notFound', true)
|
||||
->assertSee('No ballot with this receipt code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Actions\Jetstream\DeleteUser;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use App\Polls\VoteWithdrawal;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Votes of permanently deleted profiles: identity removed, ballot withdrawn in the ledger.
|
||||
*/
|
||||
class PollVoteWithdrawalTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private Ledger $ledger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
$this->ledger = new Ledger();
|
||||
}
|
||||
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, '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, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => [],
|
||||
]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/** Vote the way PollForm does. Returns the receipt code. */
|
||||
private function vote(Poll $poll, Model $profile, User $user, string $value): string
|
||||
{
|
||||
$question = $poll->questions()->first();
|
||||
$receipt = $this->ledger->newReceiptCode();
|
||||
|
||||
DB::transaction(function () use ($poll, $profile, $user, $value, $question, $receipt) {
|
||||
$entry = $this->ledger->append($poll, PollLedgerEntry::CAST,
|
||||
$this->ledger->ballotPayload([$question->id => ['value' => $value, 'text' => null]]),
|
||||
$this->ledger->fingerprint($receipt));
|
||||
$response = PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $profile->getKey(), 'voter_type' => get_class($profile), 'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(),
|
||||
]);
|
||||
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $question->id, 'value' => $value]);
|
||||
});
|
||||
|
||||
return $receipt;
|
||||
}
|
||||
|
||||
public function test_a_users_votes_are_withdrawn_and_the_ledger_stays_consistent(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$leaving = User::factory()->create();
|
||||
$staying = User::factory()->create();
|
||||
$receipt = $this->vote($poll, $leaving, $leaving, 'yes');
|
||||
$this->vote($poll, $staying, $staying, 'no');
|
||||
|
||||
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($leaving));
|
||||
|
||||
$this->assertSame(0, PollResponse::where('voter_id', $leaving->id)->where('voter_type', User::class)->count());
|
||||
$this->assertSame(1, $poll->responses()->count());
|
||||
$withdrawal = PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::WITHDRAW)->sole();
|
||||
$this->assertSame(1, $withdrawal->refers_to_sequence);
|
||||
$this->assertSame('withdrawn', $this->ledger->lookupReceipt($poll, $receipt)['status']);
|
||||
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
|
||||
// The recount still matches the published result
|
||||
$this->assertSame($this->ledger->publishedTally($poll), $this->ledger->ledgerTally($poll));
|
||||
$this->assertSame(1, $poll->questionSummaries()[$poll->questions()->first()->id]['total']);
|
||||
}
|
||||
|
||||
public function test_an_organizations_votes_are_withdrawn(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$organization = Organization::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
$this->vote($poll, $organization, $manager, 'yes');
|
||||
|
||||
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($organization));
|
||||
$this->assertSame(0, $poll->responses()->count());
|
||||
}
|
||||
|
||||
public function test_a_users_own_vote_for_an_organization_goes_only_in_per_user_polls(): void
|
||||
{
|
||||
$perUser = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
$perProfile = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
$organization = Organization::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
$this->vote($perUser, $organization, $manager, 'yes');
|
||||
$this->vote($perProfile, $organization, $manager, 'yes');
|
||||
|
||||
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($manager));
|
||||
|
||||
$this->assertSame(0, $perUser->responses()->count());
|
||||
// The organization's vote in a per-profile poll stays
|
||||
$this->assertSame(1, $perProfile->responses()->count());
|
||||
}
|
||||
|
||||
public function test_withdrawal_after_the_seal_keeps_the_chain_valid(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user, 'yes');
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
$this->ledger->seal($poll);
|
||||
|
||||
app(VoteWithdrawal::class)->withdrawFor($user);
|
||||
|
||||
$this->assertTrue($this->ledger->verifyChain($poll->fresh())['valid']);
|
||||
$types = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->pluck('entry_type')->all();
|
||||
$this->assertSame(['cast', 'seal', 'withdraw'], $types);
|
||||
}
|
||||
|
||||
public function test_permanent_deletion_withdraws_votes(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user, 'yes');
|
||||
|
||||
$withdrawal = \Mockery::mock(VoteWithdrawal::class);
|
||||
$withdrawal->shouldReceive('withdrawFor')->once()->with(\Mockery::on(fn ($profile) => $profile->is($user)))->andReturn(1);
|
||||
$this->app->instance(VoteWithdrawal::class, $withdrawal);
|
||||
|
||||
(new DeleteUser())->permanentlyDelete($user);
|
||||
}
|
||||
|
||||
public function test_a_failed_deletion_keeps_the_votes(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user, 'yes');
|
||||
|
||||
// The test database has no debit account, so the balance step fails and the transaction rolls back
|
||||
$result = (new DeleteUser())->permanentlyDelete($user);
|
||||
|
||||
$this->assertSame('error', $result['status']);
|
||||
$this->assertSame(1, $poll->responses()->count());
|
||||
$this->assertSame(0, PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::WITHDRAW)->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Polls\QuestionTypeRegistry;
|
||||
use App\Polls\Types\AbstractQuestionType;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvalidArgumentException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FakeQuestionType extends AbstractQuestionType
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'fake';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Fake type';
|
||||
}
|
||||
|
||||
public function answerRules(string $prefix, array $settings, bool $required): array
|
||||
{
|
||||
return [$prefix . '.value' => $required ? 'required' : 'nullable'];
|
||||
}
|
||||
|
||||
public function aggregate(Collection $answers, array $settings): array
|
||||
{
|
||||
return ['count' => $answers->count()];
|
||||
}
|
||||
}
|
||||
|
||||
class NotAQuestionType
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry reads the enabled question types from the platform config.
|
||||
*/
|
||||
class QuestionTypeRegistryTest extends TestCase
|
||||
{
|
||||
private function registryWith(array $types): QuestionTypeRegistry
|
||||
{
|
||||
config([env('TIMEBANK_CONFIG', 'timebank-default') . '.polls.question_types' => $types]);
|
||||
|
||||
return new QuestionTypeRegistry();
|
||||
}
|
||||
|
||||
public function test_platform_config_without_the_polls_section_gets_all_built_in_types(): void
|
||||
{
|
||||
// A platform config that has not merged the polls section from its .example yet
|
||||
$platform = env('TIMEBANK_CONFIG', 'timebank-default');
|
||||
$settings = config($platform);
|
||||
unset($settings['polls']);
|
||||
config([$platform => $settings]);
|
||||
|
||||
$registry = new QuestionTypeRegistry();
|
||||
|
||||
$this->assertSame(array_keys(QuestionTypeRegistry::DEFAULT_TYPES), array_keys($registry->all()));
|
||||
}
|
||||
|
||||
public function test_an_empty_list_disables_all_types(): void
|
||||
{
|
||||
$this->assertSame([], $this->registryWith([])->all());
|
||||
}
|
||||
|
||||
public function test_registry_is_a_singleton(): void
|
||||
{
|
||||
$this->assertSame(app(QuestionTypeRegistry::class), app(QuestionTypeRegistry::class));
|
||||
}
|
||||
|
||||
public function test_resolves_configured_types(): void
|
||||
{
|
||||
$registry = $this->registryWith(['fake' => FakeQuestionType::class]);
|
||||
|
||||
$this->assertTrue($registry->has('fake'));
|
||||
$this->assertInstanceOf(FakeQuestionType::class, $registry->get('fake'));
|
||||
$this->assertSame(['fake' => 'Fake type'], $registry->options());
|
||||
$this->assertSame('polls.types.fake.input', $registry->get('fake')->inputView());
|
||||
}
|
||||
|
||||
public function test_disabled_type_is_unknown(): void
|
||||
{
|
||||
$registry = $this->registryWith(['fake' => FakeQuestionType::class]);
|
||||
|
||||
$this->assertFalse($registry->has('ranking'));
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$registry->get('ranking');
|
||||
}
|
||||
|
||||
public function test_rejects_class_that_is_not_a_question_type(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->registryWith(['fake' => NotAQuestionType::class])->all();
|
||||
}
|
||||
|
||||
public function test_rejects_key_mismatch(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->registryWith(['other' => FakeQuestionType::class])->all();
|
||||
}
|
||||
|
||||
public function test_base_normalize_trims_text_and_drops_empty(): void
|
||||
{
|
||||
$type = new FakeQuestionType();
|
||||
|
||||
$this->assertSame(['value' => 'yes', 'text' => 'if it rains'], $type->normalize(['value' => 'yes', 'text' => ' if it rains '], []));
|
||||
$this->assertSame(['value' => 'no', 'text' => null], $type->normalize(['value' => 'no', 'text' => ' '], []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\PollAnswer;
|
||||
use App\Polls\Contracts\QuestionType;
|
||||
use App\Polls\QuestionTypeRegistry;
|
||||
use App\Polls\Types\DateTimeSlotsType;
|
||||
use App\Polls\Types\MultiChoiceType;
|
||||
use App\Polls\Types\RankingType;
|
||||
use App\Polls\Types\ScaleType;
|
||||
use App\Polls\Types\SingleChoiceType;
|
||||
use App\Polls\Types\TextType;
|
||||
use App\Polls\Types\YesNoConditionalType;
|
||||
use App\Polls\Types\YesNoType;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Validation, normalization and aggregation of the starter question types.
|
||||
*/
|
||||
class QuestionTypesTest extends TestCase
|
||||
{
|
||||
private const PREFIX = 'answers.12';
|
||||
|
||||
private function opts(array $keys): array
|
||||
{
|
||||
return array_map(fn ($key) => ['key' => $key, 'label' => ['en' => strtoupper($key)]], $keys);
|
||||
}
|
||||
|
||||
/** Errors for one answer, keyed by attribute; empty when valid. */
|
||||
private function answerErrors(QuestionType $type, array $settings, $answer, bool $required = true): array
|
||||
{
|
||||
$data = ['answers' => [12 => $answer]];
|
||||
|
||||
return Validator::make($data, $type->answerRules(self::PREFIX, $settings, $required))->errors()->toArray();
|
||||
}
|
||||
|
||||
private function settingsErrors(QuestionType $type, array $settings): array
|
||||
{
|
||||
$prefix = 'questions.0.settings';
|
||||
|
||||
return Validator::make(['questions' => [['settings' => $settings]]], $type->settingsRules($prefix, $settings))->errors()->toArray();
|
||||
}
|
||||
|
||||
/** Unsaved answer models, as aggregate() receives them. */
|
||||
private function answers(QuestionType $type, array $settings, array $raw): Collection
|
||||
{
|
||||
return collect($raw)->map(fn ($answer) => new PollAnswer($type->normalize($answer, $settings)));
|
||||
}
|
||||
|
||||
public function test_all_starter_types_are_registered_with_partials(): void
|
||||
{
|
||||
$types = app(QuestionTypeRegistry::class)->all();
|
||||
|
||||
$this->assertSame(
|
||||
['yes_no', 'yes_no_conditional', 'single_choice', 'multi_choice', 'date_time_slots', 'scale', 'text', 'ranking'],
|
||||
array_keys($types)
|
||||
);
|
||||
foreach ($types as $type) {
|
||||
$this->assertEmpty($this->settingsErrors($type, $this->validDefaults($type)), $type->key() . ' defaults');
|
||||
foreach ([$type->editorView(), $type->inputView(), $type->resultsView()] as $view) {
|
||||
$this->assertTrue(view()->exists($view), "Missing view $view");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_all_partials_render_with_sample_data(): void
|
||||
{
|
||||
$registry = app(QuestionTypeRegistry::class);
|
||||
$raw = [
|
||||
'yes_no' => [['value' => 'yes'], ['value' => 'no']],
|
||||
'yes_no_conditional' => [['value' => 'yes'], ['value' => 'maybe_if', 'text' => 'Only <b>after</b> 18:00']],
|
||||
'single_choice' => [['value' => 'k0']],
|
||||
'multi_choice' => [['value' => ['k0', 'k1']]],
|
||||
'date_time_slots' => [['value' => ['s0' => 'yes']]],
|
||||
'scale' => [['value' => 4]],
|
||||
'text' => [['text' => 'Open <script>answer</script>']],
|
||||
'ranking' => [['value' => ['k1', 'k0']]],
|
||||
];
|
||||
|
||||
foreach ($registry->all() as $key => $type) {
|
||||
$settings = $this->validDefaults($type);
|
||||
foreach ($settings['options'] ?? [] as $i => $option) {
|
||||
$settings['options'][$i]['key'] = "k$i";
|
||||
}
|
||||
foreach ($settings['slots'] ?? [] as $i => $slot) {
|
||||
$settings['slots'][$i]['key'] = "s$i";
|
||||
}
|
||||
$question = new \App\Models\PollQuestion(['type' => $key, 'label' => ['en' => 'Q'], 'settings' => $settings]);
|
||||
$question->id = 12;
|
||||
$errors = (new ViewErrorBag())->put('default', new MessageBag([self::PREFIX . '.value' => ['Value error shown']]));
|
||||
$common = ['type' => $type, 'settings' => $settings, 'locale' => 'en', 'errors' => $errors];
|
||||
|
||||
$editor = view($type->editorView(), $common + ['prefix' => 'questions.0.settings', 'index' => 0])->render();
|
||||
if ($type->defaultSettings() !== []) {
|
||||
$this->assertStringContainsString('questions.0.settings', $editor, "$key editor");
|
||||
}
|
||||
|
||||
$input = view($type->inputView(), $common + ['question' => $question, 'prefix' => self::PREFIX])->render();
|
||||
$this->assertStringContainsString(self::PREFIX, $input, "$key input");
|
||||
if ($key !== 'text') {
|
||||
$this->assertStringContainsString('Value error shown', $input, "$key input error");
|
||||
}
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, $raw[$key]), $settings);
|
||||
$results = view($type->resultsView(), $common + ['question' => $question, 'summary' => $summary])->render();
|
||||
$this->assertMatchesRegularExpression('/\d+ responses?/', $results, "$key results");
|
||||
$this->assertStringNotContainsString('<script>answer', $results, "$key results must escape answers");
|
||||
}
|
||||
}
|
||||
|
||||
/** Defaults with the blanks an admin has to fill in. */
|
||||
private function validDefaults(QuestionType $type): array
|
||||
{
|
||||
$settings = $type->defaultSettings();
|
||||
foreach ($settings['options'] ?? [] as $i => $option) {
|
||||
$settings['options'][$i]['label'] = ['en' => "Option $i"];
|
||||
}
|
||||
foreach ($settings['slots'] ?? [] as $i => $slot) {
|
||||
$settings['slots'][$i]['starts_at'] = '2026-11-01T10:00';
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function test_yes_no_conditional(): void
|
||||
{
|
||||
$type = new YesNoConditionalType();
|
||||
$settings = ['conditional_label' => ['en' => 'Maybe, if it is on a weekday']];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => 'yes']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['value' => 'maybe_if', 'text' => '']));
|
||||
// The old "Yes, if..." and a plain "Maybe" are no longer answers
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 'maybe']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 'yes_if']));
|
||||
$this->assertSame(['yes', 'maybe_if', 'no'], array_keys($type->choices($settings)));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => null]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => null], false));
|
||||
$this->assertSame('Maybe, if it is on a weekday', $type->choices($settings, 'en')['maybe_if']);
|
||||
$this->assertSame('Maybe, if...', $type->choices([], 'en')['maybe_if']);
|
||||
|
||||
// The condition text is dropped for any answer but "Maybe, if..."
|
||||
$this->assertSame(['value' => 'no', 'text' => null], $type->normalize(['value' => 'no', 'text' => 'stale'], $settings));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => 'yes'], ['value' => 'maybe_if', 'text' => 'after 18:00'], ['value' => 'no'], ['value' => null],
|
||||
]), $settings);
|
||||
$this->assertSame(3, $summary['total']);
|
||||
$this->assertSame(['yes' => 1, 'maybe_if' => 1, 'no' => 1], $summary['counts']);
|
||||
$this->assertSame(['after 18:00'], $summary['conditions']);
|
||||
}
|
||||
|
||||
public function test_yes_no_without_maybe_if(): void
|
||||
{
|
||||
$type = new YesNoType();
|
||||
|
||||
$this->assertSame(['yes', 'no'], array_keys($type->choices([])));
|
||||
$this->assertEmpty($this->answerErrors($type, [], ['value' => 'no']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, [], ['value' => 'maybe_if', 'text' => 'if it rains']));
|
||||
$this->assertSame(['value' => 'yes', 'text' => null], $type->normalize(['value' => 'yes', 'text' => 'stray'], []));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, [], [['value' => 'yes'], ['value' => 'yes'], ['value' => 'no']]), []);
|
||||
$this->assertSame(['yes' => 2, 'no' => 1], $summary['counts']);
|
||||
$this->assertSame([], $summary['conditions']);
|
||||
}
|
||||
|
||||
public function test_single_choice_with_other(): void
|
||||
{
|
||||
$type = new SingleChoiceType();
|
||||
$settings = ['options' => $this->opts(['a', 'b']), 'allow_other' => true];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => 'a']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 'z']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['value' => '__other']));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => '__other', 'text' => 'Sunday']));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => 'a'], ['value' => 'a'], ['value' => '__other', 'text' => 'Sunday'],
|
||||
]), $settings);
|
||||
$this->assertSame(['a' => 2, 'b' => 0, '__other' => 1], $summary['counts']);
|
||||
$this->assertSame(['Sunday'], $summary['other_texts']);
|
||||
}
|
||||
|
||||
public function test_options_need_two_distinct_labelled_items(): void
|
||||
{
|
||||
$type = new SingleChoiceType();
|
||||
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => $this->opts(['a'])]));
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => $this->opts(['a', 'a'])]));
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => [
|
||||
['key' => 'a', 'label' => ['en' => 'A']], ['key' => 'b', 'label' => ['en' => ' ']],
|
||||
]]));
|
||||
// A label in any one language is enough
|
||||
$this->assertEmpty($this->settingsErrors($type, ['options' => [
|
||||
['key' => 'a', 'label' => ['nl' => 'A']], ['key' => 'b', 'label' => ['de' => 'B']],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function test_multi_choice_limits_and_other(): void
|
||||
{
|
||||
$type = new MultiChoiceType();
|
||||
$settings = ['options' => $this->opts(['a', 'b', 'c']), 'allow_other' => true, 'min_selections' => 2, 'max_selections' => 2];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['a', 'b']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['a']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['a', 'b', 'c']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => []]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => []], false));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['value' => ['a', '__other']]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['a', '__other'], 'text' => 'D']));
|
||||
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => $this->opts(['a', 'b']), 'min_selections' => 2, 'max_selections' => 1]));
|
||||
// A maximum without a minimum is fine
|
||||
$this->assertEmpty($this->settingsErrors($type, ['options' => $this->opts(['a', 'b']), 'min_selections' => null, 'max_selections' => 1]));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => ['a', 'b']], ['value' => ['a', '__other'], 'text' => 'D'], ['value' => []],
|
||||
]), $settings);
|
||||
$this->assertSame(2, $summary['total']);
|
||||
$this->assertSame(['a' => 2, 'b' => 1, 'c' => 0, '__other' => 1], $summary['counts']);
|
||||
$this->assertSame(['D'], $summary['other_texts']);
|
||||
}
|
||||
|
||||
public function test_date_time_slots(): void
|
||||
{
|
||||
$type = new DateTimeSlotsType();
|
||||
$settings = ['allow_if_needed' => true, 'slots' => [
|
||||
['key' => 's1', 'starts_at' => '2026-11-02T10:00', 'ends_at' => '2026-11-02T12:00'],
|
||||
['key' => 's2', 'starts_at' => '2026-11-03T14:00', 'ends_at' => null],
|
||||
]];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['s1' => 'yes', 's2' => 'if_needed']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.s2', $this->answerErrors($type, $settings, ['value' => ['s1' => 'yes']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.s1', $this->answerErrors($type, $settings, ['value' => ['s1' => 'perhaps', 's2' => 'no']]));
|
||||
$this->assertSame('Monday 2 November 2026, 10:00 – 12:00', $type->slotLabels($settings, 'en')['s1']);
|
||||
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['slots' => [
|
||||
['key' => 's1', 'starts_at' => '2026-11-02T10:00', 'ends_at' => '2026-11-02T09:00'],
|
||||
]]));
|
||||
|
||||
// Unknown slot keys are dropped
|
||||
$this->assertSame(['s1' => 'yes'], $type->normalize(['value' => ['s1' => 'yes', 'x' => 'yes']], $settings)['value']);
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => ['s1' => 'yes', 's2' => 'yes']],
|
||||
['value' => ['s1' => 'if_needed', 's2' => 'yes']],
|
||||
['value' => ['s1' => 'yes', 's2' => 'no']],
|
||||
]), $settings);
|
||||
$this->assertSame(['yes' => 2, 'if_needed' => 1, 'no' => 0], $summary['slots']['s1']);
|
||||
$this->assertSame(['s1'], $summary['best']);
|
||||
}
|
||||
|
||||
public function test_scale(): void
|
||||
{
|
||||
$type = new ScaleType();
|
||||
$settings = ['min' => 1, 'max' => 5];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => 5]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 6]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 0]));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [['value' => '4'], ['value' => 5], ['value' => '']]), $settings);
|
||||
$this->assertSame(2, $summary['total']);
|
||||
$this->assertSame(4.5, $summary['average']);
|
||||
$this->assertSame([1 => 0, 2 => 0, 3 => 0, 4 => 1, 5 => 1], $summary['distribution']);
|
||||
}
|
||||
|
||||
public function test_text(): void
|
||||
{
|
||||
$type = new TextType();
|
||||
$settings = ['multiline' => true, 'max_length' => 20];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['text' => 'Short answer']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['text' => str_repeat('x', 21)]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['text' => '']));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [['text' => ' One '], ['text' => ' ']]), $settings);
|
||||
$this->assertSame(['total' => 1, 'texts' => ['One']], $summary);
|
||||
}
|
||||
|
||||
public function test_ranking_borda_count(): void
|
||||
{
|
||||
$type = new RankingType();
|
||||
$settings = ['options' => $this->opts(['a', 'b', 'c']), 'top_n' => null];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['b', 'a', 'c']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['b', 'a']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.1', $this->answerErrors($type, $settings, ['value' => ['b', 'b', 'c']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.2', $this->answerErrors($type, $settings, ['value' => ['b', 'a', 'z']]));
|
||||
|
||||
// 3 options: 1st = 3 points, 2nd = 2, 3rd = 1
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => ['b', 'a', 'c']], ['value' => ['a', 'b', 'c']], ['value' => ['b', 'c', 'a']],
|
||||
]), $settings);
|
||||
$this->assertSame(['b' => 8, 'a' => 6, 'c' => 4], $summary['points']);
|
||||
$this->assertSame(['a' => 1, 'b' => 2, 'c' => 0], $summary['first_place']);
|
||||
}
|
||||
|
||||
public function test_ranking_top_n(): void
|
||||
{
|
||||
$type = new RankingType();
|
||||
$settings = ['options' => $this->opts(['a', 'b', 'c', 'd']), 'top_n' => 2];
|
||||
|
||||
$this->assertSame(2, $type->rankCount($settings));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['d', 'a']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['d', 'a', 'b']]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => []], false));
|
||||
|
||||
// Unranked options score 0; 1st of 4 = 4 points, 2nd = 3
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [['value' => ['d', 'a']]]), $settings);
|
||||
$this->assertSame(['d' => 4, 'a' => 3, 'b' => 0, 'c' => 0], $summary['points']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user