├── .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 | [](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 |
--------------------------------------------------------------------------------