diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f188e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.10.12' + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Run tests with coverage + run: | + coverage run -m pytest + coverage xml + coverage-badge -o coverage.svg + + - name: Setup Git for Pages + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Checkout gh-pages branch + + run: | + git fetch origin + git checkout gh-pages || git checkout --orphan gh-pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Push to gh-pages branch + run: | + echo 'coverage badge' > index.html + git add coverage.svg index.html + git commit -m "Update coverage badge" + git push --force origin gh-pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b77c750 --- /dev/null +++ b/.gitignore @@ -0,0 +1,128 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# Celery +celerybeat-schedule.* +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# VS Code settings +.vscode/ + +# Local virtual environment directory +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b973811 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Test Containers with FastAPI + +![Coverage](https://gil-air-may.github.io/test-containers/coverage.svg) + +## Overview + +This project demonstrates how to set up and use Testcontainers with FastAPI to ensure reliable and isolated test environments. By automating your tests, you can maintain high code quality and gain valuable insights into your system's performance and behavior. + +## Table of Contents +- [Installation](#installation) +- [Usage](#usage) +- [Running Tests](#running-tests) +- [GitHub Actions Setup](#github-actions-setup) +- [Contributing](#contributing) +- [License](#license) + +## Installation + +Clone the repository and install the dependencies: + +```bash +git clone https://github.com/gil-air-may/test-containers.git +cd test-containers +pip install -r requirements.txt +``` + +## Usage + +Start the FastAPI application: + +```bash +uvicorn main:app --reload +``` + +## Running Tests + +Run tests with coverage: + +```bash +pytest --cov=app tests/ +``` + +## GitHub Actions Setup + +This project uses GitHub Actions for continuous integration (CI) to automate testing. The CI workflow is defined in the `.github/workflows/ci.yml` file and includes the following steps: + +1. **Checkout code**: Checks out the repository code. +2. **Set up Python**: Sets up the specified version of Python. +3. **Install dependencies**: Installs the required dependencies listed in `requirements.txt`. +4. **Run tests with coverage**: Executes tests and generates a coverage report. +5. **Set up Git for Pages**: Configures Git with GitHub Actions bot credentials. +6. **Checkout `gh-pages` branch**: Checks out the `gh-pages` branch for updating the coverage badge. +7. **Copy coverage badge**: Copies the generated coverage badge to the correct location. +8. **Add and commit coverage badge**: Adds and commits the coverage badge. +9. **Push to `gh-pages` branch**: Pushes the updated badge to the `gh-pages` branch. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request. + +## License + +This project is licensed under the MIT License. + +--- diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..5e44097 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,4 @@ +connections = { + "MYSQL": "mysql+mysqldb://gervasgu:gervasgu@0.0.0.0/FoodOps", + "REDIS": {"host": "localhost", "port": 6379}, +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/cache.py b/core/cache.py new file mode 100644 index 0000000..5424874 --- /dev/null +++ b/core/cache.py @@ -0,0 +1,6 @@ +from infrastructure.cache import tab_cache +import json + + +def get_cached(id): + return json.loads(tab_cache.get_value(id)) diff --git a/core/repo.py b/core/repo.py new file mode 100644 index 0000000..999ab6d --- /dev/null +++ b/core/repo.py @@ -0,0 +1,9 @@ +from infrastructure.db import tab_db + + +def get_all_tabs(): + return tab_db.get_all_tabs() + + +def get_todays_tabs(): + return tab_db.get_todays_tabs() diff --git a/coverage.svg b/coverage.svg new file mode 100644 index 0000000..ee07d4c --- /dev/null +++ b/coverage.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + coverage + coverage + 96% + 96% + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..7098cef --- /dev/null +++ b/index.html @@ -0,0 +1 @@ +coverage badge diff --git a/infrastructure/__init__.py b/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/cache/__init__.py b/infrastructure/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/cache/tab_cache.py b/infrastructure/cache/tab_cache.py new file mode 100644 index 0000000..1338896 --- /dev/null +++ b/infrastructure/cache/tab_cache.py @@ -0,0 +1,13 @@ +import redis +import ipdb +from config import connections + + +redis_host = connections["REDIS"]["host"] +redis_port = connections["REDIS"]["port"] + +r = redis.Redis(host=redis_host, port=redis_port, decode_responses=True) + + +def get_value(key): + return r.get(key) diff --git a/infrastructure/db/__init__.py b/infrastructure/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/db/tab_db.py b/infrastructure/db/tab_db.py new file mode 100644 index 0000000..53166bb --- /dev/null +++ b/infrastructure/db/tab_db.py @@ -0,0 +1,16 @@ +import sqlalchemy +from config import connections +from sqlalchemy import text + +engine = sqlalchemy.create_engine(connections["MYSQL"]) + + +def execute_raw_query(raw_query, params=None): + with engine.connect() as connection: + result = connection.execute(text(raw_query)) + return result.mappings().all() + + +def get_all_tabs(): + sql = "select * from Tab" + return execute_raw_query(sql) diff --git a/main.py b/main.py new file mode 100644 index 0000000..eead405 --- /dev/null +++ b/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from core import repo, cache +from dotenv import load_dotenv + +load_dotenv() + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"Hello": "world"} + + +@app.get("/tabs") +def get_tabs(): + return repo.get_all_tabs() + + +@app.get("/cache") +def get_cache(tab: int): + return cache.get_cached(tab) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed3fa83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,69 @@ +annotated-types==0.7.0 +anyio==4.4.0 +asttokens==2.4.1 +async-timeout==4.0.3 +certifi==2024.6.2 +charset-normalizer==3.3.2 +click==8.1.7 +coverage==7.5.4 +coverage-badge==1.1.1 +decorator==5.1.1 +dnspython==2.6.1 +docker==7.1.0 +email_validator==2.2.0 +exceptiongroup==1.2.1 +executing==2.0.1 +fastapi==0.111.0 +fastapi-cli==0.0.4 +h11==0.14.0 +httpcore==1.0.5 +httptools==0.6.1 +httpx==0.27.0 +idna==3.7 +iniconfig==2.0.0 +ipdb==0.13.13 +ipython==8.25.0 +jedi==0.19.1 +Jinja2==3.1.4 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +matplotlib-inline==0.1.7 +mdurl==0.1.2 +orjson==3.10.5 +packaging==24.1 +parso==0.8.4 +pexpect==4.9.0 +pluggy==1.5.0 +prompt_toolkit==3.0.47 +ptyprocess==0.7.0 +pure-eval==0.2.2 +pydantic==2.7.4 +pydantic_core==2.18.4 +Pygments==2.18.0 +PyMySQL==1.1.1 +pytest==8.2.2 +python-dotenv==1.0.1 +python-multipart==0.0.9 +PyYAML==6.0.1 +redis==5.0.6 +requests==2.32.3 +rich==13.7.1 +shellingham==1.5.4 +six==1.16.0 +sniffio==1.3.1 +SQLAlchemy==2.0.31 +stack-data==0.6.3 +starlette==0.37.2 +testcontainers==4.6.0 +tomli==2.0.1 +traitlets==5.14.3 +typer==0.12.3 +typing_extensions==4.12.2 +ujson==5.10.0 +urllib3==2.2.2 +uvicorn==0.30.1 +uvloop==0.19.0 +watchfiles==0.22.0 +wcwidth==0.2.13 +websockets==12.0 +wrapt==1.16.0 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/database/__init__.py b/scripts/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/database/seed_utils.py b/scripts/database/seed_utils.py new file mode 100644 index 0000000..f6d25a8 --- /dev/null +++ b/scripts/database/seed_utils.py @@ -0,0 +1,4 @@ +def get_seed_commands(): + with open("scripts/database/seeds.sql", "r") as file: + content = file.read() + return content diff --git a/scripts/database/seeds.sql b/scripts/database/seeds.sql new file mode 100644 index 0000000..608799d --- /dev/null +++ b/scripts/database/seeds.sql @@ -0,0 +1,15 @@ +CREATE TABLE Tab ( + tab_id INT PRIMARY KEY AUTO_INCREMENT, + table_number INT NOT NULL, + is_paid BOOLEAN DEFAULT FALSE, + items JSON NULL, + from_day DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_from_day ON Tab (from_day); + +CREATE TABLE test (column_1 INT); +INSERT INTO test VALUES (1); + +INSERT INTO Tab (table_number,is_paid,items,from_day,created_at) VALUES (1,0,'[{"name": "chicken_salad", "amount": 1}]','2024-05-18','2024-05-18 20:36:42') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..405dc32 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,38 @@ +from testcontainers.mysql import MySqlContainer +from testcontainers.redis import RedisContainer +import sqlalchemy +from config import connections +from scripts.database import seed_utils +from utils import execute_non_query +import json + +mysql = MySqlContainer("mysql:5.7.17", port=3306) +mysql.start() +connections["MYSQL"] = mysql.get_connection_url() + +engine = sqlalchemy.create_engine(mysql.get_connection_url()) +seed_commands = seed_utils.get_seed_commands() + +for statement in seed_commands.split(";"): + execute_non_query(engine, statement) + +redis_container = RedisContainer().__enter__() +redis_client = redis_container.get_client() +redis_conn = redis_client.get_connection_kwargs() + +redis_client.set( + 1, + json.dumps( + { + "tab_id": 1, + "table_number": 1, + "is_paid": 0, + "items": '[{"name": "chicken_salad", "amount": 1}]', + "from_day": "2024-05-18", + "created_at": "2024-05-18T20:36:42", + }, + ), +) + +connections["REDIS"]["host"] = redis_conn["host"] +connections["REDIS"]["port"] = redis_conn["port"] diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..e0bb0fb --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,40 @@ +from starlette.testclient import TestClient + + +from main import app + +client = TestClient(app) + + +def test_hello_world(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"Hello": "world"} + + +def test_get_all_tabs(): + response = client.get("/tabs") + assert response.status_code == 200 + assert response.json() == [ + { + "tab_id": 1, + "table_number": 1, + "is_paid": 0, + "items": '[{"name": "chicken_salad", "amount": 1}]', + "from_day": "2024-05-18", + "created_at": "2024-05-18T20:36:42", + } + ] + + +def test_get_cache(): + response = client.get("/cache?tab=1") + assert response.status_code == 200 + assert response.json() == { + "tab_id": 1, + "table_number": 1, + "is_paid": 0, + "items": '[{"name": "chicken_salad", "amount": 1}]', + "from_day": "2024-05-18", + "created_at": "2024-05-18T20:36:42", + } diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..64bdb9e --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,14 @@ +from sqlalchemy import text + + +def execute_raw_query(engine, raw_query, params=None): + with engine.connect() as connection: + result = connection.execute(text(raw_query)) + return result.mappings().all() + + +def execute_non_query(engine, raw_query, params=None): + with engine.connect() as connection: + result = connection.execute(text(raw_query)) + connection.commit() + return result