diff --git a/Makefile b/Makefile index 2a8d0508..265f13d3 100644 --- a/Makefile +++ b/Makefile @@ -31,22 +31,22 @@ clean-test: ## cleanup pytests leftovers rm -f test-results.html rm -f output.xml -test: clean ## Run pytest unit tests - python3 -m pytest +test: clean-test ## Run pytest unit tests + python3 -m pytest --verbosity=1 test-debug: ## Run unit tests with debugging enabled python3 -m pytest --pdb -test-coverage: clean ## Run unit tests and check code coverage +test-coverage: clean-test ## Run unit tests and check code coverage PYTHONPATH=src python3 -m pytest --cov=src tests/ --disable-warnings docker-up: ## Startup docker docker-compose --verbose up -docker-build: ## Startup docker +docker-build: ## Startup docker with build switch docker-compose --verbose up --build -setup: requirements pre-commit-setup docker-build test-setup api-setup ## setup & run after downloaded repo +setup: requirements venv-create pre-commit-setup docker-build test-setup api-setup ## setup & run after downloaded repo pre-commit-setup: ## Install pre-commit python3 -m pip install pre-commit @@ -55,33 +55,34 @@ pre-commit-setup: ## Install pre-commit pre-commit: ## Run pre-commit pre-commit run --all-files -test-setup: +test-setup: ## installs pytest singular package for local testing python3 -m pip install pytest -requirements: +requirements: ## installs all requirements python3 -m pip install -r requirements.txt -docker-down: +docker-down: ## shutdown docker docker-compose down -docker-rebuild: docker-down +docker-rebuild: docker-down ## shuts down docker then brings it up and rebuilds docker-compose --verbose up --build -docker-force-rebuild: +docker-force-rebuild: docker-down ## shuts down docker than brings it up and force rebuilds docker-compose --verbose up --build --force-recreate -api-setup: +api-setup: ## installs fastapi singular package, for local testing python3 -m pip install "fastapi[all]" -env-setup: - touch .env - echo "KAFKA_HOST= 'localhost:9092'" >> .env - echo "DATABASE_URL= 'mysql+aiomysql://root:root_bot_buster@localhost:3306/playerdata'" >> .env - echo "ENV='DEV'" >> .env - echo "POOL_RECYCLE='60'" >> .env - echo "POOL_TIMEOUT='30'" >> .env - -docs: +docs: # opens your browser to the webapps testing docs open http://localhost:5000/docs xdg-open http://localhost:5000/docs - . http://localhost:5000/docs \ No newline at end of file + . http://localhost:5000/docs + +venv-create: venv-remove ## cleans the .venv then creates a venv in the folder .venv + python3 -m venv .venv + +venv-remove: ## removes the .venv folder + rm -rf .venv + +check: ## run pre commit hooks for github, should do black checks + pre-commit \ No newline at end of file diff --git a/README.md b/README.md index 8d3628db..16af457b 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,12 @@ structure: - src.core: contains the core componetns - src.core.database: contains all the database functionality -# setup -## windows +# Setup +1. Create fork of primary repo +2. Download a clone of the forked repo `git clone ` +3. Open a terminal to your cloned repo +4. follow linux or windows sections for further setup +## Windows creating a python venv to work in and install the project requirements ```sh python -m venv .venv @@ -16,18 +20,198 @@ python -m pip install --upgrade pip pip install -r requirements.txt pre-commit install ``` -## linux -make sure to have pip +## Linux +Setup overview: +1. install requirements: python, pip, make, docker +2. create venv `make venv-create` or `python3 -m venv .venv` +3. activate venv `source .venv/bin/activate` +4. run setup `make setup` + +detailed guide in sections below + +### Install make and pip +#### Debian +distros such as ubuntu, mint, pop, kali ```sh +sudo apt install make sudo apt install python3-pip ``` + +#### Red Hat +distros such as fedora, centos, rocky +```sh +sudo yum install make +sudo yum install python3-pip +``` + +#### Arch Linux +```sh +sudo pacman -Syu make +sudo pacman -Syu python3-pip +``` + +#### MacOS +typically requires xcode command line tools from http://developer.apple.com/ +or using homebrew from https://brew.sh/ (example below). note the command it installs is `gmake`, ideally just make an alias `make` by adding it to your bash profile +```sh +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +brew --version +Homebrew 4.1.17 +brew install make +brew install python3-pip +``` + +add your alias to your bash profile (loaded on terminal start) +```sh +nano ~/.bash_profile +``` + +ctrl+x and enter to save once you have added the alias line (example below line to add) +```sh +alias make="gmake" +``` + +### Python Virtual Enviornment +#### Setup +run the following command to create your venv +```sh +make venv-create +``` + +#### Activate +next command is to activate your virtiual envionrment, you should see to the far left of your prompt showing you what env you are in ```sh -python3 -m venv .venv source .venv/bin/activate -python3 -m pip install --upgrade pip -pip install -r requirements.txt -pre-commit install ``` + +if you ever need to get out of the virtual enviornment run the `deactivate` command +```sh +deactivate +``` + +### Make User Guide +use the `make` command with an `action` when developing on linux. think of `actions` as a list of predefined commands to help simplify common development routines. + +#### view avaiable actions +`make help` to see all avaiable actions +```sh +clean-pyc clean python cache files +clean-test cleanup pytests leftovers +test Run pytest unit tests +test-debug Run unit tests with debugging enabled +test-coverage Run unit tests and check code coverage +docker-up Startup docker +docker-build Startup docker with build switch +setup setup & run after downloaded repo +pre-commit-setup Install pre-commit +pre-commit Run pre-commit +test-setup installs pytest singular package for local testing +requirements installs all requirements +docker-down shutdown docker +docker-rebuild shuts down docker then brings it up and rebuilds +docker-force-rebuild shuts down docker than brings it up and force rebuilds +api-setup installs fastapi singular package, for local testing +venv-create cleans the .venv then creates a venv in the folder .venv +venv-remove removes the .venv folder +``` + +### Docker +#### Pre-reqs +- docker +- python3 +- local clone of repo +- terminal opened to cloned repos root path + + +#### Setup Docker Enviornment +run `setup` +```sh +make setup +``` + +once complete you will have to open a new terminal as the docker output will be running in the current one. you are looking for the message 'Application startup complete.' to know when the `setup` is done and containers are running. + +more explinations of different `make` `actions` that can be run in the other sections below + +#### Shutdown Docker Containers +run `docker-down` + +```sh +make docker-down +``` +the outcome should be like +```sh +docker-compose down +[+] Running 6/6 + ✔ Container kafdrop Removed 10.2s + ✔ Container public_api Removed 0.4s + ✔ Container kafka_setup Removed 0.0s + ✔ Container database Removed 0.8s + ✔ Container kafka Removed 0.9s + ✔ Network public-api_botdetector-network Removed 0.0s +``` + +##### Startup Docker Containers +run `docker-up`. example below is using public-api +```sh +make docker-up +``` +the outcome should be like +```sh +docker-compose --verbose up +[+] Building 0.0s (0/0) +[+] Running 7/5 + ✔ Network public-api_botdetector-Network + ✔ Container database kafka_setup + ✔ Container kafka + ✔ Container kafka_setup + ✔ Container kafdrop + ... + ... + public_api | INFO: Application startup complete. + ... +``` + +##### Force Rebuild of Docker Containers +run `docker-force-rebuild`. usually done if requirements.txt are changed. or if containers are having weird issues and you want to start fresh. +```sh +make docker-force-rebuild +``` + +### TDD using pytest +#### Verifying Tests Pass +run `test` +```sh +make test +``` +the outcome should be like +```sh +python3 -m pytest --verbosity=1 --rootdir=./ +=============== test session starts =============== +platform darwin -- Python 3.11.6, pytest-7.4.2, pluggy-1.3.0 -- /opt/homebrew/opt/python@3.11/bin/python3.11 +cachedir: .pytest_cache +hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('./bot-detector/public-api/.hypothesis/examples')) +rootdir: public-api +plugins: hypothesis-6.88.1, anyio-3.7.1 +collected 13 items + +tests/test_player_api.py::TestPlayerAPI::test_invalid_player_returns_empty PASSED [ 7%] +tests/test_player_api.py::TestPlayerAPI::test_invalid_player_returns_success PASSED [ 15%] +tests/test_player_api.py::TestPlayerAPI::test_valid_player_returns_data PASSED [ 23%] +tests/test_player_api.py::TestPlayerAPI::test_valid_player_returns_success PASSED [ 30%] +tests/test_prediction_api.py::TestPredictionAPI::test_invalid_max_player_name_length_returns_unkonwn PASSED [ 38%] +tests/test_prediction_api.py::TestPredictionAPI::test_invalid_min_player_name_length_returns_unknown PASSED [ 46%] +tests/test_prediction_api.py::TestPredictionAPI::test_invalid_player_breakdown_false_returns_player_not_found PASSED [ 53%] +tests/test_prediction_api.py::TestPredictionAPI::test_invalid_player_breakdown_true_returns_player_not_found PASSED [ 61%] +tests/test_prediction_api.py::TestPredictionAPI::test_invalid_player_breakdown_true_returns_unknown PASSED [ 69%] +tests/test_prediction_api.py::TestPredictionAPI::test_valid_player_breakdown_false_returns_empty_property PASSED [ 76%] +tests/test_prediction_api.py::TestPredictionAPI::test_valid_player_breakdown_false_returns_success PASSED [ 84%] +tests/test_prediction_api.py::TestPredictionAPI::test_valid_player_breakdown_true_returns_populated_property PASSED [ 92%] +tests/test_prediction_api.py::TestPredictionAPI::test_valid_player_breakdown_true_returns_success PASSED [100%] + +=============== 13 passed in 1.54s =============== +``` + # for admin purposes saving & upgrading when you added some dependancies update the requirements ```sh @@ -41,4 +225,4 @@ powershell "(Get-Content requirements.txt) | ForEach-Object { $_ -replace '==', call pip install -r requirements.txt --upgrade call pip freeze > requirements.txt powershell "(Get-Content requirements.txt) | ForEach-Object { $_ -replace '>=', '==' } | Set-Content requirements.txt" -``` \ No newline at end of file +``` diff --git a/requirements.txt b/requirements.txt index b1c0ec41..3ab70794 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,3 +40,5 @@ uvicorn==0.23.2 virtualenv==20.24.5 watchfiles==0.20.0 websockets==11.0.3 +hypothesis==6.88.1 +cryptography==41.0.4 \ No newline at end of file diff --git a/tests/test_player_api.py b/tests/test_player_api.py index e106aa27..1179e981 100644 --- a/tests/test_player_api.py +++ b/tests/test_player_api.py @@ -1,6 +1,7 @@ import os import sys import requests +import json from unittest import TestCase @@ -8,23 +9,38 @@ class TestPlayerAPI(TestCase): + def test_valid_player_returns_success(self): + url = "http://localhost:5000/v2/players/score" + params = {} + # build params + params["name"] = ["Player1"] + score = requests.get(url, params) + assert score.status_code == 200 - def testValidPlayerReturns200(self): - url = "http://localhost:5000/v2/players/score" - params = {"name": ["Player1"]} - score = requests.get(url, params) - assert score.status_code == 200 + def test_invalid_player_returns_success(self): + url = "http://localhost:5000/v2/players/score" + params = {"name": [""]} + score = requests.get(url, params) + assert score.status_code == 200 - def testInvalidPlayerReturns200(self): - url = "http://localhost:5000/v2/players/score" - params = {"name": [""]} - response = requests.get(url, params) - assert response.status_code == 200 - - def testInvalidPlayerBodyIsEmpty(self): - url = "http://localhost:5000/v2/players/score" - params = {"name": [""]} - response = requests.get(url, params) - error = f"Invalid response return type, expected list[dict]" - print(response.json()) - assert isinstance(response.json(), list), error \ No newline at end of file + def test_valid_player_returns_data(self): + url = "http://localhost:5000/v2/players/score" + params = {} + # build params + params["name"] = ["Player1"] + score = requests.get(url, params) + json_data = json.loads(score.text) + error = f"Invalid response return type, expected list[dict]" + # print(len(json_data)) + assert len(json_data) > 0 + + def test_invalid_player_returns_empty(self): + url = "http://localhost:5000/v2/players/score" + params = {} + # build params + params["name"] = [""] + score = requests.get(url, params) + # get first element because conversion makes it in a list + json_data = json.loads(score.text) + # should return a populated dictionary, should be True + assert len(json_data) == 0 diff --git a/tests/test_prediction_api.py b/tests/test_prediction_api.py new file mode 100644 index 00000000..45316189 --- /dev/null +++ b/tests/test_prediction_api.py @@ -0,0 +1,105 @@ +import os +import sys +import requests +import json +from unittest import TestCase + +from hypothesis import given +import hypothesis.strategies as st + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestPredictionAPI(TestCase): + def test_valid_player_breakdown_false_returns_success(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = ["Player1"] + params["breakdown"] = False + score = requests.get(url, params) + assert score.status_code == 200 + + def test_valid_player_breakdown_false_returns_empty_property(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = ["Player1"] + params["breakdown"] = False + score = requests.get(url, params) + # get first element because conversion makes it in a list + json_data = (json.loads(score.text))[0] + # should be an empty list, empty dicts return False + assert not json_data["predictions_breakdown"] + + def test_valid_player_breakdown_true_returns_success(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = ["Player1"] + params["breakdown"] = True + score = requests.get(url, params) + assert score.status_code == 200 + + def test_valid_player_breakdown_true_returns_populated_property(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = ["Player1"] + params["breakdown"] = True + score = requests.get(url, params) + # get first element because conversion makes it in a list + json_data = (json.loads(score.text))[0] + # should return a populated dictionary, should be True + assert bool(json_data["predictions_breakdown"]) + + @given(st.text(min_size=0, max_size=2)) + def test_invalid_min_player_name_length_returns_unknown(self, name): + url = "http://localhost:5000/v2/prediction" + # build params + params = {"name": [name]} + params["breakdown"] = False + response = requests.get(url, params) + assert response.status_code == 404 + + @given(st.text(min_size=14, max_size=None)) + def test_invalid_max_player_name_length_returns_unkonwn(self, name): + url = "http://localhost:5000/v2/prediction" + # build params + params = {"name": [name]} + params["breakdown"] = False + response = requests.get(url, params) + assert response.status_code == 404 + + def test_invalid_player_breakdown_true_returns_unknown(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = [""] + params["breakdown"] = True + response = requests.get(url, params) + assert response.status_code == 404 + + def test_invalid_player_breakdown_false_returns_player_not_found(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = [""] + params["breakdown"] = False + score = requests.get(url, params) + # get first element because conversion makes it in a list + json_data = json.loads(score.text) + # should be an empty list, empty dicts return False + assert json_data["detail"] == "Player not found" + + def test_invalid_player_breakdown_true_returns_player_not_found(self): + url = "http://localhost:5000/v2/prediction" + params = {} + # build params + params["name"] = [""] + params["breakdown"] = True + score = requests.get(url, params) + # get first element because conversion makes it in a list + json_data = json.loads(score.text) + # should be an empty list, empty dicts return False + assert json_data["detail"] == "Player not found"