diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..9e6a643 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,11 @@ +.idea/ +.vscode/ +/coverage/ +/vendor/ +/logs/* +!/logs/README.md +.phpunit.result.cache +/composer.lock +/codeception.yml + +/tests/ diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..8707838 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,5 @@ +FROM php:8.0-apache + +WORKDIR /var/www + +RUN a2enmod rewrite \ No newline at end of file diff --git a/api/app/dependencies.php b/api/app/dependencies.php new file mode 100644 index 0000000..ec98059 --- /dev/null +++ b/api/app/dependencies.php @@ -0,0 +1,29 @@ +addDefinitions([ + LoggerInterface::class => function (ContainerInterface $c) { + $settings = $c->get(SettingsInterface::class); + + $loggerSettings = $settings->get('logger'); + $logger = new Logger($loggerSettings['name']); + + $processor = new UidProcessor(); + $logger->pushProcessor($processor); + + $handler = new StreamHandler($loggerSettings['path'], $loggerSettings['level']); + $logger->pushHandler($handler); + + return $logger; + }, + ]); +}; diff --git a/api/app/middleware.php b/api/app/middleware.php new file mode 100644 index 0000000..780c46f --- /dev/null +++ b/api/app/middleware.php @@ -0,0 +1,9 @@ +add(SessionMiddleware::class); +}; diff --git a/api/app/repositories.php b/api/app/repositories.php new file mode 100644 index 0000000..b3d4996 --- /dev/null +++ b/api/app/repositories.php @@ -0,0 +1,13 @@ +addDefinitions([ + UserRepository::class => \DI\autowire(InMemoryUserRepository::class), + ]); +}; diff --git a/api/app/routes.php b/api/app/routes.php new file mode 100644 index 0000000..fc279da --- /dev/null +++ b/api/app/routes.php @@ -0,0 +1,26 @@ +options('/{routes:.*}', function (Request $request, Response $response) { + // CORS Pre-Flight OPTIONS Request Handler + return $response; + }); + + $app->get('/', function (Request $request, Response $response) { + $response->getBody()->write('Hello world!'); + return $response; + }); + + $app->group('/users', function (Group $group) { + $group->get('', ListUsersAction::class); + $group->get('/{id}', ViewUserAction::class); + }); +}; diff --git a/api/app/settings.php b/api/app/settings.php new file mode 100644 index 0000000..2ad3504 --- /dev/null +++ b/api/app/settings.php @@ -0,0 +1,26 @@ +addDefinitions([ + SettingsInterface::class => function () { + return new Settings([ + 'displayErrorDetails' => true, // Should be set to false in production + 'logError' => false, + 'logErrorDetails' => false, + 'logger' => [ + 'name' => 'slim-app', + 'path' => isset($_ENV['docker']) ? 'php://stdout' : __DIR__ . '/../logs/app.log', + 'level' => Logger::DEBUG, + ], + ]); + } + ]); +}; diff --git a/api/composer.json b/api/composer.json new file mode 100644 index 0000000..babb684 --- /dev/null +++ b/api/composer.json @@ -0,0 +1,42 @@ +{ + "name": "torsten-hettstedt/timekeeping-api", + "description": "REST-Api zum F\u00fcllen einer Tabelle f\u00fcr die Arbeitszeiterfassung.", + "keywords": [ + "microframework", + "rest", + "router", + "psr7" + ], + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Torsten Lücke", + "email": "arbeit@torsten-hettstedt.de", + "homepage": "http://torsten-hettstedt.de/" + } + ], + "require": { + "php": "^8.0", + "monolog/monolog": "^2.2", + "php-di/php-di": "^6.3", + "slim/psr7": "^1.3", + "slim/slim": "^4.7", + "ext-json": "*" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.80", + "codeception/codeception": "^4.1.18", + "codeception/module-phpbrowser": "^1.0.0", + "codeception/module-asserts": "^1.0.0" + }, + "autoload": { + "psr-4": { + "TorstenHettstedt\\TimekeepingApi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "TorstenHettstedt\\TimekeepingApi\\Tests\\Unit\\": "tests/unit" + } + } +} \ No newline at end of file diff --git a/api/html/.htaccess b/api/html/.htaccess new file mode 100644 index 0000000..f5d1969 --- /dev/null +++ b/api/html/.htaccess @@ -0,0 +1,21 @@ + + RewriteEngine On + + # Some hosts may require you to use the `RewriteBase` directive. + # Determine the RewriteBase automatically and set it as environment variable. + # If you are using Apache aliases to do mass virtual hosting or installed the + # project in a subdirectory, the base path will be prepended to allow proper + # resolution of the index.php file and to redirect to the correct URI. It will + # work in environments without path prefix as well, providing a safe, one-size + # fits all solution. But as you do not need it in this case, you can comment + # the following 2 lines to eliminate the overhead. + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + # If the above doesn't work you might need to set the `RewriteBase` directive manually, it should be the + # absolute physical path to the directory that contains this htaccess file. + # RewriteBase / + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [QSA,L] + diff --git a/api/html/index.php b/api/html/index.php new file mode 100644 index 0000000..8b4f272 --- /dev/null +++ b/api/html/index.php @@ -0,0 +1,78 @@ +enableCompilation(__DIR__ . '/../var/cache'); +} + +// Set up settings +$settings = require __DIR__ . '/../app/settings.php'; +$settings($containerBuilder); + +// Set up dependencies +$dependencies = require __DIR__ . '/../app/dependencies.php'; +$dependencies($containerBuilder); + +// Set up repositories +$repositories = require __DIR__ . '/../app/repositories.php'; +$repositories($containerBuilder); + +// Build PHP-DI Container instance +$container = $containerBuilder->build(); + +// Instantiate the app +AppFactory::setContainer($container); +$app = AppFactory::create(); +$callableResolver = $app->getCallableResolver(); + +// Register middleware +$middleware = require __DIR__ . '/../app/middleware.php'; +$middleware($app); + +// Register routes +$routes = require __DIR__ . '/../app/routes.php'; +$routes($app); + +/** @var SettingsInterface $settings */ +$settings = $container->get(SettingsInterface::class); + +$displayErrorDetails = $settings->get('displayErrorDetails'); +$logError = $settings->get('logError'); +$logErrorDetails = $settings->get('logErrorDetails'); + +// Create Request object from globals +$serverRequestCreator = ServerRequestCreatorFactory::create(); +$request = $serverRequestCreator->createServerRequestFromGlobals(); + +// Create Error Handler +$responseFactory = $app->getResponseFactory(); +$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory); + +// Create Shutdown Handler +$shutdownHandler = new ShutdownHandler($request, $errorHandler, $displayErrorDetails); +register_shutdown_function($shutdownHandler); + +// Add Routing Middleware +$app->addRoutingMiddleware(); + +// Add Error Middleware +$errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, $logError, $logErrorDetails); +$errorMiddleware->setDefaultErrorHandler($errorHandler); + +// Run App & Emit Response +$response = $app->handle($request); +$responseEmitter = new ResponseEmitter(); +$responseEmitter->emit($response); diff --git a/api/logs/README.md b/api/logs/README.md new file mode 100644 index 0000000..d4a602e --- /dev/null +++ b/api/logs/README.md @@ -0,0 +1 @@ +Your Slim Framework application's log files will be written to this directory. diff --git a/api/phpstan.neon.dist b/api/phpstan.neon.dist new file mode 100644 index 0000000..45876ad --- /dev/null +++ b/api/phpstan.neon.dist @@ -0,0 +1,2 @@ +parameters: + level: 4 diff --git a/api/src/Application/Actions/Action.php b/api/src/Application/Actions/Action.php new file mode 100644 index 0000000..90b0806 --- /dev/null +++ b/api/src/Application/Actions/Action.php @@ -0,0 +1,125 @@ +logger = $logger; + } + + /** + * @param Request $request + * @param Response $response + * @param array $args + * @return Response + * @throws HttpNotFoundException + * @throws HttpBadRequestException + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + $this->request = $request; + $this->response = $response; + $this->args = $args; + + try { + return $this->action(); + } catch (DomainRecordNotFoundException $e) { + throw new HttpNotFoundException($this->request, $e->getMessage()); + } + } + + /** + * @return Response + * @throws DomainRecordNotFoundException + * @throws HttpBadRequestException + */ + abstract protected function action(): Response; + + /** + * @return array|object + * @throws HttpBadRequestException + */ + protected function getFormData() + { + $input = json_decode(file_get_contents('php://input')); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new HttpBadRequestException($this->request, 'Malformed JSON input.'); + } + + return $input; + } + + /** + * @param string $name + * @return mixed + * @throws HttpBadRequestException + */ + protected function resolveArg(string $name) + { + if (!isset($this->args[$name])) { + throw new HttpBadRequestException($this->request, "Could not resolve argument `{$name}`."); + } + + return $this->args[$name]; + } + + /** + * @param array|object|null $data + * @param int $statusCode + * @return Response + */ + protected function respondWithData($data = null, int $statusCode = 200): Response + { + $payload = new ActionPayload($statusCode, $data); + + return $this->respond($payload); + } + + /** + * @param ActionPayload $payload + * @return Response + */ + protected function respond(ActionPayload $payload): Response + { + $json = json_encode($payload, JSON_PRETTY_PRINT); + $this->response->getBody()->write($json); + + return $this->response + ->withHeader('Content-Type', 'application/json') + ->withStatus($payload->getStatusCode()); + } +} diff --git a/api/src/Application/Actions/ActionError.php b/api/src/Application/Actions/ActionError.php new file mode 100644 index 0000000..2a326db --- /dev/null +++ b/api/src/Application/Actions/ActionError.php @@ -0,0 +1,88 @@ +type = $type; + $this->description = $description; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @param string $type + * @return self + */ + public function setType(string $type): self + { + $this->type = $type; + return $this; + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @param string|null $description + * @return self + */ + public function setDescription(?string $description = null): self + { + $this->description = $description; + return $this; + } + + /** + * @return array + */ + public function jsonSerialize() + { + $payload = [ + 'type' => $this->type, + 'description' => $this->description, + ]; + + return $payload; + } +} diff --git a/api/src/Application/Actions/ActionPayload.php b/api/src/Application/Actions/ActionPayload.php new file mode 100644 index 0000000..bf29e73 --- /dev/null +++ b/api/src/Application/Actions/ActionPayload.php @@ -0,0 +1,81 @@ +statusCode = $statusCode; + $this->data = $data; + $this->error = $error; + } + + /** + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return array|null|object + */ + public function getData() + { + return $this->data; + } + + /** + * @return ActionError|null + */ + public function getError(): ?ActionError + { + return $this->error; + } + + /** + * @return array + */ + public function jsonSerialize() + { + $payload = [ + 'statusCode' => $this->statusCode, + ]; + + if ($this->data !== null) { + $payload['data'] = $this->data; + } elseif ($this->error !== null) { + $payload['error'] = $this->error; + } + + return $payload; + } +} diff --git a/api/src/Application/Actions/User/ListUsersAction.php b/api/src/Application/Actions/User/ListUsersAction.php new file mode 100644 index 0000000..395fcd0 --- /dev/null +++ b/api/src/Application/Actions/User/ListUsersAction.php @@ -0,0 +1,21 @@ +userRepository->findAll(); + + $this->logger->info("Users list was viewed."); + + return $this->respondWithData($users); + } +} diff --git a/api/src/Application/Actions/User/UserAction.php b/api/src/Application/Actions/User/UserAction.php new file mode 100644 index 0000000..b225511 --- /dev/null +++ b/api/src/Application/Actions/User/UserAction.php @@ -0,0 +1,27 @@ +userRepository = $userRepository; + } +} diff --git a/api/src/Application/Actions/User/ViewUserAction.php b/api/src/Application/Actions/User/ViewUserAction.php new file mode 100644 index 0000000..f5b2d58 --- /dev/null +++ b/api/src/Application/Actions/User/ViewUserAction.php @@ -0,0 +1,22 @@ +resolveArg('id'); + $user = $this->userRepository->findUserOfId($userId); + + $this->logger->info("User of id `${userId}` was viewed."); + + return $this->respondWithData($user); + } +} diff --git a/api/src/Application/Handlers/HttpErrorHandler.php b/api/src/Application/Handlers/HttpErrorHandler.php new file mode 100644 index 0000000..d7982e5 --- /dev/null +++ b/api/src/Application/Handlers/HttpErrorHandler.php @@ -0,0 +1,69 @@ +exception; + $statusCode = 500; + $error = new ActionError( + ActionError::SERVER_ERROR, + 'An internal error has occurred while processing your request.' + ); + + if ($exception instanceof HttpException) { + $statusCode = $exception->getCode(); + $error->setDescription($exception->getMessage()); + + if ($exception instanceof HttpNotFoundException) { + $error->setType(ActionError::RESOURCE_NOT_FOUND); + } elseif ($exception instanceof HttpMethodNotAllowedException) { + $error->setType(ActionError::NOT_ALLOWED); + } elseif ($exception instanceof HttpUnauthorizedException) { + $error->setType(ActionError::UNAUTHENTICATED); + } elseif ($exception instanceof HttpForbiddenException) { + $error->setType(ActionError::INSUFFICIENT_PRIVILEGES); + } elseif ($exception instanceof HttpBadRequestException) { + $error->setType(ActionError::BAD_REQUEST); + } elseif ($exception instanceof HttpNotImplementedException) { + $error->setType(ActionError::NOT_IMPLEMENTED); + } + } + + if ( + !($exception instanceof HttpException) + && $exception instanceof Throwable + && $this->displayErrorDetails + ) { + $error->setDescription($exception->getMessage()); + } + + $payload = new ActionPayload($statusCode, null, $error); + $encodedPayload = json_encode($payload, JSON_PRETTY_PRINT); + + $response = $this->responseFactory->createResponse($statusCode); + $response->getBody()->write($encodedPayload); + + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/api/src/Application/Handlers/ShutdownHandler.php b/api/src/Application/Handlers/ShutdownHandler.php new file mode 100644 index 0000000..f81a71b --- /dev/null +++ b/api/src/Application/Handlers/ShutdownHandler.php @@ -0,0 +1,83 @@ +request = $request; + $this->errorHandler = $errorHandler; + $this->displayErrorDetails = $displayErrorDetails; + } + + public function __invoke() + { + $error = error_get_last(); + if ($error) { + $errorFile = $error['file']; + $errorLine = $error['line']; + $errorMessage = $error['message']; + $errorType = $error['type']; + $message = 'An error while processing your request. Please try again later.'; + + if ($this->displayErrorDetails) { + switch ($errorType) { + case E_USER_ERROR: + $message = "FATAL ERROR: {$errorMessage}. "; + $message .= " on line {$errorLine} in file {$errorFile}."; + break; + + case E_USER_WARNING: + $message = "WARNING: {$errorMessage}"; + break; + + case E_USER_NOTICE: + $message = "NOTICE: {$errorMessage}"; + break; + + default: + $message = "ERROR: {$errorMessage}"; + $message .= " on line {$errorLine} in file {$errorFile}."; + break; + } + } + + $exception = new HttpInternalServerErrorException($this->request, $message); + $response = $this->errorHandler->__invoke($this->request, $exception, $this->displayErrorDetails, false, false); + + $responseEmitter = new ResponseEmitter(); + $responseEmitter->emit($response); + } + } +} diff --git a/api/src/Application/Middleware/SessionMiddleware.php b/api/src/Application/Middleware/SessionMiddleware.php new file mode 100644 index 0000000..e06d788 --- /dev/null +++ b/api/src/Application/Middleware/SessionMiddleware.php @@ -0,0 +1,25 @@ +withAttribute('session', $_SESSION); + } + + return $handler->handle($request); + } +} diff --git a/api/src/Application/ResponseEmitter/ResponseEmitter.php b/api/src/Application/ResponseEmitter/ResponseEmitter.php new file mode 100644 index 0000000..eeee133 --- /dev/null +++ b/api/src/Application/ResponseEmitter/ResponseEmitter.php @@ -0,0 +1,34 @@ +withHeader('Access-Control-Allow-Credentials', 'true') + ->withHeader('Access-Control-Allow-Origin', $origin) + ->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization') + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->withAddedHeader('Cache-Control', 'post-check=0, pre-check=0') + ->withHeader('Pragma', 'no-cache'); + + if (ob_get_contents()) { + ob_clean(); + } + + parent::emit($response); + } +} diff --git a/api/src/Application/Settings/Settings.php b/api/src/Application/Settings/Settings.php new file mode 100644 index 0000000..ab2143f --- /dev/null +++ b/api/src/Application/Settings/Settings.php @@ -0,0 +1,30 @@ +settings = $settings; + } + + /** + * @param string $key + * @return mixed + */ + public function get(string $key = '') + { + return (empty($key)) ? $this->settings : $this->settings[$key]; + } +} \ No newline at end of file diff --git a/api/src/Application/Settings/SettingsInterface.php b/api/src/Application/Settings/SettingsInterface.php new file mode 100644 index 0000000..f830cda --- /dev/null +++ b/api/src/Application/Settings/SettingsInterface.php @@ -0,0 +1,13 @@ +id = $id; + $this->username = strtolower($username); + $this->firstName = ucfirst($firstName); + $this->lastName = ucfirst($lastName); + } + + /** + * @return int|null + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * @return string + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * @return string + */ + public function getFirstName(): string + { + return $this->firstName; + } + + /** + * @return string + */ + public function getLastName(): string + { + return $this->lastName; + } + + /** + * @return array + */ + public function jsonSerialize() + { + return [ + 'id' => $this->id, + 'username' => $this->username, + 'firstName' => $this->firstName, + 'lastName' => $this->lastName, + ]; + } +} diff --git a/api/src/Domain/User/UserNotFoundException.php b/api/src/Domain/User/UserNotFoundException.php new file mode 100644 index 0000000..21e1561 --- /dev/null +++ b/api/src/Domain/User/UserNotFoundException.php @@ -0,0 +1,11 @@ +users = $users ?? [ + 1 => new User(1, 'bill.gates', 'Bill', 'Gates'), + 2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'), + 3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'), + 4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'), + 5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'), + ]; + } + + /** + * {@inheritdoc} + */ + public function findAll(): array + { + return array_values($this->users); + } + + /** + * {@inheritdoc} + */ + public function findUserOfId(int $id): User + { + if (!isset($this->users[$id])) { + throw new UserNotFoundException(); + } + + return $this->users[$id]; + } +} diff --git a/api/tests/unit/Application/Actions/ActionTest.php b/api/tests/unit/Application/Actions/ActionTest.php new file mode 100644 index 0000000..feb9900 --- /dev/null +++ b/api/tests/unit/Application/Actions/ActionTest.php @@ -0,0 +1,78 @@ +getAppInstance(); + $container = $app->getContainer(); + $logger = $container->get(LoggerInterface::class); + + $testAction = new class($logger) extends Action { + public function __construct( + LoggerInterface $loggerInterface + ) { + parent::__construct($loggerInterface); + } + + public function action(): Response + { + return $this->respond( + new ActionPayload( + 202, + [ + 'willBeDoneAt' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM) + ] + ) + ); + } + }; + + $app->get('/test-action-response-code', $testAction); + $request = $this->createRequest('GET', '/test-action-response-code'); + $response = $app->handle($request); + + $this->assertEquals(202, $response->getStatusCode()); + } + + public function testActionSetsHttpCodeRespondData() + { + $app = $this->getAppInstance(); + $container = $app->getContainer(); + $logger = $container->get(LoggerInterface::class); + + $testAction = new class($logger) extends Action { + public function __construct( + LoggerInterface $loggerInterface + ) { + parent::__construct($loggerInterface); + } + + public function action(): Response + { + return $this->respondWithData( + [ + 'willBeDoneAt' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM) + ], + 202 + ); + } + }; + + $app->get('/test-action-response-code', $testAction); + $request = $this->createRequest('GET', '/test-action-response-code'); + $response = $app->handle($request); + + $this->assertEquals(202, $response->getStatusCode()); + } +} diff --git a/api/tests/unit/Application/Actions/User/ListUserActionTest.php b/api/tests/unit/Application/Actions/User/ListUserActionTest.php new file mode 100644 index 0000000..31f154f --- /dev/null +++ b/api/tests/unit/Application/Actions/User/ListUserActionTest.php @@ -0,0 +1,40 @@ +getAppInstance(); + + /** @var Container $container */ + $container = $app->getContainer(); + + $user = new User(1, 'bill.gates', 'Bill', 'Gates'); + + $userRepositoryProphecy = $this->prophesize(UserRepository::class); + $userRepositoryProphecy + ->findAll() + ->willReturn([$user]) + ->shouldBeCalledOnce(); + + $container->set(UserRepository::class, $userRepositoryProphecy->reveal()); + + $request = $this->createRequest('GET', '/users'); + $response = $app->handle($request); + + $payload = (string) $response->getBody(); + $expectedPayload = new ActionPayload(200, [$user]); + $serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT); + + $this->assertEquals($serializedPayload, $payload); + } +} diff --git a/api/tests/unit/Application/Actions/User/ViewUserActionTest.php b/api/tests/unit/Application/Actions/User/ViewUserActionTest.php new file mode 100644 index 0000000..c9334bf --- /dev/null +++ b/api/tests/unit/Application/Actions/User/ViewUserActionTest.php @@ -0,0 +1,79 @@ +getAppInstance(); + + /** @var Container $container */ + $container = $app->getContainer(); + + $user = new User(1, 'bill.gates', 'Bill', 'Gates'); + + $userRepositoryProphecy = $this->prophesize(UserRepository::class); + $userRepositoryProphecy + ->findUserOfId(1) + ->willReturn($user) + ->shouldBeCalledOnce(); + + $container->set(UserRepository::class, $userRepositoryProphecy->reveal()); + + $request = $this->createRequest('GET', '/users/1'); + $response = $app->handle($request); + + $payload = (string) $response->getBody(); + $expectedPayload = new ActionPayload(200, $user); + $serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT); + + $this->assertEquals($serializedPayload, $payload); + } + + public function testActionThrowsUserNotFoundException() + { + $app = $this->getAppInstance(); + + $callableResolver = $app->getCallableResolver(); + $responseFactory = $app->getResponseFactory(); + + $errorHandler = new HttpErrorHandler($callableResolver, $responseFactory); + $errorMiddleware = new ErrorMiddleware($callableResolver, $responseFactory, true, false ,false); + $errorMiddleware->setDefaultErrorHandler($errorHandler); + + $app->add($errorMiddleware); + + /** @var Container $container */ + $container = $app->getContainer(); + + $userRepositoryProphecy = $this->prophesize(UserRepository::class); + $userRepositoryProphecy + ->findUserOfId(1) + ->willThrow(new UserNotFoundException()) + ->shouldBeCalledOnce(); + + $container->set(UserRepository::class, $userRepositoryProphecy->reveal()); + + $request = $this->createRequest('GET', '/users/1'); + $response = $app->handle($request); + + $payload = (string) $response->getBody(); + $expectedError = new ActionError(ActionError::RESOURCE_NOT_FOUND, 'The user you requested does not exist.'); + $expectedPayload = new ActionPayload(404, null, $expectedError); + $serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT); + + $this->assertEquals($serializedPayload, $payload); + } +} diff --git a/api/tests/unit/Domain/User/UserTest.php b/api/tests/unit/Domain/User/UserTest.php new file mode 100644 index 0000000..56bc1f2 --- /dev/null +++ b/api/tests/unit/Domain/User/UserTest.php @@ -0,0 +1,59 @@ +assertEquals($id, $user->getId()); + $this->assertEquals($username, $user->getUsername()); + $this->assertEquals($firstName, $user->getFirstName()); + $this->assertEquals($lastName, $user->getLastName()); + } + + /** + * @dataProvider userProvider + * @param int $id + * @param string $username + * @param string $firstName + * @param string $lastName + */ + public function testJsonSerialize(int $id, string $username, string $firstName, string $lastName) + { + $user = new User($id, $username, $firstName, $lastName); + + $expectedPayload = json_encode([ + 'id' => $id, + 'username' => $username, + 'firstName' => $firstName, + 'lastName' => $lastName, + ]); + + $this->assertEquals($expectedPayload, json_encode($user)); + } +} diff --git a/api/tests/unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php b/api/tests/unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php new file mode 100644 index 0000000..975d1c4 --- /dev/null +++ b/api/tests/unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php @@ -0,0 +1,52 @@ + $user]); + + $this->assertEquals([$user], $userRepository->findAll()); + } + + public function testFindAllUsersByDefault() + { + $users = [ + 1 => new User(1, 'bill.gates', 'Bill', 'Gates'), + 2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'), + 3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'), + 4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'), + 5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'), + ]; + + $userRepository = new InMemoryUserRepository(); + + $this->assertEquals(array_values($users), $userRepository->findAll()); + } + + public function testFindUserOfId() + { + $user = new User(1, 'bill.gates', 'Bill', 'Gates'); + + $userRepository = new InMemoryUserRepository([1 => $user]); + + $this->assertEquals($user, $userRepository->findUserOfId(1)); + } + + public function testFindUserOfIdThrowsNotFoundException() + { + $userRepository = new InMemoryUserRepository([]); + $this->expectException(UserNotFoundException::class); + $userRepository->findUserOfId(1); + } +} diff --git a/api/tests/unit/TestCase.php b/api/tests/unit/TestCase.php new file mode 100644 index 0000000..f789e27 --- /dev/null +++ b/api/tests/unit/TestCase.php @@ -0,0 +1,86 @@ +build(); + + // Instantiate the app + AppFactory::setContainer($container); + $app = AppFactory::create(); + + // Register middleware + $middleware = require __DIR__ . '/../../app/middleware.php'; + $middleware($app); + + // Register routes + $routes = require __DIR__ . '/../../app/routes.php'; + $routes($app); + + return $app; + } + + /** + * @param string $method + * @param string $path + * @param array $headers + * @param array $cookies + * @param array $serverParams + * @return Request + */ + protected function createRequest( + string $method, + string $path, + array $headers = ['HTTP_ACCEPT' => 'application/json'], + array $cookies = [], + array $serverParams = [] + ): Request { + $uri = new Uri('', '', 80, $path); + $handle = fopen('php://temp', 'w+'); + $stream = (new StreamFactory())->createStreamFromResource($handle); + + $h = new Headers(); + foreach ($headers as $name => $value) { + $h->addHeader($name, $value); + } + + return new SlimRequest($method, $uri, $h, $cookies, $serverParams, $stream); + } +} diff --git a/api/var/cache/.gitignore b/api/var/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/api/var/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..9ab158a --- /dev/null +++ b/compose.yml @@ -0,0 +1,14 @@ +volumes: + logs: + driver: local + +services: + api: + build: ./api/ + environment: + docker: "true" + ports: + - 8090:80 + volumes: + - ./api:/var/www + - logs:/var/www/logs