├── .editorconfig ├── .env ├── .env.test ├── .gitignore ├── .php_cs.dist ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── behat.yml.dist ├── bin └── console ├── composer.json ├── composer.lock ├── config ├── bootstrap.php ├── bundles.php ├── packages │ ├── cache.yaml │ ├── dev │ │ ├── twig.yaml │ │ └── web_profiler.yaml │ ├── doctrine.yaml │ ├── framework.yaml │ ├── nelmio_cors.yaml │ ├── prod │ │ ├── doctrine.yaml │ │ └── routing.yaml │ ├── routing.yaml │ └── test │ │ ├── framework.yaml │ │ └── services.yaml ├── preload.php ├── routes.yaml ├── routes │ ├── annotations.yaml │ └── dev │ │ ├── framework.yaml │ │ └── web_profiler.yaml └── services.yaml ├── docker-compose.yml ├── docker ├── php │ └── Dockerfile └── postgres │ ├── Dockerfile │ └── init.sql ├── docs ├── index.html └── openapi.yml ├── features ├── bootstrap │ ├── BehatProphecyTrait.php │ ├── FeatureContext.php │ └── bootstrap.php └── running-session.feature ├── phpunit.xml.dist ├── public └── index.php ├── src ├── Application │ └── Command │ │ ├── RegisterRunningSession.php │ │ └── RegisterRunningSessionHandler.php ├── Domain │ ├── CannotGetCurrentTemperature.php │ ├── RunningSession.php │ ├── RunningSessionRepository.php │ └── WeatherProvider.php ├── Infrastructure │ ├── Database │ │ └── PostgresRunningSessionRepository.php │ ├── Http │ │ ├── CurrentConditionDeserializer.php │ │ └── HttpAccuWeatherProvider.php │ └── Symfony │ │ ├── Controller │ │ └── RunningSessionController.php │ │ └── Serializer │ │ ├── RegisterRunningSessionDeserializer.php │ │ └── RunningSessionSerializer.php └── Kernel.php ├── symfony.lock └── tests ├── Application └── Command │ └── RegisterRunningSessionHandlerTest.php ├── Domain └── RunningSessionFactory.php ├── Infrastructure ├── Database │ └── PostgresRunningSessionRepositoryTest.php ├── Http │ ├── CurrentConditionDeserializerTest.php │ └── HttpAccuWeatherProviderTest.php └── Symfony │ ├── Controller │ └── RunningSessionControllerTest.php │ └── Serializer │ ├── RegisterRunningSessionDeserializerTest.php │ └── RunningSessionSerializerTest.php └── bootstrap.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; see https://editorconfig.org/ 2 | ; top-most EditorConfig file 3 | root = true 4 | 5 | [*] 6 | end_of_line = LF 7 | indent_style = space 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | 12 | [*.php] 13 | indent_size = 4 14 | 15 | [{*.yml,*.yaml}] 16 | indent_size = 4 17 | 18 | [docs/**openapi.yml] 19 | indent_size = 2 20 | 21 | [docker-compose*.yml] 22 | indent_size = 2 23 | 24 | [*.json] 25 | indent_size = 4 26 | 27 | [*.feature] 28 | indent_size = 2 29 | 30 | [{.travis.yml,package.json}] 31 | # The indent size used in the `package.json` file cannot be changed 32 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 33 | indent_size = 2 34 | 35 | [Makefile] 36 | indent_style = tab 37 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # 13 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 14 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 15 | 16 | # See https://home.openweathermap.org/api_keys 17 | # OPENWEATHER_API_KEY=??? (defined in .env.local ignored by .gitignore) 18 | # See https://developer.accuweather.com/user/me/apps 19 | # ACCUWEATHER_API_KEY=??? (defined in .env.local ignored by .gitignore) 20 | ACCUWEATHER_BASE_URI='http://dataservice.accuweather.com/' 21 | 22 | ###> symfony/framework-bundle ### 23 | APP_ENV=dev 24 | APP_SECRET=d63e877b0444331114e7842dd8105ae1 25 | #TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 26 | #TRUSTED_HOSTS='^(localhost|example\.com)$' 27 | ###< symfony/framework-bundle ### 28 | 29 | ###> nelmio/cors-bundle ### 30 | CORS_ALLOW_CREDENTIALS=true 31 | CORS_ORIGIN_REGEX=false 32 | CORS_ALLOW_ORIGIN=* 33 | ###< nelmio/cors-bundle ### 34 | 35 | ###> doctrine/doctrine-bundle ### 36 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 37 | # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" 38 | # For a PostgreSQL database, use: "postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=11&charset=utf8" 39 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 40 | # DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7 41 | # NB : already set in dev and test by symfony server, see https://symfony.com/doc/4.4/setup/symfony_server.html#docker-integration 42 | ###< doctrine/doctrine-bundle ### 43 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | 6 | ACCUWEATHER_API_KEY='accuweatherTestKey' 7 | # See docker-compose.yml 8 | ACCUWEATHER_BASE_URI='http://wiremock:8080/' 9 | 10 | ###> doctrine/doctrine-bundle ### 11 | # See docker-compose.yml 12 | DATABASE_URL=postgres://forumphp:forumphp@database:5432/forumphp?sslmode=disable&charset=utf8 13 | ###< doctrine/doctrine-bundle ### 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ###> symfony/framework-bundle ### 3 | /.env.local 4 | /.env.local.php 5 | /.env.*.local 6 | /config/secrets/prod/prod.decrypt.private.php 7 | /public/bundles/ 8 | /var/ 9 | /vendor/ 10 | ###< symfony/framework-bundle ### 11 | 12 | ###> phpunit/phpunit ### 13 | /phpunit.xml 14 | .phpunit.result.cache 15 | ###< phpunit/phpunit ### 16 | 17 | ###> symfony/phpunit-bridge ### 18 | .phpunit 19 | .phpunit.result.cache 20 | /phpunit.xml 21 | ###< symfony/phpunit-bridge ### 22 | 23 | ###> friendsofphp/php-cs-fixer ### 24 | /.php_cs 25 | /.php_cs.cache 26 | ###< friendsofphp/php-cs-fixer ### 27 | 28 | ###> behat/symfony2-extension ### 29 | behat.yml 30 | ###< behat/symfony2-extension ### 31 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ->exclude('public/bundles') 7 | ->exclude('public/build') 8 | // exclude files generated by Symfony Flex recipes 9 | ->notPath('bin/console') 10 | ->notPath('public/index.php') 11 | ; 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRiskyAllowed(true) 15 | ->setRules([ 16 | '@Symfony' => true, 17 | '@Symfony:risky' => true, 18 | 'linebreak_after_opening_tag' => true, 19 | 'mb_str_functions' => true, 20 | 'no_php4_constructor' => true, 21 | 'no_unreachable_default_argument_value' => true, 22 | 'no_useless_else' => true, 23 | 'no_useless_return' => true, 24 | 'phpdoc_order' => true, 25 | 'strict_comparison' => true, 26 | 'strict_param' => true, 27 | ]) 28 | ->setFinder($finder) 29 | ; 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | env: 4 | global: 5 | - APP_ENV = test 6 | 7 | services: 8 | - docker 9 | 10 | # Travis can cache content between builds. This speeds up the build process and saves resources. 11 | cache: 12 | directories: 13 | # Cache composer packages so "composer install" is faster. 14 | - $HOME/.composer/cache/files 15 | 16 | # Defines all jobs which Travis will run in parallel. For each PHP version we support we will run one job. 17 | jobs: 18 | # With fast finishing enabled, Travis CI will mark your build as finished as soon as one of two 19 | # conditions are met: The only remaining jobs are allowed to fail, or a job has already failed. In 20 | # these cases, the status of the build can already be determined, so there’s no need to wait around 21 | fast_finish: true 22 | 23 | branches: 24 | # Those branch will not be deleted. We want push to them without a Pull Request, but trigger the CI anyway 25 | only: 26 | - main 27 | - bad_implementation 28 | - bad_tests 29 | - integration_infra_medium_domain 30 | - integration_infra_medium_domain_wiremock 31 | - integration_infra_medium_domain_no_di 32 | - integration_infra_sociable 33 | - mock_secondary_ports_in_behat 34 | 35 | before_install: 36 | - make preinstall 37 | 38 | install: 39 | - make install 40 | 41 | script: 42 | - make test 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jean-Marie Lamodière 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # set default shell 2 | SHELL := $(shell which bash) 3 | 4 | # default shell options 5 | .SHELLFLAGS = -c 6 | 7 | .SILENT: ; # no need for @ 8 | .ONESHELL: ; # recipes execute in same shell 9 | .NOTPARALLEL: ; # wait for this target to finish 10 | .EXPORT_ALL_VARIABLES: ; # send all vars to shell 11 | 12 | .PHONY: help 13 | 14 | help: ## Show Help 15 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 16 | 17 | # Dev environment. Require php and symfony executables 18 | 19 | start: ## [dev] Start local dev server 20 | docker-compose up -d 21 | symfony serve -d 22 | 23 | ps: ## [dev] docker-compose ps 24 | docker-compose ps 25 | 26 | server-logs: ## [dev] Follow logs on local dev server 27 | symfony server:log 28 | 29 | docker-logs: ## [dev] Follow logs on docker containers 30 | docker-compose logs -f 31 | 32 | stop: ## [dev] Stop local dev server 33 | symfony server:stop 34 | docker-compose stop 35 | 36 | prune: ## [dev] Prune docker containers 37 | docker-compose down -v --rmi local --remove-orphans 38 | 39 | fix-style: ## [dev] Run php-cs-fixer 40 | vendor/bin/php-cs-fixer fix --config=.php_cs.dist -v 41 | 42 | # Test / CI environment. Require docker and docker-compose executables 43 | 44 | preinstall: ## Pre-install steps 45 | docker-compose up -d 46 | 47 | install: ## Install steps 48 | docker-compose run php composer install 49 | 50 | test:test-static ## Run all tests (static + dynamic) 51 | test:test-dynamic 52 | 53 | test-static:php-cs-fixer ## Run static tests 54 | test-static:lint-yaml 55 | test-static:lint-container 56 | test-static:composer-validate 57 | 58 | test-dynamic:phpunit ## Run dynamic tests 59 | test-dynamic:behat 60 | 61 | php-cs-fixer: ## [static] Checks coding standards. Fixable with "make fix-style" 62 | docker-compose run php ./vendor/bin/php-cs-fixer --config=.php_cs.dist fix --diff --dry-run -v 63 | 64 | lint-yaml: ## [static] Checks that the YAML config files contain no syntax errors 65 | docker-compose run php ./bin/console lint:yaml config --parse-tags 66 | 67 | lint-container: ## [static] Checks that arguments injected into services match type declarations. 68 | docker-compose run php ./bin/console lint:container 69 | 70 | composer-validate: ## [static] Checks that the composer.json and composer.lock files are valid 71 | docker-compose run php composer validate --strict 72 | 73 | phpunit: ## [dynamic] Run phpunit 74 | docker-compose run php vendor/bin/phpunit 75 | 76 | behat: ## [dynamic] Run behat 77 | docker-compose run php vendor/bin/behat 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TDD Demo - ForumPHP 2020 2 | 3 | [![Build Status](https://travis-ci.com/JMLamodiere/tdd-demo-forumphp2020.svg?branch=main)](https://travis-ci.com/JMLamodiere/tdd-demo-forumphp2020) 4 | 5 | Live coding examples used during [Forum PHP 2020 talk](https://afup.org/talks/3414-trop-de-mock-tue-le-test-ce-que-l-archi-hexagonale-a-change): 6 | 7 | - [:fr: :arrow_forward: Voir sur Youtube](http://www.youtube.com/watch?v=rSO1y3lCBfk) 8 | - [:fr: :clipboard: Trop de mock tue le test : ce que l'archi hexagonale a changé](https://speakerdeck.com/jmlamodiere/trop-de-mock-tue-le-test-ce-que-larchi-hexagonale-a-change) 9 | - [:uk: :clipboard: Too many mocks killed the test: What Hexagonal Architecture has changed](https://speakerdeck.com/jmlamodiere/too-many-mocks-killed-the-test-what-hexagonal-architecture-has-changed) 10 | 11 | For a bit of theory, see [:fr: De CRUD à DDD, comment Meetic a sauvé son legacy](https://afup.org/talks/3037-de-crud-a-ddd-comment-meetic-a-sauve-son-legacy) 12 | 13 | ## Steps by step refactoring 14 | 15 | :warning: **Warning** : Only steps 1 & 2 are really considered *bad*. Next steps just show different testing styles. 16 | 17 | 1. [bad_implementation](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/bad_implementation) branch 18 | contains : 19 | - Architecture mistakes according to [Hexagonal architecture](https://alistair.cockburn.us/hexagonal-architecture/) (aka Port & Adapters) 20 | - Tests too much coupled with implementation details, and an incorrect usage of mocks 21 | 1. [bad_tests](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/bad_tests) branch 22 | [(see Pull Request)](https://github.com/JMLamodiere/tdd-demo-forumphp2020/pull/12) only fixes (some) hexagonal mistakes. 23 | Many obscure changes are required in the tests, proving they do not help much during refactoring 24 | 1. [integration_infra_medium_domain](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/integration_infra_medium_domain) branch 25 | [(see Pull Request)](https://github.com/JMLamodiere/tdd-demo-forumphp2020/pull/13) split tests this way: 26 | - Domain logic (Application/Domain folders): medium Unit tests, mocking only infrastructure 27 | - Technical logic (Infrastructure folder): integration tests for each specific technology 28 | 1. [integration_infra_medium_domain_wiremock](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/integration_infra_medium_domain_wiremock) branch 29 | [(see Pull Request)](https://github.com/JMLamodiere/tdd-demo-forumphp2020/pull/14) 30 | only replaces [Guzzle MockHandler](https://docs.guzzlephp.org/en/stable/testing.html) with [Wiremock](#wiremock), 31 | decoupling HTTP tests with the library being used for HTTP calls. 32 | 1. [integration_infra_medium_domain_no_di](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/integration_infra_medium_domain_no_di) branch 33 | [(see Pull Request)](https://github.com/JMLamodiere/tdd-demo-forumphp2020/pull/15) 34 | removes [Dependency Injection Container](https://www.loosecouplings.com/2011/01/dependency-injection-using-di-container.html) 35 | usage and manually build tested classes instead. 36 | 1. [integration_infra_sociable](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/integration_infra_sociable) branch 37 | [(see Pull Request)](https://github.com/JMLamodiere/tdd-demo-forumphp2020/pull/16) 38 | replaces medium sized tests with [Overlapping Sociable Tests](https://www.jamesshore.com/v2/blog/2018/testing-without-mocks#sociable-tests) 39 | to allow easily test and evolve individual behaviours (ex : class serialization) while still being able to 40 | split/merge/refactor classes inside some class clusters by not checking specific calls between them. 41 | 1. [mock_secondary_ports_in_behat](https://github.com/JMLamodiere/tdd-demo-forumphp2020/tree/mock_secondary_ports_in_behat) branch 42 | [(see Pull Request)](https://github.com/JMLamodiere/tdd-demo-forumphp2020/pull/18): Mock Secondary Ports 43 | (according to [Hexagonal architecture](https://alistair.cockburn.us/hexagonal-architecture/)) in 44 | [Behat](https://behat.org). Makes behat tests much faster and 45 | easier to write. Pre-requisite : well defined secondary ports and Integration tests on their 46 | Infrastructure layer implementation. 47 | 48 | ## API documentation 49 | 50 | - Local : [docs/openapi.yml](docs/openapi.yml) 51 | - Github Pages : https://jmlamodiere.github.io/tdd-demo-forumphp2020 52 | - Swaggger Hub (with [SwaggerHub API Auto Mocking](https://app.swaggerhub.com/help/integrations/api-auto-mocking) 53 | activated) : https://app.swaggerhub.com/apis/JMLamodiere/tdd-demo_forum_php_2020/1.0.0 54 | 55 | Example : 56 | 57 | curl -i -X PUT "https://virtserver.swaggerhub.com/JMLamodiere/tdd-demo_forum_php_2020/1.0.0/runningsessions/42" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"id\":42,\"distance\":5.5,\"shoes\":\"Adadis Turbo2\"}" 58 | 59 | ## Makefile 60 | 61 | Run `make` or `make help` to see available commands. 62 | 63 | ### Test environment 64 | 65 | Pre-requisites : 66 | 67 | - [docker](https://www.docker.com/) 68 | - [docker-compose](https://docs.docker.com/compose/) 69 | 70 | Run tests with: 71 | 72 | make install 73 | make test 74 | 75 | ### Dev environment 76 | 77 | Pre-requisites (see [composer.json](composer.json)) : 78 | 79 | - PHP >= 7.4 80 | - ext-pgsql 81 | - [Symfony local web server](https://symfony.com/doc/current/setup/symfony_server.html) 82 | 83 | Create an App on [AccuWeather](https://developer.accuweather.com/) and copy your API Key: 84 | 85 | ``` 86 | # in /.env.dev.local 87 | ACCUWEATHER_API_KEY=xxx 88 | ``` 89 | 90 | Start local dev environment with: 91 | 92 | ``` 93 | composer install 94 | make start 95 | ``` 96 | 97 | - Symfony homepage (404): https://127.0.0.1:8000/ 98 | - Symfony profiler: https://127.0.0.1:8000/_profiler/ 99 | 100 | ## Postgresql 101 | 102 | To access Postgresql database, configure a tool such as 103 | [Database Tools and SQL](https://www.jetbrains.com/help/phpstorm/connecting-to-a-database.html#connect-to-postgresql-database) 104 | included in PHPStorm: 105 | 106 | - URL: `jdbc:postgresql://localhost:32774/forumphp` (replace `32774` with the port given by `make ps` command) 107 | - login: `forumphp` 108 | - password: `forumphp` (see [docker-compose.yml](docker-compose.yml)) 109 | 110 | ## Wiremock 111 | 112 | - Local server: https://hub.docker.com/r/rodolpheche/wiremock/ (see [docker-compose.yml](docker-compose.yml)) 113 | - PHP Client: https://github.com/rowanhill/wiremock-php (used in PHP tests) 114 | 115 | See Swagger UI documentation at http://localhost:32775/__admin/swagger-ui/ 116 | 117 | Replace `32775` with the port given by `make ps` command 118 | -------------------------------------------------------------------------------- /behat.yml.dist: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | default: 4 | contexts: 5 | - FeatureContext: 6 | kernel: '@kernel' 7 | 8 | extensions: 9 | Behat\Symfony2Extension: 10 | kernel: 11 | bootstrap: features/bootstrap/bootstrap.php 12 | class: App\Kernel 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 23 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 24 | } 25 | 26 | if ($input->hasParameterOption('--no-debug', true)) { 27 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 28 | } 29 | 30 | require dirname(__DIR__).'/config/bootstrap.php'; 31 | 32 | if ($_SERVER['APP_DEBUG']) { 33 | umask(0000); 34 | 35 | if (class_exists(Debug::class)) { 36 | Debug::enable(); 37 | } 38 | } 39 | 40 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 41 | $application = new Application($kernel); 42 | $application->run($input); 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jmlamodiere/tdd-demo-forumphp2020", 3 | "description": "TDD Demo - ForumPHP 2020", 4 | "type": "project", 5 | "license": "MIT", 6 | "require": { 7 | "php": ">=7.4.0", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "ext-json": "*", 11 | "ext-pgsql": "*", 12 | "doctrine/doctrine-bundle": "^2.1", 13 | "guzzlehttp/guzzle": "^7.2", 14 | "symfony/console": "4.4.*", 15 | "symfony/dotenv": "4.4.*", 16 | "symfony/flex": "^1.3.1", 17 | "symfony/framework-bundle": "4.4.*", 18 | "symfony/serializer": "4.4.*", 19 | "symfony/yaml": "4.4.*", 20 | "webmozart/assert": "^1.9" 21 | }, 22 | "require-dev": { 23 | "behat/behat": "^3.7", 24 | "behat/symfony2-extension": "^2.1", 25 | "friendsofphp/php-cs-fixer": "^2.16", 26 | "nelmio/cors-bundle": "^2.1", 27 | "phpspec/prophecy-phpunit": "^2.0", 28 | "phpunit/phpunit": "^9.4", 29 | "symfony/phpunit-bridge": "^5.1", 30 | "symfony/web-profiler-bundle": "4.4.*", 31 | "wiremock-php/wiremock-php": "^2.27" 32 | }, 33 | "config": { 34 | "platform": { 35 | "php": "7.4.3" 36 | }, 37 | "preferred-install": { 38 | "*": "dist" 39 | }, 40 | "sort-packages": true 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "App\\": "src/" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "App\\": "tests/" 50 | } 51 | }, 52 | "replace": { 53 | "paragonie/random_compat": "2.*", 54 | "symfony/polyfill-ctype": "*", 55 | "symfony/polyfill-iconv": "*", 56 | "symfony/polyfill-php71": "*", 57 | "symfony/polyfill-php70": "*", 58 | "symfony/polyfill-php56": "*" 59 | }, 60 | "scripts": { 61 | "auto-scripts": { 62 | "cache:clear": "symfony-cmd", 63 | "assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd" 64 | }, 65 | "post-install-cmd": [ 66 | "@auto-scripts" 67 | ], 68 | "post-update-cmd": [ 69 | "@auto-scripts" 70 | ] 71 | }, 72 | "conflict": { 73 | "symfony/symfony": "*" 74 | }, 75 | "extra": { 76 | "symfony": { 77 | "allow-contrib": false, 78 | "require": "4.4.*" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | =1.2) 13 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 14 | (new Dotenv(false))->populate($env); 15 | } else { 16 | // load all the .env files 17 | (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env'); 18 | } 19 | 20 | $_SERVER += $_ENV; 21 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 22 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 23 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 24 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 6 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['dev' => true], 7 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true], 8 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 9 | ]; 10 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/dev/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | debug: '%kernel.debug%' 3 | strict_variables: '%kernel.debug%' 4 | exception_controller: null 5 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { only_exceptions: false } 7 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | #http_method_override: true 6 | 7 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 8 | # Remove or comment this section to explicitly disable session support. 9 | session: 10 | handler_id: null 11 | cookie_secure: auto 12 | cookie_samesite: lax 13 | 14 | #esi: true 15 | #fragments: true 16 | php_errors: 17 | log: true 18 | -------------------------------------------------------------------------------- /config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | allow_credentials: '%env(bool:CORS_ALLOW_CREDENTIALS)%' 4 | origin_regex: '%env(bool:CORS_ORIGIN_REGEX)%' 5 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 6 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 7 | allow_headers: ['Content-Type', 'Authorization'] 8 | expose_headers: ['Link'] 9 | max_age: 3600 10 | paths: 11 | '^/': null 12 | -------------------------------------------------------------------------------- /config/packages/prod/doctrine.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | pools: 4 | doctrine.result_cache_pool: 5 | adapter: cache.app 6 | doctrine.system_cache_pool: 7 | adapter: cache.system 8 | -------------------------------------------------------------------------------- /config/packages/prod/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: null 4 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | session: 4 | storage_id: session.storage.mock_file 5 | -------------------------------------------------------------------------------- /config/packages/test/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | # default configuration for services in *this* file 3 | _defaults: 4 | public: true 5 | 6 | WireMock\Client\WireMock: 7 | factory: ['WireMock\Client\WireMock', 'create'] 8 | # See docker-compose.yml 9 | arguments: ['wiremock', '8080'] 10 | 11 | # Mark Secondary Ports (Hexagonal Architecture meaning) public to allow mocking them in behat tests 12 | # To be done in config/services.yml if concrete class, or in config/packages/test/services.yml if Interface 13 | App\Domain\WeatherProvider: ~ 14 | App\Domain\RunningSessionRepository: ~ 15 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TDD Demo ForumPHP 2020 6 | 7 | 8 | 9 |
10 | 11 | 12 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /docs/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: TDD Demo ForumPHP 2020 4 | description: | 5 | See [JMLamodiere/tdd-demo-forumphp2020](https://github.com/JMLamodiere/tdd-demo-forumphp2020/blob/main/README.md) on GitHub. 6 | 7 | To call [SwaggerHub API Auto Mocking](https://app.swaggerhub.com/help/integrations/api-auto-mocking) urls, 8 | choose `virtserver.swaggerhub.com` in `Servers` list 9 | 10 | version: 1.0.0 11 | 12 | # See https://swagger.io/docs/specification/paths-and-operations/ 13 | paths: 14 | /runningsessions/{id}: 15 | parameters: 16 | - name: id 17 | in: path 18 | required: true 19 | schema: 20 | $ref: '#/components/schemas/RunningSessionId' 21 | 22 | put: 23 | summary: Register a running session 24 | tags: [ Running Session ] 25 | requestBody: 26 | required: true 27 | description: Temperature is enriched by a weather provider 28 | content: 29 | application/json: 30 | schema: 31 | $ref: '#/components/schemas/PutRunningSession' 32 | responses: 33 | '201': 34 | description: Created 35 | content: 36 | application/json: 37 | schema: 38 | $ref: '#/components/schemas/RunningSession' 39 | 40 | # See https://swagger.io/docs/specification/components/ 41 | components: 42 | 43 | # See https://swagger.io/docs/specification/data-models/ 44 | # NB : they appear in the generated doc 45 | schemas: 46 | 47 | PutRunningSession: 48 | type: object 49 | required: [ id, distance, shoes ] 50 | properties: 51 | id: 52 | $ref: '#/components/schemas/RunningSessionId' 53 | distance: 54 | type: number 55 | format: float 56 | example: 5.5 57 | shoes: 58 | type: string 59 | example: 'Adadis Turbo2' 60 | 61 | RunningSession: 62 | allOf: 63 | # Inherit PutRunningSession 64 | - $ref: '#/components/schemas/PutRunningSession' 65 | required: [ temperatureCelcius ] 66 | properties: 67 | temperatureCelcius: 68 | type: number 69 | format: float 70 | example: 37.2 71 | 72 | RunningSessionId: 73 | type: integer 74 | minimum: 1 75 | example: 42 76 | 77 | # See https://swagger.io/docs/specification/api-host-and-base-path/ 78 | servers: 79 | - url: 'https://localhost:8000' 80 | description: dev 81 | 82 | # See https://app.swaggerhub.com/help/integrations/api-auto-mocking 83 | - description: SwaggerHub API Auto Mocking 84 | url: https://virtserver.swaggerhub.com/JMLamodiere/tdd-demo_forum_php_2020/1.0.0 85 | -------------------------------------------------------------------------------- /features/bootstrap/BehatProphecyTrait.php: -------------------------------------------------------------------------------- 1 | prophet) { 15 | $this->prophet = new Prophet(); 16 | } 17 | 18 | return $this->prophet->prophesize($classOrInterface); 19 | } 20 | 21 | /** 22 | * @AfterScenario 23 | */ 24 | public function verifyProphecyDoubles(): void 25 | { 26 | if (null === $this->prophet) { 27 | return; 28 | } 29 | 30 | $this->prophet->checkPredictions(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /features/bootstrap/FeatureContext.php: -------------------------------------------------------------------------------- 1 | kernel = $kernel; 28 | 29 | $this->weatherProvider = $this->prophesize(WeatherProvider::class); 30 | $kernel->getContainer()->set(WeatherProvider::class, $this->weatherProvider->reveal()); 31 | 32 | $this->runningSessionRepository = $this->prophesize(RunningSessionRepository::class); 33 | $kernel->getContainer()->set(RunningSessionRepository::class, $this->runningSessionRepository->reveal()); 34 | } 35 | 36 | /** 37 | * @Given current temperature is :temperature celcius degrees 38 | */ 39 | public function currentTemperatureIs($temperature) 40 | { 41 | $this->weatherProvider 42 | ->getCurrentCelciusTemperature() 43 | ->willReturn($temperature); 44 | } 45 | 46 | /** 47 | * @When I register a running session with id :id distance :distance and shoes :shoes 48 | */ 49 | public function iRegisterARunningSessionWith($id, $distance, $shoes) 50 | { 51 | $body = RegisterRunningSessionDeserializerTest::createBody($id, $distance, $shoes); 52 | $request = Request::create('/runningsessions/'.$id, 'PUT', [], [], [], [], $body); 53 | 54 | $this->response = $this->kernel->handle($request); 55 | } 56 | 57 | /** 58 | * @Then a running session should be added with id :id distance :distance shoes :shoes and temperature :temperature 59 | */ 60 | public function aRunningSessionShouldBeAddedWith($id, $distance, $shoes, $temperature) 61 | { 62 | Assert::assertEquals(201, $this->response->getStatusCode()); 63 | 64 | $this->runningSessionRepository 65 | ->add(RunningSessionFactory::create( 66 | $id, 67 | $distance, 68 | $shoes, 69 | $temperature 70 | )) 71 | ->shouldHaveBeenCalled(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /features/bootstrap/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__, 2).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /features/running-session.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | Running session 3 | 4 | Scenario: The running session I register is enriched with current weather data 5 | Given current temperature is "37.5" celcius degrees 6 | When I register a running session with id "15" distance "25.7" and shoes "black shoes" 7 | Then a running session should be added with id "15" distance "25.7" shoes "black shoes" and temperature "37.5" 8 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | src/Kernel.php 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handle($request); 26 | $response->send(); 27 | $kernel->terminate($request, $response); 28 | -------------------------------------------------------------------------------- /src/Application/Command/RegisterRunningSession.php: -------------------------------------------------------------------------------- 1 | id = $id; 16 | $this->distance = $distance; 17 | $this->shoes = $shoes; 18 | } 19 | 20 | public function getId(): int 21 | { 22 | return $this->id; 23 | } 24 | 25 | public function getDistance(): float 26 | { 27 | return $this->distance; 28 | } 29 | 30 | public function getShoes(): string 31 | { 32 | return $this->shoes; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Application/Command/RegisterRunningSessionHandler.php: -------------------------------------------------------------------------------- 1 | weatherProvider = $weatherProvider; 19 | $this->repository = $repository; 20 | } 21 | 22 | public function handle(RegisterRunningSession $command): RunningSession 23 | { 24 | $currentTemperature = $this->weatherProvider->getCurrentCelciusTemperature(); 25 | 26 | $session = new RunningSession( 27 | $command->getId(), 28 | $command->getDistance(), 29 | $command->getShoes(), 30 | $currentTemperature 31 | ); 32 | 33 | $this->repository->add($session); 34 | 35 | return $session; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Domain/CannotGetCurrentTemperature.php: -------------------------------------------------------------------------------- 1 | id = $id; 17 | $this->distance = $distance; 18 | $this->shoes = $shoes; 19 | $this->metricTemperature = $metricTemperature; 20 | } 21 | 22 | public function getId(): int 23 | { 24 | return $this->id; 25 | } 26 | 27 | public function getDistance(): float 28 | { 29 | return $this->distance; 30 | } 31 | 32 | public function getShoes(): string 33 | { 34 | return $this->shoes; 35 | } 36 | 37 | public function getMetricTemperature(): float 38 | { 39 | return $this->metricTemperature; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Domain/RunningSessionRepository.php: -------------------------------------------------------------------------------- 1 | dbal = $dbal; 20 | } 21 | 22 | public function add(RunningSession $session): void 23 | { 24 | $queryBuilder = $this->dbal->createQueryBuilder(); 25 | 26 | $queryBuilder 27 | ->insert(self::TABLE_NAME) 28 | ->setValue('ID', ':id')->setParameter(':id', $session->getId()) 29 | ->setValue('DISTANCE', ':distance')->setParameter(':distance', $session->getDistance()) 30 | ->setValue('SHOES', ':shoes')->setParameter(':shoes', $session->getShoes()) 31 | ->setValue('TEMPERATURE_CELCIUS', ':celcius')->setParameter(':celcius', $session->getMetricTemperature()) 32 | ; 33 | 34 | $queryBuilder->execute(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Infrastructure/Http/CurrentConditionDeserializer.php: -------------------------------------------------------------------------------- 1 | denormalizeObservation($firstObservation); 24 | } 25 | 26 | private function denormalizeObservation(array $data): float 27 | { 28 | Assert::keyExists($data, 'Temperature', 'missing Temperature key'); 29 | Assert::keyExists($data['Temperature'], 'Metric', 'missing Temperature.Metric key'); 30 | Assert::keyExists($data['Temperature']['Metric'], 'Value', 'missing Temperature.Metric.Value key'); 31 | 32 | return $data['Temperature']['Metric']['Value']; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Infrastructure/Http/HttpAccuWeatherProvider.php: -------------------------------------------------------------------------------- 1 | client = $client; 25 | $this->apiKey = $apiKey; 26 | $this->serializer = $serializer; 27 | } 28 | 29 | /** 30 | * @throws CannotGetCurrentTemperature 31 | */ 32 | public function getCurrentCelciusTemperature(): float 33 | { 34 | $uri = sprintf(self::CURRENT_CONDITION_URI, self::LOCATION_KEY_PARIS, $this->apiKey); 35 | 36 | try { 37 | $response = $this->client->get($uri); 38 | } catch (GuzzleException $previous) { 39 | throw new CannotGetCurrentTemperature('Cannot retrieve current condition', 0, $previous); 40 | } 41 | 42 | try { 43 | return $this->serializer->deserialize($response->getBody()->getContents()); 44 | } catch (\Exception $previous) { 45 | throw new CannotGetCurrentTemperature('Cannot decode current condition', 0, $previous); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Infrastructure/Symfony/Controller/RunningSessionController.php: -------------------------------------------------------------------------------- 1 | commandDeserializer = $commandDeserializer; 29 | $this->responseSerializer = $responseSerializer; 30 | $this->registerRunningSessionHandler = $registerRunningSessionHandler; 31 | } 32 | 33 | /** 34 | * @Route("/runningsessions/{id}", methods="PUT", name="runningsessions_put", requirements={"_format"="json"}) 35 | */ 36 | public function put(string $id, Request $request): Response 37 | { 38 | $command = $this->commandDeserializer->deserialize($request->getContent()); 39 | Assert::same($command->getId(), (int) $id, 'id must be the same in payload and uri'); 40 | 41 | $session = $this->registerRunningSessionHandler->handle($command); 42 | 43 | return new JsonResponse( 44 | $this->responseSerializer->serialize($session), 45 | Response::HTTP_CREATED, 46 | [], 47 | //json is already encoded by serializer 48 | true 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Infrastructure/Symfony/Serializer/RegisterRunningSessionDeserializer.php: -------------------------------------------------------------------------------- 1 | $session->getId(), 18 | 'distance' => $session->getDistance(), 19 | 'shoes' => $session->getShoes(), 20 | 'temperatureCelcius' => $session->getMetricTemperature(), 21 | ]; 22 | 23 | return json_encode($data, JSON_THROW_ON_ERROR); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | getProjectDir().'/config/bundles.php'; 21 | foreach ($contents as $class => $envs) { 22 | if ($envs[$this->environment] ?? $envs['all'] ?? false) { 23 | yield new $class(); 24 | } 25 | } 26 | } 27 | 28 | public function getProjectDir(): string 29 | { 30 | return \dirname(__DIR__); 31 | } 32 | 33 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void 34 | { 35 | $container->addResource(new FileResource($this->getProjectDir().'/config/bundles.php')); 36 | $container->setParameter('container.dumper.inline_class_loader', \PHP_VERSION_ID < 70400 || $this->debug); 37 | $container->setParameter('container.dumper.inline_factories', true); 38 | $confDir = $this->getProjectDir().'/config'; 39 | 40 | $loader->load($confDir.'/{packages}/*'.self::CONFIG_EXTS, 'glob'); 41 | $loader->load($confDir.'/{packages}/'.$this->environment.'/*'.self::CONFIG_EXTS, 'glob'); 42 | $loader->load($confDir.'/{services}'.self::CONFIG_EXTS, 'glob'); 43 | $loader->load($confDir.'/{services}_'.$this->environment.self::CONFIG_EXTS, 'glob'); 44 | } 45 | 46 | protected function configureRoutes(RouteCollectionBuilder $routes): void 47 | { 48 | $confDir = $this->getProjectDir().'/config'; 49 | 50 | $routes->import($confDir.'/{routes}/'.$this->environment.'/*'.self::CONFIG_EXTS, '/', 'glob'); 51 | $routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS, '/', 'glob'); 52 | $routes->import($confDir.'/{routes}'.self::CONFIG_EXTS, '/', 'glob'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "behat/behat": { 3 | "version": "v3.7.0" 4 | }, 5 | "behat/gherkin": { 6 | "version": "v4.6.2" 7 | }, 8 | "behat/symfony2-extension": { 9 | "version": "2.1", 10 | "recipe": { 11 | "repo": "github.com/symfony/recipes", 12 | "branch": "master", 13 | "version": "2.1", 14 | "ref": "af4f8fa93b1fee9e6f15d1e208ce743bb31cb2b2" 15 | }, 16 | "files": [ 17 | "behat.yml.dist", 18 | "features/bootstrap/FeatureContext.php", 19 | "features/bootstrap/bootstrap.php", 20 | "features/demo.feature" 21 | ] 22 | }, 23 | "behat/transliterator": { 24 | "version": "v1.3.0" 25 | }, 26 | "composer/semver": { 27 | "version": "1.7.1" 28 | }, 29 | "composer/xdebug-handler": { 30 | "version": "1.4.3" 31 | }, 32 | "doctrine/annotations": { 33 | "version": "1.0", 34 | "recipe": { 35 | "repo": "github.com/symfony/recipes", 36 | "branch": "master", 37 | "version": "1.0", 38 | "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457" 39 | }, 40 | "files": [ 41 | "config/routes/annotations.yaml" 42 | ] 43 | }, 44 | "doctrine/cache": { 45 | "version": "1.10.2" 46 | }, 47 | "doctrine/collections": { 48 | "version": "1.6.7" 49 | }, 50 | "doctrine/dbal": { 51 | "version": "2.11.1" 52 | }, 53 | "doctrine/doctrine-bundle": { 54 | "version": "2.0", 55 | "recipe": { 56 | "repo": "github.com/symfony/recipes", 57 | "branch": "master", 58 | "version": "2.0", 59 | "ref": "a9f2463b9f73efe74482f831f03a204a41328555" 60 | }, 61 | "files": [ 62 | "config/packages/doctrine.yaml", 63 | "config/packages/prod/doctrine.yaml", 64 | "src/Entity/.gitignore", 65 | "src/Repository/.gitignore" 66 | ] 67 | }, 68 | "doctrine/event-manager": { 69 | "version": "1.1.1" 70 | }, 71 | "doctrine/instantiator": { 72 | "version": "1.3.1" 73 | }, 74 | "doctrine/lexer": { 75 | "version": "1.2.1" 76 | }, 77 | "doctrine/persistence": { 78 | "version": "2.0.0" 79 | }, 80 | "doctrine/reflection": { 81 | "version": "1.2.1" 82 | }, 83 | "doctrine/sql-formatter": { 84 | "version": "1.1.1" 85 | }, 86 | "friendsofphp/php-cs-fixer": { 87 | "version": "2.2", 88 | "recipe": { 89 | "repo": "github.com/symfony/recipes", 90 | "branch": "master", 91 | "version": "2.2", 92 | "ref": "cc05ab6abf6894bddb9bbd6a252459010ebe040b" 93 | }, 94 | "files": [ 95 | ".php_cs.dist" 96 | ] 97 | }, 98 | "guzzlehttp/guzzle": { 99 | "version": "7.2.0" 100 | }, 101 | "guzzlehttp/promises": { 102 | "version": "1.4.0" 103 | }, 104 | "guzzlehttp/psr7": { 105 | "version": "1.7.0" 106 | }, 107 | "myclabs/deep-copy": { 108 | "version": "1.10.1" 109 | }, 110 | "nelmio/cors-bundle": { 111 | "version": "1.5", 112 | "recipe": { 113 | "repo": "github.com/symfony/recipes", 114 | "branch": "master", 115 | "version": "1.5", 116 | "ref": "6388de23860284db9acce0a7a5d9d13153bcb571" 117 | }, 118 | "files": [ 119 | "config/packages/nelmio_cors.yaml" 120 | ] 121 | }, 122 | "nikic/php-parser": { 123 | "version": "v4.10.2" 124 | }, 125 | "phar-io/manifest": { 126 | "version": "2.0.1" 127 | }, 128 | "phar-io/version": { 129 | "version": "3.0.2" 130 | }, 131 | "php": { 132 | "version": "7.4.3" 133 | }, 134 | "php-cs-fixer/diff": { 135 | "version": "v1.3.1" 136 | }, 137 | "phpdocumentor/reflection-common": { 138 | "version": "2.2.0" 139 | }, 140 | "phpdocumentor/reflection-docblock": { 141 | "version": "5.2.2" 142 | }, 143 | "phpdocumentor/type-resolver": { 144 | "version": "1.4.0" 145 | }, 146 | "phpspec/prophecy": { 147 | "version": "1.12.1" 148 | }, 149 | "phpspec/prophecy-phpunit": { 150 | "version": "v2.0.1" 151 | }, 152 | "phpunit/php-code-coverage": { 153 | "version": "9.2.0" 154 | }, 155 | "phpunit/php-file-iterator": { 156 | "version": "3.0.5" 157 | }, 158 | "phpunit/php-invoker": { 159 | "version": "3.1.1" 160 | }, 161 | "phpunit/php-text-template": { 162 | "version": "2.0.3" 163 | }, 164 | "phpunit/php-timer": { 165 | "version": "5.0.2" 166 | }, 167 | "phpunit/phpunit": { 168 | "version": "4.7", 169 | "recipe": { 170 | "repo": "github.com/symfony/recipes", 171 | "branch": "master", 172 | "version": "4.7", 173 | "ref": "477e1387616f39505ba79715f43f124836020d71" 174 | }, 175 | "files": [ 176 | ".env.test", 177 | "phpunit.xml.dist", 178 | "tests/bootstrap.php" 179 | ] 180 | }, 181 | "psr/cache": { 182 | "version": "1.0.1" 183 | }, 184 | "psr/container": { 185 | "version": "1.0.0" 186 | }, 187 | "psr/http-client": { 188 | "version": "1.0.1" 189 | }, 190 | "psr/http-message": { 191 | "version": "1.0.1" 192 | }, 193 | "psr/log": { 194 | "version": "1.1.3" 195 | }, 196 | "ralouphie/getallheaders": { 197 | "version": "3.0.3" 198 | }, 199 | "sebastian/cli-parser": { 200 | "version": "1.0.1" 201 | }, 202 | "sebastian/code-unit": { 203 | "version": "1.0.7" 204 | }, 205 | "sebastian/code-unit-reverse-lookup": { 206 | "version": "2.0.3" 207 | }, 208 | "sebastian/comparator": { 209 | "version": "4.0.5" 210 | }, 211 | "sebastian/complexity": { 212 | "version": "2.0.1" 213 | }, 214 | "sebastian/diff": { 215 | "version": "4.0.3" 216 | }, 217 | "sebastian/environment": { 218 | "version": "5.1.3" 219 | }, 220 | "sebastian/exporter": { 221 | "version": "4.0.3" 222 | }, 223 | "sebastian/global-state": { 224 | "version": "5.0.1" 225 | }, 226 | "sebastian/lines-of-code": { 227 | "version": "1.0.1" 228 | }, 229 | "sebastian/object-enumerator": { 230 | "version": "4.0.3" 231 | }, 232 | "sebastian/object-reflector": { 233 | "version": "2.0.3" 234 | }, 235 | "sebastian/recursion-context": { 236 | "version": "4.0.3" 237 | }, 238 | "sebastian/resource-operations": { 239 | "version": "3.0.3" 240 | }, 241 | "sebastian/type": { 242 | "version": "2.3.0" 243 | }, 244 | "sebastian/version": { 245 | "version": "3.0.2" 246 | }, 247 | "symfony/cache": { 248 | "version": "v4.4.15" 249 | }, 250 | "symfony/cache-contracts": { 251 | "version": "v2.2.0" 252 | }, 253 | "symfony/config": { 254 | "version": "v4.4.15" 255 | }, 256 | "symfony/console": { 257 | "version": "4.4", 258 | "recipe": { 259 | "repo": "github.com/symfony/recipes", 260 | "branch": "master", 261 | "version": "4.4", 262 | "ref": "ea8c0eda34fda57e7d5cd8cbd889e2a387e3472c" 263 | }, 264 | "files": [ 265 | "bin/console", 266 | "config/bootstrap.php" 267 | ] 268 | }, 269 | "symfony/debug": { 270 | "version": "v4.4.15" 271 | }, 272 | "symfony/dependency-injection": { 273 | "version": "v4.4.15" 274 | }, 275 | "symfony/doctrine-bridge": { 276 | "version": "v4.4.15" 277 | }, 278 | "symfony/dotenv": { 279 | "version": "v4.4.15" 280 | }, 281 | "symfony/error-handler": { 282 | "version": "v4.4.15" 283 | }, 284 | "symfony/event-dispatcher": { 285 | "version": "v4.4.15" 286 | }, 287 | "symfony/event-dispatcher-contracts": { 288 | "version": "v1.1.9" 289 | }, 290 | "symfony/filesystem": { 291 | "version": "v4.4.15" 292 | }, 293 | "symfony/finder": { 294 | "version": "v4.4.15" 295 | }, 296 | "symfony/flex": { 297 | "version": "1.0", 298 | "recipe": { 299 | "repo": "github.com/symfony/recipes", 300 | "branch": "master", 301 | "version": "1.0", 302 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 303 | }, 304 | "files": [ 305 | ".env" 306 | ] 307 | }, 308 | "symfony/framework-bundle": { 309 | "version": "4.4", 310 | "recipe": { 311 | "repo": "github.com/symfony/recipes", 312 | "branch": "master", 313 | "version": "4.4", 314 | "ref": "2257d2a1754c7840f49ad04e1d529c402415f4b5" 315 | }, 316 | "files": [ 317 | "config/bootstrap.php", 318 | "config/packages/cache.yaml", 319 | "config/packages/framework.yaml", 320 | "config/packages/test/framework.yaml", 321 | "config/preload.php", 322 | "config/routes/dev/framework.yaml", 323 | "config/services.yaml", 324 | "public/index.php", 325 | "src/Controller/.gitignore", 326 | "src/Kernel.php" 327 | ] 328 | }, 329 | "symfony/http-client-contracts": { 330 | "version": "v2.3.1" 331 | }, 332 | "symfony/http-foundation": { 333 | "version": "v4.4.15" 334 | }, 335 | "symfony/http-kernel": { 336 | "version": "v4.4.15" 337 | }, 338 | "symfony/mime": { 339 | "version": "v4.4.15" 340 | }, 341 | "symfony/options-resolver": { 342 | "version": "v4.4.15" 343 | }, 344 | "symfony/phpunit-bridge": { 345 | "version": "4.3", 346 | "recipe": { 347 | "repo": "github.com/symfony/recipes", 348 | "branch": "master", 349 | "version": "4.3", 350 | "ref": "6d0e35f749d5f4bfe1f011762875275cd3f9874f" 351 | }, 352 | "files": [ 353 | ".env.test", 354 | "bin/phpunit", 355 | "phpunit.xml.dist", 356 | "tests/bootstrap.php" 357 | ] 358 | }, 359 | "symfony/polyfill-intl-idn": { 360 | "version": "v1.18.1" 361 | }, 362 | "symfony/polyfill-intl-normalizer": { 363 | "version": "v1.18.1" 364 | }, 365 | "symfony/polyfill-mbstring": { 366 | "version": "v1.18.1" 367 | }, 368 | "symfony/polyfill-php72": { 369 | "version": "v1.18.1" 370 | }, 371 | "symfony/polyfill-php73": { 372 | "version": "v1.18.1" 373 | }, 374 | "symfony/polyfill-php80": { 375 | "version": "v1.18.1" 376 | }, 377 | "symfony/process": { 378 | "version": "v4.4.15" 379 | }, 380 | "symfony/routing": { 381 | "version": "4.2", 382 | "recipe": { 383 | "repo": "github.com/symfony/recipes", 384 | "branch": "master", 385 | "version": "4.2", 386 | "ref": "683dcb08707ba8d41b7e34adb0344bfd68d248a7" 387 | }, 388 | "files": [ 389 | "config/packages/prod/routing.yaml", 390 | "config/packages/routing.yaml", 391 | "config/routes.yaml" 392 | ] 393 | }, 394 | "symfony/serializer": { 395 | "version": "v4.4.15" 396 | }, 397 | "symfony/service-contracts": { 398 | "version": "v2.2.0" 399 | }, 400 | "symfony/stopwatch": { 401 | "version": "v4.4.15" 402 | }, 403 | "symfony/translation": { 404 | "version": "3.3", 405 | "recipe": { 406 | "repo": "github.com/symfony/recipes", 407 | "branch": "master", 408 | "version": "3.3", 409 | "ref": "2ad9d2545bce8ca1a863e50e92141f0b9d87ffcd" 410 | }, 411 | "files": [ 412 | "config/packages/translation.yaml", 413 | "translations/.gitignore" 414 | ] 415 | }, 416 | "symfony/translation-contracts": { 417 | "version": "v2.3.0" 418 | }, 419 | "symfony/twig-bridge": { 420 | "version": "v4.4.15" 421 | }, 422 | "symfony/twig-bundle": { 423 | "version": "4.4", 424 | "recipe": { 425 | "repo": "github.com/symfony/recipes", 426 | "branch": "master", 427 | "version": "4.4", 428 | "ref": "15a41bbd66a1323d09824a189b485c126bbefa51" 429 | }, 430 | "files": [ 431 | "config/packages/test/twig.yaml", 432 | "config/packages/twig.yaml", 433 | "templates/base.html.twig" 434 | ] 435 | }, 436 | "symfony/var-dumper": { 437 | "version": "v4.4.15" 438 | }, 439 | "symfony/var-exporter": { 440 | "version": "v4.4.15" 441 | }, 442 | "symfony/web-profiler-bundle": { 443 | "version": "3.3", 444 | "recipe": { 445 | "repo": "github.com/symfony/recipes", 446 | "branch": "master", 447 | "version": "3.3", 448 | "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" 449 | }, 450 | "files": [ 451 | "config/packages/dev/web_profiler.yaml", 452 | "config/packages/test/web_profiler.yaml", 453 | "config/routes/dev/web_profiler.yaml" 454 | ] 455 | }, 456 | "symfony/yaml": { 457 | "version": "v4.4.15" 458 | }, 459 | "theseer/tokenizer": { 460 | "version": "1.2.0" 461 | }, 462 | "twig/twig": { 463 | "version": "v3.0.5" 464 | }, 465 | "webmozart/assert": { 466 | "version": "1.9.1" 467 | }, 468 | "wiremock-php/wiremock-php": { 469 | "version": "2.27.0" 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /tests/Application/Command/RegisterRunningSessionHandlerTest.php: -------------------------------------------------------------------------------- 1 | weatherProvider = $this->prophesize(WeatherProvider::class); 29 | $this->repository = $this->prophesize(RunningSessionRepository::class); 30 | $this->handler = new RegisterRunningSessionHandler( 31 | $this->weatherProvider->reveal(), 32 | $this->repository->reveal() 33 | ); 34 | } 35 | 36 | public function testTheRunningSessionIRegisterIsEnrichedWithCurrentWeatherData() 37 | { 38 | //Given (Arrange) 39 | $this->givenCrrentTemperatureIs(15.5); 40 | 41 | //When (Act) 42 | $this->whenIRegisterARunningSession(new RegisterRunningSession( 43 | 12, 44 | 125.7, 45 | 'shoes' 46 | )); 47 | 48 | //Then (Assert) 49 | $this->thenARunningSessionShouldBeAdded(RunningSessionFactory::create( 50 | 12, 51 | 125.7, 52 | 'shoes', 53 | 15.5 54 | )); 55 | } 56 | 57 | private function givenCrrentTemperatureIs(float $temperature): void 58 | { 59 | $this->weatherProvider 60 | ->getCurrentCelciusTemperature() 61 | ->willReturn($temperature); 62 | } 63 | 64 | private function whenIRegisterARunningSession(RegisterRunningSession $command): void 65 | { 66 | $this->handler->handle($command); 67 | } 68 | 69 | private function thenARunningSessionShouldBeAdded(RunningSession $expectedAddedEntity) 70 | { 71 | $this->repository 72 | ->add($expectedAddedEntity) 73 | ->shouldHaveBeenCalled(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Domain/RunningSessionFactory.php: -------------------------------------------------------------------------------- 1 | 'postgres://forumphp:forumphp@database:5432/forumphp?sslmode=disable&charset=utf8', 27 | ]; 28 | $this->dbal = DriverManager::getConnection($connectionParams); 29 | 30 | $this->repository = new PostgresRunningSessionRepository($this->dbal); 31 | $this->resetState(); 32 | } 33 | 34 | private function resetState(): void 35 | { 36 | $this->dbal->executeStatement('TRUNCATE TABLE '.PostgresRunningSessionRepository::TABLE_NAME); 37 | } 38 | 39 | public function testRunningSessionIsInserted() 40 | { 41 | //When (Act) 42 | $session = RunningSessionFactory::create(55, 122.3, 'The shoes!', 34.5); 43 | $this->repository->add($session); 44 | 45 | //Then (Assert) 46 | self::thenRunningSessionTableShouldContain(55, [ 47 | 'distance' => 122.3, 48 | 'shoes' => 'The shoes!', 49 | 'temperature_celcius' => 34.5, 50 | ]); 51 | } 52 | 53 | /** 54 | * @throws \Doctrine\DBAL\Exception 55 | * @throws ExpectationFailedException 56 | */ 57 | private function thenRunningSessionTableShouldContain(int $id, array $expectedArray): void 58 | { 59 | $row = $this->dbal->fetchAssociative( 60 | 'SELECT distance, shoes, temperature_celcius ' 61 | .' FROM RUNNING_SESSION' 62 | .' WHERE ID = :id', [':id' => $id]); 63 | 64 | self::assertIsArray($row, 'No session found with this id'); 65 | 66 | //DB result will be strings 67 | $expectedArray = array_map('strval', $expectedArray); 68 | //Avoid failing if key order is different 69 | asort($row); 70 | asort($expectedArray); 71 | self::assertSame($expectedArray, $row); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Infrastructure/Http/CurrentConditionDeserializerTest.php: -------------------------------------------------------------------------------- 1 | deserializer = new CurrentConditionDeserializer(); 16 | } 17 | 18 | public function testTemperatureIsExtractedFromBody() 19 | { 20 | // When (Act) 21 | $result = $this->deserializer->deserialize(self::createBody(37.2)); 22 | 23 | // Then (Assert) 24 | self::assertSame(37.2, $result); 25 | } 26 | 27 | public static function createBody(float $metricTemperature = 99.9): string 28 | { 29 | return <<provider = new HttpAccuWeatherProvider( 29 | // See https://docs.guzzlephp.org/en/stable/quickstart.html#creating-a-client 30 | new Client(['base_uri' => "http://$host:$port/"]), 31 | $accuweatherApiKey, 32 | new CurrentConditionDeserializer() 33 | ); 34 | 35 | // See docker-compose.yml 36 | $this->wireMock = WireMock::create($host, $port); 37 | self::assertTrue($this->wireMock->isAlive(), 'Wiremock should be alive'); 38 | 39 | $this->currentConditionUri = '/currentconditions/v1/623?apikey='.$accuweatherApiKey; 40 | } 41 | 42 | public function testTemperatureIsExtractedFrom200Response() 43 | { 44 | // Given (Arrange) 45 | $body = CurrentConditionDeserializerTest::createBody(); 46 | $this->wireMock->stubFor(WireMock::get(WireMock::urlEqualTo($this->currentConditionUri)) 47 | ->willReturn(WireMock::aResponse() 48 | ->withHeader('Content-Type', 'application/json') 49 | ->withBody($body))); 50 | 51 | // When (Act) 52 | $result = $this->provider->getCurrentCelciusTemperature(); 53 | 54 | // Then (Assert) 55 | // less strict assertion (type only): assertions about deserialization are in CurrentConditionDeserializerTest 56 | self::assertIsFloat($result); 57 | } 58 | 59 | public function test404ResponseIsConvertedToDomainException() 60 | { 61 | // Given (Arrange) 62 | $this->wireMock->stubFor(WireMock::get(WireMock::urlEqualTo($this->currentConditionUri)) 63 | ->willReturn(WireMock::aResponse() 64 | ->withStatus(404) 65 | )); 66 | 67 | // Then (Assert) 68 | $this->expectException(CannotGetCurrentTemperature::class); 69 | 70 | // When (Act) 71 | $this->provider->getCurrentCelciusTemperature(); 72 | } 73 | 74 | public function testInvalidBodyIsConvertedToDomainException() 75 | { 76 | // Given (Arrange) 77 | $this->wireMock->stubFor(WireMock::get(WireMock::urlEqualTo($this->currentConditionUri)) 78 | ->willReturn(WireMock::aResponse() 79 | ->withHeader('Content-Type', 'application/json') 80 | ->withBody('invalid body'))); 81 | 82 | // Then (Assert) 83 | $this->expectException(CannotGetCurrentTemperature::class); 84 | 85 | // When (Act) 86 | $this->provider->getCurrentCelciusTemperature(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Infrastructure/Symfony/Controller/RunningSessionControllerTest.php: -------------------------------------------------------------------------------- 1 | registerRunningSessionHandler = $this->prophesize(RegisterRunningSessionHandler::class); 31 | self::$container->set(RegisterRunningSessionHandler::class, $this->registerRunningSessionHandler->reveal()); 32 | } 33 | 34 | public function testPutRouteSendsCommandToHandlerAndDisplayItsResult() 35 | { 36 | //Given (Arrange) 37 | $this->givenHandlerResponseIsARunningSession(); 38 | 39 | //When (Act) 40 | $body = RegisterRunningSessionDeserializerTest::createBody(42); 41 | $response = $this->whenISendThisRequest(Request::create('/runningsessions/42', 'PUT', [], [], [], [], $body)); 42 | 43 | //Then (Assert) 44 | //less strict assertion (type only): see RegisterRunningSessionDeserializer for conversion from json to command object 45 | $this->thenARegisterRunningSessionCommandHasBeenSentToHandler(); 46 | 47 | //less strict assertion: See RunningSessionSerializerTest for json response creation 48 | self::assertSame(201, $response->getStatusCode()); 49 | self::assertSame('application/json', $response->headers->get('Content-Type')); 50 | } 51 | 52 | private function givenHandlerResponseIsARunningSession() 53 | { 54 | $this->registerRunningSessionHandler 55 | ->handle(Argument::cetera()) 56 | ->willReturn(RunningSessionFactory::create()); 57 | } 58 | 59 | private function whenISendThisRequest(Request $request): Response 60 | { 61 | //$catch=false: prevents Symfony from catching exceptions 62 | return self::$kernel->handle($request, HttpKernelInterface::MASTER_REQUEST, false); 63 | } 64 | 65 | private function thenARegisterRunningSessionCommandHasBeenSentToHandler() 66 | { 67 | $this->registerRunningSessionHandler 68 | ->handle(Argument::type(RegisterRunningSession::class)) 69 | ->shouldHaveBeenCalled(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Infrastructure/Symfony/Serializer/RegisterRunningSessionDeserializerTest.php: -------------------------------------------------------------------------------- 1 | deserializer = new RegisterRunningSessionDeserializer(); 17 | } 18 | 19 | public function testBodyIsCovertedToCommand() 20 | { 21 | //When (Act) 22 | $result = $this->deserializer->deserialize(self::createBody(42, 5.5, 'Adadis Turbo2')); 23 | 24 | //Then (Assert) 25 | self::assertEquals(new RegisterRunningSession(42, 5.5, 'Adadis Turbo2'), $result); 26 | } 27 | 28 | public static function createBody(int $id = 99, float $distance = 999.9, string $shoes = 'shoes_not_used'): string 29 | { 30 | return <<serializer = new RunningSessionSerializer(); 17 | } 18 | 19 | public function testRunningSessionIsConvertedToJson() 20 | { 21 | // When (Act) 22 | $session = RunningSessionFactory::create(42, 5.5, 'Adadis Turbo2', 37.2); 23 | 24 | $result = $this->serializer->serialize($session); 25 | 26 | // Then (Assert) 27 | $expectedJson = <<bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | --------------------------------------------------------------------------------