From 69d2bbfc83894e0328ee810cbed5f059f732c6df Mon Sep 17 00:00:00 2001 From: Jesper Beisner Date: Tue, 6 Sep 2022 06:11:34 +0200 Subject: [PATCH] add login; add users and actions; write some tests --- .docker/caddy/Caddyfile | 2 +- README.md | 3 + bin/console.php | 2 + bootstrap.php | 11 +-- composer.json | 2 +- config/commands.php | 5 +- config/routes.php | 14 ++- config/services.php | 16 ++- migrations/500-users-table.sql | 9 ++ phpstan.neon.dist | 2 - public/js/script.js | 7 ++ src/Action/CreateUserAction.php | 70 +++++++++++++ src/Action/Exception/ActionException.php | 11 +++ .../Exception/ActionResultException.php | 11 +++ .../Factory/CreateUserActionFactory.php | 23 +++++ src/Action/Interface/ActionInterface.php | 15 +++ .../Interface/ActionResultInterface.php | 20 ++++ src/Action/Result/AbstractActionResult.php | 45 +++++++++ src/Action/Result/CreateUserActionResult.php | 20 ++++ src/App.php | 42 ++++++-- src/Command/AbstractCommand.php | 10 ++ src/Command/CreateUserCommand.php | 38 +++++++ src/Command/DatabaseFixtureCommand.php | 29 ++++-- .../Factory/CreateUserCommandFactory.php | 23 +++++ .../Factory/DatabaseFixtureCommandFactory.php | 19 +++- .../Factory/ImageControllerFactory.php | 8 +- .../Factory/IndexControllerFactory.php | 10 +- .../Factory/LogControllerFactory.php | 28 ++++++ .../Factory/LogsControllerFactory.php | 31 ------ .../Factory/SecurityControllerFactory.php | 33 +++++++ src/Controller/ImageController.php | 5 +- src/Controller/IndexController.php | 4 + .../{LogsController.php => LogController.php} | 10 +- src/Controller/SecurityController.php | 57 +++++++++++ src/Helper/Length.php | 17 ++++ src/Helper/UuidV4.php | 18 ++++ src/ImageService/AbstractImageService.php | 16 ++- .../Factory/RankingImageServiceFactory.php | 9 +- src/ImageService/RankingImageService.php | 4 +- src/Model/User.php | 18 ++++ .../Factory/UserRepositoryFactory.php | 23 +++++ src/Repository/UserRepository.php | 63 ++++++++++++ src/Service/ViewRenderService.php | 19 ++-- src/Stdlib/Database.php | 41 ++++++++ src/Stdlib/Exception/RedirectException.php | 15 +++ src/Stdlib/Exception/SessionException.php | 11 +++ src/Stdlib/Factory/DatabaseFactory.php | 30 ++++++ src/Stdlib/Factory/LoggerFactory.php | 7 +- src/Stdlib/Factory/PdoFactory.php | 5 +- src/Stdlib/Factory/SessionFactory.php | 21 ++++ src/Stdlib/Interface/DatabaseInterface.php | 21 ++++ src/Stdlib/Interface/SessionInterface.php | 22 +++++ src/Stdlib/Logger.php | 7 +- src/Stdlib/Request.php | 5 + src/Stdlib/Response/HtmlResponse.php | 12 ++- src/Stdlib/Response/ImageResponse.php | 2 +- src/Stdlib/Session.php | 91 +++++++++++++++++ tests/Dummy/DatabaseDummy.php | 34 +++++++ tests/Dummy/SessionDummy.php | 52 ++++++++++ tests/Unit/Action/CreateUserActionTest.php | 99 +++++++++++++++++++ .../Result/CreateUserActionResultTest.php | 78 +++++++++++++++ tests/Unit/DTO/PlaytimeTest.php | 5 +- tests/Unit/Service/ViewRenderServiceTest.php | 8 +- tests/Unit/TestCase.php | 14 ++- views/index/index.phtml | 20 ++++ views/layout.phtml | 9 +- views/navigation.phtml | 29 ++++++ views/security/login.phtml | 19 ++++ 68 files changed, 1379 insertions(+), 100 deletions(-) create mode 100644 migrations/500-users-table.sql create mode 100644 src/Action/CreateUserAction.php create mode 100644 src/Action/Exception/ActionException.php create mode 100644 src/Action/Exception/ActionResultException.php create mode 100644 src/Action/Factory/CreateUserActionFactory.php create mode 100644 src/Action/Interface/ActionInterface.php create mode 100644 src/Action/Interface/ActionResultInterface.php create mode 100644 src/Action/Result/AbstractActionResult.php create mode 100644 src/Action/Result/CreateUserActionResult.php create mode 100644 src/Command/CreateUserCommand.php create mode 100644 src/Command/Factory/CreateUserCommandFactory.php create mode 100644 src/Controller/Factory/LogControllerFactory.php delete mode 100644 src/Controller/Factory/LogsControllerFactory.php create mode 100644 src/Controller/Factory/SecurityControllerFactory.php rename src/Controller/{LogsController.php => LogController.php} (67%) create mode 100644 src/Controller/SecurityController.php create mode 100644 src/Helper/Length.php create mode 100644 src/Helper/UuidV4.php create mode 100644 src/Model/User.php create mode 100644 src/Repository/Factory/UserRepositoryFactory.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Stdlib/Database.php create mode 100644 src/Stdlib/Exception/RedirectException.php create mode 100644 src/Stdlib/Exception/SessionException.php create mode 100644 src/Stdlib/Factory/DatabaseFactory.php create mode 100644 src/Stdlib/Factory/SessionFactory.php create mode 100644 src/Stdlib/Interface/DatabaseInterface.php create mode 100644 src/Stdlib/Interface/SessionInterface.php create mode 100644 src/Stdlib/Session.php create mode 100644 tests/Dummy/DatabaseDummy.php create mode 100644 tests/Dummy/SessionDummy.php create mode 100644 tests/Unit/Action/CreateUserActionTest.php create mode 100644 tests/Unit/Action/Result/CreateUserActionResultTest.php create mode 100644 views/security/login.phtml diff --git a/.docker/caddy/Caddyfile b/.docker/caddy/Caddyfile index c30bcbb..23c03bc 100644 --- a/.docker/caddy/Caddyfile +++ b/.docker/caddy/Caddyfile @@ -5,4 +5,4 @@ root * /var/www/html/public php_fastcgi php:9000 file_server -} \ No newline at end of file +} diff --git a/README.md b/README.md index 1a5df04..1e3b3ae 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ docker-compose exec php php bin/console.php app:database-migration docker-compose exec php php bin/console.php app:database-fixture # Visit http://localhost:8080 + +# Test-Account-Mail: test@test.com +# Test-Account-Password: Password123 ``` ### Prod diff --git a/bin/console.php b/bin/console.php index 033cb5e..cbdcda3 100644 --- a/bin/console.php +++ b/bin/console.php @@ -48,4 +48,6 @@ /** @var AbstractCommand $command */ $command = $serviceContainer->get($commandClass); +$command->setArguments($argv); + exit($command->execute()); diff --git a/bootstrap.php b/bootstrap.php index ba91cfd..5bf7b5f 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -4,14 +4,12 @@ use Jesperbeisner\Fwstats\Stdlib\ServiceContainer; -const ROOT_DIR = __DIR__; +require __DIR__ . '/vendor/autoload.php'; -require ROOT_DIR . '/vendor/autoload.php'; +$config = require __DIR__ . '/config/config.php'; -$config = require ROOT_DIR . '/config/config.php'; - -if (file_exists(ROOT_DIR . '/config/config.local.php')) { - $configLocal = require ROOT_DIR . '/config/config.local.php'; +if (file_exists(__DIR__ . '/config/config.local.php')) { + $configLocal = require __DIR__ . '/config/config.local.php'; $config = array_merge($config, $configLocal); } @@ -19,5 +17,6 @@ $serviceContainer->set('config', $config); $serviceContainer->set('appEnv', $config['app_env']); +$serviceContainer->set('rootDir', __DIR__); return $serviceContainer; diff --git a/composer.json b/composer.json index e762be4..409def7 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ }, "scripts": { "csfixer": "vendor/bin/php-cs-fixer fix --diff", - "phpunit": "vendor/bin/phpunit --do-not-cache-result --debug", + "phpunit": "vendor/bin/phpunit --do-not-cache-result", "phpstan": "vendor/bin/phpstan", "test": { "csfixer": "@csfixer", diff --git a/config/commands.php b/config/commands.php index afa8777..ec5f0c7 100644 --- a/config/commands.php +++ b/config/commands.php @@ -5,7 +5,8 @@ use Jesperbeisner\Fwstats\Command; return [ - Command\DatabaseMigrationCommand::class, - Command\DatabaseFixtureCommand::class, Command\AppCommand::class, + Command\CreateUserCommand::class, + Command\DatabaseFixtureCommand::class, + Command\DatabaseMigrationCommand::class, ]; diff --git a/config/routes.php b/config/routes.php index 5da2d05..092f392 100644 --- a/config/routes.php +++ b/config/routes.php @@ -41,8 +41,18 @@ 'controller' => [Controller\PingController::class, 'ping'], ], [ - 'route' => '/logs', + 'route' => '/admin/logs', 'methods' => ['GET'], - 'controller' => [Controller\LogsController::class, 'logs'], + 'controller' => [Controller\LogController::class, 'logs'], + ], + [ + 'route' => '/login', + 'methods' => ['GET', 'POST'], + 'controller' => [Controller\SecurityController::class, 'login'], + ], + [ + 'route' => '/logout', + 'methods' => ['GET', 'POST'], + 'controller' => [Controller\SecurityController::class, 'logout'], ], ]; diff --git a/config/services.php b/config/services.php index 499a53c..0599106 100644 --- a/config/services.php +++ b/config/services.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Jesperbeisner\Fwstats\Action; use Jesperbeisner\Fwstats\Command; use Jesperbeisner\Fwstats\Controller; use Jesperbeisner\Fwstats\ImageService; @@ -19,7 +20,11 @@ Controller\PlaytimeController::class => Controller\Factory\PlaytimeControllerFactory::class, Controller\PingController::class => Controller\Factory\PingControllerFactory::class, Controller\ChangeController::class => Controller\Factory\ChangeControllerFactory::class, - Controller\LogsController::class => Controller\Factory\LogsControllerFactory::class, + Controller\LogController::class => Controller\Factory\LogControllerFactory::class, + Controller\SecurityController::class => Controller\Factory\SecurityControllerFactory::class, + + // Actions + Action\CreateUserAction::class => Action\Factory\CreateUserActionFactory::class, // Services Service\Interface\FreewarDumpServiceInterface::class => Service\Factory\FreewarDumpServiceFactory::class, @@ -30,9 +35,10 @@ ImageService\RankingImageService::class => ImageService\Factory\RankingImageServiceFactory::class, // Commands - Command\DatabaseMigrationCommand::class => Command\Factory\DatabaseMigrationCommandFactory::class, - Command\DatabaseFixtureCommand::class => Command\Factory\DatabaseFixtureCommandFactory::class, Command\AppCommand::class => Command\Factory\AppCommandFactory::class, + Command\CreateUserCommand::class => Command\Factory\CreateUserCommandFactory::class, + Command\DatabaseFixtureCommand::class => Command\Factory\DatabaseFixtureCommandFactory::class, + Command\DatabaseMigrationCommand::class => Command\Factory\DatabaseMigrationCommandFactory::class, // Importer Importer\ClanImporter::class => Importer\Factory\ClanImporterFactory::class, @@ -59,9 +65,13 @@ Repository\AchievementRepository::class => Repository\Factory\RepositoryFactory::class, Repository\LogRepository::class => Repository\Factory\RepositoryFactory::class, + Repository\UserRepository::class => Repository\Factory\UserRepositoryFactory::class, + // Stdlib PDO::class => Stdlib\Factory\PdoFactory::class, LoggerInterface::class => Stdlib\Factory\LoggerFactory::class, Stdlib\Request::class => Stdlib\Factory\RequestFactory::class, Stdlib\Router::class => Stdlib\Factory\RouterFactory::class, + Stdlib\Interface\SessionInterface::class => Stdlib\Factory\SessionFactory::class, + Stdlib\Interface\DatabaseInterface::class => Stdlib\Factory\DatabaseFactory::class, ]; diff --git a/migrations/500-users-table.sql b/migrations/500-users-table.sql new file mode 100644 index 0000000..a66b4a5 --- /dev/null +++ b/migrations/500-users-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS users ( + uuid TEXT PRIMARY KEY, + email TEXT NOT NULL, + password TEXT NOT NULL, + created DATETIME NOT NULL +); + +CREATE UNIQUE INDEX users_email_unique_index ON users(email); +CREATE INDEX users_created_index ON users(created); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d6a3fc2..6f4a9f5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,5 +4,3 @@ parameters: - public - src - tests - ignoreErrors: - - '#Constant ROOT_DIR not found.#' \ No newline at end of file diff --git a/public/js/script.js b/public/js/script.js index adcb770..4ae74c1 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -38,4 +38,11 @@ document.addEventListener('DOMContentLoaded', () => { actionFreewarTabContent.classList.add('is-hidden'); }); } + + const $infoTexts = document.querySelectorAll('.info-text'); + $infoTexts.forEach((infoText) => { + setTimeout(() => { + infoText.classList.add('is-hidden'); + }, 2500); + }); }); diff --git a/src/Action/CreateUserAction.php b/src/Action/CreateUserAction.php new file mode 100644 index 0000000..5498440 --- /dev/null +++ b/src/Action/CreateUserAction.php @@ -0,0 +1,70 @@ +email = $data['email']; + $this->password = $data['password']; + } + + public function run(): CreateUserActionResult + { + if (null !== $this->userRepository->findOneByEmail($this->email)) { + throw new ActionException("A user with email '$this->email' already exists."); + } + + $hashedPassword = password_hash($this->password, PASSWORD_DEFAULT); + + $user = new User(UuidV4::create(), $this->email, $hashedPassword, new DateTimeImmutable()); + + $this->userRepository->insert($user); + + return new CreateUserActionResult(ActionResultInterface::SUCCESS, ['user' => $user]); + } +} diff --git a/src/Action/Exception/ActionException.php b/src/Action/Exception/ActionException.php new file mode 100644 index 0000000..bc79432 --- /dev/null +++ b/src/Action/Exception/ActionException.php @@ -0,0 +1,11 @@ +get(UserRepository::class); + + return new CreateUserAction( + $userRepository, + ); + } +} diff --git a/src/Action/Interface/ActionInterface.php b/src/Action/Interface/ActionInterface.php new file mode 100644 index 0000000..74847ce --- /dev/null +++ b/src/Action/Interface/ActionInterface.php @@ -0,0 +1,15 @@ +result = $result; + $this->data = $data; + $this->message = $message; + } + + public function isSuccess(): bool + { + return $this->result === self::SUCCESS; + } + public function getData(): array + { + return $this->data; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/src/Action/Result/CreateUserActionResult.php b/src/Action/Result/CreateUserActionResult.php new file mode 100644 index 0000000..abbf803 --- /dev/null +++ b/src/Action/Result/CreateUserActionResult.php @@ -0,0 +1,20 @@ +data['user']) && $this->data['user'] instanceof User) { + return $this->data['user']; + } + + throw new ActionResultException('No user in data array available.'); + } +} diff --git a/src/App.php b/src/App.php index 829fd4b..5eb9ddd 100644 --- a/src/App.php +++ b/src/App.php @@ -10,8 +10,10 @@ use Jesperbeisner\Fwstats\Model\Log; use Jesperbeisner\Fwstats\Repository\LogRepository; use Jesperbeisner\Fwstats\Stdlib\Exception\NotFoundException; +use Jesperbeisner\Fwstats\Stdlib\Exception\RedirectException; use Jesperbeisner\Fwstats\Stdlib\Exception\UnauthorizedException; use Jesperbeisner\Fwstats\Stdlib\Interface\ResponseInterface; +use Jesperbeisner\Fwstats\Stdlib\Interface\SessionInterface; use Jesperbeisner\Fwstats\Stdlib\Request; use Jesperbeisner\Fwstats\Stdlib\Response\HtmlResponse; use Jesperbeisner\Fwstats\Stdlib\Router; @@ -31,17 +33,26 @@ public function run(): never { $this->logRequestToDatabase(); + /** @var SessionInterface $session */ + $session = $this->serviceContainer->get(SessionInterface::class); + $session->start(); + /** @var Router $router */ $router = $this->serviceContainer->get(Router::class); - $routeResult = $router->match(); if ($routeResult[0] === Dispatcher::NOT_FOUND) { - (new HtmlResponse('error.phtml', ['message' => '404 - Page not found'], 404))->send(); + $response = new HtmlResponse('error.phtml', ['message' => '404 - Page not found'], 404); + $response->setSession($session); + + $response->send(); } if ($routeResult[0] === Dispatcher::METHOD_NOT_ALLOWED) { - (new HtmlResponse('error.phtml', ['message' => '405 - Method not allowed'], 405))->send(); + $response = new HtmlResponse('error.phtml', ['message' => '405 - Method not allowed'], 405); + $response->setSession($session); + + $response->send(); } /** @var Request $request */ @@ -63,10 +74,27 @@ public function run(): never try { /** @var ResponseInterface $response */ $response = $controller->$controllerAction(); - } catch (NotFoundException $e) { - (new HtmlResponse('error.phtml', ['message' => $e->getMessage()], 404))->send(); - } catch (UnauthorizedException $e) { - (new HtmlResponse('error.phtml', ['message' => $e->getMessage()], 401))->send(); + } catch (NotFoundException | UnauthorizedException | RedirectException $e) { + if ($e instanceof NotFoundException) { + $response = new HtmlResponse('error.phtml', ['message' => $e->getMessage()], 404); + $response->setSession($session); + + $response->send(); + } + + if ($e instanceof UnauthorizedException) { + $response = new HtmlResponse('error.phtml', ['message' => $e->getMessage()], 401); + $response->setSession($session); + + $response->send(); + } + + header(header: "Location: $e->route", response_code: 302); + exit(0); + } + + if ($response instanceof HtmlResponse) { + $response->setSession($session); } $response->send(); diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 326e25d..cfc1c2d 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -14,10 +14,20 @@ abstract class AbstractCommand public static string $name = ''; public static string $description = ''; + /** @var string[] */ + protected array $arguments = []; protected ?float $time = null; abstract public function execute(): int; + /** + * @param string[] $arguments + */ + public function setArguments(array $arguments): void + { + $this->arguments = $arguments; + } + protected function writeLine(string $text = ''): void { if (false === file_put_contents('php://stderr', $text . PHP_EOL)) { diff --git a/src/Command/CreateUserCommand.php b/src/Command/CreateUserCommand.php new file mode 100644 index 0000000..fad5d3e --- /dev/null +++ b/src/Command/CreateUserCommand.php @@ -0,0 +1,38 @@ +startTime(); + $this->writeLine("Starting the 'app:create-user' command..."); + + if (empty($this->arguments[2]) || empty($this->arguments[3])) { + $this->writeLine("Error: You forgot to pass the email and/or password to the 'app:create-user' command."); + + return self::FAILURE; + } + + $this->createUserAction->configure(['email' => $this->arguments[2], 'password' => $this->arguments[3]]); + $this->createUserAction->run(); + + $this->writeLine("Success: A new user with email '{$this->arguments[2]}' was created."); + $this->writeLine("Finished the 'app:create-user' command in {$this->getTime()} ms."); + + return self::SUCCESS; + } +} diff --git a/src/Command/DatabaseFixtureCommand.php b/src/Command/DatabaseFixtureCommand.php index e0bf008..2cdb4e0 100644 --- a/src/Command/DatabaseFixtureCommand.php +++ b/src/Command/DatabaseFixtureCommand.php @@ -5,6 +5,7 @@ namespace Jesperbeisner\Fwstats\Command; use DateTimeImmutable; +use Jesperbeisner\Fwstats\Action\CreateUserAction; use Jesperbeisner\Fwstats\Enum\WorldEnum; use Jesperbeisner\Fwstats\ImageService\RankingImageService; use Jesperbeisner\Fwstats\Model\Clan; @@ -19,6 +20,7 @@ use Jesperbeisner\Fwstats\Repository\PlayerProfessionHistoryRepository; use Jesperbeisner\Fwstats\Repository\PlayerRaceHistoryRepository; use Jesperbeisner\Fwstats\Repository\PlayerRepository; +use Jesperbeisner\Fwstats\Repository\UserRepository; final class DatabaseFixtureCommand extends AbstractCommand { @@ -27,6 +29,7 @@ final class DatabaseFixtureCommand extends AbstractCommand public function __construct( private readonly string $appEnv, + private readonly string $rootDir, private readonly PlayerRepository $playerRepository, private readonly ClanRepository $clanRepository, private readonly PlayerActiveSecondRepository $playerActiveSecondRepository, @@ -34,13 +37,15 @@ public function __construct( private readonly PlayerRaceHistoryRepository $playerRaceHistoryRepository, private readonly PlayerProfessionHistoryRepository $playerProfessionHistoryRepository, private readonly RankingImageService $rankingImageService, + private readonly UserRepository $userRepository, + private readonly CreateUserAction $createUserAction, ) { } public function execute(): int { if ($this->appEnv !== 'dev') { - $this->writeLine("Rhe 'app:database-fixture' command can only be executed in the dev environment."); + $this->writeLine("The 'app:database-fixture' command can only be executed in the dev environment."); return self::FAILURE; } @@ -69,6 +74,9 @@ public function execute(): int $this->writeLine("Creating ranking images..."); $this->createRankingImages(); + $this->writeLine("Creating user account..."); + $this->createUserAccount(); + $this->writeLine("Finished the 'app:database-fixture' command in {$this->getTime()} ms."); return self::SUCCESS; @@ -76,7 +84,7 @@ public function execute(): int private function createPlayers(): void { - $playersFixtureData = require ROOT_DIR . '/data/fixtures/players.php'; + $playersFixtureData = require $this->rootDir . '/data/fixtures/players.php'; $this->playerRepository->deleteAll(); @@ -112,7 +120,7 @@ private function createPlayers(): void private function createClans(): void { - $clansFixtureData = require ROOT_DIR . '/data/fixtures/clans.php'; + $clansFixtureData = require $this->rootDir . '/data/fixtures/clans.php'; $this->clanRepository->deleteAll(); @@ -149,7 +157,7 @@ private function createClans(): void private function createPlayerPlaytimes(): void { - $playerPlaytimesFixtureData = require ROOT_DIR . '/data/fixtures/player-playtimes.php'; + $playerPlaytimesFixtureData = require $this->rootDir . '/data/fixtures/player-playtimes.php'; $this->playerActiveSecondRepository->deleteAll(); @@ -174,7 +182,7 @@ private function createPlayerPlaytimes(): void private function createPlayerNameHistories(): void { - $playerNameHistoriesFixtureData = require ROOT_DIR . '/data/fixtures/player-name-histories.php'; + $playerNameHistoriesFixtureData = require $this->rootDir . '/data/fixtures/player-name-histories.php'; $this->playerNameHistoryRepository->deleteAll(); @@ -201,7 +209,7 @@ private function createPlayerNameHistories(): void private function createPlayerRaceHistories(): void { - $playerRaceHistoriesFixtureData = require ROOT_DIR . '/data/fixtures/player-race-histories.php'; + $playerRaceHistoriesFixtureData = require $this->rootDir . '/data/fixtures/player-race-histories.php'; $this->playerRaceHistoryRepository->deleteAll(); @@ -228,7 +236,7 @@ private function createPlayerRaceHistories(): void private function createPlayerProfessionHistories(): void { - $playerProfessionHistoriesFixtureData = require ROOT_DIR . '/data/fixtures/player-profession-histories.php'; + $playerProfessionHistoriesFixtureData = require $this->rootDir . '/data/fixtures/player-profession-histories.php'; $this->playerProfessionHistoryRepository->deleteAll(); @@ -259,4 +267,11 @@ private function createRankingImages(): void $this->rankingImageService->create($world); } } + + private function createUserAccount(): void + { + $this->userRepository->deleteAll(); + $this->createUserAction->configure(['email' => 'test@test.com', 'password' => 'Password123']); + $this->createUserAction->run(); + } } diff --git a/src/Command/Factory/CreateUserCommandFactory.php b/src/Command/Factory/CreateUserCommandFactory.php new file mode 100644 index 0000000..5308141 --- /dev/null +++ b/src/Command/Factory/CreateUserCommandFactory.php @@ -0,0 +1,23 @@ +get(CreateUserAction::class); + + return new CreateUserCommand( + $createUserAction, + ); + } +} diff --git a/src/Command/Factory/DatabaseFixtureCommandFactory.php b/src/Command/Factory/DatabaseFixtureCommandFactory.php index ae3daba..22dcee6 100644 --- a/src/Command/Factory/DatabaseFixtureCommandFactory.php +++ b/src/Command/Factory/DatabaseFixtureCommandFactory.php @@ -4,6 +4,7 @@ namespace Jesperbeisner\Fwstats\Command\Factory; +use Jesperbeisner\Fwstats\Action\CreateUserAction; use Jesperbeisner\Fwstats\Command\DatabaseFixtureCommand; use Jesperbeisner\Fwstats\ImageService\RankingImageService; use Jesperbeisner\Fwstats\Repository\ClanRepository; @@ -12,6 +13,7 @@ use Jesperbeisner\Fwstats\Repository\PlayerProfessionHistoryRepository; use Jesperbeisner\Fwstats\Repository\PlayerRaceHistoryRepository; use Jesperbeisner\Fwstats\Repository\PlayerRepository; +use Jesperbeisner\Fwstats\Repository\UserRepository; use Jesperbeisner\Fwstats\Stdlib\Interface\FactoryInterface; use Psr\Container\ContainerInterface; @@ -19,11 +21,11 @@ class DatabaseFixtureCommandFactory implements FactoryInterface { public function __invoke(ContainerInterface $serviceContainer, string $serviceName): DatabaseFixtureCommand { - /** @var mixed[] $config */ - $config = $serviceContainer->get('config'); - /** @var string $appEnv */ - $appEnv = $config['app_env']; + $appEnv = $serviceContainer->get('appEnv'); + + /** @var string $rootDir */ + $rootDir = $serviceContainer->get('rootDir'); /** @var PlayerRepository $playerRepository */ $playerRepository = $serviceContainer->get(PlayerRepository::class); @@ -46,8 +48,15 @@ public function __invoke(ContainerInterface $serviceContainer, string $serviceNa /** @var RankingImageService $rankingImageService */ $rankingImageService = $serviceContainer->get(RankingImageService::class); + /** @var UserRepository $userRepository */ + $userRepository = $serviceContainer->get(UserRepository::class); + + /** @var CreateUserAction $createUserAction */ + $createUserAction = $serviceContainer->get(CreateUserAction::class); + return new DatabaseFixtureCommand( $appEnv, + $rootDir, $playerRepository, $clanRepository, $playerActiveSecondRepository, @@ -55,6 +64,8 @@ public function __invoke(ContainerInterface $serviceContainer, string $serviceNa $playerRaceHistoryRepository, $playerProfessionHistoryRepository, $rankingImageService, + $userRepository, + $createUserAction, ); } } diff --git a/src/Controller/Factory/ImageControllerFactory.php b/src/Controller/Factory/ImageControllerFactory.php index 16969ea..15bfb8e 100644 --- a/src/Controller/Factory/ImageControllerFactory.php +++ b/src/Controller/Factory/ImageControllerFactory.php @@ -13,9 +13,15 @@ final class ImageControllerFactory implements FactoryInterface { public function __invoke(ContainerInterface $serviceContainer, string $serviceName): ImageController { + /** @var string $rootDir */ + $rootDir = $serviceContainer->get('rootDir'); + /** @var Request $request */ $request = $serviceContainer->get(Request::class); - return new ImageController($request); + return new ImageController( + $rootDir, + $request, + ); } } diff --git a/src/Controller/Factory/IndexControllerFactory.php b/src/Controller/Factory/IndexControllerFactory.php index 5163a48..2b8940f 100644 --- a/src/Controller/Factory/IndexControllerFactory.php +++ b/src/Controller/Factory/IndexControllerFactory.php @@ -8,18 +8,26 @@ use Jesperbeisner\Fwstats\Repository\ClanRepository; use Jesperbeisner\Fwstats\Repository\PlayerRepository; use Jesperbeisner\Fwstats\Stdlib\Interface\FactoryInterface; +use Jesperbeisner\Fwstats\Stdlib\Request; use Psr\Container\ContainerInterface; final class IndexControllerFactory implements FactoryInterface { public function __invoke(ContainerInterface $serviceContainer, string $serviceName): IndexController { + /** @var Request $request */ + $request = $serviceContainer->get(Request::class); + /** @var PlayerRepository $playerRepository */ $playerRepository = $serviceContainer->get(PlayerRepository::class); /** @var ClanRepository $clanRepository */ $clanRepository = $serviceContainer->get(ClanRepository::class); - return new IndexController($playerRepository, $clanRepository); + return new IndexController( + $request, + $playerRepository, + $clanRepository + ); } } diff --git a/src/Controller/Factory/LogControllerFactory.php b/src/Controller/Factory/LogControllerFactory.php new file mode 100644 index 0000000..a421fd5 --- /dev/null +++ b/src/Controller/Factory/LogControllerFactory.php @@ -0,0 +1,28 @@ +get(SessionInterface::class); + + /** @var LogRepository $logRepository */ + $logRepository = $serviceContainer->get(LogRepository::class); + + return new LogController( + $session, + $logRepository + ); + } +} diff --git a/src/Controller/Factory/LogsControllerFactory.php b/src/Controller/Factory/LogsControllerFactory.php deleted file mode 100644 index 9402a6d..0000000 --- a/src/Controller/Factory/LogsControllerFactory.php +++ /dev/null @@ -1,31 +0,0 @@ -get('config'); - - /** @var string $logsPassword */ - $logsPassword = $config['logs_password']; - - /** @var Request $request */ - $request = $serviceContainer->get(Request::class); - - /** @var LogRepository $logRepository */ - $logRepository = $serviceContainer->get(LogRepository::class); - - return new LogsController($logsPassword, $request, $logRepository); - } -} diff --git a/src/Controller/Factory/SecurityControllerFactory.php b/src/Controller/Factory/SecurityControllerFactory.php new file mode 100644 index 0000000..009d98c --- /dev/null +++ b/src/Controller/Factory/SecurityControllerFactory.php @@ -0,0 +1,33 @@ +get(Request::class); + + /** @var SessionInterface $session */ + $session = $serviceContainer->get(SessionInterface::class); + + /** @var UserRepository $userRepository */ + $userRepository = $serviceContainer->get(UserRepository::class); + + return new SecurityController( + $request, + $session, + $userRepository, + ); + } +} diff --git a/src/Controller/ImageController.php b/src/Controller/ImageController.php index 29b1488..ffea650 100644 --- a/src/Controller/ImageController.php +++ b/src/Controller/ImageController.php @@ -13,9 +13,10 @@ final class ImageController extends AbstractController { - private const RANKING_IMAGE = ROOT_DIR . '/data/images/[WORLD]-ranking.png'; + private const RANKING_IMAGE = '/data/images/[WORLD]-ranking.png'; public function __construct( + private readonly string $rootDir, private readonly Request $request, ) { } @@ -33,6 +34,6 @@ public function image(): ResponseInterface throw new NotFoundException(); } - return new ImageResponse(str_replace('[WORLD]', $world->value, self::RANKING_IMAGE)); + return new ImageResponse(str_replace('[WORLD]', $world->value, $this->rootDir . self::RANKING_IMAGE)); } } diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index 6926138..c96ab67 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -8,11 +8,13 @@ use Jesperbeisner\Fwstats\Repository\ClanRepository; use Jesperbeisner\Fwstats\Repository\PlayerRepository; use Jesperbeisner\Fwstats\Stdlib\Interface\ResponseInterface; +use Jesperbeisner\Fwstats\Stdlib\Request; use Jesperbeisner\Fwstats\Stdlib\Response\HtmlResponse; final class IndexController extends AbstractController { public function __construct( + private readonly Request $request, private readonly PlayerRepository $playerRepository, private readonly ClanRepository $clanRepository, ) { @@ -25,6 +27,8 @@ public function index(): ResponseInterface 'chaosPlayers' => $this->playerRepository->findAllByWorldAndOrderedByTotalXp(WorldEnum::CHAOS), 'afsrvClans' => $this->clanRepository->findAllByWorld(WorldEnum::AFSRV), 'chaosClans' => $this->clanRepository->findAllByWorld(WorldEnum::CHAOS), + 'login' => $this->request->getGetParameter('login'), + 'logout' => $this->request->getGetParameter('logout'), ]); } } diff --git a/src/Controller/LogsController.php b/src/Controller/LogController.php similarity index 67% rename from src/Controller/LogsController.php rename to src/Controller/LogController.php index 4feeb53..f4716f9 100644 --- a/src/Controller/LogsController.php +++ b/src/Controller/LogController.php @@ -7,22 +7,20 @@ use Jesperbeisner\Fwstats\Repository\LogRepository; use Jesperbeisner\Fwstats\Stdlib\Exception\UnauthorizedException; use Jesperbeisner\Fwstats\Stdlib\Interface\ResponseInterface; -use Jesperbeisner\Fwstats\Stdlib\Request; +use Jesperbeisner\Fwstats\Stdlib\Interface\SessionInterface; use Jesperbeisner\Fwstats\Stdlib\Response\HtmlResponse; -final class LogsController extends AbstractController +final class LogController extends AbstractController { public function __construct( - private readonly string $logsPassword, - private readonly Request $request, + private readonly SessionInterface $session, private readonly LogRepository $logRepository, ) { } public function logs(): ResponseInterface { - $password = $this->request->getGetParameter('password'); - if ($password === null || $password !== $this->logsPassword) { + if ($this->session->getUser() === null) { throw new UnauthorizedException(); } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..578720d --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,57 @@ +request->isPost()) { + $email = $this->request->getPostParameter('email'); + $password = $this->request->getPostParameter('password'); + + if (empty($email) || empty($password)) { + throw new RedirectException('/login?error=fields-empty'); + } + + if (null === $user = $this->userRepository->findOneByEmail($email)) { + throw new RedirectException('/login?error=email-not-found'); + } + + if (password_verify($password, $user->password) === false) { + throw new RedirectException('/login?error=wrong-password'); + } + + $this->session->setUser($user); + + throw new RedirectException('/?login=success'); + } + + return new HtmlResponse('security/login.phtml', [ + 'error' => $this->request->getGetParameter('error'), + ]); + } + + public function logout(): void + { + $this->session->destroy(); + + throw new RedirectException('/?logout=success'); + } +} diff --git a/src/Helper/Length.php b/src/Helper/Length.php new file mode 100644 index 0000000..a7e479e --- /dev/null +++ b/src/Helper/Length.php @@ -0,0 +1,17 @@ + $length) { + return substr($string, 0, 20); + } + + return $string; + } +} diff --git a/src/Helper/UuidV4.php b/src/Helper/UuidV4.php new file mode 100644 index 0000000..2d09a00 --- /dev/null +++ b/src/Helper/UuidV4.php @@ -0,0 +1,18 @@ +colorBlack(); - if (false === imagettftext($this->image, $size, $angle, $x, $y, $color, self::ROBOTO_FONT, $text)) { + if (false === imagettftext($this->image, $size, $angle, $x, $y, $color, $this->rootDir . self::ROBOTO_FONT, $text)) { throw new ImageException('Could not write to image.'); } } @@ -108,4 +113,9 @@ protected function colorWhite(): int return $color; } + + protected function getImageFolder(): string + { + return $this->rootDir . self::IMAGE_FOLDER; + } } diff --git a/src/ImageService/Factory/RankingImageServiceFactory.php b/src/ImageService/Factory/RankingImageServiceFactory.php index 2da3216..85f1362 100644 --- a/src/ImageService/Factory/RankingImageServiceFactory.php +++ b/src/ImageService/Factory/RankingImageServiceFactory.php @@ -14,12 +14,19 @@ final class RankingImageServiceFactory implements FactoryInterface { public function __invoke(ContainerInterface $serviceContainer, string $serviceName): RankingImageService { + /** @var string $rootDir */ + $rootDir = $serviceContainer->get('rootDir'); + /** @var PlayerRepository $playerRepository */ $playerRepository = $serviceContainer->get(PlayerRepository::class); /** @var ClanRepository $clanRepository */ $clanRepository = $serviceContainer->get(ClanRepository::class); - return new RankingImageService($playerRepository, $clanRepository); + return new RankingImageService( + $rootDir, + $playerRepository, + $clanRepository, + ); } } diff --git a/src/ImageService/RankingImageService.php b/src/ImageService/RankingImageService.php index 846105c..4eb4e8e 100644 --- a/src/ImageService/RankingImageService.php +++ b/src/ImageService/RankingImageService.php @@ -14,9 +14,11 @@ final class RankingImageService extends AbstractImageService { public function __construct( + string $rootDir, private readonly PlayerRepository $playerRepository, private readonly ClanRepository $clanRepository, ) { + parent::__construct($rootDir); } public function create(WorldEnum $world): void @@ -45,7 +47,7 @@ public function create(WorldEnum $world): void $this->createTotalXpColumn($players); $this->createSoulLevelColumn($players); - $this->save(self::IMAGE_FOLDER . $world->value . '-ranking.png'); + $this->save($this->getImageFolder() . $world->value . '-ranking.png'); } /** diff --git a/src/Model/User.php b/src/Model/User.php new file mode 100644 index 0000000..6c5d041 --- /dev/null +++ b/src/Model/User.php @@ -0,0 +1,18 @@ +get(DatabaseInterface::class); + + return new UserRepository( + $database, + ); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..cf18674 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,63 @@ +table (uuid, email, password, created) VALUES (:uuid, :email, :password, :created)"; + + $this->database->insert($sql, [ + 'uuid' => $user->uuid, + 'email' => $user->email, + 'password' => $user->password, + 'created' => $user->created->format('Y-m-d H:i:s'), + ]); + } + + public function findOneByEmail(string $email): ?User + { + $sql = "SELECT uuid, email, password, created FROM $this->table WHERE email = :email"; + + /** @var array{uuid: string, email: string, password: string, created: string}|null $userData */ + $userData = $this->database->fetchOne($sql, ['email' => $email]); + + if ($userData === null) { + return null; + } + + return $this->hydrateUser($userData); + } + + public function deleteAll(): void + { + $this->database->deleteAll($this->table); + } + + /** + * @param array{uuid: string, email: string, password: string, created: string} $row + */ + private function hydrateUser(array $row): User + { + return new User( + $row['uuid'], + $row['email'], + $row['password'], + new DateTimeImmutable($row['created']), + ); + } +} diff --git a/src/Service/ViewRenderService.php b/src/Service/ViewRenderService.php index f3e8cb9..f432822 100644 --- a/src/Service/ViewRenderService.php +++ b/src/Service/ViewRenderService.php @@ -5,11 +5,12 @@ namespace Jesperbeisner\Fwstats\Service; use Jesperbeisner\Fwstats\Stdlib\Exception\RuntimeException; +use Jesperbeisner\Fwstats\Stdlib\Interface\SessionInterface; final class ViewRenderService { private const TITLE = 'FWSTATS'; - private const VIEWS_FOLDER = ROOT_DIR . '/views/'; + private const VIEWS_FOLDER = __DIR__ . '/../../views/'; private ?string $title = null; @@ -18,7 +19,8 @@ final class ViewRenderService */ public function __construct( private readonly string $template, - private readonly array $vars = [], + private readonly array $vars, + private readonly SessionInterface $session, ) { } @@ -39,13 +41,13 @@ public function render(): string return $content; } - public function get(string $id): mixed + public function get(string $key): mixed { - if (array_key_exists($id, $this->vars)) { - return $this->vars[$id]; + if (array_key_exists($key, $this->vars)) { + return $this->vars[$key]; } - throw new RuntimeException(); + throw new RuntimeException("The variable with key '$key' does not exist."); } public function getTitle(): string @@ -61,4 +63,9 @@ public function setTitle(string $title): void { $this->title = $title; } + + public function getSession(): SessionInterface + { + return $this->session; + } } diff --git a/src/Stdlib/Database.php b/src/Stdlib/Database.php new file mode 100644 index 0000000..1bec061 --- /dev/null +++ b/src/Stdlib/Database.php @@ -0,0 +1,41 @@ +pdo->prepare($sql)->execute($params); + } + + public function fetchOne(string $sql, array $params = []): ?array + { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + /** @var mixed[]|false $data */ + $data = $stmt->fetch(); + + if ($data === false) { + return null; + } + + return $data; + } + + public function deleteAll(string $tableName): void + { + $this->pdo->prepare("DELETE FROM $tableName")->execute(); + } +} diff --git a/src/Stdlib/Exception/RedirectException.php b/src/Stdlib/Exception/RedirectException.php new file mode 100644 index 0000000..39f5a9f --- /dev/null +++ b/src/Stdlib/Exception/RedirectException.php @@ -0,0 +1,15 @@ +get('rootDir'); + + $options = [ + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + + return new Database( + new PDO(dsn: 'sqlite:' . $rootDir . '/data/database/sqlite.db', options: $options), + ); + } +} diff --git a/src/Stdlib/Factory/LoggerFactory.php b/src/Stdlib/Factory/LoggerFactory.php index 8b8bba7..4726879 100644 --- a/src/Stdlib/Factory/LoggerFactory.php +++ b/src/Stdlib/Factory/LoggerFactory.php @@ -13,6 +13,11 @@ final class LoggerFactory implements FactoryInterface { public function __invoke(ContainerInterface $serviceContainer, string $serviceName): LoggerInterface { - return new Logger(); + /** @var string $rootDir */ + $rootDir = $serviceContainer->get('rootDir'); + + return new Logger( + $rootDir, + ); } } diff --git a/src/Stdlib/Factory/PdoFactory.php b/src/Stdlib/Factory/PdoFactory.php index c348d07..2bf663b 100644 --- a/src/Stdlib/Factory/PdoFactory.php +++ b/src/Stdlib/Factory/PdoFactory.php @@ -12,12 +12,15 @@ final class PdoFactory implements FactoryInterface { public function __invoke(ContainerInterface $serviceContainer, string $serviceName): PDO { + /** @var string $rootDir */ + $rootDir = $serviceContainer->get('rootDir'); + $options = [ PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]; - return new PDO(dsn: 'sqlite:' . ROOT_DIR . '/data/database/sqlite.db', options: $options); + return new PDO(dsn: 'sqlite:' . $rootDir . '/data/database/sqlite.db', options: $options); } } diff --git a/src/Stdlib/Factory/SessionFactory.php b/src/Stdlib/Factory/SessionFactory.php new file mode 100644 index 0000000..be7482b --- /dev/null +++ b/src/Stdlib/Factory/SessionFactory.php @@ -0,0 +1,21 @@ +get(UserRepository::class); + + return new Session($userRepository); + } +} diff --git a/src/Stdlib/Interface/DatabaseInterface.php b/src/Stdlib/Interface/DatabaseInterface.php new file mode 100644 index 0000000..9125ffe --- /dev/null +++ b/src/Stdlib/Interface/DatabaseInterface.php @@ -0,0 +1,21 @@ + $params + */ + public function insert(string $sql, array $params = []): void; + + /** + * @param array $params + * @return mixed[]|null + */ + public function fetchOne(string $sql, array $params = []): ?array; + + public function deleteAll(string $tableName): void; +} diff --git a/src/Stdlib/Interface/SessionInterface.php b/src/Stdlib/Interface/SessionInterface.php new file mode 100644 index 0000000..39e8a99 --- /dev/null +++ b/src/Stdlib/Interface/SessionInterface.php @@ -0,0 +1,22 @@ +rootDir . '/data/logs/fwstats.log', 'a')) { throw new RuntimeException('Could not open stdout resource'); } diff --git a/src/Stdlib/Request.php b/src/Stdlib/Request.php index 9fdeb24..ceddb68 100644 --- a/src/Stdlib/Request.php +++ b/src/Stdlib/Request.php @@ -67,4 +67,9 @@ public function getCookieParameter(string $id): ?string return null; } + + public function isPost(): bool + { + return strtoupper($this->httpMethod) === 'POST'; + } } diff --git a/src/Stdlib/Response/HtmlResponse.php b/src/Stdlib/Response/HtmlResponse.php index 71ded9e..b4f9f00 100644 --- a/src/Stdlib/Response/HtmlResponse.php +++ b/src/Stdlib/Response/HtmlResponse.php @@ -6,9 +6,12 @@ use Jesperbeisner\Fwstats\Service\ViewRenderService; use Jesperbeisner\Fwstats\Stdlib\Interface\ResponseInterface; +use Jesperbeisner\Fwstats\Stdlib\Interface\SessionInterface; final class HtmlResponse implements ResponseInterface { + private SessionInterface $session; + /** * @param array $vars */ @@ -21,9 +24,16 @@ public function __construct( public function send(): never { + $viewRenderService = new ViewRenderService($this->template, $this->vars, $this->session); + http_response_code($this->statusCode); - echo (new ViewRenderService($this->template, $this->vars))->render(); + echo $viewRenderService->render(); exit(0); } + + public function setSession(SessionInterface $session): void + { + $this->session = $session; + } } diff --git a/src/Stdlib/Response/ImageResponse.php b/src/Stdlib/Response/ImageResponse.php index 43e2821..a0dcdf4 100644 --- a/src/Stdlib/Response/ImageResponse.php +++ b/src/Stdlib/Response/ImageResponse.php @@ -8,7 +8,7 @@ final class ImageResponse implements ResponseInterface { - private const PLACEHOLDER_IMAGE = ROOT_DIR . '/data/images/404-image.png'; + private const PLACEHOLDER_IMAGE = __DIR__ . '/../../data/images/404-image.png'; public function __construct( private readonly string $imageFileName diff --git a/src/Stdlib/Session.php b/src/Stdlib/Session.php new file mode 100644 index 0000000..7741ac5 --- /dev/null +++ b/src/Stdlib/Session.php @@ -0,0 +1,91 @@ +isSessionStarted() === false) { + $options = [ + 'name' => 'FWSTATS', + ]; + + if (session_start($options) === false) { + throw new SessionException('Could not start the session?! o.O'); + } + } + } + + public function destroy(): void + { + if ($this->isSessionStarted()) { + $this->user = null; + $_SESSION = []; + session_destroy(); + } + } + + public function get(string $key): mixed + { + if ($this->isSessionStarted() === false) { + throw new SessionException('The session has not been started yet.'); + } + + return $_SESSION[$key] ?? null; + } + + public function set(string $key, mixed $value): void + { + if ($this->isSessionStarted() === false) { + throw new SessionException('The session has not been started yet.'); + } + + $_SESSION[$key] = $value; + } + + public function getUser(): ?User + { + if ($this->user !== null) { + return $this->user; + } + + $user = $this->get('user'); + + if ($user === null) { + return null; + } + + if (is_string($user) === false) { + throw new SessionException("Method 'get' did not return a string."); + } + + $this->user = $this->userRepository->findOneByEmail($user); + + return $this->user; + } + + public function setUser(User $user): void + { + $this->set('user', $user->email); + } + + private function isSessionStarted(): bool + { + return session_status() === PHP_SESSION_ACTIVE; + } +} diff --git a/tests/Dummy/DatabaseDummy.php b/tests/Dummy/DatabaseDummy.php new file mode 100644 index 0000000..663412d --- /dev/null +++ b/tests/Dummy/DatabaseDummy.php @@ -0,0 +1,34 @@ +fetchOneResult = $fetchOneResult; + } + + public function deleteAll(string $tableName): void + { + } + + public function insert(string $sql, array $params = []): void + { + } + + public function fetchOne(string $sql, array $params = []): ?array + { + return $this->fetchOneResult; + } +} diff --git a/tests/Dummy/SessionDummy.php b/tests/Dummy/SessionDummy.php new file mode 100644 index 0000000..997478b --- /dev/null +++ b/tests/Dummy/SessionDummy.php @@ -0,0 +1,52 @@ +session[$key] ?? null; + } + + public function set(string $key, mixed $value): void + { + $this->session[$key] = $value; + } + + public function getUser(): ?User + { + if ($this->user !== null) { + return $this->user; + } + + /** @var User|null $user */ + $user = $this->session['user'] ?? null; + + $this->user = $user; + + return $this->user; + } + + public function setUser(User $user): void + { + $this->session['user'] = $user; + } +} diff --git a/tests/Unit/Action/CreateUserActionTest.php b/tests/Unit/Action/CreateUserActionTest.php new file mode 100644 index 0000000..a67ed0d --- /dev/null +++ b/tests/Unit/Action/CreateUserActionTest.php @@ -0,0 +1,99 @@ +createUserAction = new CreateUserAction(new UserRepository(new DatabaseDummy())); + } + + public function test_will_throw_ActionException_when_email_is_not_set(): void + { + self::expectException(ActionException::class); + self::expectExceptionMessage("No email set in the 'AbstractAction::configure' method."); + + $this->createUserAction->configure(['no-email' => 'test@test.com', 'password' => 'Password123']); + } + + public function test_will_throw_ActionException_when_password_is_not_set(): void + { + self::expectException(ActionException::class); + self::expectExceptionMessage("No password set in the 'AbstractAction::configure' method."); + + $this->createUserAction->configure(['email' => 'test@test.com', 'no-password' => 'Password123']); + } + + public function test_will_throw_ActionException_when_email_is_not_a_string(): void + { + self::expectException(ActionException::class); + self::expectExceptionMessage("The email set in the 'AbstractAction::configure' method is not a string."); + + $this->createUserAction->configure(['email' => 123, 'password' => 'Password123']); + } + + public function test_will_throw_ActionException_when_password_is_not_a_string(): void + { + self::expectException(ActionException::class); + self::expectExceptionMessage("The password set in the 'AbstractAction::configure' method is not a string."); + + $this->createUserAction->configure(['email' => 'test@test.com', 'password' => 123]); + } + + public function test_will_throw_ActionException_when_email_is_not_valid(): void + { + self::expectException(ActionException::class); + self::expectExceptionMessage("The email 'test' is not valid email address."); + + $this->createUserAction->configure(['email' => 'test', 'password' => 'Password123']); + } + + public function test_will_throw_ActionException_when_password_is_not_at_least_8_characters_long(): void + { + self::expectException(ActionException::class); + self::expectExceptionMessage("The password must be at least 8 characters long."); + + $this->createUserAction->configure(['email' => 'test@test.com', 'password' => 'test']); + } + + public function test_will_throw_ActionException_when_user_with_this_email_already_exists(): void + { + $database = new DatabaseDummy(); + $database->setFetchOneResult(['uuid' => 'test', 'email' => 'test@test.com', 'password' => 'test', 'created' => '2022-01-01']); + + $createUserAction = new CreateUserAction(new UserRepository($database)); + + $createUserAction->configure(['email' => 'test@test.com', 'password' => 'Password123']); + + self::expectException(ActionException::class); + self::expectExceptionMessage("A user with email 'test@test.com' already exists."); + + $createUserAction->run(); + } + + public function test_will_create_a_new_user_and_returns_CreateUserActionResult_with_user(): void + { + $this->createUserAction->configure(['email' => 'test@test.com', 'password' => 'Password123']); + + $createUserActionResult = $this->createUserAction->run(); + + self::assertTrue($createUserActionResult->isSuccess()); + self::assertArrayHasKey('user', $createUserActionResult->getData()); + self::assertInstanceOf(User::class, $createUserActionResult->getUser()); + } +} diff --git a/tests/Unit/Action/Result/CreateUserActionResultTest.php b/tests/Unit/Action/Result/CreateUserActionResultTest.php new file mode 100644 index 0000000..e3d93f0 --- /dev/null +++ b/tests/Unit/Action/Result/CreateUserActionResultTest.php @@ -0,0 +1,78 @@ +isSuccess()); + + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::FAILURE); + self::assertFalse($createUserActionResultTest->isSuccess()); + } + + public function test_will_throw_ActionResultException_when_user_is_not_set_in_data_array(): void + { + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::SUCCESS); + + self::expectException(ActionResultException::class); + self::expectExceptionMessage('No user in data array available.'); + + $createUserActionResultTest->getUser(); + } + + public function test_will_return_user_when_user_is_set_in_data_array(): void + { + $user = new User('test', 'test@test.com', 'test', new DateTimeImmutable('2000-01-01')); + + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::SUCCESS, ['user' => $user]); + + $resultUser = $createUserActionResultTest->getUser(); + + self::assertSame($user, $resultUser); + } + + public function test_data_is_empty_when_not_set(): void + { + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::SUCCESS); + + self::assertSame([], $createUserActionResultTest->getData()); + } + + public function test_data_returns_the_same_array(): void + { + $array = ['test' => 'test', 1 => 2]; + + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::SUCCESS, $array); + + self::assertSame($array, $createUserActionResultTest->getData()); + } + + public function test_message_is_empty_when_not_set(): void + { + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::SUCCESS); + + self::assertSame('', $createUserActionResultTest->getMessage()); + } + + public function test_message_is_the_same_when_set(): void + { + $createUserActionResultTest = new CreateUserActionResult(ActionResultInterface::SUCCESS, [], 'test'); + + self::assertSame('test', $createUserActionResultTest->getMessage()); + } +} diff --git a/tests/Unit/DTO/PlaytimeTest.php b/tests/Unit/DTO/PlaytimeTest.php index c48c179..a0ba2ff 100644 --- a/tests/Unit/DTO/PlaytimeTest.php +++ b/tests/Unit/DTO/PlaytimeTest.php @@ -7,8 +7,11 @@ use Generator; use Jesperbeisner\Fwstats\DTO\Playtime; use Jesperbeisner\Fwstats\Enum\WorldEnum; -use Jesperbeisner\Fwstats\Tests\Unit\TestCase; +use PHPUnit\Framework\TestCase; +/** + * @covers \Jesperbeisner\Fwstats\DTO\Playtime + */ class PlaytimeTest extends TestCase { /** diff --git a/tests/Unit/Service/ViewRenderServiceTest.php b/tests/Unit/Service/ViewRenderServiceTest.php index 5db7e56..b92b024 100644 --- a/tests/Unit/Service/ViewRenderServiceTest.php +++ b/tests/Unit/Service/ViewRenderServiceTest.php @@ -5,13 +5,17 @@ namespace Jesperbeisner\Fwstats\Tests\Unit\Service; use Jesperbeisner\Fwstats\Service\ViewRenderService; -use Jesperbeisner\Fwstats\Tests\Unit\TestCase; +use Jesperbeisner\Fwstats\Tests\Dummy\SessionDummy; +use PHPUnit\Framework\TestCase; +/** + * @covers \Jesperbeisner\Fwstats\Service\ViewRenderService + */ final class ViewRenderServiceTest extends TestCase { public function test_title_creation(): void { - $viewRenderService = new ViewRenderService(''); + $viewRenderService = new ViewRenderService('', [], new SessionDummy()); self::assertSame('FWSTATS', $viewRenderService->getTitle()); $viewRenderService->setTitle('Index'); diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 7e8bb98..8882651 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -13,8 +13,16 @@ abstract class TestCase extends PHPUnitTestCase public function setUp(): void { - if (self::$serviceContainer === null) { - self::$serviceContainer = require __DIR__ . '/../../bootstrap.php'; - } + /** @var ServiceContainer $serviceContainer */ + $serviceContainer = require __DIR__ . '/../../bootstrap.php'; + + $serviceContainer->set('appEnv', 'test'); + + self::$serviceContainer = $serviceContainer; + } + + public function tearDown(): void + { + self::$serviceContainer = null; } } diff --git a/views/index/index.phtml b/views/index/index.phtml index 5805d7c..7d7734e 100644 --- a/views/index/index.phtml +++ b/views/index/index.phtml @@ -7,9 +7,29 @@ use Jesperbeisner\Fwstats\Model\Player; /** @var Jesperbeisner\Fwstats\Service\ViewRenderService $this */ +/** @var string|null $loginText */ +$loginText = $this->vars['login']; + +/** @var string|null $logoutText */ +$logoutText = $this->vars['logout']; + $this->setTitle('Index'); ?> + +
+
+ Du wurdest erfolgreich eingeloggt. +
+ + + +
+
+ Du wurdest erfolgreich ausgeloggt. +
+ +
    diff --git a/views/layout.phtml b/views/layout.phtml index ebba896..3fc81da 100644 --- a/views/layout.phtml +++ b/views/layout.phtml @@ -1,5 +1,10 @@ - - + diff --git a/views/navigation.phtml b/views/navigation.phtml index 9379d9e..c666367 100644 --- a/views/navigation.phtml +++ b/views/navigation.phtml @@ -1,3 +1,10 @@ +