diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab307727..4c41df8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,19 +37,17 @@ jobs: mysql -u$DB_USER -p$DB_PASSWORD -hlocalhost -P3306 -Dcafe_test < "resources/database/cafe_test_data.sql" mysql -e "USE cafe_test; SHOW TABLES;" -u$DB_USER -p$DB_PASSWORD - - name: Create .env file + - name: Create .env.testing file env: - ENV: | - PUBLIC_ROOT="http://localhost/steamy-sips/public" - DB_HOST="localhost" - DB_USERNAME="root" - DB_PASSWORD="root" - TEST_DB_NAME="cafe_test" - BUSINESS_GMAIL="" - BUSINESS_GMAIL_PASSWORD="" + ENV: | + DB_HOST="localhost" + DB_USERNAME="root" + DB_PASSWORD="root" + DB_NAME="cafe_test" + API_BASE_URI="http://steamy.localhost/api/v1/" run: | - echo "$ENV" > .env - cat .env + echo "$ENV" > .env.testing + cat .env.testing - name: Validate composer.json and composer.lock run: composer validate --strict @@ -66,5 +64,5 @@ jobs: - name: Install Composer dependencies run: composer install --prefer-dist --no-progress - - name: Run test suite - run: composer test + - name: Run model test suite + run: composer modeltest diff --git a/.gitignore b/.gitignore index fed39897..13cd2e83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ /vendor/ .vscode .idea -.env node_modules .phpunit.result.cache public/js + +.env +.env.testing diff --git a/README.md b/README.md index 88d179cb..93fd2058 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,4 @@ Attribution-ShareAlike - https://youtu.be/q0JhJBYi4sw?si=cTdEzzGijlG41ix8 - https://github.com/kevinisaac/php-mvc 4. The filesystem was inspired by https://github.com/php-pds/sklseleton -5. Additional references are included within the code itself. - -# Todo - -Add `X-TEST-ENV` to the header of your request and set its value to `testing` if you want to use the testing database. -This is required when running tests for API. Without this key-value pair, the production database will be used. - -USE DOCKER PHP - -- read guzzle documentation. read base_uri -- test database is being used -- add variable to .env - -line 18 in Reviews API is redundant -use correct namespace \ No newline at end of file +5. Additional references are included within the code itself. \ No newline at end of file diff --git a/composer.json b/composer.json index 1bcc0b6d..088ff18f 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,12 @@ "vlucas/phpdotenv": "^5.6", "phpmailer/phpmailer": "^6.9", "opis/json-schema": "^2.3", - "nesbot/carbon": "^3.3", - "fakerphp/faker": "^1.23" + "nesbot/carbon": "^3.3" }, "scripts": { - "test": "phpunit tests" + "test": "phpunit tests", + "modeltest": "phpunit --testsuite models", + "apitest": "phpunit --testsuite api" }, "autoload": { "psr-4": { @@ -34,10 +35,15 @@ "Steamy\\Core\\": "src/core/", "Steamy\\Controller\\": "src/controllers/", "Steamy\\Controller\\API\\": "src/controllers/api/", - "Steamy\\Model\\": "src/models/" + "Steamy\\Model\\": "src/models/", + "Steamy\\Tests\\": "tests/", + "Steamy\\Tests\\Model\\": "tests/models/", + "Steamy\\Tests\\Api\\": "tests/api/" } }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^10.5", + "guzzlehttp/guzzle": "^7.0", + "fakerphp/faker": "^1.23" } } diff --git a/composer.lock b/composer.lock index 5d44cac9..66ee9fde 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "806bf0c7a9b1ff411f27ccfcab5af00c", + "content-hash": "232edb4d48e7f32ade38be3e08861d6e", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -75,69 +75,6 @@ ], "time": "2024-02-09T16:56:22+00:00" }, - { - "name": "fakerphp/faker", - "version": "v1.23.1", - "source": { - "type": "git", - "url": "https://github.com/FakerPHP/Faker.git", - "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b", - "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0", - "psr/container": "^1.0 || ^2.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "conflict": { - "fzaninotto/faker": "*" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", - "doctrine/persistence": "^1.3 || ^2.0", - "ext-intl": "*", - "phpunit/phpunit": "^9.5.26", - "symfony/phpunit-bridge": "^5.4.16" - }, - "suggest": { - "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", - "ext-curl": "Required by Faker\\Provider\\Image to download images.", - "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", - "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", - "ext-mbstring": "Required for multibyte Unicode string functionality." - }, - "type": "library", - "autoload": { - "psr-4": { - "Faker\\": "src/Faker/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "François Zaninotto" - } - ], - "description": "Faker is a PHP library that generates fake data for you.", - "keywords": [ - "data", - "faker", - "fixtures" - ], - "support": { - "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1" - }, - "time": "2024-01-02T13:46:09+00:00" - }, { "name": "graham-campbell/result-type", "version": "v1.1.2", @@ -700,59 +637,6 @@ }, "time": "2022-11-25T14:36:26+00:00" }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, { "name": "symfony/clock", "version": "v6.4.7", @@ -1479,140 +1363,528 @@ ], "packages-dev": [ { - "name": "myclabs/deep-copy", - "version": "1.11.1", + "name": "fakerphp/faker", + "version": "v1.23.1", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b", + "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" }, "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "fzaninotto/faker": "*" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." }, "type": "library", "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Faker\\": "src/Faker/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "data", + "faker", + "fixtures" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1" }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2024-01-02T13:46:09+00:00" }, { - "name": "nikic/php-parser", - "version": "v5.0.0", + "name": "guzzlehttp/guzzle", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc" + "url": "https://github.com/guzzle/guzzle.git", + "reference": "41042bc7ab002487b876a0683fc8dce04ddce104" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4a21235f7e56e713259a6f76bf4b5ea08502b9dc", - "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104", + "reference": "41042bc7ab002487b876a0683fc8dce04ddce104", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" + "guzzlehttp/promises": "^1.5.3 || ^2.0.1", + "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" }, - "bin": [ - "bin/php-parse" - ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.0-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } }, "autoload": { + "files": [ + "src/functions_include.php" + ], "psr-4": { - "PhpParser\\": "lib/PhpParser" + "GuzzleHttp\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Nikita Popov" + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" } ], - "description": "A PHP parser written in PHP", + "description": "Guzzle is a PHP HTTP client library", "keywords": [ - "parser", - "php" + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.0" + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.8.1" }, - "time": "2024-01-07T17:17:35+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2023-12-03T20:35:24+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.3", + "name": "guzzlehttp/promises", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "url": "https://github.com/guzzle/promises.git", + "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223", + "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.36 || ^9.6.15" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2023-12-03T20:19:20+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.6.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221", + "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^8.5.36 || ^9.6.15" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2023-12-03T20:05:35+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.11.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2023-03-08T13:26:56+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "reference": "4a21235f7e56e713259a6f76bf4b5ea08502b9dc", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.0" + }, + "time": "2024-01-07T17:17:35+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", "php": "^7.2 || ^8.0" }, @@ -2128,6 +2400,263 @@ ], "time": "2024-01-14T16:40:30+00:00" }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "sebastian/cli-parser", "version": "2.0.0", diff --git a/docs/API.md b/docs/API.md index d3a08ce4..c5c07cbc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -13,6 +13,9 @@ The Steamy Sips API is a REST API. +Add `X-TEST-ENV` to the header of your request if you want to use the testing database. This is required when running +tests for API. + ## Endpoints There are two types of endpoints: diff --git a/docs/INSTALLATION_GUIDE.md b/docs/INSTALLATION_GUIDE.md index 062ea7f5..4e2ab37d 100644 --- a/docs/INSTALLATION_GUIDE.md +++ b/docs/INSTALLATION_GUIDE.md @@ -60,9 +60,7 @@ In the root directory of the project, create a `.env` file with the following co DB_HOST="localhost" DB_USERNAME="root" DB_PASSWORD="" - -PROD_DB_NAME="cafe" -TEST_DB_NAME="cafe_test" +DB_NAME="cafe" BUSINESS_GMAIL="" BUSINESS_GMAIL_PASSWORD="" @@ -78,6 +76,16 @@ whenever a client places an order. > a [Gmail App password](https://knowledge.workspace.google.com/kb/how-to-create-app-passwords-000009237) > for `BUSINESS_GMAIL_PASSWORD` instead of your actual gmail account password. +If you want to run tests, create `.env.testing` file in the root directory: + +``` +DB_HOST="localhost" +DB_USERNAME="root" +DB_PASSWORD="" +DB_NAME="cafe_test" +API_BASE_URI="http://steamy.localhost/api/v1/" +``` + ## Database setup Start your MySQL server: @@ -159,4 +167,15 @@ You can use `php --ini` to find the location of your `php.ini` file. ## Autoload setup -Whenever changes are made to the autoload settings in `composer.json`, you must run `composer dump-autoload`. \ No newline at end of file +Whenever changes are made to the autoload settings in `composer.json`, you must run `composer dump-autoload`. + +## Modifying CSS/JS Files + +If you need to make changes to the CSS or JavaScript files located in the `public` folder, you need to run the following +command in your terminal: + +```bash +npm run build +``` + +This command will compile your changes and update the necessary files for your application. \ No newline at end of file diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index a0953dc5..cb8acf7a 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -28,12 +28,28 @@ Visit [`http://steamy.localhost/`](http://steamy.localhost/) in your browser to ## Run tests -Assuming that your MySQL database is running, in the root directory of the project run tests: +> [!IMPORTANT] +> You must start your MySQL database to be able to run tests in the `tests/models` folder. +> To run tests in the `tests/api` folder, you need to start both your Apache server and your MySQL database. + +To run all tests: ```bash composer test ``` +To run tests only in `tests/models`: + +```bash +composer modeltest +``` + +To run tests only in `tests/api`: + +```bash +composer apitest +``` + ## Export database To export only the schema of the `cafe` database: diff --git a/phpunit.xml b/phpunit.xml index 24c274bd..db2b44ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,8 +4,11 @@ - - tests/ + + tests/models + + + tests/api \ No newline at end of file diff --git a/public/.htaccess b/public/.htaccess index 62c38266..8ed56dcb 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -6,3 +6,8 @@ RewriteCond %{REQUEST_FILENAME} !-d # send everything else to index page RewriteRule ^(.*)$ index.php?url=$1 [NC,L,QSA] + +# pass X-Test-Env header to PHP as $_SERVER['HTTP_X_TEST_ENV']. + + SetEnvIfNoCase X-Test-Env "testing" HTTP_X_TEST_ENV=testing + \ No newline at end of file diff --git a/resources/schema/Administrator.json b/resources/schemas/Administrator.json similarity index 100% rename from resources/schema/Administrator.json rename to resources/schemas/Administrator.json diff --git a/resources/schema/Client.json b/resources/schemas/Client.json similarity index 92% rename from resources/schema/Client.json rename to resources/schemas/Client.json index 842b1652..67ce1edf 100644 --- a/resources/schema/Client.json +++ b/resources/schemas/Client.json @@ -24,7 +24,8 @@ "street", "city", "district_id" - ] + ], + "additionalProperties": false } ] } diff --git a/resources/schema/Comment.json b/resources/schemas/Comment.json similarity index 94% rename from resources/schema/Comment.json rename to resources/schemas/Comment.json index a6ed716d..156884b6 100644 --- a/resources/schema/Comment.json +++ b/resources/schemas/Comment.json @@ -30,5 +30,6 @@ "text", "created_date", "user_id" - ] + ], + "additionalProperties": false } diff --git a/resources/schema/Review.json b/resources/schemas/Review.json similarity index 94% rename from resources/schema/Review.json rename to resources/schemas/Review.json index 04657aa2..ec6e6e30 100644 --- a/resources/schema/Review.json +++ b/resources/schemas/Review.json @@ -34,5 +34,6 @@ "text", "client_id", "product_id" - ] + ], + "additionalProperties": false } diff --git a/resources/schema/User.json b/resources/schemas/User.json similarity index 100% rename from resources/schema/User.json rename to resources/schemas/User.json diff --git a/resources/schema/Product.json b/resources/schemas/common/product.json similarity index 74% rename from resources/schema/Product.json rename to resources/schemas/common/product.json index 3c6f3dff..024d7387 100644 --- a/resources/schema/Product.json +++ b/resources/schemas/common/product.json @@ -1,9 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.com/common/product.json", "title": "Product", - "description": "Schema for a product object", + "description": "A product object", "type": "object", "properties": { + "product_id": { + "type": "integer", + "description": "Unique identifier for product" + }, "name": { "type": "string", "description": "The name of the product", @@ -17,7 +22,7 @@ }, "img_url": { "type": "string", - "format": "uri", + "pattern": "^.+\\.(png|jpeg|avif|jpg|webp)$", "description": "The URL of the product image" }, "img_alt_text": { @@ -48,21 +53,5 @@ "description": "The date and time when the product was created" } }, - "required": [ - "name", - "calories", - "img_url", - "img_alt_text", - "category", - "price", - "description", - "created_date" - ], - "additionalProperties": false, - "patternProperties": { - "img_url": { - "pattern": "^.+\\.(png|jpeg|avif|jpg|webp)$", - "description": "The URL should end with one of the supported image formats: png, jpeg, avif, jpg, webp" - } - } + "additionalProperties": false } diff --git a/resources/schemas/products/create.json b/resources/schemas/products/create.json new file mode 100644 index 00000000..23e23164 --- /dev/null +++ b/resources/schemas/products/create.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.com/products/create.json", + "title": "Create Product", + "properties": { + "name": { + "$ref": "https://example.com/common/product.json#/properties/name" + }, + "calories": { + "$ref": "https://example.com/common/product.json#/properties/calories" + }, + "img_url": { + "$ref": "https://example.com/common/product.json#/properties/img_url" + }, + "img_alt_text": { + "$ref": "https://example.com/common/product.json#/properties/img_alt_text" + }, + "category": { + "$ref": "https://example.com/common/product.json#/properties/category" + }, + "price": { + "$ref": "https://example.com/common/product.json#/properties/price" + }, + "description": { + "$ref": "https://example.com/common/product.json#/properties/description" + } + }, + "required": [ + "name", + "calories", + "img_url", + "img_alt_text", + "category", + "price", + "description" + ], + "additionalProperties": false +} diff --git a/resources/schemas/products/update.json b/resources/schemas/products/update.json new file mode 100644 index 00000000..f957dfd1 --- /dev/null +++ b/resources/schemas/products/update.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://example.com/products/update.json", + "title": "Create Product", + "properties": { + "name": { + "$ref": "https://example.com/common/product.json#/properties/name" + }, + "calories": { + "$ref": "https://example.com/common/product.json#/properties/calories" + }, + "img_url": { + "$ref": "https://example.com/common/product.json#/properties/img_url" + }, + "img_alt_text": { + "$ref": "https://example.com/common/product.json#/properties/img_alt_text" + }, + "category": { + "$ref": "https://example.com/common/product.json#/properties/category" + }, + "price": { + "$ref": "https://example.com/common/product.json#/properties/price" + }, + "description": { + "$ref": "https://example.com/common/product.json#/properties/description" + } + }, + "additionalProperties": false +} diff --git a/src/controllers/API.php b/src/controllers/API.php index 84f387f9..a8afffdd 100644 --- a/src/controllers/API.php +++ b/src/controllers/API.php @@ -17,6 +17,8 @@ class API { use Controller; + public static string $API_BASE_URI = '/api/v1'; // root-relative + private string $resource; public function __construct() @@ -56,6 +58,7 @@ private function getHandler(string $controllerName): ?string } foreach ($my_routes as $route => $handler) { + $route = API::$API_BASE_URI . $route; $pattern = str_replace('/', '\/', $route); // Convert to regex pattern $pattern = preg_replace( '/\{([a-zA-Z0-9_]+)\}/', diff --git a/src/controllers/api/Districts.php b/src/controllers/api/Districts.php index ea0f6105..37db780c 100644 --- a/src/controllers/api/Districts.php +++ b/src/controllers/api/Districts.php @@ -14,8 +14,8 @@ class Districts public static array $routes = [ 'GET' => [ - '/api/v1/districts' => 'getAllDistricts', - '/api/v1/districts/{id}' => 'getDistrictById', + '/districts' => 'getAllDistricts', + '/districts/{id}' => 'getDistrictById', ] ]; diff --git a/src/controllers/api/Products.php b/src/controllers/api/Products.php index c973359f..98327222 100644 --- a/src/controllers/api/Products.php +++ b/src/controllers/api/Products.php @@ -4,9 +4,12 @@ namespace Steamy\Controller\API; +use Opis\JsonSchema\{Errors\ErrorFormatter}; use Steamy\Core\Utility; use Steamy\Model\Product; use Steamy\Core\Model; +use Steamy\Model\Product as ProductModel; +use Steamy\Model\Review; class Products { @@ -14,19 +17,19 @@ class Products public static array $routes = [ 'GET' => [ - '/api/v1/products' => 'getAllProducts', - '/api/v1/products/categories' => 'getProductCategories', - '/api/v1/products/{id}' => 'getProductById', - '/api/v1/products/{id}/reviews' => 'getAllReviewsForProduct', + '/products' => 'getAllProducts', + '/products/categories' => 'getProductCategories', + '/products/{id}' => 'getProductById', + '/products/{id}/reviews' => 'getAllReviewsForProduct', ], 'POST' => [ - '/api/v1/products' => 'createProduct', + '/products' => 'createProduct', ], 'PUT' => [ - '/api/v1/products/{id}' => 'updateProduct', + '/products/{id}' => 'updateProduct', ], 'DELETE' => [ - '/api/v1/products/{id}' => 'deleteProduct', + '/products/{id}' => 'deleteProduct', ] ]; @@ -87,45 +90,29 @@ public function getProductCategories(): void */ public function createProduct(): void { - // Retrieve POST data - $postData = $_POST; - - // TODO : Use json schema validation here - // Check if required fields are present - $requiredFields = [ - 'name', - 'calories', - 'img_url', - 'img_alt_text', - 'category', - 'price', - 'description' - ]; - - if (empty($postData)) { + $data = (object)json_decode(file_get_contents("php://input"), true); + + $result = Utility::validateAgainstSchema($data, "products/create.json"); + + if (!($result->isValid())) { + $errors = (new ErrorFormatter())->format($result->error()); + $response = [ + 'error' => $errors + ]; http_response_code(400); - echo json_encode(['error' => "Missing fields: " . implode(', ', $requiredFields)]); + echo json_encode($response); return; } - foreach ($requiredFields as $field) { - if (empty($postData[$field])) { - // Required field is missing, return 400 Bad Request - http_response_code(400); - echo json_encode(['error' => "Missing required field: $field"]); - return; - } - } - // Create a new Product object $newProduct = new Product( - $postData['name'], - (int)$postData['calories'], - $postData['img_url'], - $postData['img_alt_text'], - $postData['category'], - (float)$postData['price'], - $postData['description'] + $data->name, + $data->calories, + $data->img_url, + $data->img_alt_text, + $data->category, + (float)$data->price, + $data->description ); // Save the new product to the database @@ -177,30 +164,32 @@ public function updateProduct(): void { $productId = (int)Utility::splitURL()[3]; - // Retrieve PUT request data - $putData = json_decode(file_get_contents("php://input"), true); - - // Check if PUT data is valid - if (empty($putData)) { - // Invalid JSON data - http_response_code(400); // Bad Request - echo json_encode(['error' => 'Invalid JSON data']); - return; - } - - // Retrieve existing product + // Retrieve the product by ID $product = Product::getByID($productId); // Check if product exists if ($product === null) { - // Product not found - http_response_code(404); // Not Found + // Product not found, return 404 + http_response_code(404); echo json_encode(['error' => 'Product not found']); return; } + // Retrieve PUT request data + $data = (object)json_decode(file_get_contents("php://input"), true); + $result = Utility::validateAgainstSchema($data, "products/update.json"); + + if (!($result->isValid())) { + $errors = (new ErrorFormatter())->format($result->error()); + $response = [ + 'error' => $errors + ]; + http_response_code(400); + echo json_encode($response); + return; + } // Update product in the database - $success = $product->updateProduct($putData); + $success = $product->updateProduct((array)$data); if ($success) { // Product updated successfully @@ -221,14 +210,18 @@ public function getAllReviewsForProduct(): void // Get product ID from URL $productId = (int)Utility::splitURL()[3]; - // Instantiate the Reviews controller - $reviewsController = new Reviews(); + // Check if product exists + if (ProductModel::getById($productId) === null) { + // product not found, return 404 + http_response_code(404); + echo json_encode(['error' => 'Product not found']); + return; + } - // Call the method to get all reviews for the specified product - // Since the Reviews controller method expects the ID to be in the URL, we'll set it directly - $_SERVER['REQUEST_URI'] = "/api/v1/products/$productId/reviews"; + // Retrieve all reviews for the specified product from the database + $reviews = Review::getAllReviewsForProduct($productId); - // Call the method from the Reviews controller - $reviewsController->getAllReviewsForProduct(); + // Return JSON response + echo json_encode($reviews); } } diff --git a/src/controllers/api/Reviews.php b/src/controllers/api/Reviews.php index 892735fb..eeaec323 100644 --- a/src/controllers/api/Reviews.php +++ b/src/controllers/api/Reviews.php @@ -13,18 +13,17 @@ class Reviews public static array $routes = [ 'GET' => [ - '/api/v1/reviews' => 'getAllReviews', - '/api/v1/reviews/{id}' => 'getReviewByID', - '/api/v1/products/{id}/reviews' => 'getAllReviewsForProduct', + '/reviews' => 'getAllReviews', + '/reviews/{id}' => 'getReviewByID', ], 'POST' => [ - '/api/v1/reviews' => 'createReview', + '/reviews' => 'createReview', ], 'PUT' => [ - '/api/v1/reviews/{id}' => 'updateReview', + '/reviews/{id}' => 'updateReview', ], 'DELETE' => [ - '/api/v1/reviews/{id}' => 'deleteReview', + '/reviews/{id}' => 'deleteReview', ] ]; @@ -65,28 +64,6 @@ public function getReviewByID(): void echo json_encode($review->toArray()); } - - /** - * Get all reviews for a particular product by its ID. - */ - public function getAllReviewsForProduct(): void - { - $productId = (int)Utility::splitURL()[3]; - // Check if product exists - if (ProductModel::getById($productId) === null) { - // product not found, return 404 - http_response_code(404); - echo json_encode(['error' => 'Product not found']); - return; - } - - // Retrieve all reviews for the specified product from the database - $reviews = Review::getAllReviewsForProduct($productId); - - // Return JSON response - echo json_encode($reviews); - } - /** * Create a new review for a product. */ diff --git a/src/controllers/api/Sessions.php b/src/controllers/api/Sessions.php index 537c7c34..39a8101e 100644 --- a/src/controllers/api/Sessions.php +++ b/src/controllers/api/Sessions.php @@ -14,7 +14,7 @@ class Sessions public static array $routes = [ 'POST' => [ - '/api/v1/products' => 'handleLogin', + '/sessions' => 'handleLogin', ] ]; diff --git a/src/core/Database.php b/src/core/Database.php index 0e32b126..636164b3 100644 --- a/src/core/Database.php +++ b/src/core/Database.php @@ -18,9 +18,9 @@ trait Database */ protected static function connect(): PDO { - $string = "mysql:hostname=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4"; + $string = "mysql:hostname=" . $_ENV['DB_HOST'] . ";dbname=" . $_ENV['DB_NAME'] . ";charset=utf8mb4"; try { - $conn = new PDO($string, DB_USERNAME, DB_PASSWORD); + $conn = new PDO($string, $_ENV['DB_USERNAME'], $_ENV['DB_PASSWORD']); $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); return $conn; } catch (PDOException $e) { diff --git a/src/core/Utility.php b/src/core/Utility.php index 75a41703..d19f5d7a 100644 --- a/src/core/Utility.php +++ b/src/core/Utility.php @@ -6,6 +6,8 @@ use DateTime; use Exception; +use Opis\JsonSchema\{ValidationResult, Validator}; + /** * Utility class containing various helper functions. @@ -157,4 +159,28 @@ public static function stringToDate(string $date): ?DateTime return null; } } + + /** + * @param object $data Data to be validated + * @param string $schemaPath Relative path (starting from schema folder) to schema file. + * Example: `products/create.json` + * @return ValidationResult + */ + public static function validateAgainstSchema(object $data, string $schemaPath): ValidationResult + { + $schemaDirPath = __DIR__ . '/../../resources/schemas'; + $schemaPrefix = "https://example.com/"; + + $validator = new Validator(); + + $validator->resolver()->registerPrefix( + $schemaPrefix, + $schemaDirPath, + ); + + return $validator->validate( + $data, + $schemaPrefix . $schemaPath + ); + } } diff --git a/src/core/config.php b/src/core/config.php index 8d8b887b..49b52168 100644 --- a/src/core/config.php +++ b/src/core/config.php @@ -2,20 +2,16 @@ declare(strict_types=1); -// load environment variables -$dotenv = Dotenv\Dotenv::createImmutable(__DIR__.'/../..'); -$dotenv->load(); - -// define database credentials -define('DB_HOST', $_ENV['DB_HOST']); -define('DB_USERNAME', $_ENV['DB_USERNAME']); -define('DB_PASSWORD', $_ENV['DB_PASSWORD']); - -if (defined('PHPUNIT_STEAMY_TESTSUITE') && PHPUNIT_STEAMY_TESTSUITE) { +// Check for a custom header to switch to the testing environment +if ((isset($_SERVER['HTTP_X_TEST_ENV']) && $_SERVER['HTTP_X_TEST_ENV'] === 'testing')) { + // a request is coming from the testing environment + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../..', '.env.testing'); +} elseif (defined('PHPUNIT_STEAMY_TESTSUITE') && PHPUNIT_STEAMY_TESTSUITE) { // application is currently being tested with phpunit => use testing database - define('DB_NAME', $_ENV['TEST_DB_NAME']); + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../..', '.env.testing'); } else { // application is running normally => use production database - define('DB_NAME', $_ENV['PROD_DB_NAME']); + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../..'); } +$dotenv->load(); diff --git a/src/models/Product.php b/src/models/Product.php index 1035c4b9..1c687bc9 100644 --- a/src/models/Product.php +++ b/src/models/Product.php @@ -114,6 +114,10 @@ public static function getAll(): array $query = "SELECT * FROM product"; $results = self::query($query); + if (empty($results)) { + return []; + } + // convert results to an array of Product $products = []; foreach ($results as $result) { @@ -310,7 +314,7 @@ public function validate(): array } // Validate img_url - if (!preg_match('/\.(png|jpeg|avif)$/', $this->img_url)) { + if (!preg_match('/\.(png|jpeg|avif|jpg|webp)$/', $this->img_url)) { $errors['img_url'] = "Image URL must end with .png, .jpeg, or .avif"; } diff --git a/src/models/Store.php b/src/models/Store.php index 7bb82bef..d9fc9855 100644 --- a/src/models/Store.php +++ b/src/models/Store.php @@ -137,7 +137,7 @@ public function validate(): array $latitude = $this->address->getLatitude(); $longitude = $this->address->getLongitude(); - if ($latitude == null || $longitude == null || + if ($latitude === null || $longitude === null || ($latitude < -90 || $latitude > 90 || $longitude < -180 || $longitude > 180)) { $errors['coordinates'] = "Invalid latitude or longitude."; diff --git a/tests/api/ProductsTest.php b/tests/api/ProductsTest.php new file mode 100644 index 00000000..b59f6997 --- /dev/null +++ b/tests/api/ProductsTest.php @@ -0,0 +1,263 @@ +dummy_product = self::createProduct(); + } + + public function tearDown(): void + { + self::resetDatabase(); + } + + /** + * @throws GuzzleException + */ + public function testGetAllProducts() + { + $response = self::$guzzle->get('products'); + $this->assertEquals(200, $response->getStatusCode()); + + $body = $response->getBody(); + $json = json_decode($body->getContents(), true); +// echo json_encode($json, JSON_PRETTY_PRINT) . "\n"; + + self::assertIsArray($json); + self::assertCount(1, $json); + + $data = $json[0]; + + $this->assertEquals($this->dummy_product->getName(), $data['name']); + $this->assertEquals($this->dummy_product->getCalories(), $data['calories']); + $this->assertEquals($this->dummy_product->getImgRelativePath(), $data['img_url']); + $this->assertEquals($this->dummy_product->getCategory(), $data['category']); + $this->assertEquals($this->dummy_product->getPrice(), $data['price']); + $this->assertEquals($this->dummy_product->getDescription(), $data['description']); + $this->assertEquals($this->dummy_product->getImgAltText(), $data['img_alt_text']); + + // only check presence of the following keys but not the actual value + $this->assertArrayHasKey('product_id', $data); + $this->assertArrayHasKey('created_date', $data); + } + + + /** + * @throws GuzzleException + */ + public function testGetProductById() + { + // test valid product ID + $response = self::$guzzle->get('products/' . $this->dummy_product->getProductID()); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('product_id', $data); + $this->assertEquals($this->dummy_product->getProductID(), $data['product_id']); + + // test invalid product ID + $response = self::$guzzle->get('products/-1'); + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @throws GuzzleException + */ + public function testGetProductCategories() + { + $response = self::$guzzle->get('products/categories'); + $this->assertEquals(200, $response->getStatusCode()); + $data = json_decode($response->getBody()->getContents(), true); + $this->assertIsArray($data); + self::assertCount(1, $data); + self::assertEquals($this->dummy_product->getCategory(), $data[0]); + } + + /** + * @throws GuzzleException + * @throws Exception Expected product could not be created + */ + public function testCreateValidProduct() + { + $expected_product = self::createProduct(false); + + $data_to_send = $expected_product->toArray(); + unset($data_to_send['product_id']); + unset($data_to_send['created_date']); + +// self::log_json($data_to_send); + + $response = self::$guzzle->post( + 'products', + ['json' => $data_to_send] + ); + + $data_received = json_decode($response->getBody()->getContents(), true); +// self::log_json($data_received); + + $this->assertEquals(201, $response->getStatusCode()); + + $this->assertArrayHasKey('product_id', $data_received); + self::assertTrue($data_received['product_id'] > 0); + } + + /** + * @throws GuzzleException + * @throws Exception + */ + public function testDeleteProductById() + { + // delete a non-existent product + $response = self::$guzzle->delete('products/0'); + $this->assertEquals(404, $response->getStatusCode()); + + // delete a valid product + $product = self::createProduct(); + $response = self::$guzzle->delete('products/' . $product->getProductID()); + $this->assertEquals(204, $response->getStatusCode()); + self::assertNull(Product::getByID($product->getProductID())); + } + + /** + * @throws GuzzleException + * @throws Exception + */ + public function testUpdateProductByIdForInvalidProduct() + { + $response = self::$guzzle->put('products/0'); + $this->assertEquals(404, $response->getStatusCode()); + + $response = self::$guzzle->put('products/-43'); + $this->assertEquals(404, $response->getStatusCode()); + } + + + public static function provideNewProductData(): array + { + return [ + 'new name' => [ + 'new_data' => [ + 'name' => 'dsajd' + ], + 'changed_data' => [ + 'name' => 'dsajd' + ] + ], + 'new product id' => [ + 'new_data' => [ + 'product_id' => 444 + ], + 'changed_data' => [ + 'product_id' => null + ] + ], + 'new name and description' => [ + 'new_data' => [ + 'name' => 'my new name', + 'description' => 'new description' + ], + 'changed_data' => [ + 'name' => 'my new name', + 'description' => 'new description' + ] + ] + ]; + } + + /** + * @throws GuzzleException + * @throws Exception + * @dataProvider provideNewProductData + */ + public function testUpdateProductByIdForValidProduct(array $new_data, array $expected_data) + { + // save a valid product to database + $old_product = self::createProduct(); + $old_data = $old_product->toArray(); + + // update the name of a valid product + $response = self::$guzzle->put( + 'products/' . $old_product->getProductID(), + ['json' => $new_data] + ); + + if (array_key_exists('product_id', $new_data)) { + // if request attempts to modify product ID, request should be rejected + $this->assertEquals(400, $response->getStatusCode()); + + // ensure that original product was not modified + $fetched_product = Product::getByID($old_product->getProductID()); + self::assertNotNull($fetched_product); + + $fetched_data = $fetched_product->toArray(); + + unset($old_data['created_date']); + unset($fetched_data['created_date']); + + assertEquals($old_data, $fetched_data); + + return; + } + + // else request is valid + + $this->assertEquals(200, $response->getStatusCode()); + + // fetch same product directly from database + $fetched_product = Product::getByID($old_product->getProductID()); + self::assertNotNull($fetched_product); + $fetched_data = $fetched_product->toArray(); + + foreach (array_keys($expected_data) as $key) { + if ($expected_data[$key] === null) { + // data corresponding to key must not change + $this->assertEquals($old_data[$key], $fetched_data[$key]); + } else { + // data corresponding to key must change + $this->assertEquals($expected_data[$key], $fetched_data[$key]); + } + } + } +} \ No newline at end of file diff --git a/tests/helpers/APIHelper.php b/tests/helpers/APIHelper.php new file mode 100644 index 00000000..66bdbfdc --- /dev/null +++ b/tests/helpers/APIHelper.php @@ -0,0 +1,46 @@ +push(Middleware::mapRequest(function ($request) { + // Add custom header to each request + return $request->withHeader('X-Test-Env', 'testing'); + })); + + self::$guzzle = new GuzzleClient([ + 'base_uri' => $_ENV['API_BASE_URI'], + 'http_errors' => false, // disable throwing exceptions for HTTP errors + 'handler' => $handlerStack, + ]); + } + + /** + * Logs data in JSON format in terminal. Use for debugging only. + * @param $data + * @return void + */ + public static function log_json($data): void + { + error_log(json_encode($data, JSON_PRETTY_PRINT)); + } +} \ No newline at end of file diff --git a/tests/helpers/TestHelper.php b/tests/helpers/TestHelper.php new file mode 100644 index 00000000..3d97a675 --- /dev/null +++ b/tests/helpers/TestHelper.php @@ -0,0 +1,286 @@ +seed(self::$seed); + } + + /** + * Prints current faker seed to terminal + * @return void + */ + public static function printFakerSeed(): void + { + $seed = self::$seed; + + $error_message = <<< EOL + + ------------ Faker seed ------------ + Faker seed for failed test: $seed + ------------------------------------ + + EOL; + + error_log($error_message); + } + + /** + * Clears data from all tables except district table. + * @return void + */ + public static function resetDatabase(): void + { + $conn = self::connect(); + + // Order of deletion is important to prevent foreign key violation + $query = <<< SQL + DELETE FROM password_change_request; + + DELETE FROM `order_product`; + DELETE FROM `order`; + + DELETE FROM `comment`; + DELETE FROM `review`; + + DELETE FROM `administrator`; + DELETE FROM `client`; + DELETE FROM `user`; + + DELETE FROM `store_product`; + DELETE FROM `store`; + DELETE FROM `product`; + SQL; + + $conn->exec($query); + $conn = null; + } + + /** + * Creates a random valid client and may save it to database. + * Client email is guaranteed to be unique. + * @param bool $saveToDatabase Defaults to true. + * @return Client + * @throws Exception + */ + public static function createClient(bool $saveToDatabase = true): Client + { + $first_name = self::$faker->firstName(); + $last_name = self::$faker->lastName(); + + // ensure that length is correct + if (strlen($first_name) < 3) { + $first_name .= "aaa"; + } + if (strlen($last_name) < 3) { + $last_name .= "aaa"; + } + + $client = new Client( + self::$faker->unique()->email(), + $first_name, + $last_name, + self::$faker->password(), + self::$faker->phoneNumber(), + new Location(self::$faker->streetAddress(), self::$faker->city(), self::$faker->numberBetween(1, 9)) + ); + + if (!$saveToDatabase) { + return $client; + } + + $success = $client->save(); + if (!$success) { + $json = json_encode($client->toArray()); + $errors = json_encode($client->validate()); + + $msg = <<< EOL + Unable to save client to database: + $json + + Attribute errors: + $errors + EOL; + + throw new Exception($msg); + } + return $client; + } + + /** + * Creates a random valid product and may save it to database. + * @param bool $saveToDatabase Defaults to True. + * @return Product + * @throws Exception + */ + public static function createProduct(bool $saveToDatabase = true): Product + { + $img_ext = self::$faker->randomElement(['png', 'jpeg', 'avif', 'jpg', 'webp']); + $product_name = self::$faker->words(2, true); + + $product = new Product( + name: $product_name, + calories: self::$faker->numberBetween(1, 500), + img_url: $product_name . "." . $img_ext, + img_alt_text: self::$faker->sentence(), + category: self::$faker->lexify(), + price: 6.50, + description: self::$faker->sentence() + ); + + if (!$saveToDatabase) { + return $product; + } + + $success = $product->save(); + + if (!$success) { + $json = json_encode($product->toArray()); + $errors = json_encode($product->validate()); + + $msg = <<< EOL + Unable to save product to database: + $json + + Attribute errors: + $errors + EOL; + + throw new Exception($msg); + } + + return $product; + } + + /** + * @throws Exception + */ + public static function createStore(bool $saveToDatabase = true): Store + { + $store = new Store( + phone_no: self::$faker->phoneNumber(), + address: new Location( + street: self::$faker->streetAddress(), + city: self::$faker->city(), + district_id: self::$faker->numberBetween(1, 9), + latitude: self::$faker->numberBetween(-90, 90), + longitude: self::$faker->numberBetween(-180, 180) + ) + ); + + if (!$saveToDatabase) { + return $store; + } + + $success = $store->save(); + + if (!$success) { + $json = json_encode($store->toArray()); + $errors = json_encode($store->validate()); + + $msg = <<< EOL + Unable to save store to database: + $json + + Attribute errors: + $errors + EOL; + + throw new Exception($msg); + } + return $store; + } + + /** + * Create a review and saves it to database. + * @param Product $product A valid product already present in database + * @param Client $client A valid client already present in database + * @param bool $verified Whether to create an order for client for given product. + * @return Review + * @throws Exception + */ + public static function createReview(Product $product, Client $client, bool $verified = false): Review + { + if ($verified) { + // place an order for client and product + + // create store + $store = self::createStore(); + + // Add stock to the store for the product to be bought + $store->addProductStock($product->getProductID(), 10); + + $order = new Order($store->getStoreID(), $client->getUserID(), [ + new OrderProduct($product->getProductID(), 'small', 'oat', 1) + ]); + + $success = $order->save(); + if (!$success) { + $json = json_encode($order->toArray()); + $errors = json_encode($order->validate()); + + $msg = <<< EOL + Unable to save order to database: + $json + + Attribute errors: + $errors + EOL; + throw new Exception($msg); + } + } + + $review = new Review( + product_id: $product->getProductID(), + client_id: $client->getUserID(), + text: self::$faker->sentence(10), + rating: self::$faker->numberBetween(1, 5) + ); + + $success = $review->save(); + + if (!$success) { + $json = json_encode($review->toArray()); + $errors = json_encode($review->validate()); + + $msg = <<< EOL + Unable to save review to database: + $json + + Attribute errors: + $errors + EOL; + throw new Exception($msg); + } + + return $review; + } +} \ No newline at end of file diff --git a/tests/AdministratorTest.php b/tests/models/AdministratorTest.php similarity index 95% rename from tests/AdministratorTest.php rename to tests/models/AdministratorTest.php index dde5673a..1a4b6f66 100644 --- a/tests/AdministratorTest.php +++ b/tests/models/AdministratorTest.php @@ -2,16 +2,24 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use Exception; use PHPUnit\Framework\TestCase; use Steamy\Model\Administrator; -use Steamy\Core\Database; +use Steamy\Tests\helpers\TestHelper; final class AdministratorTest extends TestCase { - use Database; + use TestHelper; private ?Administrator $dummy_admin; + public static function setUpBeforeClass(): void + { + self::resetDatabase(); + } + /** * @throws Exception */ @@ -33,9 +41,7 @@ public function tearDown(): void { // Clear the administrator object $this->dummy_admin = null; - - // Clear all data from administrator and user tables - self::query('DELETE FROM administrator; DELETE FROM user;'); + self::resetDatabase(); } public function testConstructor(): void diff --git a/tests/ClientTest.php b/tests/models/ClientTest.php similarity index 50% rename from tests/ClientTest.php rename to tests/models/ClientTest.php index 6eb2a10b..74f4949a 100644 --- a/tests/ClientTest.php +++ b/tests/models/ClientTest.php @@ -2,27 +2,36 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use Exception; use PHPUnit\Framework\TestCase; +use Steamy\Core\Database; use Steamy\Model\Client; use Steamy\Model\Location; -use Steamy\Core\Database; +use Steamy\Tests\helpers\TestHelper; final class ClientTest extends TestCase { - use Database; + use TestHelper; + private ?Client $dummy_client; + public static function setUpBeforeClass(): void + { + self::resetDatabase(); + } + + /** + * @throws Exception + */ public function setUp(): void { $address = new Location("Royal Road", "Curepipe", 1); $this->dummy_client = new Client( - "john_u@gmail.com", - "john", - "johhny", - "abcd", - "13213431", - $address + "john_u@gmail.com", "john", "johhny", "abcd", + "13213431", $address ); $success = $this->dummy_client->save(); @@ -36,7 +45,7 @@ public function tearDown(): void $this->dummy_client = null; // Clear all data from client and user tables - self::query('DELETE FROM client; DELETE FROM user;'); + self::resetDatabase(); } public function testConstructor(): void @@ -77,12 +86,8 @@ public function testToArray(): void public function testValidate(): void { $client = new Client( - "", - "", - "", - "abcd", - "", - new Location(), // pass an empty Location object for testing + "", "", "", "abcd", + "", new Location(), // pass an empty Location object for testing ); // Test if existence checks work @@ -96,12 +101,8 @@ public function testValidate(): void // Test for range checks $client = new Client( - "a@a.com", - "Jo", - "Doe", - "1234567", - "123456", - new Location(), // pass an empty Location object for testing + "a@a.com", "Jo", "Doe", "1234567", + "123456", new Location(), // pass an empty Location object for testing ); self::assertEquals([ @@ -125,108 +126,34 @@ public function testVerifyPassword(): void self::assertFalse($this->dummy_client->verifyPassword(" abcd")); } - /** - * @dataProvider getByIDProvider - */ - public static function testGetByID(int $userID, ?string $expectedEmail): void - { - $client = Client::getByID($userID); - if ($expectedEmail !== null) { - self::assertNotNull($client); - self::assertEquals($expectedEmail, $client->getEmail()); - } else { - self::assertNull($client); - } - } - - public static function getByIDProvider(): array - { - return [ - [999, null], // Non-existing user - [-1, null], // Negative ID - ]; - } - - /** - * @dataProvider getByEmailProvider - */ - public static function testGetByEmail(string $email, ?string $expectedEmail): void - { - $client = Client::getByEmail($email); - if ($expectedEmail !== null) { - self::assertNotNull($client); - self::assertEquals($expectedEmail, $client->getEmail()); - } else { - self::assertNull($client); - } - } - - public static function getByEmailProvider(): array + public function testGetByEmail(): void { - return [ - ['john_u@gmail.com', 'john_u@gmail.com'], // Existing email - ['nonexistent@gmail.com', null], // Non-existing email - ['invalidemail', null], // Invalid email format - ]; - } - - /** - * @dataProvider updateUserProvider - */ - public static function testUpdateUser(bool $updatePassword, bool $success): void - { - // Create a client with a known ID - $client = Client::getByEmail('john_u@gmail.com'); - if ($client === null) { - self::fail('Failed to fetch client'); - } - - // Update user and check if successful - $client->setFirstName('UpdatedName'); - $client->setLastName('UpdatedLastName'); - $client->getAddress()->setCity('UpdatedCity'); - - if ($updatePassword) { - $client->setPassword('newPassword'); - } - - $result = $client->updateUser($updatePassword); - self::assertEquals($success, $result); - - // Check if data was actually updated in the database - $updatedClient = Client::getByID($client->getUserID()); - if ($updatedClient === null) { - self::fail('Failed to fetch updated client'); - } - - self::assertEquals('UpdatedName', $updatedClient->getFirstName()); - self::assertEquals('UpdatedLastName', $updatedClient->getLastName()); - self::assertEquals('UpdatedCity', $updatedClient->getAddress()->getCity()); - } - - public static function updateUserProvider(): array - { - return [ - [false, true], // Update without password change - [true, true], // Update with password change - ]; - } - - public function testDeleteUser(): void - { - // Fetch the client by email to get its ID - $client = Client::getByEmail('john_u@gmail.com'); - if ($client === null) { - self::fail('Failed to fetch client'); - } - - // Delete the user - $client->deleteUser(); - - // Attempt to fetch the user again - $deletedClient = Client::getByID($client->getUserID()); - - // Ensure the user does not exist anymore - self::assertNull($deletedClient); + // Test for valid email + // Save the dummy record to the database + $this->dummy_client->save(); + // Fetch the client by email + $fetched_client = Client::getByEmail($this->dummy_client->getEmail()); + // Assert that the fetched client is not null + self::assertNotNull($fetched_client); + + // Assert the attributes of the fetched client + self::assertEquals("john_u@gmail.com", $fetched_client->getEmail()); + self::assertEquals("john", $fetched_client->getFirstName()); + self::assertEquals("johhny", $fetched_client->getLastName()); + self::assertEquals("13213431", $fetched_client->getPhoneNo()); + self::assertEquals("Royal Road, Curepipe, Moka", $fetched_client->getAddress()->getFormattedAddress()); + + // Delete the dummy record + $fetched_client->deleteUser(); + + // Add a small delay to ensure the deletion operation is completed + usleep(500000); // 500 milliseconds = 0.5 seconds + + // Fetch the client by email again + $fetched_client = Client::getByEmail($this->dummy_client->getEmail()); + + // Test for invalid email + // Assert that the fetched client is null or false + self::assertNull($fetched_client); } } diff --git a/tests/CommentTest.php b/tests/models/CommentTest.php similarity index 88% rename from tests/CommentTest.php rename to tests/models/CommentTest.php index cdbba2d7..70d122f3 100644 --- a/tests/CommentTest.php +++ b/tests/models/CommentTest.php @@ -2,22 +2,35 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use DateTime; +use Exception; use PHPUnit\Framework\TestCase; +use Steamy\Model\Client; use Steamy\Model\Comment; -use Steamy\Model\Review; use Steamy\Model\Location; -use Steamy\Model\Client; -use Steamy\Core\Database; use Steamy\Model\Product; +use Steamy\Model\Review; +use Steamy\Tests\helpers\TestHelper; -Class CommentTest extends TestCase +class CommentTest extends TestCase { - use Database; + use TestHelper; + private ?Comment $dummy_comment; private ?Review $dummy_review; private ?Client $reviewer; private ?Product $dummy_product; + public static function setUpBeforeClass(): void + { + self::resetDatabase(); + } + + /** + * @throws Exception + */ public function setUp(): void { // Create a dummy product for testing @@ -31,7 +44,7 @@ public function setUp(): void "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", new DateTime() ); - + $success = $this->dummy_product->save(); if (!$success) { throw new Exception('Unable to save product'); @@ -84,16 +97,16 @@ public function tearDown(): void $this->dummy_product = null; // clear all data from review and client tables - self::query('DELETE FROM comment; DELETE FROM review; DELETE FROM client; DELETE FROM user; DELETE FROM store_product; DELETE FROM product;'); + self::resetDatabase(); } public function testConstructor(): void { self::assertEquals('This is a test comment.', $this->dummy_comment->getText()); - self::assertNotNull($this->dummy_comment->getUserID()); - self::assertNotNull($this->dummy_comment->getReviewID()); - self::assertNull($this->dummy_comment->getParentCommentID()); - self::assertInstanceOf(DateTime::class, $this->dummy_comment->getCreatedDate()); + self::assertNotNull($this->dummy_comment->getUserID()); + self::assertNotNull($this->dummy_comment->getReviewID()); + self::assertNull($this->dummy_comment->getParentCommentID()); + self::assertInstanceOf(DateTime::class, $this->dummy_comment->getCreatedDate()); } public function testValidate(): void @@ -146,7 +159,7 @@ public function testSave(): void self::assertFalse($saved); } - public function testGetByID(): void + public function testGetById(): void { // Test fetching an existing comment $comment_id = $this->dummy_comment->getCommentID(); diff --git a/tests/FuzzyTest.php b/tests/models/FuzzyTest.php similarity index 94% rename from tests/FuzzyTest.php rename to tests/models/FuzzyTest.php index 96bb5fe9..e5bc4b88 100644 --- a/tests/FuzzyTest.php +++ b/tests/models/FuzzyTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + use PHPUnit\Framework\TestCase; use Steamy\Core\Utility; @@ -24,7 +26,7 @@ public static function fuzzySearchDataProvider(): array ['Espreso', $strings, 1, ['Espresso']], // Missing 's' ['Espressso', $strings, 1, ['Espresso']], // Extra 's' ['', $strings, 1, []], // Empty search term - [(string) 123, $strings, 1, []], // Non-string search term (integer) + ["123", $strings, 1, []], // Non-string search term (integer) ['Latte!', $strings, 1, ['Latte']], // Search term containing special characters ['eSPRESSO', $strings, 1, ['Espresso']], // Case sensitivity test ]; diff --git a/tests/OrderProductTest.php b/tests/models/OrderProductTest.php similarity index 60% rename from tests/OrderProductTest.php rename to tests/models/OrderProductTest.php index 28508968..69563b6b 100644 --- a/tests/OrderProductTest.php +++ b/tests/models/OrderProductTest.php @@ -2,18 +2,21 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use Exception; use PHPUnit\Framework\TestCase; +use Steamy\Model\Client; use Steamy\Model\Order; use Steamy\Model\OrderProduct; -use Steamy\Model\Store; -use Steamy\Model\Client; use Steamy\Model\Product; -use Steamy\Core\Database; -use Steamy\Model\Location; +use Steamy\Model\Store; +use Steamy\Tests\helpers\TestHelper; +use Throwable; class OrderProductTest extends TestCase { - use Database; + use TestHelper; private ?Order $dummy_order; private ?Client $client; @@ -21,74 +24,45 @@ class OrderProductTest extends TestCase private ?Product $dummy_product; private array $line_items = []; + public static function setUpBeforeClass(): void + { + self::initFaker(); + self::resetDatabase(); + } + + public static function tearDownAfterClass(): void + { + self::$faker = null; + } + + public function onNotSuccessfulTest(Throwable $t): never + { + self::printFakerSeed(); + parent::onNotSuccessfulTest($t); + } + /** * @throws Exception */ public function setUp(): void { - parent::setUp(); - // Initialize a dummy store object for testing - $this->dummy_store = new Store( - phone_no: "987654321", // Phone number - address: new Location( - street: "Augus", - city: "Flacq", - district_id: 2, - latitude: 60, - longitude: 60 - ) - ); - - $success = $this->dummy_store->save(); - if (!$success) { - $errors = $this->dummy_store->validate(); - $error_msg = "Unable to save store to database. "; - if (!empty($errors)) { - $error_msg .= "Errors: " . implode(',', $errors); - } else { - $error_msg .= "Attributes seem to be ok as per validate()."; - } - - throw new Exception($error_msg); - } + $this->dummy_store = self::createStore(); // Create a dummy client - $this->client = new Client( - "john@example.com", - "John", - "Doe", - "john_doe", - "password", - new Location("Royal", "Curepipe", 1, 50, 50) - ); - $success = $this->client->save(); - if (!$success) { - throw new Exception('Unable to save client'); - } + $this->client = self::createClient(); // Create a dummy product - $this->dummy_product = new Product( - "Latte", - 50, - "latte.jpeg", - "A delicious latte", - "Beverage", - 5.0, - "A cup of latte", - new DateTime() - ); - $success = $this->dummy_product->save(); - if (!$success) { - throw new Exception('Unable to save product'); - } + $this->dummy_product = self::createProduct(); // Update stock level for the product $this->dummy_store->addProductStock($this->dummy_product->getProductID(), 10); // Create dummy order line items $this->line_items = [ - new OrderProduct($this->dummy_product->getProductID(), "medium", "oat", 2, 5.0) + new OrderProduct( + $this->dummy_product->getProductID(), "medium", "oat", 2 + ) ]; // Create a dummy order @@ -117,9 +91,7 @@ public function tearDown(): void $this->line_items = []; // Clear all data from relevant tables - self::query( - 'DELETE FROM order_product; DELETE FROM `order`; DELETE FROM client; DELETE FROM user; DELETE FROM store_product; DELETE FROM product; DELETE FROM store;' - ); + self::resetDatabase(); } public function testValidate(): void @@ -141,7 +113,7 @@ public function testValidate(): void $this->assertArrayHasKey('unit_price', $errors); } - public function testGetByID(): void + public function testGetById(): void { // Assuming getByID is a method that retrieves an OrderProduct by order ID and product ID $retrievedOrderProduct = OrderProduct::getByID( @@ -155,6 +127,6 @@ public function testGetByID(): void $this->assertEquals("medium", $retrievedOrderProduct->getCupSize()); $this->assertEquals("oat", $retrievedOrderProduct->getMilkType()); $this->assertEquals(2, $retrievedOrderProduct->getQuantity()); - $this->assertEquals(5.0, $retrievedOrderProduct->getUnitPrice()); + $this->assertEquals($this->dummy_product->getPrice(), $retrievedOrderProduct->getUnitPrice()); } } diff --git a/tests/OrderTest.php b/tests/models/OrderTest.php similarity index 95% rename from tests/OrderTest.php rename to tests/models/OrderTest.php index 0bbda2a0..b7a91735 100644 --- a/tests/OrderTest.php +++ b/tests/models/OrderTest.php @@ -2,32 +2,40 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use DateTime; +use Exception; use PHPUnit\Framework\TestCase; +use Steamy\Model\Client; +use Steamy\Model\Location; use Steamy\Model\Order; use Steamy\Model\OrderProduct; use Steamy\Model\OrderStatus; -use Steamy\Model\Store; -use Steamy\Model\Client; -use Steamy\Core\Database; -use Steamy\Model\Location; use Steamy\Model\Product; +use Steamy\Model\Store; +use Steamy\Tests\helpers\TestHelper; class OrderTest extends TestCase { - use Database; + use TestHelper; private ?Order $dummy_order = null; private ?Client $client = null; private ?Store $dummy_store = null; private array $line_items = []; + public static function setUpBeforeClass(): void + { + self::initFaker(); + self::resetDatabase(); + } + /** * @throws Exception */ public function setUp(): void { - parent::setUp(); - // Initialize a dummy store object for testing $this->dummy_store = new Store( phone_no: "987654321", // Phone number @@ -124,9 +132,7 @@ public function tearDown(): void $this->line_items = []; // Clear all data from relevant tables - self::query( - 'DELETE FROM order_product; DELETE FROM `order`; DELETE FROM client; DELETE FROM user; DELETE FROM store_product; DELETE FROM product; DELETE FROM store;' - ); + self::resetDatabase(); } public function testConstructor(): void @@ -202,7 +208,7 @@ public function testAddLineItem(): void /** * @throws Exception */ - public function testGetByID(): void + public function testGetById(): void { $this->dummy_order->save(); $order_id = $this->dummy_order->getOrderID(); diff --git a/tests/ProductTest.php b/tests/models/ProductTest.php similarity index 64% rename from tests/ProductTest.php rename to tests/models/ProductTest.php index e849e78c..26ba5685 100644 --- a/tests/ProductTest.php +++ b/tests/models/ProductTest.php @@ -2,72 +2,60 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use DateTime; +use Exception; use PHPUnit\Framework\TestCase; use Steamy\Model\Product; use Steamy\Model\Review; -use Steamy\Core\Database; -use Steamy\Model\Location; use Steamy\Model\Client; +use Steamy\Tests\helpers\TestHelper; +use Throwable; final class ProductTest extends TestCase { - use Database; + use TestHelper; private ?Product $dummy_product; private ?Client $dummy_client; + + /** + * @var Review|null A review written by $dummy_client for $dummy_product + */ private ?Review $dummy_review; + + public static function setUpBeforeClass(): void + { + self::resetDatabase(); + self::initFaker(); + } + + public static function tearDownAfterClass(): void + { + self::$faker = null; + } + + public function onNotSuccessfulTest(Throwable $t): never + { + self::printFakerSeed(); + parent::onNotSuccessfulTest($t); + } + /** * @throws Exception */ public function setUp(): void { - $address = new Location("Royal Road", "Curepipe", 1); - $this->dummy_client = new Client( - "jo@gmail.com", - "john", - "johnny", - "abcd", - "13213431", - $address - ); - - $success = $this->dummy_client->save(); - if (!$success) { - throw new Exception('Unable to save client'); - } + $this->dummy_client = self::createClient(); // Create a dummy product for testing - $this->dummy_product = new Product( - "Velvet Bean", - 70, - "Velvet.jpeg", - "Velvet Bean Image", - "Velvet", - 6.50, - "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", - new DateTime() - ); - - $success = $this->dummy_product->save(); - if (!$success) { - throw new Exception('Unable to save product'); - } + $this->dummy_product = self::createProduct(); // Create a review object and save to the database - $this->dummy_review = new Review( - product_id: $this->dummy_product->getProductID(), - client_id: $this->dummy_client->getUserID(), - text: "This is a test review.", - rating: 5, - created_date: new DateTime() - ); - $success = $this->dummy_review->save(); - - if (!$success) { - throw new Exception('Unable to save review'); - } + $this->dummy_review = self::createReview($this->dummy_product, $this->dummy_client); } public function tearDown(): void @@ -76,26 +64,38 @@ public function tearDown(): void $this->dummy_client = null; $this->dummy_review = null; - // Clear all data from product, review, and client tables - self::query('DELETE FROM review; DELETE FROM product; DELETE FROM client; DELETE FROM user;'); + self::resetDatabase(); } public function testConstructor(): void { + // Do not use dummy_product to test constructor as dummy_product attributes may change + + $product = new Product( + "Velvet Bean", + 70, + "Velvet.jpeg", + "Velvet Bean Image", + "Velvet", + 6.50, + "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", + new DateTime() + ); + // Check if product attributes are correctly set - self::assertEquals("Velvet Bean", $this->dummy_product->getName()); - self::assertEquals(70, $this->dummy_product->getCalories()); - self::assertEquals("Velvet.jpeg", $this->dummy_product->getImgRelativePath()); - self::assertEquals("Velvet Bean Image", $this->dummy_product->getImgAltText()); - self::assertEquals("Velvet", $this->dummy_product->getCategory()); - self::assertEquals(6.50, $this->dummy_product->getPrice()); + self::assertEquals("Velvet Bean", $product->getName()); + self::assertEquals(70, $product->getCalories()); + self::assertEquals("Velvet.jpeg", $product->getImgRelativePath()); + self::assertEquals("Velvet Bean Image", $product->getImgAltText()); + self::assertEquals("Velvet", $product->getCategory()); + self::assertEquals(6.50, $product->getPrice()); self::assertEquals( "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", - $this->dummy_product->getDescription() + $product->getDescription() ); self::assertInstanceOf( DateTime::class, - $this->dummy_product->getCreatedDate() + $product->getCreatedDate() ); // Check if created_date is an instance of DateTime } @@ -115,14 +115,14 @@ public function testToArray(): void $this->assertArrayHasKey('created_date', $result); // Ensure created_date is included in toArray result // Check if the actual values are correct - self::assertEquals("Velvet Bean", $result['name']); - self::assertEquals(70, $result['calories']); - self::assertEquals("Velvet.jpeg", $result['img_url']); - self::assertEquals("Velvet Bean Image", $result['img_alt_text']); - self::assertEquals("Velvet", $result['category']); - self::assertEquals(6.50, $result['price']); + self::assertEquals($this->dummy_product->getName(), $result['name']); + self::assertEquals($this->dummy_product->getCalories(), $result['calories']); + self::assertEquals($this->dummy_product->getImgRelativePath(), $result['img_url']); + self::assertEquals($this->dummy_product->getImgAltText(), $result['img_alt_text']); + self::assertEquals($this->dummy_product->getCategory(), $result['category']); + self::assertEquals($this->dummy_product->getPrice(), $result['price']); self::assertEquals( - "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", + $this->dummy_product->getDescription(), $result['description'] ); self::assertInstanceOf( @@ -133,12 +133,9 @@ public function testToArray(): void public function testSave(): void { - // Save the dummy product - $result = $this->dummy_product->save(); - - // Check if the product was saved successfully - self::assertTrue($result); // Assert that save() returns true upon successful save - self::assertNotNull($this->dummy_product->getProductID()); + $this->markTestIncomplete( + 'Use data providers here for at least 3 test cases, ...', + ); } public function testValidate(): void @@ -159,11 +156,12 @@ public function testGetRatingDistribution(): void $distribution = $this->dummy_product->getRatingDistribution(); // Check if the distribution contains the expected keys and values - $this->assertArrayHasKey(5, $distribution); - $this->assertEquals(100.0, $distribution[5]); // 1 out of 1 reviews is 5 stars + // Here dummy product contains a single review: + $this->assertArrayHasKey($this->dummy_review->getRating(), $distribution); + $this->assertEquals(100.0, $distribution[$this->dummy_review->getRating()]); $this->markTestIncomplete( - 'This test lacks test cases, ...', + 'This test lacks test cases. This test might fail when getRatingDistribution excludes unverified reviews.', ); } @@ -232,7 +230,7 @@ public function testGetReviews(): void // Check if the reviews contain the expected values $this->assertCount(1, $reviews); - $this->assertEquals('This is a test review.', $reviews[0]->getText()); - $this->assertEquals(5, $reviews[0]->getRating()); + $this->assertEquals($this->dummy_review->getText(), $reviews[0]->getText()); + $this->assertEquals($this->dummy_review->getRating(), $reviews[0]->getRating()); } } diff --git a/tests/ReviewTest.php b/tests/models/ReviewTest.php similarity index 69% rename from tests/ReviewTest.php rename to tests/models/ReviewTest.php index fee94c0b..4a34579f 100644 --- a/tests/ReviewTest.php +++ b/tests/models/ReviewTest.php @@ -2,30 +2,30 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use DateTime; +use Exception; use PHPUnit\Framework\TestCase; -use Steamy\Core\Database; use Steamy\Model\Client; use Steamy\Model\Location; -use Steamy\Model\Order; -use Steamy\Model\OrderProduct; use Steamy\Model\Review; use Steamy\Model\Product; -use Steamy\Model\Store; -use Faker\Factory; -use Faker\Generator; +use Steamy\Tests\helpers\TestHelper; +use Throwable; final class ReviewTest extends TestCase { - use Database; + use TestHelper; - private static ?Generator $faker; private ?Review $dummy_review; private ?Client $reviewer; private ?Product $dummy_product; public static function setUpBeforeClass(): void { - self::$faker = Factory::create(); + self::initFaker(); + self::resetDatabase(); } public static function tearDownAfterClass(): void @@ -33,6 +33,12 @@ public static function tearDownAfterClass(): void self::$faker = null; } + public function onNotSuccessfulTest(Throwable $t): never + { + self::printFakerSeed(); + parent::onNotSuccessfulTest($t); + } + /** * Clears previously inserted data in database. * @return void @@ -43,125 +49,7 @@ public function tearDown(): void $this->reviewer = null; $this->dummy_product = null; - - self::query( - "DELETE FROM order_product; - DELETE FROM `order`; - DELETE FROM comment; - DELETE FROM review; - DELETE FROM client; - DELETE FROM user; - DELETE FROM store_product; - DELETE FROM store; - DELETE FROM product; - " - ); - } - - /** - * Creates a client and saves it to database - * @return Client - * @throws Exception - */ - public static function createClient(): Client - { - $client = new Client( - self::$faker->email(), - self::$faker->name(), - self::$faker->name(), - "User0", - "13213431", - new Location("Royal Road", "Curepipe", 1) - ); - - $success = $client->save(); - if (!$success) { - throw new Exception('Unable to save client'); - } - return $client; - } - - /** - * Creates a product and saves it to database. - * @return Product - * @throws Exception - */ - public static function createProduct(): Product - { - $product = new Product( - "Velvet Bean", - 70, - "Velvet.jpeg", - "Velvet Bean Image", - "Velvet", - 6.50, - "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", - new DateTime() - ); - - $success = $product->save(); - if (!$success) { - throw new Exception('Unable to save product'); - } - return $product; - } - - /** - * Create a review and saves it to database. - * @param Product $product A valid product already present in database - * @param Client $client A valid client already present in database - * @param bool $verified Whether to create an order for client for given product. - * @return Review - * @throws Exception - */ - public static function createReview(Product $product, Client $client, bool $verified = false): Review - { - if ($verified) { - // place an order for client and product - - // create store - $store = new Store( - phone_no: "13213431", - address: new Location( - street: "Royal", - city: "Curepipe", - district_id: 1, - latitude: 50, - longitude: 50 - ) - ); - $success = $store->save(); - if (!$success) { - throw new Exception('Unable to create store'); - } - - // Add stock to the store for the product to be bought - $store->addProductStock($product->getProductID(), 10); - - $order = new Order($store->getStoreID(), $client->getUserID(), [ - new OrderProduct($product->getProductID(), 'small', 'oat', 1) - ]); - - $success = $order->save(); - if (!$success) { - throw new Exception('Unable to save order'); - } - } - - $review = new Review( - product_id: $product->getProductID(), - client_id: $client->getUserID(), - text: "This is a test review.", - rating: 5 - ); - - $success = $review->save(); - - if (!$success) { - throw new Exception('Unable to save review'); - } - - return $review; + self::resetDatabase(); } /** @@ -180,8 +68,7 @@ public function setUp(): void "Velvet Bean Image", "Velvet", 6.50, - "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", - new DateTime() + "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder" ); $success = $this->dummy_product->save(); @@ -336,22 +223,32 @@ public function testValidate(string $text, int $rating, DateTime $created_date, $this->assertEquals($expectedErrors, $review->validate()); } - public function testGetByID(): void + public function testGetByIDForValidId(): void { $fetched_review = Review::getByID($this->dummy_review->getReviewID()); $this->assertNotNull($fetched_review); - // Assert that the properties of the returned Review object match the data - $this->assertEquals($this->dummy_review->getText(), $fetched_review->getText()); - $this->assertEquals($this->dummy_review->getRating(), $fetched_review->getRating()); + $expected_data = $this->dummy_review->toArray(); + $fetched_data = $fetched_review->toArray(); + + // ignore creation dates because the date for expected review + // was set by php while the date for fetched_data was set by mysql + unset($expected_data['created_date']); + unset($fetched_data['created_date']); + + // compare all attributes except created_date + $this->assertEquals($expected_data, $fetched_data); // Compare dates by formatting $this->assertEquals( $this->dummy_review->getCreatedDate()->format('Y-m-d'), $fetched_review->getCreatedDate()->format('Y-m-d') ); + } + public function testGetByIDForInvalidId(): void + { // Test getByID with invalid ID $invalid_ids = [0, -1, 999, -111]; foreach ($invalid_ids as $id) { diff --git a/tests/StoreTest.php b/tests/models/StoreTest.php similarity index 78% rename from tests/StoreTest.php rename to tests/models/StoreTest.php index a560c7d9..70a8de3a 100644 --- a/tests/StoreTest.php +++ b/tests/models/StoreTest.php @@ -2,24 +2,30 @@ declare(strict_types=1); +namespace Steamy\Tests\Model; + +use Exception; use PHPUnit\Framework\TestCase; -use Steamy\Model\Store; use Steamy\Model\Location; -use Steamy\Core\Database; +use Steamy\Model\Store; +use Steamy\Tests\helpers\TestHelper; class StoreTest extends TestCase { - use Database; + use TestHelper; private ?Store $dummy_store; + public static function setUpBeforeClass(): void + { + self::resetDatabase(); + } + /** * @throws Exception */ public function setUp(): void { - parent::setUp(); - // Initialize a dummy store object for testing $this->dummy_store = new Store( phone_no: "12345678", // Phone number @@ -54,7 +60,7 @@ public function tearDown(): void } // clear all data from store tables - self::query('DELETE FROM store;'); + self::resetDatabase(); } /** @@ -114,13 +120,29 @@ public static function validateDataProvider(): array // Valid phone number, valid address (no errors) ["1234567890", new Location("Royal", "Curepipe", 1, 50, 50), []], // Invalid phone number (less than 7 characters) - ["123456", new Location("Royal", "Curepipe", 1, 50, 50), ["phone_no" => "Phone number must be at least 7 characters long"]], + [ + "123456", + new Location("Royal", "Curepipe", 1, 50, 50), + ["phone_no" => "Phone number must be at least 7 characters long"] + ], // Empty phone number - ["", new Location("Royal", "Curepipe", 1, 50, 50), ["phone_no" => "Phone number must be at least 7 characters long"]], + [ + "", + new Location("Royal", "Curepipe", 1, 50, 50), + ["phone_no" => "Phone number must be at least 7 characters long"] + ], // Invalid characters in phone number - ["123abc", new Location("Royal", "Curepipe", 1, 50, 50), ["phone_no" => "Phone number must be at least 7 characters long"]], + [ + "123abc", + new Location("Royal", "Curepipe", 1, 50, 50), + ["phone_no" => "Phone number must be at least 7 characters long"] + ], // Invalid address with invalid latitude/longitude - ["1234567890", new Location("Royal", "Curepipe", 1, -100, 50), ["coordinates" => "Invalid latitude or longitude."]], + [ + "1234567890", + new Location("Royal", "Curepipe", 1, -100, 50), + ["coordinates" => "Invalid latitude or longitude."] + ], ]; } }