diff --git a/.env-t b/.env-t new file mode 100644 index 0000000..7419fde --- /dev/null +++ b/.env-t @@ -0,0 +1,4 @@ +SERVICE_NAME="flask-app-template" +ENVIRONMENT="development" +SECRET_TOKEN="secret-token" +SERVER_URL="http://localhost:5000" diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 0000000..fb64855 --- /dev/null +++ b/.github/workflows/pytest.yaml @@ -0,0 +1,59 @@ +# .github/workflows/pytest.yaml +name: PyTest + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.12'] + mongodb-version: ['7.0'] + + timeout-minutes: 10 + + name: PyTests + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest-cov diff-cover + + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.10.0 + + - name: Run tests with PyTest + env: + SERVICE_NAME: "flask-app-template" + ENVIRONMENT: "development" + SECRET_TOKEN: "secret-token" + SERVER_URL: "http://localhost:5000" + MONGO_URL: "localhost" + + run: | + set -o pipefail + pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=app tests/ | tee pytest-coverage.txt + coverage xml + exit ${PIPESTATUS[0]} + + - name: PyTest Coverage Commentator + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt + junitxml-path: ./pytest.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..995f222 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +### Python template +# 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/ +cover/ + +# 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/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +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/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af72cd9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Use an official Python runtime as a parent image +FROM python:3.12 +LABEL authors="adhishthite" + +# Install Nginx +RUN apt-get update && \ + apt-get install -y nginx && \ + rm -rf /var/lib/apt/lists/* + +# Set the working directory in the container to /app +WORKDIR /app + +# First, copy only the requirements.txt file +COPY requirements.txt /app/ + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the current directory contents into the container at /app +COPY . /app + +# Setup Nginx to forward requests to Gunicorn +COPY nginx.conf /etc/nginx/sites-available/default + +# Make port 8000 available to the world outside this container +EXPOSE 8000 +EXPOSE 80 + +# Run app.py when the container launches +CMD service nginx start && gunicorn --workers 8 app.app:app --bind unix:/app/app.sock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf88a96 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Adhish Thite + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f0ac30 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Flask App Template + +This is a template of a Flask App that uses NGINX and GUNICORN for deployment. + +## Overview + +The Flask App Template is designed to provide a starting point for developing web applications using the Flask framework. It includes integration with NGINX and GUNICORN for efficient deployment. + +## Features + +- Integration with NGINX and GUNICORN +- Simplified structure for easy project initiation +- Use of best practices and recommended plugins +- Integration with Docker for easy deployment +- Use of MongoDB for data storage and Redis for caching +- Integrated with GitHub Actions + +## Getting Started + +To get started with this template, follow these steps: + +0. Clone the repository. + + ```bash + git clone https://github.com/adhishthite/flask-app-template + ``` + +1. Navigate to the repository + + ```bash + cd flask-app-template + ``` + +2. Rename the `.env-t` file to `.env` and add/update the required environment variables. + + ```bash + mv .env-t .env + ``` + +3. Build the Docker image using docker-compose. + + ```bash + docker-compose up --build + ``` + + +[WIP] + + +## License + + +## Feedback + +I welcome feedback and suggestions. Please feel free to open an issue or submit a pull request. + +--- diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..f573b6d --- /dev/null +++ b/app/app.py @@ -0,0 +1,37 @@ +import os + +from flask import Flask, jsonify +import elasticapm +from elasticapm.contrib.flask import ElasticAPM +from dotenv import load_dotenv + +from . import data + +load_dotenv() + +app = Flask(__name__) +if os.environ['ENVIRONMENT'] == 'production': + app.config['ELASTIC_APM'] = { + 'SERVICE_NAME': os.environ['SERVICE_NAME'], + 'SECRET_TOKEN': os.environ['SECRET_TOKEN'], + 'SERVER_URL': os.environ['SERVER_URL'], + 'ENVIRONMENT': os.environ['ENVIRONMENT'], + } + + apm = ElasticAPM(app) + + +@app.route('/', methods=['GET']) +@elasticapm.capture_span() +def hello_world(): + return data.healthcheck() + + +@app.route('/mongo_status', methods=['GET']) +@elasticapm.capture_span() +def mongo_status(): + return jsonify(data.get_mongo_status()), 200 + + +if __name__ == '__main__': + app.run(debug=True, port=6062) diff --git a/app/data.py b/app/data.py new file mode 100644 index 0000000..63394c2 --- /dev/null +++ b/app/data.py @@ -0,0 +1,26 @@ +import os + +import elasticapm + +from .database import MongoDB + + +@elasticapm.capture_span() +def healthcheck(): + """ + Simple healthcheck function that returns a byte string. + """ + return b'Hello, World!' + + +@elasticapm.capture_span() +def get_mongo_status(): + """ + Retrieves the current MongoDB connection information. + + Returns: + dict: A dictionary containing the collection name, database name, host, and port. + """ + MONGO_URL = os.environ.get("MONGO_URL") + mongo_db = MongoDB(mongo_url=MONGO_URL, database_name="admin") + return mongo_db.ping() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..4650d6b --- /dev/null +++ b/app/database.py @@ -0,0 +1,215 @@ +import os +from typing import Optional + + +import pymongo +from pymongo.errors import BulkWriteError, ConnectionFailure, PyMongoError +from bson import ObjectId + + +class MongoDB: + """ + A MongoDB class for managing connections and operations with a MongoDB database. + + Attributes: + __client (MongoClient): A client connection to the MongoDB server. + __db (Database): The MongoDB database instance. + collection (Collection): The MongoDB collection instance. + """ + def __init__(self, mongo_url: str, database_name: str = "admin", collection_name: str = "admin"): + """ + Initializes the MongoDB connection using the provided MongoDB URL, database name, and collection name. + If the mongo_url is not provided, it attempts to retrieve it from the environment variable "MONGO_URL". + + Args: + mongo_url (str): The MongoDB connection URL. + database_name (str, optional): The name of the database. Defaults to "admin". + collection_name (str, optional): The name of the collection. Defaults to "admin". + + Raises: + Exception: If the connection to MongoDB fails. + """ + if not mongo_url: + mongo_url = os.environ.get("MONGO_URL") + + try: + self.__client = pymongo.MongoClient( + host=mongo_url, + serverSelectionTimeoutMS=5000, + appname=os.getenv("SERVICE_NAME", "flask-app-template"), + connect=True + ) + + self.__db = self.__client[database_name] + self.collection = self.__db[collection_name] + except ConnectionFailure as exc: + raise Exception(f"Connection to MongoDB failed: {exc}") + + def ping(self) -> dict: + """ + Pings the MongoDB server + + Returns: + dict: A dictionary containing the server status. + """ + return self.__client.admin.command("ping") + + def set_collection(self, collection_name: str) -> None: + """ + Sets the collection to the specified collection name within the current database. + + Args: + collection_name (str): The name of the collection to switch to. + """ + self.collection = self.__db[collection_name] + + def insert_one(self, data) -> Optional[ObjectId]: + """ + Inserts a single document into the collection. + + Args: + data (dict): The document to insert. + + Returns: + ObjectId or None: The ObjectId of the inserted document, or None if insertion fails. + """ + try: + result = self.collection.insert_one(data) + return result.inserted_id + except PyMongoError as exc: + print(f"Insert failed: {exc}") + return None + + def insert_many(self, data_list) -> Optional[list[ObjectId]]: + """ + Inserts multiple documents into the collection. + + Args: + data_list (list): A list of documents to insert. + + Returns: + list or None: A list of ObjectIds of the inserted documents, or None if insertion fails. + """ + try: + result = self.collection.insert_many(data_list) + return result.inserted_ids + except BulkWriteError as exc: + print(f"Bulk insert failed: {exc}") + return None + + def find(self, query) -> Optional[list[dict]]: + """ + Finds documents in the collection matching the query. + + Args: + query (dict): The query criteria to apply. + + Returns: + list or None: A list of matching documents, or None if the query fails. + """ + try: + return list(self.collection.find(query)) + except PyMongoError as exc: + print(f"Query failed: {exc}") + return None + + def find_one(self, query) -> Optional[dict]: + """ + Finds a single document in the collection matching the query. + + Args: + query (dict): The query criteria to apply. + + Returns: + dict or None: The first document matching the query, or None if the query fails. + """ + try: + return self.collection.find_one(query) + except PyMongoError as exc: + print(f"Query failed: {exc}") + return None + + def update_one(self, query, update) -> Optional[int]: + """ + Updates a single document in the collection matching the query. + + Args: + query (dict): The query criteria for the document to update. + update (dict): The update operations to apply. + + Returns: + int or None: The count of documents modified, or None if the update fails. + """ + try: + result = self.collection.update_one(query, update) + return result.modified_count + except pymongo.errors.PyMongoError as exc: + print(f"Update failed: {exc}") + return None + + def update_many(self, query, update) -> Optional[int]: + """ + Updates multiple documents in the collection matching the query. + + Args: + query (dict): The query criteria for the documents to update. + update (dict): The update operations to apply. + + Returns: + int or None: The count of documents modified, or None if the update fails. + """ + try: + result = self.collection.update_many(query, update) + return result.modified_count + except pymongo.errors.PyMongoError as exc: + print(f"Update failed: {exc}") + return None + + def delete_one(self, query) -> Optional[int]: + """ + Deletes a single document from the collection matching the query. + + Args: + query (dict): The query criteria for the document to delete. + + Returns: + int or None: The count of documents deleted, or None if the delete operation fails. + """ + try: + result = self.collection.delete_one(query) + return result.deleted_count + except pymongo.errors.PyMongoError as exc: + print(f"Delete failed: {exc}") + return None + + def delete_many(self, query) -> Optional[int]: + """ + Deletes multiple documents from the collection matching the query. + + Args: + query (dict): The query criteria for the documents to delete. + + Returns: + int or None: The count of documents deleted, or None if the delete operation fails. + """ + try: + result = self.collection.delete_many(query) + return result.deleted_count + except pymongo.errors.PyMongoError as exc: + print(f"Delete failed: {exc}") + return None + + def close(self) -> None: + """ + Closes the MongoDB client connection. + """ + self.__client.close() + + def drop_db(self, db_name) -> None: + """ + Drops the specified database from the MongoDB server. + + Args: + db_name (str): The name of the database to drop. + """ + self.__client.drop_database(db_name) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..63aa0e5 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,45 @@ +version: '3.9' + +services: + + mongo: + container_name: T_mongo + image: mongo:7.0 + + ports: + - "27017:27017" + + redis: + container_name: T_redis + image: redis:6.0 + + ports: + - "6379:6379" + + app: + build: . + + container_name: T_app + + ports: + - "80:80" + + volumes: + - .:/app + + environment: + # General + FLASK_ENV: "development" + SERVICE_NAME: "app-template" + ENVIRONMENT: "development" + SECRET_TOKEN: "secret-token" + SERVER_URL: "http://localhost:5000" + ELASTIC_APM_ENABLED: "false" + + # MongoDB and Redis + MONGO_URL: "mongo" + REDIS_HOST: "redis" + + depends_on: + - mongo + - redis diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..d8fecd6 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,33 @@ +# Upstream server pool +upstream app { + # Gunicorn is listening on this Unix socket + server unix:/app/app.sock; +} + +# Server configuration +server { + # Listen on port 80 (HTTP) + listen 80 default_server; + server_name localhost; # Adjust if you have a specific server name + + # Security headers + server_tokens off; # Don't show the Nginx version number + add_header X-Frame-Options "SAMEORIGIN"; # Protect against clickjacking + add_header X-Content-Type-Options "nosniff"; # Prevent MIME-sniffing + add_header X-XSS-Protection "1; mode=block"; # XSS filter protection + add_header Content-Security-Policy "default-src 'self'"; # Content security policy + + # Charset setting + charset utf-8; + + # Location block for routing requests + location / { + proxy_pass http://app; # Forward requests to the Gunicorn upstream + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1000; # Adjust timeout as needed + client_max_body_size 30m; # Max upload size + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce5c3b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.2 +elastic-apm[flask] +psutil +gunicorn +python-dotenv +pytest +redis +pymongo diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..ffdc0f2 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,21 @@ +import pytest + +from app.app import app + + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +def test_hello_world(client): + response = client.get('/') + assert response.data == b'Hello, World!' + assert response.status_code == 200 + + +def test_mongo_status(client): + response = client.get('/mongo_status') + assert response.status_code == 200 diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..694942a --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,10 @@ +from app import data + + +def test_return_check(): + assert data.healthcheck() == b'Hello, World!' + + +def test_mongo_status(): + result = data.get_mongo_status() + assert result['ok'] == 1 diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..498669a --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,117 @@ +import os +from unittest.mock import patch + +import pytest +from pymongo.errors import PyMongoError, BulkWriteError + + +from app.database import MongoDB + + +@pytest.fixture(scope="class") +def mongo_client(request): + test_mongo_url = os.environ.get("MONGO_URL") + mongo_instance = MongoDB(mongo_url=test_mongo_url, database_name="test_db") + if hasattr(request, "cls"): + request.cls.mongo = mongo_instance + yield mongo_instance + mongo_instance.drop_db('test_db') # Cleanup: Drop the test database after tests + mongo_instance.close() + + +@pytest.mark.usefixtures("mongo_client") +class TestMongoDB: + mongo: MongoDB + + def test_insert_one(self): + test_data = {"name": "John Doe"} + inserted_id = self.mongo.insert_one(test_data) + assert inserted_id is not None + found = self.mongo.find_one({"_id": inserted_id}) + assert found["name"] == "John Doe" + + def test_find(self): + test_data = {"name": "Find Test"} + self.mongo.insert_one(test_data) + results = self.mongo.find({"name": "Find Test"}) + assert len(results) > 0 + assert results[0]["name"] == "Find Test" + + def test_update_one(self): + test_data = {"name": "Update Test"} + inserted_id = self.mongo.insert_one(test_data) + self.mongo.update_one({"_id": inserted_id}, {"$set": {"name": "Updated Name"}}) + updated = self.mongo.find_one({"_id": inserted_id}) + assert updated["name"] == "Updated Name" + + def test_delete_one(self): + test_data = {"name": "Delete Test"} + inserted_id = self.mongo.insert_one(test_data) + self.mongo.delete_one({"_id": inserted_id}) + result = self.mongo.find_one({"_id": inserted_id}) + assert result is None + + def test_insert_many(self): + data_list = [{"name": "Test1"}, {"name": "Test2"}] + result = self.mongo.insert_many(data_list) + assert len(result) == 2 + # Verify that the documents are inserted + assert self.mongo.find({"name": {"$in": ["Test1", "Test2"]}}) + + def test_update_many(self): + # Setup: Insert multiple documents to update + self.mongo.insert_many([{"category": "update_many_test"}, {"category": "update_many_test"}]) + update_result = self.mongo.update_many({"category": "update_many_test"}, {"$set": {"updated": True}}) + assert update_result > 0 + # Verify that the documents are updated + updated_docs = self.mongo.find({"category": "update_many_test", "updated": True}) + assert len(updated_docs) > 1 + + def test_delete_many(self): + # Setup: Insert multiple documents to delete + self.mongo.insert_many([{"category": "delete_many_test"}, {"category": "delete_many_test"}]) + delete_result = self.mongo.delete_many({"category": "delete_many_test"}) + assert delete_result > 0 + # Verify that the documents are deleted + deleted_docs = self.mongo.find({"category": "delete_many_test"}) + assert len(deleted_docs) == 0 + + def test_exception_handling_insert_one(self): + with patch('pymongo.collection.Collection.insert_one', side_effect=PyMongoError): + result = self.mongo.insert_one({"name": "Exception Test"}) + assert result is None, "Expected insert_one to handle PyMongoError by returning None" + + def test_exception_handling_insert_many(self): + with patch('pymongo.collection.Collection.insert_many', side_effect=BulkWriteError({})): + result = self.mongo.insert_many([{"name": "Exception Test 1"}, {"name": "Exception Test 2"}]) + assert result is None, "Expected insert_many to handle BulkWriteError by returning None" + + def test_exception_handling_find(self): + with patch('pymongo.collection.Collection.find', side_effect=PyMongoError): + result = self.mongo.find({"name": "Exception Test"}) + assert result is None, "Expected find to handle PyMongoError by returning None" + + def test_exception_handling_find_one(self): + with patch('pymongo.collection.Collection.find_one', side_effect=PyMongoError): + result = self.mongo.find_one({"name": "Exception Test"}) + assert result is None, "Expected find_one to handle PyMongoError by returning None" + + def test_exception_handling_update_one(self): + with patch('pymongo.collection.Collection.update_one', side_effect=PyMongoError): + result = self.mongo.update_one({"name": "Original Name"}, {"$set": {"name": "Updated Name"}}) + assert result is None, "Expected update_one to handle PyMongoError by returning None" + + def test_exception_handling_update_many(self): + with patch('pymongo.collection.Collection.update_many', side_effect=PyMongoError): + result = self.mongo.update_many({"name": "Original Name"}, {"$set": {"name": "Updated Name"}}) + assert result is None, "Expected update_many to handle PyMongoError by returning None" + + def test_exception_handling_delete_one(self): + with patch('pymongo.collection.Collection.delete_one', side_effect=PyMongoError): + result = self.mongo.delete_one({"name": "Delete Test"}) + assert result is None, "Expected delete_one to handle PyMongoError by returning None" + + def test_exception_handling_delete_many(self): + with patch('pymongo.collection.Collection.delete_many', side_effect=PyMongoError): + result = self.mongo.delete_many({"name": "Delete Test"}) + assert result is None, "Expected delete_many to handle PyMongoError by returning None"