diff --git a/phpstan.neon b/phpstan.neon index b6c29d79..cb4bc627 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,8 @@ parameters: - stubs/Colors/Color.stub - stubs/Kadet/KeyLighter.stub treatPhpDocTypesAsCertain: false + ignoreErrors: + - + # public helper for downstream workshops, not used within the framework itself + identifier: trait.unused + path: src/Exercise/TemporaryDirectoryTrait.php diff --git a/src/Check/DatabaseCheck.php b/src/Check/DatabaseCheck.php index 6d9545fe..81e05d7a 100644 --- a/src/Check/DatabaseCheck.php +++ b/src/Check/DatabaseCheck.php @@ -5,15 +5,18 @@ namespace PhpSchool\PhpWorkshop\Check; use PDO; -use PDOException; +use PhpSchool\PhpWorkshop\Event\CgiExerciseRunnerEvent; use PhpSchool\PhpWorkshop\Event\CliExecuteEvent; +use PhpSchool\PhpWorkshop\Event\CliExerciseRunnerEvent; use PhpSchool\PhpWorkshop\Event\Event; use PhpSchool\PhpWorkshop\Event\EventDispatcher; -use PhpSchool\PhpWorkshop\Exercise\TemporaryDirectoryTrait; +use PhpSchool\PhpWorkshop\Event\ExerciseRunnerEvent; use PhpSchool\PhpWorkshop\ExerciseCheck\DatabaseExerciseCheck; use PhpSchool\PhpWorkshop\Result\Failure; use PhpSchool\PhpWorkshop\Result\Success; -use RuntimeException; +use PhpSchool\PhpWorkshop\Utils\Path; +use PhpSchool\PhpWorkshop\Utils\System; +use Symfony\Component\Filesystem\Filesystem; /** * This check sets up a database and a `PDO` object. It prepends the database DSN as a CLI argument to the student's @@ -23,24 +26,12 @@ */ class DatabaseCheck implements ListenableCheckInterface { - use TemporaryDirectoryTrait; + private Filesystem $filesystem; + private ?string $dbContent = null; - private string $databaseDirectory; - private string $userDatabasePath; - private string $solutionDatabasePath; - private string $userDsn; - private string $solutionDsn; - - /** - * Setup paths and DSN's. - */ - public function __construct() + public function __construct(?Filesystem $filesystem = null) { - $this->databaseDirectory = $this->getTemporaryPath(); - $this->userDatabasePath = sprintf('%s/user-db.sqlite', $this->databaseDirectory); - $this->solutionDatabasePath = sprintf('%s/solution-db.sqlite', $this->databaseDirectory); - $this->solutionDsn = sprintf('sqlite:%s', $this->solutionDatabasePath); - $this->userDsn = sprintf('sqlite:%s', $this->userDatabasePath); + $this->filesystem = $filesystem ?? new Filesystem(); } /** @@ -64,78 +55,69 @@ public function getExerciseInterface(): string */ public function attach(EventDispatcher $eventDispatcher): void { - if (file_exists($this->databaseDirectory)) { - throw new RuntimeException( - sprintf('Database directory: "%s" already exists', $this->databaseDirectory), - ); - } - - mkdir($this->databaseDirectory, 0777, true); - - try { - $db = new PDO($this->userDsn); - $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - } catch (PDOException $e) { - rmdir($this->databaseDirectory); - throw $e; - } - - $eventDispatcher->listen('verify.start', function (Event $e) use ($db) { - /** @var DatabaseExerciseCheck $exercise */ - $exercise = $e->getParameter('exercise'); - $exercise->seed($db); - //make a copy - so solution can modify without effecting database user has access to - copy($this->userDatabasePath, $this->solutionDatabasePath); - }); + $eventDispatcher->listen(['verify.start', 'run.start'], function (Event $e) { + $path = System::randomTempPath('sqlite'); - $eventDispatcher->listen('run.start', function (Event $e) use ($db) { - /** @var DatabaseExerciseCheck $exercise */ - $exercise = $e->getParameter('exercise'); - $exercise->seed($db); - }); + $this->filesystem->touch($path); + + try { + $db = $this->getPDO($path); + + /** @var DatabaseExerciseCheck $exercise */ + $exercise = $e->getParameter('exercise'); + $exercise->seed($db); - $eventDispatcher->listen('cli.verify.reference-execute.pre', function (CliExecuteEvent $e) { - $e->prependArg($this->solutionDsn); + $this->dbContent = (string) file_get_contents($path); + } finally { + unset($db); + + $this->filesystem->remove($path); + } }); $eventDispatcher->listen( - ['cli.verify.student-execute.pre', 'cli.run.student-execute.pre'], - function (CliExecuteEvent $e) { - $e->prependArg($this->userDsn); + ['cli.verify.prepare', 'cgi.verify.prepare', 'cli.run.prepare', 'cgi.run.prepare'], + function (CliExerciseRunnerEvent|CgiExerciseRunnerEvent $e) { + $e->getScenario()->withFile('db.sqlite', (string) $this->dbContent); + + $this->dbContent = null; }, ); - $eventDispatcher->insertVerifier('verify.finish', function (Event $e) use ($db) { - /** @var DatabaseExerciseCheck $exercise */ - $exercise = $e->getParameter('exercise'); - $verifyResult = $exercise->verify($db); + $eventDispatcher->listen( + 'cli.verify.reference-execute.pre', + fn(CliExecuteEvent $e) => $e->prependArg('sqlite:db.sqlite'), + ); - if (false === $verifyResult) { - return Failure::fromNameAndReason($this->getName(), 'Database verification failed'); - } + $eventDispatcher->listen( + ['cli.verify.student-execute.pre', 'cli.run.student-execute.pre'], + fn(CliExecuteEvent $e) => $e->prependArg('sqlite:db.sqlite'), + ); - return new Success('Database Verification Check'); - }); + $eventDispatcher->insertVerifier('verify.finish', function (ExerciseRunnerEvent $e) { + $db = $this->getPDO(Path::join($e->getContext()->getStudentExecutionDirectory(), 'db.sqlite')); - $eventDispatcher->listen( - [ - 'cli.verify.reference-execute.fail', - 'verify.finish', - 'run.finish', - ], - function () use ($db) { + try { + /** @var DatabaseExerciseCheck $exercise */ + $exercise = $e->getParameter('exercise'); + $verifyResult = $exercise->verify($db); + + if (false === $verifyResult) { + return Failure::fromNameAndReason($this->getName(), 'Database verification failed'); + } + + return new Success('Database Verification Check'); + } finally { unset($db); - $this->unlink($this->userDatabasePath); - $this->unlink($this->solutionDatabasePath); - rmdir($this->databaseDirectory); - }, - ); + } + }); } - private function unlink(string $file): void + private function getPDO(string $path): PDO { - if (file_exists($file)) { - unlink($file); - } + $db = new PDO('sqlite:' . $path); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + return $db; } } diff --git a/src/ExerciseRunner/CgiRunner.php b/src/ExerciseRunner/CgiRunner.php index 579faaa4..7645e5e6 100644 --- a/src/ExerciseRunner/CgiRunner.php +++ b/src/ExerciseRunner/CgiRunner.php @@ -102,6 +102,8 @@ public function verify(ExecutionContext $context): ResultInterface { $scenario = $this->exercise->defineTestScenario(); + $this->eventDispatcher->dispatch(new CgiExerciseRunnerEvent('cgi.verify.prepare', $context, $scenario)); + $this->environmentManager->prepareStudent($context, $scenario); $this->environmentManager->prepareReference($context, $scenario); @@ -295,6 +297,8 @@ public function run(ExecutionContext $context, OutputInterface $output): bool { $scenario = $this->exercise->defineTestScenario(); + $this->eventDispatcher->dispatch(new CgiExerciseRunnerEvent('cgi.run.prepare', $context, $scenario)); + $this->environmentManager->prepareStudent($context, $scenario); $this->eventDispatcher->dispatch(new CgiExerciseRunnerEvent('cgi.run.start', $context, $scenario)); diff --git a/src/ExerciseRunner/CliRunner.php b/src/ExerciseRunner/CliRunner.php index bb5a5b56..c85caaaf 100644 --- a/src/ExerciseRunner/CliRunner.php +++ b/src/ExerciseRunner/CliRunner.php @@ -101,6 +101,8 @@ public function verify(ExecutionContext $context): ResultInterface { $scenario = $this->exercise->defineTestScenario(); + $this->eventDispatcher->dispatch(new CliExerciseRunnerEvent('cli.verify.prepare', $context, $scenario)); + $this->environmentManager->prepareStudent($context, $scenario); $this->environmentManager->prepareReference($context, $scenario); @@ -203,6 +205,8 @@ public function run(ExecutionContext $context, OutputInterface $output): bool { $scenario = $this->exercise->defineTestScenario(); + $this->eventDispatcher->dispatch(new CliExerciseRunnerEvent('cli.run.prepare', $context, $scenario)); + $this->environmentManager->prepareStudent($context, $scenario); $this->eventDispatcher->dispatch(new CliExerciseRunnerEvent('cli.run.start', $context, $scenario)); diff --git a/src/Utils/System.php b/src/Utils/System.php index eb8da511..8664dead 100644 --- a/src/Utils/System.php +++ b/src/Utils/System.php @@ -19,6 +19,11 @@ public static function realpath(string $path): string return $realpath; } + public static function randomTempPath(string $ext): string + { + return Path::join(self::realpath(sys_get_temp_dir()), 'php-school', bin2hex(random_bytes(4)) . '.' . $ext); + } + public static function tempDir(string $path = ''): string { return Path::join(self::realpath(sys_get_temp_dir()), 'php-school', $path); diff --git a/test/Check/DatabaseCheckTest.php b/test/Check/DatabaseCheckTest.php index 8b91078f..7ff48326 100644 --- a/test/Check/DatabaseCheckTest.php +++ b/test/Check/DatabaseCheckTest.php @@ -22,8 +22,6 @@ use PhpSchool\PhpWorkshopTest\Asset\DatabaseExercise; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionProperty; -use RuntimeException; use Symfony\Component\Filesystem\Filesystem; class DatabaseCheckTest extends TestCase @@ -75,57 +73,6 @@ private function getRunnerManager(ExerciseInterface $exercise, EventDispatcher $ return $runnerManager; } - public function testIfDatabaseFolderExistsExceptionIsThrown(): void - { - $eventDispatcher = new EventDispatcher(new ResultAggregator()); - mkdir($this->dbDir, 0777, true); - try { - $this->check->attach($eventDispatcher); - $this->fail('Exception was not thrown'); - } catch (RuntimeException $e) { - $this->assertEquals(sprintf('Database directory: "%s" already exists', $this->dbDir), $e->getMessage()); - } - } - - /** - * If an exception is thrown from PDO, check that the check can be run straight away - * Previously files were not cleaned up that caused exceptions. - */ - public function testIfPDOThrowsExceptionItCleansUp(): void - { - $eventDispatcher = new EventDispatcher(new ResultAggregator()); - - $refProp = new ReflectionProperty(DatabaseCheck::class, 'userDsn'); - $refProp->setValue($this->check, 'notvaliddsn'); - - try { - $this->check->attach($eventDispatcher); - $this->fail('Exception was not thrown'); - } catch (\PDOException $e) { - } - - //try to run the check as usual - $this->check = new DatabaseCheck(); - $solution = SingleFileSolution::fromFile(realpath(__DIR__ . '/../res/database/solution.php')); - $this->exercise->setSolution($solution); - $this->exercise->setScenario((new CliScenario())->withExecution([1, 2, 3])); - $this->exercise->setVerifier(fn() => true); - - $this->checkRepository->registerCheck($this->check); - - $results = new ResultAggregator(); - $eventDispatcher = new EventDispatcher($results); - $dispatcher = new ExerciseDispatcher( - $this->getRunnerManager($this->exercise, $eventDispatcher), - $results, - $eventDispatcher, - $this->checkRepository, - ); - - $dispatcher->verify($this->exercise, new Input('app', ['program' => __DIR__ . '/../res/database/user.php'])); - $this->assertTrue($results->isSuccessful()); - } - public function testSuccessIsReturnedIfDatabaseVerificationPassed(): void { $solution = SingleFileSolution::fromFile(realpath(__DIR__ . '/../res/database/solution.php'));