mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 01:20:14 +02:00
feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button Wires up the previously disabled "Download data" button on the settings page. Downloads a zip with a profile.xlsx (account + owned seasons), and per owned season a folder with each quiz's xlsx (questions, results, eliminations tabs) and a candidates.xlsx (candidates + season info tabs). Soft-deleted rows are included and flagged so the export reflects everything the app still holds about the user. * feat: include question bank in GDPR data export Adds a question-bank.xlsx per season folder with Questions (bank questions, answers, reusable/complete flags, labels, and which quizzes they've been used in) and Labels tabs, since BankQuestion/BankAnswer content was previously missing from the export. * fix: hard-delete quiz/audit-log data when deleting an account QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their removal through Season -> Quiz/Candidate only set deletedAt instead of physically deleting the row. Since Candidate and Answer are hard-deleted via orphanRemoval, this broke their foreign keys and rolled back the entire account deletion whenever a candidate had actually participated in a quiz. Bulk DQL deletes now purge these rows before the cascade runs. Also purge BankQuestion audit-log rows (ext_log_entries), which store the editor's username/email but aren't foreign-keyed to the entity they log, so they were never cleaned up and would otherwise keep a deleted user's email around indefinitely. * style: make the download data button primary (blue) * feat: add raw answers crosstab to quiz export Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one column per question, with the given answer text in each cell — the raw data behind the aggregated Results tab. * i18n: translate new settings page string to Dutch * refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by symfony/string's AsciiSlugger) and uses it everywhere user-controlled text (season/quiz names, account email) ends up in a zip entry path or a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9 survive; everything else, including unicode and path-traversal sequences, is folded or stripped) rather than a denylist of "unsafe" characters, and it's an officially maintained Symfony component already present as a transitive dependency. Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint that built its Content-Disposition filename directly from an unsanitized quiz name — the same class of risk the data export already guarded against. Naming note: sanitized names are now slugs (spaces become dashes, unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend". * fix: check ZipArchive open/close results and clean up temp files on failure Addresses CodeRabbit findings on the GDPR export: - ZipArchive::open() and close() can both return false without throwing; neither was checked, so a failure silently produced an empty or corrupt zip, and the temp zip path was never cleaned up on a mid-build exception. - writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save() threw before the caller could track it for cleanup. * fix: drop public link identifier from the candidates export sheet The nameHash is a public quiz-access token, not something a data export should hand out — remove it from candidates.xlsx. * feat: add Quiz info tab covering dropouts, finalization, and disabled questions An entity-by-entity audit of the export vs. delete flows found the delete flow fully covered, but three Quiz/Question fields missing from the export: dropouts, finalizedAt, and Question.enabled. Adds a new "Quiz info" tab (first sheet) to each quiz's xlsx with this data, without touching the shared fillQuestionsSheet() used by the existing single-quiz template export/import feature. Deliberately left out per user decision: the BankQuestion audit log (would leak other owners' emails, consistent with hiding co-owner identities elsewhere in this export) and a few low-value timestamp fields already covered by existing Started/time-taken columns. * feat: require a confirmed email before exporting data Antispam measure: both the full data export (SettingsController::downloadData) and the single-quiz export (BackofficeController::exportQuiz) now redirect with a flash warning instead of exporting when the account's email isn't verified yet. Adds a matching hint on the settings page next to the download button.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\BackofficeController;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(BackofficeController::class)]
|
||||
final class BackofficeControllerTest extends WebTestCase
|
||||
{
|
||||
public function testExportQuizFilenameIsSanitized(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
$entityManager->flush();
|
||||
$client->loginUser($user);
|
||||
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition');
|
||||
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
|
||||
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
|
||||
}
|
||||
|
||||
public function testExportQuizRequiresVerifiedEmail(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertFalse($user->isVerified);
|
||||
$client->loginUser($user);
|
||||
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
|
||||
}
|
||||
}
|
||||
@@ -350,4 +350,47 @@ final class SettingsControllerTest extends WebTestCase
|
||||
$this->assertNotEmpty($ownerEmails);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDownloadDataRequiresAuthentication(): void
|
||||
{
|
||||
$this->client->restart();
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
|
||||
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testDownloadDataReturnsAZipWithATimestampedAccountFilename(): void
|
||||
{
|
||||
$this->markUserVerified('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseHeaderSame('Content-Type', 'application/zip');
|
||||
|
||||
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/filename=tijd-voor-de-test-data-test-example-org-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/',
|
||||
$disposition,
|
||||
);
|
||||
}
|
||||
|
||||
public function testDownloadDataRequiresVerifiedEmail(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertFalse($user->isVerified);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
|
||||
|
||||
self::assertResponseRedirects('/backoffice/settings');
|
||||
}
|
||||
|
||||
private function markUserVerified(string $email): void
|
||||
{
|
||||
$user = $this->getUserByEmail($email);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Helpers;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tvdt\Helpers\FilenameSanitizer;
|
||||
|
||||
final class FilenameSanitizerTest extends TestCase
|
||||
{
|
||||
public function testReplacesSpacesWithDashes(): void
|
||||
{
|
||||
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend'));
|
||||
}
|
||||
|
||||
public function testStripsPathSeparatorsAndTraversal(): void
|
||||
{
|
||||
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd'));
|
||||
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
|
||||
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
|
||||
}
|
||||
|
||||
public function testStripsControlCharactersAndSpecialSymbols(): void
|
||||
{
|
||||
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
|
||||
}
|
||||
|
||||
public function testTransliteratesUnicodeToAscii(): void
|
||||
{
|
||||
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
|
||||
}
|
||||
|
||||
public function testTransliteratesAtSignInEmail(): void
|
||||
{
|
||||
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
|
||||
}
|
||||
|
||||
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
|
||||
{
|
||||
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
|
||||
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ namespace Tvdt\Tests\Repository;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Tvdt\DataFixtures\TestFixtures;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Repository\UserRepository;
|
||||
|
||||
use function PHPUnit\Framework\assertEmpty;
|
||||
@@ -42,4 +48,89 @@ final class UserRepositoryTest extends DatabaseTestCase
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->userRepository->makeAdmin('invalid@example.org');
|
||||
}
|
||||
|
||||
/**
|
||||
* GDPR right-to-erasure: deleting the sole owner of a season must physically remove every
|
||||
* row tied to it, not merely soft-delete it. QuizCandidate, GivenAnswer, and Elimination are
|
||||
* all Gedmo\SoftDeleteable, so a naive $em->remove($season) cascade leaves them (or the
|
||||
* transaction itself) behind. Assertions bypass the softdeleteable filter and read raw SQL,
|
||||
* since a soft-deleted row would otherwise still be invisible to a filtered ORM query.
|
||||
*/
|
||||
public function testDeleteUserHardDeletesQuizCandidateGivenAnswerAndElimination(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('sole-owner@example.org');
|
||||
$season = $this->getSeasonByCode('doomd');
|
||||
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz', 'season' => $season]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$candidate = $this->getCandidateBySeasonAndName($season, 'Vera');
|
||||
|
||||
/** @var Question $question */
|
||||
$question = $quiz->questions->first();
|
||||
$rightAnswer = $question->answers->first();
|
||||
$this->assertNotFalse($rightAnswer);
|
||||
|
||||
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
|
||||
$this->assertInstanceOf(QuizCandidate::class, $quizCandidate);
|
||||
|
||||
$givenAnswer = new GivenAnswer($candidate, $quiz, $rightAnswer);
|
||||
$this->entityManager->persist($givenAnswer);
|
||||
|
||||
$elimination = new Elimination($quiz);
|
||||
$elimination->data = ['Vera' => Elimination::SCREEN_GREEN];
|
||||
|
||||
$this->entityManager->persist($elimination);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$quizCandidateId = $quizCandidate->id->toString();
|
||||
$givenAnswerId = $givenAnswer->id->toString();
|
||||
$eliminationId = $elimination->id->toString();
|
||||
|
||||
$this->userRepository->deleteUser($user);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$connection = $this->entityManager->getConnection();
|
||||
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from quiz_candidate where id = ?', [$quizCandidateId]));
|
||||
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from given_answer where id = ?', [$givenAnswerId]));
|
||||
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from elimination where id = ?', [$eliminationId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gedmo\Loggable writes an audit row (including the editor's username/email) to
|
||||
* ext_log_entries for every change to a Versioned field. Those rows aren't linked via a
|
||||
* foreign key (object_id is a plain string), so deleting the season/BankQuestion never
|
||||
* cleans them up on its own — the deleted account's email would otherwise live on forever.
|
||||
*/
|
||||
public function testDeleteUserPurgesBankQuestionAuditLogEntries(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('sole-owner@example.org');
|
||||
$season = $this->getSeasonByCode('doomd');
|
||||
|
||||
$bankQuestion = new BankQuestion();
|
||||
$bankQuestion->question = 'Wie is de Krtek eigenlijk?';
|
||||
$bankQuestion->season = $season;
|
||||
|
||||
$this->entityManager->persist($bankQuestion);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$bankQuestionId = $bankQuestion->id->toString();
|
||||
$connection = $this->entityManager->getConnection();
|
||||
|
||||
$logCountBefore = (int) $connection->fetchOne(
|
||||
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
|
||||
[BankQuestion::class, $bankQuestionId],
|
||||
);
|
||||
$this->assertGreaterThan(0, $logCountBefore);
|
||||
|
||||
$this->userRepository->deleteUser($user);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$logCountAfter = (int) $connection->fetchOne(
|
||||
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
|
||||
[BankQuestion::class, $bankQuestionId],
|
||||
);
|
||||
$this->assertSame(0, $logCountAfter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Service\DataExportService;
|
||||
use Tvdt\Tests\Repository\DatabaseTestCase;
|
||||
|
||||
use function Safe\file_put_contents;
|
||||
use function Safe\tempnam;
|
||||
use function Safe\unlink;
|
||||
|
||||
#[CoversClass(DataExportService::class)]
|
||||
final class DataExportServiceTest extends DatabaseTestCase
|
||||
{
|
||||
private DataExportService $subject;
|
||||
|
||||
/** @var list<string> */
|
||||
private array $tempFiles = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->subject = self::getContainer()->get(DataExportService::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->tempFiles as $path) {
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportForUserWithNoSeasonsContainsOnlyProfile(): void
|
||||
{
|
||||
$zip = $this->openZip($this->getUserByEmail('test@example.org'));
|
||||
|
||||
$this->assertSame(1, $zip->numFiles);
|
||||
$this->assertNotFalse($zip->locateName('profile.xlsx'));
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
public function testExportForUserIncludesOwnedSeasonsQuizzesAndCandidates(): void
|
||||
{
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
|
||||
$names = $this->entryNames($zip);
|
||||
|
||||
$this->assertContains('profile.xlsx', $names);
|
||||
$this->assertContains('krtek-Krtek-Weekend/Quiz-1.xlsx', $names);
|
||||
$this->assertContains('krtek-Krtek-Weekend/Quiz-2.xlsx', $names);
|
||||
$this->assertContains('krtek-Krtek-Weekend/candidates.xlsx', $names);
|
||||
$this->assertContains('krtek-Krtek-Weekend/question-bank.xlsx', $names);
|
||||
$this->assertContains('bbbbb-Another-Season/candidates.xlsx', $names);
|
||||
$this->assertContains('bbbbb-Another-Season/question-bank.xlsx', $names);
|
||||
|
||||
// Another Season has no quizzes, so no quiz xlsx should be present for it.
|
||||
foreach ($names as $name) {
|
||||
$this->assertStringStartsNotWith('bbbbb-Another-Season/Quiz', $name);
|
||||
}
|
||||
|
||||
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||
$this->assertIsString($quizContent);
|
||||
$this->assertSame(['Quiz info', 'Questions', 'Raw answers', 'Results', 'Eliminations'], $this->sheetNames($quizContent));
|
||||
|
||||
$candidatesContent = $zip->getFromName('krtek-Krtek-Weekend/candidates.xlsx');
|
||||
$this->assertIsString($candidatesContent);
|
||||
$this->assertSame(['Candidates', 'Season info'], $this->sheetNames($candidatesContent));
|
||||
|
||||
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
|
||||
$this->assertIsString($questionBankContent);
|
||||
$this->assertSame(['Questions', 'Labels'], $this->sheetNames($questionBankContent));
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
public function testQuestionBankSheetIncludesBankQuestionsAndUsage(): void
|
||||
{
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
|
||||
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
|
||||
$this->assertIsString($questionBankContent);
|
||||
$zip->close();
|
||||
|
||||
$rows = $this->loadSheet($questionBankContent, 'Questions')->toArray();
|
||||
$header = $rows[0];
|
||||
$dataRows = \array_slice($rows, 1);
|
||||
|
||||
$questionIndex = array_search('Question', $header, true);
|
||||
$reusableIndex = array_search('Reusable', $header, true);
|
||||
$labelsIndex = array_search('Labels', $header, true);
|
||||
$usedInQuizzesIndex = array_search('Used in quizzes', $header, true);
|
||||
|
||||
$reusableRow = current(array_filter($dataRows, static fn (array $row): bool => 'Wie is de Krtek?' === $row[$questionIndex]));
|
||||
$this->assertIsArray($reusableRow);
|
||||
$this->assertSame('Yes', $reusableRow[$reusableIndex]);
|
||||
$this->assertSame('Finale', $reusableRow[$labelsIndex]);
|
||||
|
||||
$usedRow = current(array_filter($dataRows, static fn (array $row): bool => 'Waar sliep de Krtek?' === $row[$questionIndex]));
|
||||
$this->assertIsArray($usedRow);
|
||||
$this->assertSame('Quiz 2', $usedRow[$usedInQuizzesIndex]);
|
||||
|
||||
$labelRows = $this->loadSheet($questionBankContent, 'Labels')->toArray();
|
||||
$labelNames = array_column(\array_slice($labelRows, 1), 0);
|
||||
$this->assertContains('Locatie', $labelNames);
|
||||
$this->assertContains('Finale', $labelNames);
|
||||
}
|
||||
|
||||
public function testProfileSheetDoesNotContainPasswordHash(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('user2@example.org');
|
||||
$zip = $this->openZip($user);
|
||||
|
||||
$profileContent = $zip->getFromName('profile.xlsx');
|
||||
$this->assertIsString($profileContent);
|
||||
$zip->close();
|
||||
|
||||
$rows = $this->loadSheet($profileContent, 'Account')->toArray();
|
||||
$flattened = implode(' ', array_merge(...$rows));
|
||||
|
||||
$this->assertStringNotContainsString($user->password, $flattened);
|
||||
}
|
||||
|
||||
public function testResultsSheetIncludesSoftDeletedQuizCandidates(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$this->entityManager->persist($quizCandidate);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->entityManager->remove($quizCandidate);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||
$this->assertIsString($quizContent);
|
||||
$zip->close();
|
||||
|
||||
$rows = $this->loadSheet($quizContent, 'Results')->toArray();
|
||||
$deletedColumnIndex = array_search('Deleted', $rows[0], true);
|
||||
$this->assertIsInt($deletedColumnIndex);
|
||||
$hasDeletedRow = array_any(\array_slice($rows, 1), static fn (array $row): bool => null !== $row[$deletedColumnIndex] && '' !== $row[$deletedColumnIndex]);
|
||||
|
||||
$this->assertTrue($hasDeletedRow, 'Expected the soft-deleted QuizCandidate to still appear with a Deleted timestamp');
|
||||
}
|
||||
|
||||
public function testRawAnswersSheetShowsCandidatesByQuestionsGrid(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||
|
||||
/** @var Question $firstQuestion */
|
||||
$firstQuestion = $quiz->questions->first();
|
||||
$chosenAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => 'Man' === $answer->text)->first();
|
||||
$this->assertInstanceOf(Answer::class, $chosenAnswer);
|
||||
|
||||
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||
|
||||
$givenAnswer = new GivenAnswer($candidate, $quiz, $chosenAnswer);
|
||||
$this->entityManager->persist($givenAnswer);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||
$this->assertIsString($quizContent);
|
||||
$zip->close();
|
||||
|
||||
$rows = $this->loadSheet($quizContent, 'Raw answers')->toArray();
|
||||
$header = $rows[0];
|
||||
$this->assertSame('Candidate', $header[0]);
|
||||
|
||||
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
|
||||
$this->assertIsInt($questionColumnIndex);
|
||||
|
||||
$claudiaRow = current(array_filter(
|
||||
\array_slice($rows, 1),
|
||||
static fn (array $row): bool => 'Claudia' === $row[0],
|
||||
));
|
||||
$this->assertIsArray($claudiaRow);
|
||||
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
|
||||
}
|
||||
|
||||
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$this->assertTrue($quiz->isFinalized);
|
||||
|
||||
/** @var Question $disabledQuestion */
|
||||
$disabledQuestion = $quiz->questions->first();
|
||||
$disabledQuestion->enabled = false;
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||
$this->assertIsString($quizContent);
|
||||
$zip->close();
|
||||
|
||||
$rows = $this->loadSheet($quizContent, 'Quiz info')->toArray();
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$values[$row[0]] = $row[1];
|
||||
}
|
||||
|
||||
$this->assertSame('Quiz 1', $values['Quiz name']);
|
||||
$this->assertSame($quiz->dropouts, (int) $values['Number of dropouts']);
|
||||
$this->assertSame('Yes', $values['Finalized']);
|
||||
$this->assertNotEmpty($values['Finalized at']);
|
||||
$this->assertStringContainsString($disabledQuestion->question, (string) $values['Disabled questions']);
|
||||
}
|
||||
|
||||
private function openZip(User $user): \ZipArchive
|
||||
{
|
||||
$zipPath = $this->subject->exportForUser($user);
|
||||
$this->tempFiles[] = $zipPath;
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$this->assertTrue($zip->open($zipPath));
|
||||
|
||||
return $zip;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function entryNames(\ZipArchive $zip): array
|
||||
{
|
||||
$names = [];
|
||||
for ($i = 0; $i < $zip->numFiles; ++$i) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
$this->assertIsString($name);
|
||||
$names[] = $name;
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function sheetNames(string $xlsxContent): array
|
||||
{
|
||||
$path = $this->createTempPath();
|
||||
file_put_contents($path, $xlsxContent);
|
||||
|
||||
return array_values(new Reader\Xlsx()->load($path)->getSheetNames());
|
||||
}
|
||||
|
||||
private function loadSheet(string $xlsxContent, string $sheetName): Worksheet
|
||||
{
|
||||
$path = $this->createTempPath();
|
||||
file_put_contents($path, $xlsxContent);
|
||||
|
||||
$sheet = new Reader\Xlsx()->load($path)->getSheetByName($sheetName);
|
||||
$this->assertInstanceOf(Worksheet::class, $sheet);
|
||||
|
||||
return $sheet;
|
||||
}
|
||||
|
||||
private function createTempPath(): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_test_');
|
||||
$this->tempFiles[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user