├── .env ├── .env.test ├── .gitignore ├── bin └── console ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── cache.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── framework.yaml │ ├── routing.yaml │ └── validator.yaml ├── preload.php ├── routes.yaml ├── routes │ └── framework.yaml └── services.yaml ├── docker-compose.yaml ├── migrations ├── .gitignore └── Version20220420135640.php ├── notes.txt ├── phpunit.xml.dist ├── public └── index.php ├── src ├── Cache │ └── PromotionCache.php ├── Controller │ ├── .gitignore │ └── ProductsController.php ├── DTO │ ├── LowestPriceEnquiry.php │ ├── PriceEnquiryInterface.php │ └── PromotionEnquiryInterface.php ├── Entity │ ├── .gitignore │ ├── Product.php │ ├── ProductPromotion.php │ └── Promotion.php ├── Event │ └── AfterDtoCreatedEvent.php ├── EventListener │ └── ExceptionListener.php ├── EventSubscriber │ └── DtoSubscriber.php ├── Filter │ ├── LowestPriceFilter.php │ ├── Modifier │ │ ├── DateRangeMultiplier.php │ │ ├── EvenItemsMultiplier.php │ │ ├── Factory │ │ │ ├── PriceModifierFactory.php │ │ │ └── PriceModifierFactoryInterface.php │ │ ├── FixedPriceVoucher.php │ │ └── PriceModifierInterface.php │ ├── PriceFilterInterface.php │ └── PromotionsFilterInterface.php ├── Kernel.php ├── Repository │ ├── .gitignore │ ├── ProductPromotionRepository.php │ ├── ProductRepository.php │ └── PromotionRepository.php └── Service │ ├── Serializer │ └── DTOSerializer.php │ ├── ServiceException.php │ ├── ServiceExceptionData.php │ └── ValidationExceptionData.php ├── symfony.lock └── tests ├── ServiceTestCase.php ├── bootstrap.php └── unit ├── DtoSubscriberTest.php ├── LowestPriceFilterTest.php └── PriceModifiersTest.php /.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 | ###> symfony/framework-bundle ### 17 | APP_ENV=dev 18 | APP_SECRET=b345902942fdb0e38442631c01b22490 19 | ###< symfony/framework-bundle ### 20 | 21 | ###> doctrine/doctrine-bundle ### 22 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 23 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 24 | # 25 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 26 | # DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7&charset=utf8mb4" 27 | DATABASE_URL="postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8" 28 | ###< doctrine/doctrine-bundle ### 29 | -------------------------------------------------------------------------------- /.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 | PANTHER_APP_ENV=panther 6 | PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots 7 | -------------------------------------------------------------------------------- /.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 | ###> symfony/phpunit-bridge ### 13 | .phpunit.result.cache 14 | /phpunit.xml 15 | ###< symfony/phpunit-bridge ### 16 | 17 | ###> phpunit/phpunit ### 18 | ###< phpunit/phpunit ### 19 | 20 | 21 | 22 | # Created by https://www.toptal.com/developers/gitignore/api/phpstorm,symfony 23 | # Edit at https://www.toptal.com/developers/gitignore?templates=phpstorm,symfony 24 | 25 | ### PhpStorm ### 26 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 27 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 28 | 29 | # User-specific stuff 30 | .idea/**/workspace.xml 31 | .idea/**/tasks.xml 32 | .idea/**/usage.statistics.xml 33 | .idea/**/dictionaries 34 | .idea/**/shelf 35 | 36 | # AWS User-specific 37 | .idea/**/aws.xml 38 | 39 | # Generated files 40 | .idea/**/contentModel.xml 41 | 42 | # Sensitive or high-churn files 43 | .idea/**/dataSources/ 44 | .idea/**/dataSources.ids 45 | .idea/**/dataSources.local.xml 46 | .idea/**/sqlDataSources.xml 47 | .idea/**/dynamic.xml 48 | .idea/**/uiDesigner.xml 49 | .idea/**/dbnavigator.xml 50 | 51 | # Gradle 52 | .idea/**/gradle.xml 53 | .idea/**/libraries 54 | 55 | # Gradle and Maven with auto-import 56 | # When using Gradle or Maven with auto-import, you should exclude module files, 57 | # since they will be recreated, and may cause churn. Uncomment if using 58 | # auto-import. 59 | # .idea/artifacts 60 | # .idea/compiler.xml 61 | # .idea/jarRepositories.xml 62 | # .idea/modules.xml 63 | # .idea/*.iml 64 | # .idea/modules 65 | # *.iml 66 | # *.ipr 67 | 68 | # CMake 69 | cmake-build-*/ 70 | 71 | # Mongo Explorer plugin 72 | .idea/**/mongoSettings.xml 73 | 74 | # File-based project format 75 | *.iws 76 | 77 | # IntelliJ 78 | out/ 79 | 80 | # mpeltonen/sbt-idea plugin 81 | .idea_modules/ 82 | 83 | # JIRA plugin 84 | atlassian-ide-plugin.xml 85 | 86 | # Cursive Clojure plugin 87 | .idea/replstate.xml 88 | 89 | # SonarLint plugin 90 | .idea/sonarlint/ 91 | 92 | # Crashlytics plugin (for Android Studio and IntelliJ) 93 | com_crashlytics_export_strings.xml 94 | crashlytics.properties 95 | crashlytics-build.properties 96 | fabric.properties 97 | 98 | # Editor-based Rest Client 99 | .idea/httpRequests 100 | 101 | # Android studio 3.1+ serialized cache file 102 | .idea/caches/build_file_checksums.ser 103 | 104 | ### PhpStorm Patch ### 105 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 106 | 107 | # *.iml 108 | # modules.xml 109 | # .idea/misc.xml 110 | # *.ipr 111 | 112 | # Sonarlint plugin 113 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 114 | .idea/**/sonarlint/ 115 | 116 | # SonarQube Plugin 117 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 118 | .idea/**/sonarIssues.xml 119 | 120 | # Markdown Navigator plugin 121 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 122 | .idea/**/markdown-navigator.xml 123 | .idea/**/markdown-navigator-enh.xml 124 | .idea/**/markdown-navigator/ 125 | 126 | # Cache file creation bug 127 | # See https://youtrack.jetbrains.com/issue/JBR-2257 128 | .idea/$CACHE_FILE$ 129 | 130 | # CodeStream plugin 131 | # https://plugins.jetbrains.com/plugin/12206-codestream 132 | .idea/codestream.xml 133 | 134 | ### Symfony ### 135 | # Cache and logs (Symfony2) 136 | /app/cache/* 137 | /app/logs/* 138 | !app/cache/.gitkeep 139 | !app/logs/.gitkeep 140 | 141 | # Email spool folder 142 | /app/spool/* 143 | 144 | # Cache, session files and logs (Symfony3) 145 | /var/cache/* 146 | /var/logs/* 147 | /var/sessions/* 148 | !var/cache/.gitkeep 149 | !var/logs/.gitkeep 150 | !var/sessions/.gitkeep 151 | 152 | # Logs (Symfony4) 153 | /var/log/* 154 | !var/log/.gitkeep 155 | 156 | # Parameters 157 | /app/config/parameters.yml 158 | /app/config/parameters.ini 159 | 160 | # Managed by Composer 161 | /app/bootstrap.php.cache 162 | /var/bootstrap.php.cache 163 | /bin/* 164 | !bin/console 165 | !bin/symfony_requirements 166 | 167 | # Assets and user uploads 168 | /web/bundles/ 169 | /web/uploads/ 170 | 171 | # PHPUnit 172 | /app/phpunit.xml 173 | 174 | # Build data 175 | /build/ 176 | 177 | # Composer PHAR 178 | /composer.phar 179 | 180 | # Backup entities generated with doctrine:generate:entities command 181 | **/Entity/*~ 182 | 183 | # Embedded web-server pid file 184 | /.web-server-pid 185 | 186 | ### Symfony Patch ### 187 | /web/css/ 188 | /web/js/ 189 | 190 | # End of https://www.toptal.com/developers/gitignore/api/phpstorm,symfony 191 | 192 | .idea/ 193 | 194 | mysql 195 | 196 | SQL 197 | 198 | .DS_Store -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =8.0.2", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "doctrine/annotations": "^1.0", 11 | "doctrine/doctrine-bundle": "^2.6", 12 | "doctrine/doctrine-migrations-bundle": "^3.2", 13 | "doctrine/orm": "^2.11", 14 | "phpdocumentor/reflection-docblock": "^5.3", 15 | "phpstan/phpdoc-parser": "^1.4", 16 | "predis/predis": "^1.1", 17 | "symfony/cache": "6.0.*", 18 | "symfony/console": "6.0.*", 19 | "symfony/dotenv": "6.0.*", 20 | "symfony/flex": "^2", 21 | "symfony/framework-bundle": "6.0.*", 22 | "symfony/property-access": "6.0.*", 23 | "symfony/property-info": "6.0.*", 24 | "symfony/proxy-manager-bridge": "6.0.*", 25 | "symfony/runtime": "6.0.*", 26 | "symfony/serializer": "6.0.*", 27 | "symfony/validator": "6.0.*", 28 | "symfony/yaml": "6.0.*" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "^9.5", 32 | "symfony/browser-kit": "6.0.*", 33 | "symfony/css-selector": "6.0.*", 34 | "symfony/maker-bundle": "^1.38", 35 | "symfony/phpunit-bridge": "^6.0" 36 | }, 37 | "config": { 38 | "allow-plugins": { 39 | "composer/package-versions-deprecated": true, 40 | "symfony/flex": true, 41 | "symfony/runtime": true 42 | }, 43 | "optimize-autoloader": true, 44 | "preferred-install": { 45 | "*": "dist" 46 | }, 47 | "sort-packages": true 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "App\\": "src/" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "App\\Tests\\": "tests/" 57 | } 58 | }, 59 | "replace": { 60 | "symfony/polyfill-ctype": "*", 61 | "symfony/polyfill-iconv": "*", 62 | "symfony/polyfill-php72": "*", 63 | "symfony/polyfill-php73": "*", 64 | "symfony/polyfill-php74": "*", 65 | "symfony/polyfill-php80": "*" 66 | }, 67 | "scripts": { 68 | "auto-scripts": { 69 | "cache:clear": "symfony-cmd", 70 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 71 | }, 72 | "post-install-cmd": [ 73 | "@auto-scripts" 74 | ], 75 | "post-update-cmd": [ 76 | "@auto-scripts" 77 | ] 78 | }, 79 | "conflict": { 80 | "symfony/symfony": "*" 81 | }, 82 | "extra": { 83 | "symfony": { 84 | "allow-contrib": false, 85 | "require": "6.0.*", 86 | "docker": false 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 6 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 7 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 8 | ]; 9 | -------------------------------------------------------------------------------- /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: '%env(resolve:REDIS_URL)%' 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/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '13' 8 | orm: 9 | auto_generate_proxy_classes: true 10 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 11 | auto_mapping: true 12 | mappings: 13 | App: 14 | is_bundle: false 15 | dir: '%kernel.project_dir%/src/Entity' 16 | prefix: 'App\Entity' 17 | alias: App 18 | 19 | when@test: 20 | doctrine: 21 | dbal: 22 | # "TEST_TOKEN" is typically set by ParaTest 23 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 24 | 25 | when@prod: 26 | doctrine: 27 | orm: 28 | auto_generate_proxy_classes: false 29 | query_cache_driver: 30 | type: pool 31 | pool: doctrine.system_cache_pool 32 | result_cache_driver: 33 | type: pool 34 | pool: doctrine.result_cache_pool 35 | 36 | framework: 37 | cache: 38 | pools: 39 | doctrine.result_cache_pool: 40 | adapter: cache.app 41 | doctrine.system_cache_pool: 42 | adapter: cache.system 43 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: '%kernel.debug%' 7 | -------------------------------------------------------------------------------- /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: false 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 | storage_factory_id: session.storage.factory.native 14 | 15 | #esi: true 16 | #fragments: true 17 | php_errors: 18 | log: true 19 | 20 | when@test: 21 | framework: 22 | test: true 23 | session: 24 | storage_factory_id: session.storage.factory.mock_file 25 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | #auto_mapping: 8 | # App\Entity\: [] 9 | 10 | when@test: 11 | framework: 12 | validation: 13 | not_compromised_password: false 14 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE product ( 24 | id INT AUTO_INCREMENT NOT NULL, 25 | price INT NOT NULL, 26 | PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 27 | 28 | $this->addSql('CREATE TABLE product_promotion ( 29 | id INT AUTO_INCREMENT NOT NULL, 30 | product_id INT NOT NULL, 31 | promotion_id INT NOT NULL, 32 | valid_to DATETIME DEFAULT NULL, 33 | INDEX IDX_AFBDCB5C4584665A (product_id), 34 | INDEX IDX_AFBDCB5C139DF194 (promotion_id), 35 | PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 36 | 37 | $this->addSql('CREATE TABLE promotion ( 38 | id INT AUTO_INCREMENT NOT NULL, 39 | name VARCHAR(255) NOT NULL, 40 | type VARCHAR(255) NOT NULL, 41 | adjustment DOUBLE PRECISION NOT NULL, 42 | criteria JSON NOT NULL, 43 | PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 44 | 45 | $this->addSql('ALTER TABLE product_promotion ADD CONSTRAINT FK_AFBDCB5C4584665A FOREIGN KEY (product_id) REFERENCES product (id)'); 46 | $this->addSql('ALTER TABLE product_promotion ADD CONSTRAINT FK_AFBDCB5C139DF194 FOREIGN KEY (promotion_id) REFERENCES promotion (id)'); 47 | } 48 | 49 | public function down(Schema $schema): void 50 | { 51 | // this down() migration is auto-generated, please modify it to your needs 52 | $this->addSql('ALTER TABLE product_promotion DROP FOREIGN KEY FK_AFBDCB5C4584665A'); 53 | $this->addSql('ALTER TABLE product_promotion DROP FOREIGN KEY FK_AFBDCB5C139DF194'); 54 | $this->addSql('DROP TABLE product'); 55 | $this->addSql('DROP TABLE product_promotion'); 56 | $this->addSql('DROP TABLE promotion'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | PRODUCT 2 | - id (int) 3 | - price (int) 4 | 5 | PROMOTION 6 | - id (int) 7 | - name (string) 8 | - type (string) 9 | - adjustment (float) 10 | - criteria (string|json) 11 | 12 | ======================================================== 13 | 14 | id: 1 15 | name: Black Friday half price sale 16 | type: date_range_multiplier 17 | adjustment: 0.5 18 | criteria: {"from": "2022-11-25", "to": "2022-11-28"} 19 | 20 | ======================================================== 21 | 22 | id: 2 23 | name: Voucher OU812 24 | type: fixed_price_voucher 25 | adjustment: 100 26 | criteria: {"code": "OU812"} 27 | 28 | ========================== DOCKER ========================= 29 | 30 | php bin/console make:docker:database 31 | docker-compose up -d 32 | 33 | symfony console make:migration 34 | symfony console doctrine:migrations:migrate 35 | 36 | ========================== EXCEPTIONS ========================= 37 | 38 | 1. Show dev exception 500 39 | 2. Show prod exception 500 40 | 3. Explain want to show user they have done something wrong (400) 41 | - want to have more control over the exception 42 | - want to have more control over the response / response format 43 | 4. Create ExceptionListener 44 | 5. Dump exception and wire up service 45 | 6. Create ServiceException 46 | 7. Show status code -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | 29 | src 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 43 | 44 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | getId()); 20 | 21 | return $this->cache->get($key, function(ItemInterface $item) use ($product, $requestDate) { 22 | 23 | $item->expiresAfter(5); 24 | 25 | return $this->repository->findValidForProduct( 26 | $product, 27 | date_create_immutable($requestDate) 28 | ); 29 | }); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Controller/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GaryClarke/symfony-microservice/9edb96d06c1156db9561c6f2155d3bc2a035cb07/src/Controller/.gitignore -------------------------------------------------------------------------------- /src/Controller/ProductsController.php: -------------------------------------------------------------------------------- 1 | deserialize( 39 | $request->getContent(), LowestPriceEnquiry::class, 'json' 40 | ); 41 | 42 | $product = $this->repository->findOrFail($id); 43 | 44 | $lowestPriceEnquiry->setProduct($product); 45 | 46 | $promotions = $promotionCache->findValidForProduct($product, $lowestPriceEnquiry->getRequestDate()); 47 | 48 | $modifiedEnquiry = $promotionsFilter->apply($lowestPriceEnquiry, ...$promotions); 49 | 50 | $responseContent = $serializer->serialize($modifiedEnquiry, 'json'); 51 | 52 | return new JsonResponse(data: $responseContent, status: Response::HTTP_OK, json: true); 53 | } 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | #[Route('/products/{id}/promotions', name: 'promotions', methods: 'GET')] 63 | public function promotions() 64 | { 65 | 66 | } 67 | } -------------------------------------------------------------------------------- /src/DTO/LowestPriceEnquiry.php: -------------------------------------------------------------------------------- 1 | product; 40 | } 41 | 42 | /** 43 | * @param Product|null $product 44 | */ 45 | public function setProduct(?Product $product): void 46 | { 47 | $this->product = $product; 48 | } 49 | 50 | /** 51 | * @return int|null 52 | */ 53 | public function getQuantity(): ?int 54 | { 55 | return $this->quantity; 56 | } 57 | 58 | /** 59 | * @param int|null $quantity 60 | */ 61 | public function setQuantity(?int $quantity): void 62 | { 63 | $this->quantity = $quantity; 64 | } 65 | 66 | /** 67 | * @return string|null 68 | */ 69 | public function getRequestLocation(): ?string 70 | { 71 | return $this->requestLocation; 72 | } 73 | 74 | /** 75 | * @param string|null $requestLocation 76 | */ 77 | public function setRequestLocation(?string $requestLocation): void 78 | { 79 | $this->requestLocation = $requestLocation; 80 | } 81 | 82 | /** 83 | * @return string|null 84 | */ 85 | public function getVoucherCode(): ?string 86 | { 87 | return $this->voucherCode; 88 | } 89 | 90 | /** 91 | * @param string|null $voucherCode 92 | */ 93 | public function setVoucherCode(?string $voucherCode): void 94 | { 95 | $this->voucherCode = $voucherCode; 96 | } 97 | 98 | /** 99 | * @return string|null 100 | */ 101 | public function getRequestDate(): ?string 102 | { 103 | return $this->requestDate; 104 | } 105 | 106 | /** 107 | * @param string|null $requestDate 108 | */ 109 | public function setRequestDate(?string $requestDate): void 110 | { 111 | $this->requestDate = $requestDate; 112 | } 113 | 114 | /** 115 | * @return int|null 116 | */ 117 | public function getPrice(): ?int 118 | { 119 | return $this->price; 120 | } 121 | 122 | /** 123 | * @param int|null $price 124 | */ 125 | public function setPrice(?int $price): void 126 | { 127 | $this->price = $price; 128 | } 129 | 130 | /** 131 | * @return int|null 132 | */ 133 | public function getDiscountedPrice(): ?int 134 | { 135 | return $this->discountedPrice; 136 | } 137 | 138 | /** 139 | * @param int|null $discountedPrice 140 | */ 141 | public function setDiscountedPrice(?int $discountedPrice): void 142 | { 143 | $this->discountedPrice = $discountedPrice; 144 | } 145 | 146 | /** 147 | * @return int|null 148 | */ 149 | public function getPromotionId(): ?int 150 | { 151 | return $this->promotionId; 152 | } 153 | 154 | /** 155 | * @param int|null $promotionId 156 | */ 157 | public function setPromotionId(?int $promotionId): void 158 | { 159 | $this->promotionId = $promotionId; 160 | } 161 | 162 | /** 163 | * @return string|null 164 | */ 165 | public function getPromotionName(): ?string 166 | { 167 | return $this->promotionName; 168 | } 169 | 170 | /** 171 | * @param string|null $promotionName 172 | */ 173 | public function setPromotionName(?string $promotionName): void 174 | { 175 | $this->promotionName = $promotionName; 176 | } 177 | } -------------------------------------------------------------------------------- /src/DTO/PriceEnquiryInterface.php: -------------------------------------------------------------------------------- 1 | id; 24 | } 25 | 26 | public function getPrice(): ?int 27 | { 28 | return $this->price; 29 | } 30 | 31 | public function setPrice(int $price): self 32 | { 33 | $this->price = $price; 34 | 35 | return $this; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Entity/ProductPromotion.php: -------------------------------------------------------------------------------- 1 | id; 30 | } 31 | 32 | public function getProduct(): ?Product 33 | { 34 | return $this->product; 35 | } 36 | 37 | public function setProduct(?Product $product): self 38 | { 39 | $this->product = $product; 40 | 41 | return $this; 42 | } 43 | 44 | public function getPromotion(): ?Promotion 45 | { 46 | return $this->promotion; 47 | } 48 | 49 | public function setPromotion(?Promotion $promotion): self 50 | { 51 | $this->promotion = $promotion; 52 | 53 | return $this; 54 | } 55 | 56 | public function getValidTo(): ?\DateTimeInterface 57 | { 58 | return $this->validTo; 59 | } 60 | 61 | public function setValidTo(?\DateTimeInterface $validTo): self 62 | { 63 | $this->validTo = $validTo; 64 | 65 | return $this; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Entity/Promotion.php: -------------------------------------------------------------------------------- 1 | productPromotions = new ArrayCollection(); 35 | } 36 | 37 | public function getId(): ?int 38 | { 39 | return $this->id; 40 | } 41 | 42 | public function getName(): ?string 43 | { 44 | return $this->name; 45 | } 46 | 47 | public function setName(string $name): self 48 | { 49 | $this->name = $name; 50 | 51 | return $this; 52 | } 53 | 54 | public function getType(): ?string 55 | { 56 | return $this->type; 57 | } 58 | 59 | public function setType(string $type): self 60 | { 61 | $this->type = $type; 62 | 63 | return $this; 64 | } 65 | 66 | public function getAdjustment(): ?float 67 | { 68 | return $this->adjustment; 69 | } 70 | 71 | public function setAdjustment(float $adjustment): self 72 | { 73 | $this->adjustment = $adjustment; 74 | 75 | return $this; 76 | } 77 | 78 | public function getCriteria(): ?array 79 | { 80 | return $this->criteria; 81 | } 82 | 83 | public function setCriteria(array $criteria): self 84 | { 85 | $this->criteria = $criteria; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * @return ArrayCollection 92 | */ 93 | public function getProductPromotions(): ArrayCollection 94 | { 95 | return $this->productPromotions; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Event/AfterDtoCreatedEvent.php: -------------------------------------------------------------------------------- 1 | dto; 19 | } 20 | } -------------------------------------------------------------------------------- /src/EventListener/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | getThrowable(); 17 | 18 | if ($exception instanceof ServiceException) { 19 | $exceptionData = $exception->getExceptionData(); 20 | } else { 21 | $statusCode = Response::HTTP_INTERNAL_SERVER_ERROR; 22 | $exceptionData = new ServiceExceptionData($statusCode, $exception->getMessage()); 23 | } 24 | 25 | $response = new JsonResponse($exceptionData->toArray()); 26 | $event->setResponse($response); 27 | } 28 | } -------------------------------------------------------------------------------- /src/EventSubscriber/DtoSubscriber.php: -------------------------------------------------------------------------------- 1 | 'validateDto' 21 | ]; 22 | } 23 | 24 | public function validateDto(AfterDtoCreatedEvent $event): void 25 | { 26 | $dto = $event->getDto(); 27 | 28 | $errors = $this->validator->validate($dto); 29 | 30 | if (count($errors) > 0) { 31 | 32 | $validationExceptionData = new ValidationExceptionData(422, 'ConstraintViolationList', $errors); 33 | 34 | throw new ServiceException($validationExceptionData); 35 | } 36 | } 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | } -------------------------------------------------------------------------------- /src/Filter/LowestPriceFilter.php: -------------------------------------------------------------------------------- 1 | getProduct()->getPrice(); 20 | $enquiry->setPrice($price); 21 | $quantity = $enquiry->getQuantity(); 22 | $lowestPrice = $quantity * $price; 23 | 24 | foreach ($promotions as $promotion) { 25 | 26 | $priceModifier = $this->priceModifierFactory->create($promotion->getType()); 27 | 28 | $modifiedPrice = $priceModifier->modify($price, $quantity, $promotion, $enquiry); 29 | 30 | if($modifiedPrice < $lowestPrice) { 31 | 32 | $enquiry->setDiscountedPrice($modifiedPrice); 33 | $enquiry->setPromotionId($promotion->getId()); 34 | $enquiry->setPromotionName($promotion->getName()); 35 | 36 | $lowestPrice = $modifiedPrice; 37 | } 38 | } 39 | 40 | return $enquiry; 41 | } 42 | } -------------------------------------------------------------------------------- /src/Filter/Modifier/DateRangeMultiplier.php: -------------------------------------------------------------------------------- 1 | getRequestDate()); 13 | $from = date_create($promotion->getCriteria()['from']); 14 | $to = date_create($promotion->getCriteria()['to']); 15 | 16 | if (!($requestDate >= $from && $requestDate < $to)) { 17 | 18 | return $price * $quantity; 19 | } 20 | 21 | return ($price * $quantity) * $promotion->getAdjustment(); 22 | } 23 | } -------------------------------------------------------------------------------- /src/Filter/Modifier/EvenItemsMultiplier.php: -------------------------------------------------------------------------------- 1 | getAdjustment()) + ($oddCount * $price); 22 | } 23 | } -------------------------------------------------------------------------------- /src/Filter/Modifier/Factory/PriceModifierFactory.php: -------------------------------------------------------------------------------- 1 | getVoucherCode() === $promotion->getCriteria()['code'])) { 13 | 14 | return $price * $quantity; 15 | } 16 | 17 | return $promotion->getAdjustment() * $quantity; 18 | } 19 | } -------------------------------------------------------------------------------- /src/Filter/Modifier/PriceModifierInterface.php: -------------------------------------------------------------------------------- 1 | _em->persist($entity); 31 | if ($flush) { 32 | $this->_em->flush(); 33 | } 34 | } 35 | 36 | /** 37 | * @throws ORMException 38 | * @throws OptimisticLockException 39 | */ 40 | public function remove(ProductPromotion $entity, bool $flush = true): void 41 | { 42 | $this->_em->remove($entity); 43 | if ($flush) { 44 | $this->_em->flush(); 45 | } 46 | } 47 | 48 | // /** 49 | // * @return ProductPromotion[] Returns an array of ProductPromotion objects 50 | // */ 51 | /* 52 | public function findByExampleField($value) 53 | { 54 | return $this->createQueryBuilder('p') 55 | ->andWhere('p.exampleField = :val') 56 | ->setParameter('val', $value) 57 | ->orderBy('p.id', 'ASC') 58 | ->setMaxResults(10) 59 | ->getQuery() 60 | ->getResult() 61 | ; 62 | } 63 | */ 64 | 65 | /* 66 | public function findOneBySomeField($value): ?ProductPromotion 67 | { 68 | return $this->createQueryBuilder('p') 69 | ->andWhere('p.exampleField = :val') 70 | ->setParameter('val', $value) 71 | ->getQuery() 72 | ->getOneOrNullResult() 73 | ; 74 | } 75 | */ 76 | } 77 | -------------------------------------------------------------------------------- /src/Repository/ProductRepository.php: -------------------------------------------------------------------------------- 1 | find($id); 29 | 30 | if (!$product) { 31 | 32 | $exceptionData = new ServiceExceptionData(404, 'Product Not Found'); 33 | 34 | throw new ServiceException($exceptionData); 35 | } 36 | 37 | return $product; 38 | } 39 | 40 | /** 41 | * @throws ORMException 42 | * @throws OptimisticLockException 43 | */ 44 | public function add(Product $entity, bool $flush = true): void 45 | { 46 | $this->_em->persist($entity); 47 | if ($flush) { 48 | $this->_em->flush(); 49 | } 50 | } 51 | 52 | /** 53 | * @throws ORMException 54 | * @throws OptimisticLockException 55 | */ 56 | public function remove(Product $entity, bool $flush = true): void 57 | { 58 | $this->_em->remove($entity); 59 | if ($flush) { 60 | $this->_em->flush(); 61 | } 62 | } 63 | 64 | // /** 65 | // * @return Product[] Returns an array of Product objects 66 | // */ 67 | /* 68 | public function findByExampleField($value) 69 | { 70 | return $this->createQueryBuilder('p') 71 | ->andWhere('p.exampleField = :val') 72 | ->setParameter('val', $value) 73 | ->orderBy('p.id', 'ASC') 74 | ->setMaxResults(10) 75 | ->getQuery() 76 | ->getResult() 77 | ; 78 | } 79 | */ 80 | 81 | /* 82 | public function findOneBySomeField($value): ?Product 83 | { 84 | return $this->createQueryBuilder('p') 85 | ->andWhere('p.exampleField = :val') 86 | ->setParameter('val', $value) 87 | ->getQuery() 88 | ->getOneOrNullResult() 89 | ; 90 | } 91 | */ 92 | } 93 | -------------------------------------------------------------------------------- /src/Repository/PromotionRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('p') 29 | ->innerJoin('p.productPromotions', 'pp') 30 | ->andWhere('pp.product = :product') 31 | ->andWhere('pp.validTo > :requestDate OR pp.validTo IS NULL') 32 | ->setParameter('product', $product) 33 | ->setParameter('requestDate', $requestDate) 34 | ->getQuery() 35 | ->getResult(); 36 | } 37 | 38 | 39 | /** 40 | * @throws ORMException 41 | * @throws OptimisticLockException 42 | */ 43 | public function add(Promotion $entity, bool $flush = true): void 44 | { 45 | $this->_em->persist($entity); 46 | if ($flush) { 47 | $this->_em->flush(); 48 | } 49 | } 50 | 51 | /** 52 | * @throws ORMException 53 | * @throws OptimisticLockException 54 | */ 55 | public function remove(Promotion $entity, bool $flush = true): void 56 | { 57 | $this->_em->remove($entity); 58 | if ($flush) { 59 | $this->_em->flush(); 60 | } 61 | } 62 | 63 | // /** 64 | // * @return Promotion[] Returns an array of Promotion objects 65 | // */ 66 | /* 67 | public function findByExampleField($value) 68 | { 69 | return $this->createQueryBuilder('p') 70 | ->andWhere('p.exampleField = :val') 71 | ->setParameter('val', $value) 72 | ->orderBy('p.id', 'ASC') 73 | ->setMaxResults(10) 74 | ->getQuery() 75 | ->getResult() 76 | ; 77 | } 78 | */ 79 | 80 | /* 81 | public function findOneBySomeField($value): ?Promotion 82 | { 83 | return $this->createQueryBuilder('p') 84 | ->andWhere('p.exampleField = :val') 85 | ->setParameter('val', $value) 86 | ->getQuery() 87 | ->getOneOrNullResult() 88 | ; 89 | } 90 | */ 91 | } 92 | -------------------------------------------------------------------------------- /src/Service/Serializer/DTOSerializer.php: -------------------------------------------------------------------------------- 1 | eventDispatcher = $eventDispatcher; 24 | 25 | $this->serializer = new Serializer( 26 | [new ObjectNormalizer( 27 | classMetadataFactory: new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())), 28 | nameConverter: new CamelCaseToSnakeCaseNameConverter())], 29 | [new JsonEncoder()] 30 | ); 31 | } 32 | 33 | public function serialize(mixed $data, string $format, array $context = []): string 34 | { 35 | return $this->serializer->serialize($data, $format, $context); 36 | } 37 | 38 | public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed 39 | { 40 | $dto = $this->serializer->deserialize($data, $type, $format, $context); 41 | 42 | $event = new AfterDtoCreatedEvent($dto); 43 | 44 | $this->eventDispatcher->dispatch($event, $event::NAME); 45 | 46 | return $dto; 47 | } 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | } -------------------------------------------------------------------------------- /src/Service/ServiceException.php: -------------------------------------------------------------------------------- 1 | getStatusCode(); 14 | $message = $exceptionData->getType(); 15 | 16 | parent::__construct($statusCode, $message); 17 | $this->exceptionData = $exceptionData; 18 | } 19 | 20 | public function getExceptionData(): ServiceExceptionData 21 | { 22 | return $this->exceptionData; 23 | } 24 | } -------------------------------------------------------------------------------- /src/Service/ServiceExceptionData.php: -------------------------------------------------------------------------------- 1 | statusCode; 15 | } 16 | 17 | public function getType(): string 18 | { 19 | return $this->type; 20 | } 21 | 22 | public function toArray(): array 23 | { 24 | return [ 25 | 'type' => $this->type 26 | ]; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Service/ValidationExceptionData.php: -------------------------------------------------------------------------------- 1 | violations = $violations; 16 | } 17 | 18 | public function toArray(): array 19 | { 20 | return [ 21 | 'type' => 'ConstraintViolationList', 22 | 'violations' => $this->getViolationsArray() 23 | ]; 24 | } 25 | 26 | public function getViolationsArray():array 27 | { 28 | $violations = []; 29 | 30 | foreach ($this->violations as $violation) { 31 | 32 | $violations[] = [ 33 | 'propertyPath' => $violation->getPropertyPath(), 34 | 'message' => $violation->getMessage() 35 | ]; 36 | } 37 | 38 | return $violations; 39 | } 40 | } -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "doctrine/annotations": { 3 | "version": "1.13", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes", 6 | "branch": "master", 7 | "version": "1.10", 8 | "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" 9 | } 10 | }, 11 | "doctrine/cache": { 12 | "version": "2.1.1" 13 | }, 14 | "doctrine/collections": { 15 | "version": "1.6.8" 16 | }, 17 | "doctrine/common": { 18 | "version": "3.3.0" 19 | }, 20 | "doctrine/dbal": { 21 | "version": "3.3.5" 22 | }, 23 | "doctrine/deprecations": { 24 | "version": "v0.5.3" 25 | }, 26 | "doctrine/doctrine-bundle": { 27 | "version": "2.6", 28 | "recipe": { 29 | "repo": "github.com/symfony/recipes", 30 | "branch": "master", 31 | "version": "2.4", 32 | "ref": "ddddd8249dd55bbda16fa7a45bb7499ef6f8e90e" 33 | }, 34 | "files": [ 35 | "config/packages/doctrine.yaml", 36 | "src/Entity/.gitignore", 37 | "src/Repository/.gitignore" 38 | ] 39 | }, 40 | "doctrine/doctrine-migrations-bundle": { 41 | "version": "3.2", 42 | "recipe": { 43 | "repo": "github.com/symfony/recipes", 44 | "branch": "master", 45 | "version": "3.1", 46 | "ref": "ee609429c9ee23e22d6fa5728211768f51ed2818" 47 | }, 48 | "files": [ 49 | "config/packages/doctrine_migrations.yaml", 50 | "migrations/.gitignore" 51 | ] 52 | }, 53 | "doctrine/event-manager": { 54 | "version": "1.1.1" 55 | }, 56 | "doctrine/inflector": { 57 | "version": "2.0.4" 58 | }, 59 | "doctrine/instantiator": { 60 | "version": "1.4.1" 61 | }, 62 | "doctrine/lexer": { 63 | "version": "1.2.3" 64 | }, 65 | "doctrine/migrations": { 66 | "version": "3.5.0" 67 | }, 68 | "doctrine/orm": { 69 | "version": "2.11.2" 70 | }, 71 | "doctrine/persistence": { 72 | "version": "2.5.1" 73 | }, 74 | "doctrine/sql-formatter": { 75 | "version": "1.1.2" 76 | }, 77 | "friendsofphp/proxy-manager-lts": { 78 | "version": "v1.0.7" 79 | }, 80 | "laminas/laminas-code": { 81 | "version": "4.5.1" 82 | }, 83 | "myclabs/deep-copy": { 84 | "version": "1.11.0" 85 | }, 86 | "nikic/php-parser": { 87 | "version": "v4.13.2" 88 | }, 89 | "phar-io/manifest": { 90 | "version": "2.0.3" 91 | }, 92 | "phar-io/version": { 93 | "version": "3.2.1" 94 | }, 95 | "phpdocumentor/reflection-common": { 96 | "version": "2.2.0" 97 | }, 98 | "phpdocumentor/reflection-docblock": { 99 | "version": "5.3.0" 100 | }, 101 | "phpdocumentor/type-resolver": { 102 | "version": "1.6.1" 103 | }, 104 | "phpspec/prophecy": { 105 | "version": "v1.15.0" 106 | }, 107 | "phpstan/phpdoc-parser": { 108 | "version": "1.4.2" 109 | }, 110 | "phpunit/php-code-coverage": { 111 | "version": "9.2.15" 112 | }, 113 | "phpunit/php-file-iterator": { 114 | "version": "3.0.6" 115 | }, 116 | "phpunit/php-invoker": { 117 | "version": "3.1.1" 118 | }, 119 | "phpunit/php-text-template": { 120 | "version": "2.0.4" 121 | }, 122 | "phpunit/php-timer": { 123 | "version": "5.0.3" 124 | }, 125 | "phpunit/phpunit": { 126 | "version": "9.5", 127 | "recipe": { 128 | "repo": "github.com/symfony/recipes", 129 | "branch": "master", 130 | "version": "9.3", 131 | "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6" 132 | }, 133 | "files": [ 134 | ".env.test", 135 | "phpunit.xml.dist", 136 | "tests/bootstrap.php" 137 | ] 138 | }, 139 | "predis/predis": { 140 | "version": "v1.1.10" 141 | }, 142 | "psr/cache": { 143 | "version": "3.0.0" 144 | }, 145 | "psr/container": { 146 | "version": "2.0.2" 147 | }, 148 | "psr/event-dispatcher": { 149 | "version": "1.0.0" 150 | }, 151 | "psr/log": { 152 | "version": "3.0.0" 153 | }, 154 | "sebastian/cli-parser": { 155 | "version": "1.0.1" 156 | }, 157 | "sebastian/code-unit": { 158 | "version": "1.0.8" 159 | }, 160 | "sebastian/code-unit-reverse-lookup": { 161 | "version": "2.0.3" 162 | }, 163 | "sebastian/comparator": { 164 | "version": "4.0.6" 165 | }, 166 | "sebastian/complexity": { 167 | "version": "2.0.2" 168 | }, 169 | "sebastian/diff": { 170 | "version": "4.0.4" 171 | }, 172 | "sebastian/environment": { 173 | "version": "5.1.3" 174 | }, 175 | "sebastian/exporter": { 176 | "version": "4.0.4" 177 | }, 178 | "sebastian/global-state": { 179 | "version": "5.0.5" 180 | }, 181 | "sebastian/lines-of-code": { 182 | "version": "1.0.3" 183 | }, 184 | "sebastian/object-enumerator": { 185 | "version": "4.0.4" 186 | }, 187 | "sebastian/object-reflector": { 188 | "version": "2.0.4" 189 | }, 190 | "sebastian/recursion-context": { 191 | "version": "4.0.4" 192 | }, 193 | "sebastian/resource-operations": { 194 | "version": "3.0.3" 195 | }, 196 | "sebastian/type": { 197 | "version": "3.0.0" 198 | }, 199 | "sebastian/version": { 200 | "version": "3.0.2" 201 | }, 202 | "symfony/browser-kit": { 203 | "version": "v6.0.3" 204 | }, 205 | "symfony/cache": { 206 | "version": "v6.0.6" 207 | }, 208 | "symfony/cache-contracts": { 209 | "version": "v3.0.0" 210 | }, 211 | "symfony/config": { 212 | "version": "v6.0.3" 213 | }, 214 | "symfony/console": { 215 | "version": "6.0", 216 | "recipe": { 217 | "repo": "github.com/symfony/recipes", 218 | "branch": "master", 219 | "version": "5.3", 220 | "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047" 221 | }, 222 | "files": [ 223 | "bin/console" 224 | ] 225 | }, 226 | "symfony/css-selector": { 227 | "version": "v6.0.3" 228 | }, 229 | "symfony/dependency-injection": { 230 | "version": "v6.0.6" 231 | }, 232 | "symfony/deprecation-contracts": { 233 | "version": "v3.0.0" 234 | }, 235 | "symfony/doctrine-bridge": { 236 | "version": "v6.0.7" 237 | }, 238 | "symfony/dom-crawler": { 239 | "version": "v6.0.6" 240 | }, 241 | "symfony/dotenv": { 242 | "version": "v6.0.5" 243 | }, 244 | "symfony/error-handler": { 245 | "version": "v6.0.3" 246 | }, 247 | "symfony/event-dispatcher": { 248 | "version": "v6.0.3" 249 | }, 250 | "symfony/event-dispatcher-contracts": { 251 | "version": "v3.0.0" 252 | }, 253 | "symfony/filesystem": { 254 | "version": "v6.0.6" 255 | }, 256 | "symfony/finder": { 257 | "version": "v6.0.3" 258 | }, 259 | "symfony/flex": { 260 | "version": "2.1", 261 | "recipe": { 262 | "repo": "github.com/symfony/recipes", 263 | "branch": "master", 264 | "version": "1.0", 265 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 266 | }, 267 | "files": [ 268 | ".env" 269 | ] 270 | }, 271 | "symfony/framework-bundle": { 272 | "version": "6.0", 273 | "recipe": { 274 | "repo": "github.com/symfony/recipes", 275 | "branch": "master", 276 | "version": "5.4", 277 | "ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb" 278 | }, 279 | "files": [ 280 | "config/packages/cache.yaml", 281 | "config/packages/framework.yaml", 282 | "config/preload.php", 283 | "config/routes/framework.yaml", 284 | "config/services.yaml", 285 | "public/index.php", 286 | "src/Controller/.gitignore", 287 | "src/Kernel.php" 288 | ] 289 | }, 290 | "symfony/http-foundation": { 291 | "version": "v6.0.6" 292 | }, 293 | "symfony/http-kernel": { 294 | "version": "v6.0.6" 295 | }, 296 | "symfony/maker-bundle": { 297 | "version": "1.38", 298 | "recipe": { 299 | "repo": "github.com/symfony/recipes", 300 | "branch": "master", 301 | "version": "1.0", 302 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" 303 | } 304 | }, 305 | "symfony/phpunit-bridge": { 306 | "version": "6.0", 307 | "recipe": { 308 | "repo": "github.com/symfony/recipes", 309 | "branch": "master", 310 | "version": "5.3", 311 | "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96" 312 | }, 313 | "files": [ 314 | ".env.test", 315 | "bin/phpunit", 316 | "phpunit.xml.dist", 317 | "tests/bootstrap.php" 318 | ] 319 | }, 320 | "symfony/polyfill-intl-grapheme": { 321 | "version": "v1.25.0" 322 | }, 323 | "symfony/polyfill-intl-normalizer": { 324 | "version": "v1.25.0" 325 | }, 326 | "symfony/polyfill-mbstring": { 327 | "version": "v1.25.0" 328 | }, 329 | "symfony/polyfill-php81": { 330 | "version": "v1.25.0" 331 | }, 332 | "symfony/property-access": { 333 | "version": "v6.0.7" 334 | }, 335 | "symfony/property-info": { 336 | "version": "v6.0.7" 337 | }, 338 | "symfony/proxy-manager-bridge": { 339 | "version": "v6.0.6" 340 | }, 341 | "symfony/routing": { 342 | "version": "6.0", 343 | "recipe": { 344 | "repo": "github.com/symfony/recipes", 345 | "branch": "master", 346 | "version": "6.0", 347 | "ref": "eb3b377a4dc07006c4bdb2c773652cc9434f5246" 348 | }, 349 | "files": [ 350 | "config/packages/routing.yaml", 351 | "config/routes.yaml" 352 | ] 353 | }, 354 | "symfony/runtime": { 355 | "version": "v6.0.5" 356 | }, 357 | "symfony/serializer": { 358 | "version": "v6.0.7" 359 | }, 360 | "symfony/service-contracts": { 361 | "version": "v3.0.0" 362 | }, 363 | "symfony/stopwatch": { 364 | "version": "v6.0.5" 365 | }, 366 | "symfony/string": { 367 | "version": "v6.0.3" 368 | }, 369 | "symfony/translation-contracts": { 370 | "version": "v3.1.0" 371 | }, 372 | "symfony/validator": { 373 | "version": "6.0", 374 | "recipe": { 375 | "repo": "github.com/symfony/recipes", 376 | "branch": "main", 377 | "version": "5.3", 378 | "ref": "c32cfd98f714894c4f128bb99aa2530c1227603c" 379 | }, 380 | "files": [ 381 | "config/packages/validator.yaml" 382 | ] 383 | }, 384 | "symfony/var-dumper": { 385 | "version": "v6.0.6" 386 | }, 387 | "symfony/var-exporter": { 388 | "version": "v6.0.6" 389 | }, 390 | "symfony/yaml": { 391 | "version": "v6.0.3" 392 | }, 393 | "theseer/tokenizer": { 394 | "version": "1.2.1" 395 | }, 396 | "webmozart/assert": { 397 | "version": "1.10.0" 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /tests/ServiceTestCase.php: -------------------------------------------------------------------------------- 1 | container = static::createClient()->getContainer(); 17 | } 18 | } -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /tests/unit/DtoSubscriberTest.php: -------------------------------------------------------------------------------- 1 | assertArrayHasKey(AfterDtoCreatedEvent::NAME, DtoSubscriber::getSubscribedEvents()); 17 | } 18 | 19 | public function testValidateDto(): void 20 | { 21 | $dto = new LowestPriceEnquiry(); 22 | $dto->setQuantity(-5); 23 | $event = new AfterDtoCreatedEvent($dto); 24 | $dispatcher = $this->container->get(EventDispatcherInterface::class); 25 | 26 | $this->expectException(ServiceException::class); 27 | $this->expectExceptionMessage('ConstraintViolationList'); 28 | 29 | $dispatcher->dispatch($event, $event::NAME); 30 | } 31 | } -------------------------------------------------------------------------------- /tests/unit/LowestPriceFilterTest.php: -------------------------------------------------------------------------------- 1 | setPrice(100); 19 | 20 | $enquiry = new LowestPriceEnquiry(); 21 | $enquiry->setProduct($product); 22 | $enquiry->setQuantity(5); 23 | $enquiry->setRequestDate('2022-11-27'); 24 | $enquiry->setVoucherCode('OU812'); 25 | 26 | $promotions = $this->promotionsDataProvider(); 27 | 28 | $lowestPriceFilter = $this->container->get(LowestPriceFilter::class); 29 | 30 | // When 31 | $filteredEnquiry = $lowestPriceFilter->apply($enquiry, ...$promotions); 32 | 33 | // Then 34 | $this->assertSame(100, $filteredEnquiry->getPrice()); 35 | $this->assertSame(250, $filteredEnquiry->getDiscountedPrice()); 36 | $this->assertSame('Black Friday half price sale', $filteredEnquiry->getPromotionName()); 37 | } 38 | 39 | public function promotionsDataProvider(): array 40 | { 41 | $promotionOne = new Promotion(); 42 | $promotionOne->setName('Black Friday half price sale'); 43 | $promotionOne->setAdjustment(0.5); 44 | $promotionOne->setCriteria(["from" => "2022-11-25", "to" => "2022-11-28"]); 45 | $promotionOne->setType('date_range_multiplier'); 46 | 47 | $promotionTwo = new Promotion(); 48 | $promotionTwo->setName('Voucher OU812'); 49 | $promotionTwo->setAdjustment(100); 50 | $promotionTwo->setCriteria(["code" => "OU812"]); 51 | $promotionTwo->setType('fixed_price_voucher'); 52 | 53 | $promotionThree = new Promotion(); 54 | $promotionThree->setName('Buy one get one free'); 55 | $promotionThree->setAdjustment(0.5); 56 | $promotionThree->setCriteria(["minimum_quantity" => 2]); 57 | $promotionThree->setType('even_items_multiplier'); 58 | 59 | return [$promotionOne, $promotionTwo, $promotionThree]; 60 | } 61 | } -------------------------------------------------------------------------------- /tests/unit/PriceModifiersTest.php: -------------------------------------------------------------------------------- 1 | setQuantity(5); 20 | $enquiry->setRequestDate('2022-11-27'); 21 | 22 | $promotion = new Promotion(); 23 | $promotion->setName('Black Friday half price sale'); 24 | $promotion->setAdjustment(0.5); 25 | $promotion->setCriteria(["from" => "2022-11-25", "to" => "2022-11-28"]); 26 | $promotion->setType('date_range_multiplier'); 27 | 28 | $dateRangeModifier = new DateRangeMultiplier(); 29 | 30 | // When 31 | $modifiedPrice = $dateRangeModifier->modify(100, 5, $promotion, $enquiry); 32 | 33 | // Then 34 | $this->assertEquals(250, $modifiedPrice); 35 | } 36 | 37 | /** @test */ 38 | public function FixedPriceVoucher_returns_a_correctly_modified_price(): void 39 | { 40 | $fixedPriceVoucher = new FixedPriceVoucher(); 41 | 42 | $promotion = new Promotion(); 43 | $promotion->setName('Voucher OU812'); 44 | $promotion->setAdjustment(100); 45 | $promotion->setCriteria(["code" => "OU812"]); 46 | $promotion->setType('fixed_price_voucher'); 47 | 48 | $enquiry = new LowestPriceEnquiry(); 49 | $enquiry->setQuantity(5); 50 | $enquiry->setVoucherCode('OU812'); 51 | 52 | $modifiedPrice = $fixedPriceVoucher->modify(150, 5, $promotion, $enquiry); 53 | 54 | $this->assertEquals(500, $modifiedPrice); 55 | } 56 | 57 | /** @test */ 58 | public function EvenItemsMultiplier_returns_a_correctly_modified_price(): void 59 | { 60 | // Given 61 | $enquiry = new LowestPriceEnquiry(); 62 | $enquiry->setQuantity(5); 63 | 64 | $promotion = new Promotion(); 65 | $promotion->setName('Buy one get one free'); 66 | $promotion->setAdjustment(0.5); 67 | $promotion->setCriteria(["minimum_quantity" => 2]); 68 | $promotion->setType('even_items_multiplier'); 69 | 70 | $evenItemsMultiplier = new EvenItemsMultiplier(); 71 | 72 | // When 73 | $modifiedPrice = $evenItemsMultiplier->modify(100, 5, $promotion, $enquiry); 74 | 75 | // Then 76 | // ((100 * 4) * 0.5) + (1 * 100) 77 | $this->assertEquals(300, $modifiedPrice); 78 | } 79 | 80 | /** @test */ 81 | public function EvenItemsMultiplier_correctly_calculates_alternatives(): void 82 | { 83 | // Given 84 | $enquiry = new LowestPriceEnquiry(); 85 | $enquiry->setQuantity(5); 86 | 87 | $promotion = new Promotion(); 88 | $promotion->setName('Buy one get one half price'); 89 | $promotion->setAdjustment(0.75); 90 | $promotion->setCriteria(["minimum_quantity" => 2]); 91 | $promotion->setType('even_items_multiplier'); 92 | 93 | $evenItemsMultiplier = new EvenItemsMultiplier(); 94 | 95 | // When 96 | $modifiedPrice = $evenItemsMultiplier->modify(100, 5, $promotion, $enquiry); 97 | 98 | // Then 99 | // 300 + 100 100 | $this->assertEquals(400, $modifiedPrice); 101 | } 102 | } --------------------------------------------------------------------------------