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,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'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user